Compare commits

..

17 Commits

Author SHA1 Message Date
Sebastian Dine 9917e364c1 Minor: Set completion tracking (#19) 2026-07-23 10:29:56 +02:00
Sebastian Dine c9e6bc2b6b Minor: Add Asian Pokemon Card Support (#18) 2026-07-22 11:13:42 +02:00
Sebastian Dine e5c830e945 minor: New Game Digimon Digi-Battle (#17)
* digimon digi battle added to supported games

* sonarqube update

* readme update

---------

Co-authored-by: sdine <sdine@sdine.com>
2026-07-19 12:06:57 +02:00
Sebastian Dine 42926f2fb5 patch: Feature/ygo set selection (#16) 2026-05-13 21:16:41 +02:00
Sebastian Dine 8a50e8daba patch: Feature/pkm autodetect (#15) 2026-05-12 15:49:37 +02:00
Sebastian Dine 98f2575b5a patch: Patch/ygo 25th set 2 (#14) 2026-05-11 11:27:32 +02:00
Sebastian Dine d6c7f60aee patch: Patch/ygo set 25th (#13) 2026-05-11 09:51:19 +02:00
Sebastian Dine 7935f2b18e fix: Fix/ci cd issues (#12) 2026-05-11 08:42:07 +02:00
Sebastian Dine 5805101d24 fix: unittests 2026-05-10 12:18:45 +02:00
Sebastian Dine 8b7d45fdac patch: Patch/misc (#10)
* yugioh adjustments

* test coverage
2026-05-10 11:43:25 +02:00
Sebastian Dine c1d42bdadd patch: sonarqube fixes. 2026-05-09 19:57:02 +02:00
Sebastian Dine 6ff4406638 minor: yugioh support added 2026-05-09 19:32:18 +02:00
Sebastian Dine 6f575f4cec fix: ci errors fixed (#7)
* patch: trigger release

* fix: ci fix

* fix: ci fix
2026-05-09 15:28:54 +02:00
Sebastian Dine 7807192ecb fix: Feature/sonarqube3 (#6)
* patch: code coverage

* fix: ci fix

* fix: ci fix

---------

Co-authored-by: sdine <sdine@sdine.com>
2026-05-09 15:11:08 +02:00
Sebastian Dine a691291413 fix: ci fix + code measurement reported (#5)
* patch: code coverage

* fix: ci fix
2026-05-09 15:01:22 +02:00
Sebastian Dine 108863b795 fix: sonarqube security hotspot fix 2026-05-09 14:51:55 +02:00
Sebastian Dine c434ee51a7 fix: sonarqube (#3)
* sonarqube

* sonarqube

* sonarqube

---------

Co-authored-by: sdine <sdine@sdine.com>
2026-05-09 14:43:03 +02:00
206 changed files with 94941 additions and 481 deletions
+4
View File
@@ -37,6 +37,10 @@ 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`
- release/tag orchestrator: `contents: write`
+25 -3
View File
@@ -27,19 +27,38 @@ jobs:
sudo apt-get install -y
cmake
ninja-build
gcovr
pkg-config
libgtk-3-dev
libwxgtk3.2-dev
- name: Generate compile commands
- name: Configure with coverage instrumentation
run: >
cmake -S . -B build -G Ninja
-DCCM_BUILD_TESTS=OFF
-DCMAKE_C_COMPILER=gcc
-DCMAKE_CXX_COMPILER=g++
-DCCM_BUILD_TESTS=ON
-DCCM_USE_SYSTEM_WX=ON
-DCMAKE_BUILD_TYPE=Debug
-DCMAKE_C_FLAGS=--coverage
-DCMAKE_CXX_FLAGS=--coverage
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
- name: Build and run tests for coverage
run: >
cmake --build build --parallel &&
ctest --test-dir build --output-on-failure
- name: Generate Sonar coverage report
run: >
gcovr -r .
--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 }}
@@ -50,6 +69,9 @@ jobs:
-Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }}
-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
+51 -11
View File
@@ -1,11 +1,9 @@
name: Master CI
on:
pull_request:
push:
branches:
- master
types:
- closed
permissions:
contents: write
@@ -13,7 +11,6 @@ permissions:
jobs:
sonarqube:
name: SonarQube Cloud scan
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -30,19 +27,38 @@ jobs:
sudo apt-get install -y
cmake
ninja-build
gcovr
pkg-config
libgtk-3-dev
libwxgtk3.2-dev
- name: Generate compile commands
- name: Configure with coverage instrumentation
run: >
cmake -S . -B build -G Ninja
-DCCM_BUILD_TESTS=OFF
-DCMAKE_C_COMPILER=gcc
-DCMAKE_CXX_COMPILER=g++
-DCCM_BUILD_TESTS=ON
-DCCM_USE_SYSTEM_WX=ON
-DCMAKE_BUILD_TYPE=Debug
-DCMAKE_C_FLAGS=--coverage
-DCMAKE_CXX_FLAGS=--coverage
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
- name: Build and run tests for coverage
run: >
cmake --build build --parallel &&
ctest --test-dir build --output-on-failure
- name: Generate Sonar coverage report
run: >
gcovr -r .
--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 }}
@@ -53,23 +69,47 @@ jobs:
-Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }}
-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
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
release_tag: ${{ steps.version.outputs.release_tag }}
pr_title: ${{ steps.pr.outputs.pr_title }}
steps:
- name: Checkout tags
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve merged PR title for pushed commit
id: pr
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const commitSha = context.sha;
const pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: commitSha,
});
if (!pulls.data.length) {
core.setFailed(`No PR found for commit ${commitSha}. Release flow expects merges into master through PRs.`);
return;
}
const mergedPr = pulls.data.find((pr) => pr.merged_at) ?? pulls.data[0];
core.setOutput("pr_title", mergedPr.title);
- name: Resolve semantic version
id: version
run: bash scripts/compute_master_semver.sh "${{ github.event.pull_request.title }}"
run: bash scripts/compute_master_semver.sh "${{ steps.pr.outputs.pr_title }}"
build-windows:
name: Windows build + tests
@@ -79,7 +119,7 @@ jobs:
uses: ./.github/workflows/master-windows.yml
with:
version: ${{ needs.compute-version.outputs.version }}
merge_commit_sha: ${{ github.event.pull_request.merge_commit_sha }}
merge_commit_sha: ${{ github.sha }}
release-master:
name: Tag and release on master
@@ -111,6 +151,6 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.compute-version.outputs.release_tag }}
target_commitish: ${{ github.event.pull_request.merge_commit_sha }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
files: release-assets/*
+3
View File
@@ -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/
+28 -5
View File
@@ -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`.
- `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,13 +52,21 @@ 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: **86 cases / 211 assertions, 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 `ccm.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.
> **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.
>
> **Windows rebuild note**: linking `ccm.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm.exe` before rebuilding app targets.
> **Windows rebuild note**: linking `ccm3.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm3.exe` before rebuilding app targets.
>
> **Windows cold-start note**: first launch right after a fresh build is often slower than subsequent launches due to cold file cache and Windows security scanning (Defender/SmartScreen) on the new exe/dll set. Warm launches are the meaningful baseline for app-side perf changes.
@@ -69,8 +77,14 @@ Run from the **workspace root**.
- Preserve "select first row on startup" behavior without blocking first paint by scheduling the initial selection with `CallAfter(...)` instead of selecting synchronously during row rebuild.
- Avoid repeated set-list loads when opening Add/Edit: cache Magic sets in `MainFrame` and reuse them in `CardEditDialog`.
- Pass preloaded set data to dialogs by pointer/reference, not by value, to avoid copying large vectors on every open.
- `MainFrame` default window size is **1210×770** (`ui_wx/src/MainFrame.cpp`).
- Saving from **Edit** in `BaseCardEditDialog`: themed Yes/No confirmation when the card changed versus the snapshot taken at dialog open; Add mode does not prompt.
- While constructing/populating dialogs with many controls/choices, wrap with `Freeze()`/`Thaw()` and append choice items in bulk (`wxArrayString`) to reduce layout/repaint churn.
- Keep selected-card preview usable when remote lookup fails: show a per-game card-back fallback image (CCM2 parity), not a blank preview panel.
- 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`, `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
@@ -83,17 +97,26 @@ Run from the **workspace root**.
- 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.
- After changing `ui_wx` theming behavior, rebuild the final app target (`cmake --build build --target ccm --parallel`), not just `ccm_ui_wx`, before validating runtime behavior.
- If linker fails with `Permission denied` on `build/bin/ccm.exe`, the app is still running; close it before rebuilding.
- If linker fails with `Permission denied` on `build/bin/ccm3.exe`, the app is still running; close it before rebuilding.
## Required follow-ups
- 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. 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).
+32 -3
View File
@@ -1,19 +1,47 @@
# Card Collection Manager 3
[![SonarCloud Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=bugs&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=security_rating&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
Card Collection Manager 3 is an extensible desktop application for managing trading card game collections. It is designed as a practical way to track cards and manage per-card images for large collections, with local per-game data, set synchronization workflows, and a desktop-first UX. The app preserves the established JSON layout from earlier CCM versions so existing collections stay compatible.
Currently, the application supports the following TCGs:
- Magic the Gathering
- Pokemon TCG
- Yu-Gi-Oh!
- Digimon (Digi-Battle)
## Screenshots
### Magic The Gathering
<details open>
<summary>Magic The Gathering</summary>
![CCM3 Demo - Magic: The Gathering](docs/assets/images/demo-mtg.png)
### Pokemon TCG
</details>
<details>
<summary>Pokemon TCG</summary>
![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png)
</details>
<details>
<summary>Yu-Gi-Oh!</summary>
![CCM3 Demo - YuGiOh](docs/assets/images/demo-ygo.png)
</details>
<details>
<summary>Digimon (Digi-Battle)</summary>
![CCM3 Demo - Digimon Digi-Battle](docs/assets/images/demo-digibattle99.png)
</details>
## Migrating From CCM1 And CCM2
CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory.
@@ -68,3 +96,4 @@ This project continues earlier versions of Card Collection Manager:
This project is licensed under the [MIT License](LICENSE).
Third-party dependencies and assets remain under their respective licenses.
+8 -4
View File
@@ -5,20 +5,24 @@ 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`.
- `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>`, `JsonSetRepository`, `LocalImageStore`, `MagicGameModule`, `PokemonGameModule`, `MagicGameView`, `PokemonGameView`, 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>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, 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).
8. **One `CprHttpClient` per app, shared by every consumer.** The single `http_` instance is handed to `SetService`, `CardPreviewService`, and every per-game module. Do **not** construct a second `CprHttpClient` (or pass `cpr::Get(...)` directly) from anywhere — the adapter holds a long-lived `cpr::Session` whose connection cache + TLS keep-alive is what makes repeat lookups fast (game-agnostic; see `core/AGENTS.md` convention 11). The shared instance also gives `CardPreviewService`'s in-memory LRU a single source of truth to cache against.
9. **One `LocalPreviewByteCache` per app**, rooted at `<exeDir>/.cache/preview-cache/` — i.e. **next to the executable**, in the same scope as `config.json`. **Do not** root the cache at `config_->current().dataStorage`: the user's data-storage path is user-configurable at runtime and is meant for the user's collection (cards, scans, set lists). Previews are downloaded-from-network artifacts that (a) must not move when the user relocates their collection, (b) must not be uploaded/synced together with the user's data dir, and (c) must not survive a fresh install elsewhere on disk. Pinning the cache to `exeDir` is what gives those properties without writing extra plumbing for each data-storage flow. The umbrella `.cache/` directory is reserved for any future computed-from-network caches (set-list snapshots, etc.); the leading dot keeps it out of the way for users poking around the install folder. Cache updates flow entirely through cache keys: `CardPreviewService` invalidates entries automatically when the cache key changes (record edits) and rewrites them when a same-key resolution flips between positive and negative — there is no `clearCache(...)` API. To wipe the cache manually, delete `<exeDir>/.cache/`; reinstalling / moving the executable also resets the cache by design. Construct the cache after `ConfigService` (so the dependency graph is the same as before; the cache itself only needs `*fs_` and the resolved `exeDir`) and before `CardPreviewService` (so the service can hold a stable raw pointer); declare the member after `config_`/`fs_` and before `previewSvc_` to keep destruction order correct. See `core/AGENTS.md` convention 10 and `docs/caching.md` ("Updating cached entries") for the full cache shape, policy, and update mechanic.
## Required follow-ups
- 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.
@@ -32,4 +36,4 @@ The `ccm` executable — composition root only. The single place where concrete
## Commands
- Build the binary: `cmake --build build --target ccm`
- Run on Windows / MinGW-w64: `.\build\bin\ccm.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3".
- Run on Windows / MinGW-w64: `.\build\bin\ccm3.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3".
+35
View File
@@ -18,3 +18,38 @@ target_link_libraries(ccm
ccm_ui_wx
ccm_warnings
)
# 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"
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()
+96 -5
View File
@@ -2,24 +2,36 @@
// 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/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/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
#include "ccm/infra/JsonSetRepository.hpp"
#include "ccm/infra/LocalImageStore.hpp"
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#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/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/YuGiOhGameView.hpp"
#include <wx/app.h>
#include <wx/icon.h>
@@ -38,8 +50,11 @@ 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::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
case ccm::Game::JapanesePokemon: return "pokemon";
}
return "magic";
}
@@ -74,12 +89,36 @@ public:
http_ = std::make_unique<ccm::CprHttpClient>();
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_);
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);
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
*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);
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);
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_);
@@ -87,19 +126,55 @@ public:
*magicRepo_, *imgStore_);
pokeCollSvc_ = std::make_unique<ccm::CollectionService<ccm::PokemonCard>>(
*pokeRepo_, *imgStore_);
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
*ygoRepo_, *imgStore_);
digiBattle99CollSvc_ =
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
*digiBattle99Repo_, *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(jpPokeMod_.get());
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_);
// Disk-backed preview cache lives next to the executable, in the same
// location scope as config.json - NOT inside the user's data-storage
// directory. Rationale: previews are downloaded artifacts, not user
// data, so they should not move when the user relocates their
// collection (data-storage path can be reconfigured at runtime), and
// they should not be uploaded together with the user's collection
// when the data dir is backed up / synced. The umbrella ".cache/"
// directory is reserved for any future computed-from-network caches
// (set-list snapshots, etc.); the leading dot keeps it out of the way
// for users poking around the install folder. Constructed before
// previewSvc_ so the service can hold a stable raw pointer to it.
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
*fs_,
exeDir / ".cache" / "preview-cache");
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(*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_,
*ygoCatalogStore_);
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_, *digiBattle99CatalogStore_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -108,7 +183,10 @@ public:
*previewSvc_,
*magicMod_,
*pokeMod_,
{ magicView_.get(), pokeView_.get() },
*ygoMod_,
*digiBattle99Mod_,
*jpPokeMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
@@ -128,17 +206,30 @@ private:
std::unique_ptr<ccm::CprHttpClient> http_;
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::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::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
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::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::AppContext> ctx_;
};
+9 -6
View File
@@ -4,12 +4,12 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `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`). 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`.
- `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/` and `pokemon/` are the reference implementations — both 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/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), `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/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `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` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/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/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `parseCatalog` / `fetchAllWithCatalog` (or Asia equivalents) 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
@@ -25,6 +25,9 @@
6. **Compiler warnings**: every target in this package links `ccm_warnings` `PRIVATE`. Treat warnings as errors locally during dev (`-Werror` is opt-in but encouraged).
7. **No `wx/...` includes** in headers or sources here. Verify with `rg "wx/" core/` — must be empty.
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`, `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
+23
View File
@@ -6,6 +6,12 @@ add_library(ccm_core STATIC
src/domain/Set.cpp
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.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
@@ -14,11 +20,18 @@ 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/PokemonSetCompletion.cpp
src/services/PokemonSetCatalogService.cpp
src/infra/CprHttpClient.cpp
src/infra/StdFileSystem.cpp
src/infra/JsonSetRepository.cpp
src/infra/LocalImageStore.cpp
src/infra/LocalPreviewByteCache.cpp
src/games/magic/MagicSetSource.cpp
src/games/magic/MagicCardPreviewSource.cpp
@@ -26,6 +39,16 @@ add_library(ccm_core STATIC
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/pokemonjp/JapanesePokemonEnCatalog.cpp
src/games/pokemonjp/JapanesePokemonSetSource.cpp
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
src/games/pokemonjp/JapanesePokemonGameModule.cpp
src/util/FsNames.cpp
)
@@ -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
+28 -7
View File
@@ -10,6 +10,7 @@
#include <array>
#include <optional>
#include <span>
#include <string>
#include <string_view>
@@ -18,6 +19,14 @@ namespace ccm {
enum class Game {
Magic,
Pokemon,
YuGiOh,
DigiBattle99,
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
};
enum class PokemonRegion {
West,
Asia,
};
enum class Language {
@@ -26,8 +35,10 @@ enum class Language {
French,
Spanish,
Italian,
Chinese,
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
TraditionalChinese, // JSON / display: "T-Chinese"
Japanese,
Korean,
Russian,
};
@@ -47,24 +58,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, 2>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
const std::array<Game, 4>& 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
+3 -1
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// YuGiOhCard - Yu-Gi-Oh card model with print-level metadata.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct YuGiOhCard {
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::English};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const YuGiOhCard&, const YuGiOhCard&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCard& c);
void from_json(const nlohmann::json& j, YuGiOhCard& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#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;
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
+4
View File
@@ -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,67 @@
#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;
// 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);
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
@@ -19,9 +19,10 @@ class MagicCardPreviewSource final : public ICardPreviewSource {
public:
explicit MagicCardPreviewSource(IHttpClient& http);
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
// Build the fully URL-encoded Scryfall search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
@@ -29,11 +30,13 @@ public:
std::string_view setId);
// Parse a Scryfall /cards/search response body and pull out the
// `data[0].image_uris.normal` URL. Returns an error result when no
// matching printing is found, when the JSON is malformed, or when the
// entry has no top-level `image_uris` (double-faced cards expose them
// on a face object - no fallback in this compatibility behavior either).
static Result<std::string> parseResponse(const std::string& body);
// `data[0].image_uris.normal` URL. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array, missing top-level `image_uris`, or missing
// `image_uris.normal` => NotFound (the upstream answered, but the
// printing simply has no preview we can use).
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
private:
IHttpClient& http_;
@@ -1,17 +1,18 @@
#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`.
// TCG. When set id + collector number are both known, prefers
// GET https://api.pokemontcg.io/v2/cards/{setId}-{number}
// then falls back to a name-less search `set.id:… number:…`. Name-based
// search is kept for lookups that lack a set number (or set id). Returns
// `images.large` (with `images.small` as a graceful fallback).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -19,21 +20,55 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
[[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.
// When both setId and setNo are non-empty, omits the name: clause so the
// Lucene query cannot miss on name∩number intersections.
// Exposed for unit testing and to keep encoding rules in one place.
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
// 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`, and returns an error result if neither is present, the
// data array is empty, or the JSON is malformed.
static Result<std::string> parseResponse(const std::string& body);
// Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}.
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
// Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by
// preview lookups, auto-detect, and set-completion ownership matching.
static std::string normalizeCollectorNumber(std::string_view setNo);
// Slimmer search URL for auto-detect: omits the number clause and asks the
// API for only the fields the print-variant parser needs.
static std::string buildDetectSearchUrl(std::string_view name,
std::string_view setId);
// Parse a Pokemon TCG /v2/cards *search* response body (`data` array) 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);
// Parse a Pokemon TCG /v2/cards/{id} response (`data` object).
static Result<std::string, PreviewLookupError>
parseCardByIdResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card
// name inside the chosen set. Exposed for unit testing without HTTP.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName);
private:
IHttpClient& http_;
@@ -6,23 +6,56 @@
// 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`.
// Set-completion catalog is built from a paginated /v2/cards dump.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#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* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards";
static constexpr int kCardsPageSize = 250;
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Sets endpoint + paginated cards dump for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build / merge checklist packs from one /v2/cards page body. Pass an
// accumulating catalog; returns page count metadata for pagination.
struct CardsPageMeta {
int page{1};
int pageSize{kCardsPageSize};
int count{0};
int totalCount{0};
};
static Result<CardsPageMeta> mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets);
static Result<PokemonSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize);
private:
IHttpClient& http_;
};
@@ -0,0 +1,68 @@
#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;
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);
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
@@ -0,0 +1,123 @@
#pragma once
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
// YuGiOhCardPreviewSource - resolves preview images for Yu-Gi-Oh! cards.
//
// The image-preview path is backed by Yugipedia's MediaWiki API
// (https://yugipedia.com/api.php). Yugipedia hosts actual per-printing card
// scans, with deterministic file names of the shape
// `<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<ext>` (e.g.
// `BlueEyesWhiteDragon-LOB-EN-UR-UE.png` vs `BlueEyesWhiteDragon-SDK-NA-UR-UE.png`),
// which lets us return the right artwork for printings that share a passcode
// but have visibly different art - a case YGOPRODeck cannot disambiguate (its
// card_images array is keyed by art-treatment passcode, not by physical
// printing).
//
// The auto-detect-first-print path keeps using YGOPRODeck (`cardinfo.php`):
// that endpoint returns a richer set listing (with rarities and release
// dates) than Yugipedia, and we don't need image data for it.
//
// 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).
class YuGiOhCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhCardPreviewSource(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;
// ---- Yugipedia helpers (image preview path) ----------------------------
// Build the list of candidate Yugipedia file names to try, in priority
// order (most likely first). Always uses English regions; the caller may
// pass an empty rarityCode when the rarity is unknown, in which case the
// returned list will skip rarity in the filename.
static std::vector<std::string> buildCandidateFilenames(
std::string_view name,
std::string_view setCode,
std::string_view rarityCode,
bool firstEdition);
// Build a single MediaWiki batch query URL that asks for imageinfo.url
// for every filename. MediaWiki's `titles=` parameter joins page titles
// with `|`, so we issue exactly one HTTP call per preview lookup.
static std::string buildYugipediaQueryUrl(
const std::vector<std::string>& filenames);
// Parse a MediaWiki `query.pages` response and return the resolved URL of
// the first filename in `filenameOrder` that exists. Missing pages have
// the `missing` marker (no `imageinfo`); existing pages carry an
// `imageinfo[0].url` we forward verbatim. Errors are classified:
// - JSON parse failure or schema deviation => Transient.
// - Every candidate came back missing => NotFound.
static Result<std::string, PreviewLookupError> parseYugipediaResponse(
const std::string& body,
const std::vector<std::string>& filenameOrder);
// Strip a card name down to Yugipedia's image-slug shape: alphanumerics
// (and parentheses) only, no whitespace, no policy-banned punctuation.
static std::string normalizeName(std::string_view name);
// Map a CCM3 rarity name (e.g. "Ultra Rare") to the Yugipedia rarity
// code used in image filenames (e.g. "UR"). Returns an empty string when
// the rarity is unknown; the caller treats that as "skip rarity".
static std::string rarityCodeFor(std::string_view rarityName);
// Pull the set abbreviation out of a CCM3 setNo such as "LOB-005" or
// "LOB-DE005" - in both cases we want "LOB". Returns the trimmed input
// unchanged if no dash is present.
static std::string extractSetCode(std::string_view setNo);
// ---- YGOPRODeck helpers (auto-detect path + fallback) ------------------
// Build a fuzzy-name `cardinfo.php` URL. `setName` may be empty for an
// unfiltered fuzzy lookup. Used by detectFirstPrint and by the
// standard-art fallback when Yugipedia has no scan for this printing.
static std::string buildSearchUrl(std::string_view name,
std::string_view setName);
// Pick the standard artwork (card_images[0]) from a YGOPRODeck response,
// preferring the exact-name match. Used only as a last-resort fallback
// when Yugipedia returns nothing for any of our candidate filenames.
// Errors are classified:
// - JSON parse failure or schema deviation => Transient.
// - Empty `data` array, or matched cards without a usable image
// variant => NotFound.
static Result<std::string, PreviewLookupError>
parseFallbackImageUrl(const std::string& body, std::string_view name);
// Pick the first printing for `preferredSetName` from a YGOPRODeck
// response. Drives the "Auto detect" button in the YGO edit dialog.
static Result<AutoDetectedPrint> parseFirstPrint(const std::string& body,
std::string_view preferredSetName);
// Every `(set_code, set_rarity)` pair for cards whose name matches
// `wantedCardName` (case-insensitive). When `wantedCardName` is empty,
// scans every row in `data[]` like `parseFirstPrint` did historically.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view preferredSetName,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,25 @@
#pragma once
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
namespace ccm {
class YuGiOhGameModule final : public IGameModule {
public:
explicit YuGiOhGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOh; }
[[nodiscard]] std::string dirName() const override { return "yugioh"; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
YuGiOhSetSource setSource_;
YuGiOhCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,47 @@
#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_;
};
} // namespace ccm
+31
View File
@@ -7,17 +7,48 @@
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
namespace cpr { class Session; }
namespace ccm {
// Concrete IHttpClient backed by libcpr/libcurl. The single owned
// `cpr::Session` keeps libcurl's connection pool alive across calls, so
// repeat HTTPS requests to the same host (api.scryfall.com, yugipedia.com,
// ms.yugipedia.com, …) reuse the existing TLS connection instead of paying
// for a fresh handshake every time. Concurrent calls are serialized through
// a mutex - libcurl easy handles are not thread-safe, and the preview path
// 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;
private:
std::chrono::milliseconds timeout_;
std::unique_ptr<cpr::Session> session_;
GetExecutor executor_;
RawGetExecutor rawExecutor_;
std::mutex sessionMutex_;
};
} // namespace ccm
+5 -1
View File
@@ -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
@@ -0,0 +1,85 @@
#pragma once
// LocalPreviewByteCache - on-disk byte cache for CardPreviewService.
//
// Layout under the configured cache directory (composition root passes
// `<exeDir>/.cache/preview-cache/` - next to the executable, NOT under
// the user-configurable `dataStorage` path; see `docs/caching.md` and
// `app/AGENTS.md` for the rationale):
// <hash>.bin raw image bytes (PNG/JPEG payload), positive entries only
// <hash>.neg zero-byte marker file, negative entries only
// <hash>.idx one-line text sidecar holding the original cache key,
// used to detect (and reject) hash collisions so we never
// serve the wrong card's image and never honor a stale
// negative entry across collisions
//
// Positive vs. negative entries are mutually exclusive for a given hash:
// `store` removes any existing `.neg`, `storeNegative` removes any existing
// `.bin`, and `load` prefers `.bin` on the off chance both somehow co-exist.
//
// The cache is bounded by total payload bytes (sum of `.bin` sizes). When
// `store` would push it past the cap we evict by file mtime (oldest first)
// until back under the cap; the `.idx` sidecar of an evicted entry is
// removed too. Negative entries are tiny (effectively `.idx` only) and are
// not subject to the byte cap directly - their count is naturally bounded
// by the user's collection size since a negative entry only ever exists
// for a card the user has actually looked at and the upstream answered
// "no image" for. Reads update mtime via a touch on hit so frequently-
// viewed cards survive eviction.
//
// All filesystem mutations go through `IFileSystem` (so the in-memory
// fake works in tests). Size and mtime queries - which the port does not
// expose - use `std::filesystem` directly inside this adapter. Tests that
// need to drive eviction stay easy to write: just call `store` past the cap
// and check the survivors.
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include <cstddef>
#include <filesystem>
#include <mutex>
#include <string>
#include <string_view>
namespace ccm {
class LocalPreviewByteCache final : public IPreviewByteCache {
public:
// Default soft cap: ~64 MiB. A typical preview is 80-200 KiB, so this
// holds several hundred cards comfortably while keeping disk usage
// bounded for users with very large collections.
static constexpr std::size_t kDefaultMaxBytes = 64ull * 1024 * 1024;
LocalPreviewByteCache(IFileSystem& fs,
std::filesystem::path cacheDir,
std::size_t maxBytes = kDefaultMaxBytes);
[[nodiscard]] LoadResult load(std::string_view key) override;
void store(std::string_view key, const std::string& payload) override;
void storeNegative(std::string_view key) override;
// Test-visible knob: total payload bytes currently on disk (recomputed
// from the directory listing so it stays accurate after external
// tampering). Negative-entry markers do not count toward the total.
[[nodiscard]] std::size_t currentSizeBytes();
private:
std::filesystem::path payloadPath(const std::string& hash) const;
std::filesystem::path negativePath(const std::string& hash) const;
std::filesystem::path indexPath(const std::string& hash) const;
// Hex-encoded FNV-1a 64-bit hash of the key. We don't need cryptographic
// strength; the sidecar `.idx` file rejects collisions on load so the
// worst case is a one-time cache miss.
static std::string hashKey(std::string_view key);
void evictIfNeededLocked(std::size_t incomingBytes);
IFileSystem& fs_;
std::filesystem::path cacheDir_;
std::size_t maxBytes_;
std::mutex mutex_;
};
} // namespace ccm
+57 -3
View File
@@ -12,9 +12,38 @@
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct AutoDetectedPrint {
std::string setNo;
std::string rarity;
};
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
// drives caching policy in CardPreviewService:
//
// NotFound -- the upstream answered cleanly that the card has no image
// (or no matching record at all). Safe to remember: the
// answer will not change until the user edits the card
// record itself, which automatically invalidates the cache
// key. Negative-cached so subsequent selections show the
// fallback card-back instantly without another HTTP call.
//
// Transient -- the upstream did not answer cleanly (HTTP / network /
// timeout failure, malformed response, parse error). The
// record may well have an image; we just couldn't see it
// this time. NOT cached, so the next selection retries.
//
// The `message` is opaque to the service and is forwarded to the UI as
// the existing free-form `Result<std::string>::error()` string.
struct PreviewLookupError {
enum class Kind { NotFound, Transient };
Kind kind{Kind::Transient};
std::string message;
};
class ICardPreviewSource {
public:
virtual ~ICardPreviewSource() = default;
@@ -22,9 +51,34 @@ public:
// Resolve the preview image URL for a single card. `setNo` is optional
// (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as
// a more precise lookup key, others (Magic/Scryfall) ignore it.
virtual Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) = 0;
//
// Errors carry a classification (`PreviewLookupError::Kind`) so
// CardPreviewService can decide whether to remember the miss
// (`NotFound`) or retry on the next call (`Transient`). See the doc
// comment on PreviewLookupError above for the exact contract.
virtual Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) = 0;
// Opt-in switch for per-game print metadata detection.
[[nodiscard]] virtual bool supportsAutoDetectPrint() const noexcept { return false; }
// Optional metadata lookup used by game-specific edit dialogs. The default
// implementation returns an explicit "unsupported" error so games without
// print metadata APIs do not need to override it.
virtual Result<AutoDetectedPrint> detectFirstPrint(std::string_view /*name*/,
std::string_view /*setId*/) {
return Result<AutoDetectedPrint>::err("Auto-detect not supported by this game.");
}
// Optional listing of every distinct `(set_code, rarity)` print returned by
// the upstream for an exact card name inside the chosen display set.
virtual Result<std::vector<AutoDetectedPrint>>
detectPrintVariants(std::string_view /*name*/, std::string_view /*setId*/) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Print variant listing not supported by this game.");
}
};
} // namespace ccm
@@ -0,0 +1,75 @@
#pragma once
// IPreviewByteCache - persistent byte cache used by CardPreviewService to
// keep preview images alive across app restarts.
//
// The cache is keyed by an opaque string. CardPreviewService composes the
// key from `(game, name, setId, setNo)` (preview lookups) or directly from
// the URL (per-game card-back fallback fetches); the cache itself does not
// interpret the key, only stores the byte payload behind it.
//
// Two kinds of entries are persisted:
//
// * Positive entries hold raw image bytes. Stored via `store(key, payload)`,
// returned as `LoadResult{HitKind::Hit, payload}`.
// * Negative entries record "we tried to resolve this exact card and the
// upstream answered cleanly that it has no preview image" - i.e. the
// `NotFound` half of `PreviewLookupError`. Stored via
// `storeNegative(key)`, returned as `LoadResult{HitKind::NegativeHit, {}}`.
// `Transient` errors (HTTP / network / parse failures) must NEVER reach
// this cache: we cannot tell whether the record genuinely has no image
// or just couldn't be reached, and persisting the miss would leave the
// user staring at the card-back placeholder until they edit the card.
//
// A negative entry is implicitly invalidated when the cache key changes -
// since the key includes `(game, name, setId, setNo)` (with game-specific
// disambiguators packed into setNo), any edit that affects a lookup-relevant
// field will hit a fresh key and re-attempt the network lookup automatically.
//
// Implementations must be thread-safe with respect to concurrent load/store
// calls because CardPreviewService is invoked from a worker thread spawned
// by `BaseSelectedCardPanel`.
//
// Errors are intentionally swallowed (load returns Miss; store and
// storeNegative are fire-and-forget). A flaky or full disk must never break
// the preview path - in the worst case the user sees the same speed as a
// fresh app install.
#include <string>
#include <string_view>
namespace ccm {
class IPreviewByteCache {
public:
enum class HitKind {
Miss, // no entry for this key (or unrecoverable I/O error)
Hit, // positive entry; bytes are in `payload`
NegativeHit, // negative entry; `payload` is empty by contract
};
struct LoadResult {
HitKind kind{HitKind::Miss};
std::string payload; // only meaningful when kind == Hit
};
virtual ~IPreviewByteCache() = default;
// Returns the cached entry for `key`. On any error - missing files,
// sidecar mismatch, malformed metadata, I/O failure - implementations
// must report `HitKind::Miss` rather than surfacing the error.
[[nodiscard]] virtual LoadResult load(std::string_view key) = 0;
// Best-effort persist of `payload` under `key`. Empty payloads are not
// stored as positive entries. If a negative entry already exists for
// this key it is replaced. Errors are swallowed.
virtual void store(std::string_view key, const std::string& payload) = 0;
// Best-effort persist of "we tried, upstream cleanly said no image".
// If a positive entry already exists for this key it is replaced.
// Errors are swallowed. Must be invoked ONLY for `NotFound`-class
// outcomes; never for transient failures.
virtual void storeNegative(std::string_view key) = 0;
};
} // namespace ccm
+3 -1
View File
@@ -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"
+14 -1
View File
@@ -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/YuGiOhCard.hpp"
#include <string_view>
@@ -33,9 +36,19 @@ 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);
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter);
} // namespace ccm
@@ -15,18 +15,28 @@
#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>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace ccm {
class CardPreviewService {
public:
explicit CardPreviewService(IHttpClient& http);
explicit CardPreviewService(IHttpClient& http,
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
@@ -38,18 +48,92 @@ public:
// The returned `std::string` is a raw byte buffer (PNG/JPEG payload) -
// it is NOT decoded text. Use std::string::data()/size() with whatever
// image-decoding facility your UI provides.
//
// Successful results are cached in two tiers, both keyed by
// (game, name, setId, setNo):
// 1. In-memory LRU (bounded by `kCacheCapacity`) for instant hits
// while the app is running.
// 2. Optional persistent byte cache (passed at construction) so
// previews survive app restarts.
// Re-selecting the same row is then a memcpy away from the wxImage
// decoder, no HTTP at all - this is the common user-facing case
// (clicking around the table).
//
// Failures are split into two policies based on
// `PreviewLookupError::Kind`:
// * `NotFound` (the upstream answered cleanly that this record has
// no preview) is *negative-cached* in both tiers, so subsequent
// selections short-circuit without touching the network. The
// cache key is invalidated automatically when the user edits a
// lookup-relevant field of the record.
// * `Transient` (HTTP / network / parse failure) is NEVER cached, so
// the next selection retries cleanly once connectivity is back.
Result<std::string> fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo);
Result<AutoDetectedPrint> detectFirstPrint(Game game,
std::string_view name,
std::string_view setId);
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(Game game,
std::string_view name,
std::string_view setId);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution.
// per-game preview-source resolution. Cached by URL (same LRU bound).
Result<std::string> fetchImageBytesByUrl(std::string_view url);
// Maximum number of cached preview entries kept in memory. Picked so a
// typical Yu-Gi-Oh! collection page can scroll up and down without
// re-hitting the network, while keeping a hard upper bound on RSS for
// very large collections (each entry is roughly one PNG, <100 KiB).
static constexpr std::size_t kCacheCapacity = 128;
private:
IHttpClient& http_;
enum class CacheLookupKind {
Miss, // not in the in-memory tier
Hit, // positive entry; bytes returned via outPayload
NegativeHit, // negative entry; outPayload is empty
};
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
// cleared. Both Hit and NegativeHit move the entry to the front of
// the LRU.
CacheLookupKind cacheLookup(const std::string& key, std::string& outPayload);
void cacheStore(const std::string& key, std::string payload);
void cacheStoreNegative(const std::string& key);
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
// for O(1) move-to-front. Mutex covers both list and map - lookups
// happen on a worker thread spawned by BaseSelectedCardPanel.
//
// A `negative` entry has an empty payload by convention; we keep the
// flag explicit (rather than abusing emptiness) so future invariants
// around eviction or stats stay easy to reason about.
struct CacheEntry {
std::string key;
std::string payload;
bool negative{false};
};
using CacheList = std::list<CacheEntry>;
CacheList cacheList_;
std::unordered_map<std::string, CacheList::iterator> cacheIndex_;
std::mutex cacheMutex_;
};
} // namespace ccm
+52
View File
@@ -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/YuGiOhCard.hpp"
#include <vector>
@@ -52,11 +55,60 @@ enum class PokemonSortColumn {
Note,
};
enum class YuGiOhSortColumn {
Name,
SetReleaseDate,
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,
};
// Japanese Pokemon mirrors Pokemon columns.
enum class JapanesePokemonSortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
Holo,
FirstEdition,
Signed,
Altered,
Note,
};
// Stable in-place sort. `ascending=false` runs the same comparator with
// inverted sign, matching `byField(field, asc)` semantics.
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
bool ascending);
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 sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending);
} // namespace ccm
@@ -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,71 @@
#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::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 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
+4
View File
@@ -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,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
+22
View File
@@ -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
+30
View File
@@ -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
+33
View File
@@ -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,99 @@
#pragma once
// Yu-Gi-Oh! collector slot equivalence for UI + metadata matching.
//
// The edit dialog composes `setNo` as `<set.id>-<digits>` using only numeric
// characters from the text field (e.g. SOD + "015" -> "SOD-015"). YGOPRODeck
// `set_code` values often embed region letters ("SOD-EN015"). Exact string
// compare would miss that both refer to the same slot.
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
[[nodiscard]] inline std::string_view trimAsciiSpaces(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 std::string ygoAbbrevBeforeDash(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
const auto dash = s.find('-');
const std::string_view pref = dash == std::string_view::npos ? s : s.substr(0, dash);
std::string out(pref);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return out;
}
[[nodiscard]] inline std::string ygoCollectorDigitsOnly(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
const auto dash = s.find('-');
const std::string_view tail =
dash == std::string_view::npos ? std::string_view{} : s.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;
}
// 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);
}
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
// the same English `set_name` (e.g. Dark Magician as "LOB-E003" vs NA "LOB-005").
// The suffix uses a single leading `E` immediately followed by digits — distinct
// from two-letter regions such as "EN" ("LOB-EN005") or "DE" ("LOB-DE005").
[[nodiscard]] inline bool ygoLikelyEuropeanRegionalSetCode(std::string_view setCode) {
const std::string_view s = trimAsciiSpaces(setCode);
const auto dash = s.find('-');
if (dash == std::string_view::npos || dash + 2 >= s.size()) return false;
const std::string_view tail = s.substr(dash + 1);
return tail.size() >= 2 && tail[0] == 'E'
&& 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
+67
View File
@@ -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
+5
View File
@@ -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);
}
+39
View File
@@ -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
+96 -33
View File
@@ -3,28 +3,47 @@
#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::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
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 {
@@ -37,7 +56,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 {
@@ -45,24 +64,37 @@ 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 == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
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;
}
@@ -83,15 +115,17 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 2>& allGames() noexcept {
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
const std::array<Game, 4>& allGames() noexcept {
static constexpr std::array<Game, 4> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, 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;
}
@@ -109,16 +143,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>());
+39
View File
@@ -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
+3
View File
@@ -17,6 +17,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
{"region", c.region},
};
}
@@ -34,6 +35,8 @@ 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);
}
} // namespace ccm
+39
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const YuGiOhCard& 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},
{"rarity", c.rarity},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, YuGiOhCard& 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("rarity").get_to(c.rarity);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#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}};
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
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,222 @@
#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;
}
} // 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) {
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)));
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.setNo = normalizeCardNumber(card.value("id", ""));
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
if (out.setNo.empty()) continue;
collected.push_back(std::move(out));
}
if (collected.empty()) {
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);
}
} // 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
+24 -38
View File
@@ -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,41 +35,51 @@ 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> MagicCardPreviewSource::parseResponse(const std::string& body) {
Result<std::string, PreviewLookupError>
MagicCardPreviewSource::parseResponse(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 Result<std::string>::err("Scryfall response missing 'data' array.");
// Treat schema deviation as transient: the API contract failed,
// not the user's record. Scryfall returns a JSON error object
// here on outage, which is rare but not stable.
return R::err({K::Transient, "Scryfall response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Scryfall returned no matching cards.");
return R::err({K::NotFound, "Scryfall returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
// Double-faced cards expose image_uris on each face; there is no
// fallback for this and surfaces it as "no preview".
return Result<std::string>::err("Card has no top-level image_uris.");
return R::err({K::NotFound, "Card has no top-level image_uris."});
}
const auto& uris = first.at("image_uris");
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
return Result<std::string>::err("Card has no 'normal' image variant.");
return R::err({K::NotFound, "Card has no 'normal' image variant."});
}
return Result<std::string>::ok(uris.at("normal").get<std::string>());
return R::ok(uris.at("normal").get<std::string>());
} catch (const std::exception& e) {
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
return R::err({K::Transient, std::string("Scryfall JSON parse error: ") + e.what()});
}
}
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view /*setNo*/) {
Result<std::string, PreviewLookupError>
MagicCardPreviewSource::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 url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
}
@@ -1,43 +1,54 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.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;
}
Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::json& card) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
if (!card.contains("images") || !card.at("images").is_object()) {
return R::err({K::NotFound, "Card has no 'images' object."});
}
const auto& images = card.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return R::ok(images.at("large").get<std::string>());
}
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."});
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
// Pokemon TCG search uses an unquoted `number:` clause (e.g. number:4 or
// number:TG14). Cards are commonly stored as `4/102`; strip the suffix.
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
@@ -46,65 +57,192 @@ std::string normalizeNumber(std::string_view setNo) {
return s;
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
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:";
// When both set id and collector number are known, omit name: — Lucene
// name∩number intersections can miss even when the print is real, and
// collector numbers are unique within a set.
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
std::string query;
if (!setId.empty() && !num.empty()) {
query = "set.id:";
query += std::string(setId);
}
const std::string num = normalizeNumber(setNo);
if (!num.empty()) {
query += " number:";
query += num;
} else {
query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
}
if (!num.empty()) {
query += " number:";
query += num;
}
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
rfc3986PercentEncode(query);
}
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
std::string_view setNo) {
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
std::string id = std::string(setId) + "-" + num;
return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id);
}
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
std::string_view setId) {
std::string url = buildSearchUrl(name, setId, "");
url += "&select=name,number,rarity,set";
url += "&pageSize=50";
return url;
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(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 Result<std::string>::err("Pokemon TCG response missing 'data' array.");
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return Result<std::string>::err("Card has no 'images' object.");
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return Result<std::string>::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return Result<std::string>::ok(images.at("small").get<std::string>());
}
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
return imageUrlFromCardObject(data.at(0));
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
}
}
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
Result<std::string, PreviewLookupError>
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_object()) {
return R::err({K::Transient, "Pokemon TCG card response missing 'data' object."});
}
return imageUrlFromCardObject(j.at("data"));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::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 num = normalizeCollectorNumber(setNo);
if (!setId.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(setId, num));
if (byId) {
auto img = parseCardByIdResponse(byId.value());
if (img) return img;
// NotFound (no images) or Transient (schema): fall through to search.
}
// HTTP failure (404/5xx/offline): fall through to search.
}
const std::string url = buildSearchUrl(name, setId, setNo);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(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>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("Pokemon TCG returned no matching cards.");
}
const std::string wantedSetId = trim(std::string(setId));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushCard = [&collected](const nlohmann::json& card) {
AutoDetectedPrint out;
out.setNo = trim(card.value("number", ""));
out.rarity = trim(card.value("rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!wantedSetId.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
}
if (cardSetId != wantedSetId) continue;
}
pushCard(card);
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::err("Pokemon TCG returned no matching cards.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what());
}
}
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 url = buildDetectSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
}
const std::string fallbackUrl = buildDetectSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm
+150
View File
@@ -1,12 +1,35 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace ccm {
namespace {
void finalizeCatalog(PokemonSetCatalog& catalog) {
for (auto& pack : catalog.packs) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
}
} // namespace
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
@@ -36,10 +59,137 @@ Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body
}
}
std::string PokemonSetSource::buildCardsPageUrl(int page, int pageSize) {
return std::string(kCardsEndpoint) + "?select=name,number,set&pageSize=" +
std::to_string(pageSize) + "&page=" + std::to_string(page);
}
Result<PokemonSetSource::CardsPageMeta>
PokemonSetSource::mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<CardsPageMeta>::err(
"Pokemon TCG cards response missing 'data' array.");
}
std::unordered_map<std::string, std::string> idToName;
idToName.reserve(sets.size());
for (const auto& set : sets) {
if (!set.id.empty()) idToName.emplace(set.id, set.name);
}
// Index existing packs for multi-page merges.
std::unordered_map<std::string, std::size_t> packIndex;
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
packIndex.emplace(catalog.packs[i].setId, i);
}
std::vector<std::unordered_set<std::string>> seenByPack(catalog.packs.size());
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
for (const auto& card : catalog.packs[i].cards) {
seenByPack[i].insert(card.setNo);
}
}
for (const auto& entry : j.at("data")) {
const std::string name = entry.value("name", "");
const std::string number =
PokemonCardPreviewSource::normalizeCollectorNumber(entry.value("number", ""));
if (name.empty() || number.empty()) continue;
std::string setId;
std::string setName;
if (entry.contains("set") && entry.at("set").is_object()) {
setId = entry.at("set").value("id", "");
setName = entry.at("set").value("name", "");
}
if (setId.empty()) continue;
if (const auto it = idToName.find(setId); it != idToName.end() && !it->second.empty()) {
setName = it->second;
}
if (setName.empty()) setName = setId;
auto pit = packIndex.find(setId);
if (pit == packIndex.end()) {
PokemonSetCatalogPack pack;
pack.setId = setId;
pack.setName = setName;
pack.cards.push_back(PokemonCatalogCard{number, name});
packIndex.emplace(setId, catalog.packs.size());
seenByPack.emplace_back(std::unordered_set<std::string>{number});
catalog.packs.push_back(std::move(pack));
continue;
}
const std::size_t idx = pit->second;
if (!seenByPack[idx].insert(number).second) continue;
if (catalog.packs[idx].setName.empty() && !setName.empty()) {
catalog.packs[idx].setName = setName;
}
catalog.packs[idx].cards.push_back(PokemonCatalogCard{number, name});
}
CardsPageMeta meta;
meta.page = j.value("page", 1);
meta.pageSize = j.value("pageSize", kCardsPageSize);
meta.count = j.value("count", static_cast<int>(j.at("data").size()));
meta.totalCount = j.value("totalCount", meta.count);
return Result<CardsPageMeta>::ok(meta);
} catch (const std::exception& e) {
return Result<CardsPageMeta>::err(
std::string("Pokemon TCG cards JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalog> PokemonSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
PokemonSetCatalog catalog;
auto meta = mergeCardsPage(body, catalog, sets);
if (!meta) return Result<PokemonSetCatalog>::err(meta.error());
finalizeCatalog(catalog);
return Result<PokemonSetCatalog>::ok(std::move(catalog));
}
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());
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::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());
PokemonSetCatalog catalog;
int page = 1;
int totalCount = 0;
int fetched = 0;
for (;;) {
auto cardsResp = http_.get(buildCardsPageUrl(page));
if (!cardsResp) return Result<FetchWithCatalog>::err(cardsResp.error());
auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value());
if (!meta) return Result<FetchWithCatalog>::err(meta.error());
fetched += meta.value().count;
totalCount = meta.value().totalCount;
if (meta.value().count <= 0 || fetched >= totalCount) break;
++page;
// Safety: avoid unbounded loops if the API lies about totals.
if (page > 10000) {
return Result<FetchWithCatalog>::err(
"Pokemon TCG cards pagination exceeded safety limit.");
}
}
finalizeCatalog(catalog);
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -0,0 +1,442 @@
#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;
}
} // 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,351 @@
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.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 0xE30xE9).
// 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) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
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
@@ -0,0 +1,558 @@
#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 <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#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;
}
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
// the closest fallback we have when Yugipedia has no scan for this printing.
std::string imageFromCard(const nlohmann::json& card) {
if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) {
return {};
}
const auto& first = card.at("card_images").at(0);
if (first.contains("image_url") && first.at("image_url").is_string()) {
return first.at("image_url").get<std::string>();
}
if (first.contains("image_url_small") && first.at("image_url_small").is_string()) {
return first.at("image_url_small").get<std::string>();
}
if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) {
return first.at("image_url_cropped").get<std::string>();
}
return {};
}
// Split a setNo encoded by the UI as `<setNo>||<rarity>||<edition>` into its
// three positional fields. Any missing trailing field becomes an empty
// string, so older callers that pass just `<setNo>` keep working.
struct ParsedSetNo {
std::string setNo;
std::string rarity;
std::string edition; // "1E" / "UE" / "" (unknown)
};
ParsedSetNo parseSetNoTuple(std::string_view raw) {
std::string s(raw);
ParsedSetNo p;
const auto a = s.find("||");
if (a == std::string::npos) {
p.setNo = trim(std::move(s));
return p;
}
p.setNo = trim(s.substr(0, a));
std::string rest = s.substr(a + 2);
const auto b = rest.find("||");
if (b == std::string::npos) {
p.rarity = trim(std::move(rest));
return p;
}
p.rarity = trim(rest.substr(0, b));
p.edition = trim(rest.substr(b + 2));
return p;
}
} // namespace
YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {}
// ============================================================================
// Yugipedia (image-preview path)
// ============================================================================
std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
// Yugipedia's image policy strips whitespace and a fixed set of
// punctuation from the displayed card name to produce the file slug.
// Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy
std::string out;
out.reserve(name.size());
for (unsigned char c : name) {
if (c <= 0x20) continue; // whitespace, including non-breaking
switch (c) {
case '#': case ',': case '.': case ':': case '\'': case '"':
case '?': case '!': case '&': case '@': case '%': case '=':
case '[': case ']': case '<': case '>': case '/': case '\\':
case '-': case '*': case ';': case '`':
continue;
default:
break;
}
out.push_back(static_cast<char>(c));
}
return out;
}
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
// are the ones Yugipedia uses in image filenames.
std::string lc;
lc.reserve(rarityName.size());
for (unsigned char c : rarityName) {
if (std::isspace(c)) continue;
lc.push_back(static_cast<char>(std::tolower(c)));
}
static const std::array<std::pair<std::string_view, std::string_view>, 32> kTable = {{
{"common", "C"},
{"shortprint", "SP"},
{"supershortprint", "SSP"},
{"normalrare", "NR"},
{"rare", "R"},
{"superrare", "SR"},
{"ultrarare", "UR"},
{"ultimaterare", "UtR"},
{"secretrare", "ScR"},
{"prismaticsecretrare", "PScR"},
{"extrasecretrare", "EScR"},
{"ultrasecretrare", "UScR"},
{"platinumsecretrare", "PtScR"},
{"goldsecretrare", "GScR"},
{"ghostrare", "GR"},
{"goldrare", "GUR"},
{"premiumgoldrare", "PGR"},
{"goldenrare", "GUR"},
{"starfoilrare", "SFR"},
{"shatterfoilrare", "SHR"},
{"mosaicrare", "MSR"},
{"parallelrare", "PR"},
{"superparallelrare", "SPR"},
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "CR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
{"prismaticredsecretrare", "PRScR"},
{"silverletter", "SLR"},
}};
for (const auto& [k, v] : kTable) {
if (lc == k) return std::string(v);
}
return {};
}
std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) {
std::string s(setNo);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
const auto dash = s.find('-');
if (dash == std::string::npos) return s;
return s.substr(0, dash);
}
std::vector<std::string> YuGiOhCardPreviewSource::buildCandidateFilenames(
std::string_view name,
std::string_view setCode,
std::string_view rarityCode,
bool firstEdition) {
std::vector<std::string> out;
const std::string slug = normalizeName(name);
if (slug.empty() || setCode.empty()) return out;
// English-only region candidates, in rough usage order: EN is the
// current default, NA was used on most LOB-era prints, EU/AU show up
// sporadically. Always English regardless of the card's stored Language.
static constexpr std::array<std::string_view, 4> kRegions =
{"EN", "NA", "EU", "AU"};
// Edition candidates: prefer the printed edition the user has, then
// try the opposite, then fall back to LE for promo-type prints.
std::array<std::string_view, 3> editions = {"", "", "LE"};
if (firstEdition) {
editions[0] = "1E";
editions[1] = "UE";
} else {
editions[0] = "UE";
editions[1] = "1E";
}
// Two extension variants: Yugipedia has a mix of .png (modern) and .jpg
// (older uploads) for the same era. Both are common for LOB-era cards.
static constexpr std::array<std::string_view, 2> kExts = {"png", "jpg"};
auto pushCombos = [&](std::string_view rarity) {
for (auto edition : editions) {
for (auto region : kRegions) {
for (auto ext : kExts) {
std::string fn;
fn.reserve(slug.size() + setCode.size() + 16);
fn += slug;
fn += '-'; fn.append(setCode);
fn += '-'; fn.append(region);
if (!rarity.empty()) {
fn += '-'; fn.append(rarity);
}
fn += '-'; fn.append(edition);
fn += '.'; fn.append(ext);
out.push_back(std::move(fn));
}
}
}
};
// Primary attempts include the rarity slot. If we don't know the rarity
// we skip straight to the rarity-less fallback (some sets are uniform
// rarity and the upload omits the slot).
if (!rarityCode.empty()) {
pushCombos(rarityCode);
}
pushCombos("");
return out;
}
std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
const std::vector<std::string>& filenames) {
// MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded).
// One HTTP call returns imageinfo for every page whose file exists; the
// missing ones come back tagged with `"missing": ""`.
std::string joined;
for (size_t i = 0; i < filenames.size(); ++i) {
if (i > 0) joined += "|";
joined += "File:";
joined += filenames[i];
}
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += rfc3986PercentEncode(joined);
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaResponse(
const std::string& body,
const std::vector<std::string>& filenameOrder) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query' object."});
}
const auto& pages = j.at("query").value("pages", nlohmann::json::object());
if (!pages.is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query.pages'."});
}
// Build a name->URL map. MediaWiki returns the title with namespace
// ("File:...") and may have replaced spaces with underscores; our
// candidate filenames never contain spaces, so a direct compare on
// the bit after "File:" is sufficient.
std::unordered_map<std::string, std::string> resolved;
resolved.reserve(filenameOrder.size());
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (!page.contains("imageinfo")) continue;
const auto& info = page.at("imageinfo");
if (!info.is_array() || info.empty()) continue;
const auto& info0 = info.at(0);
if (!info0.contains("url") || !info0.at("url").is_string()) continue;
std::string title = page.value("title", "");
constexpr std::string_view kPrefix = "File:";
if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size());
resolved[title] = info0.at("url").get<std::string>();
}
// Walk our ordered candidate list and return the first hit. This is
// how priority works: 1E English first, then UE, then jpg, etc.
for (const auto& fn : filenameOrder) {
auto it = resolved.find(fn);
if (it != resolved.end() && !it->second.empty()) {
return R::ok(it->second);
}
}
// Every candidate was tagged "missing" => Yugipedia confirmed there
// is no English scan for this printing. Treat as NotFound; the
// YGOPRODeck fallback may still surface a generic art.
return R::err({K::NotFound, "No matching Yugipedia scan found."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia JSON parse error: ") + e.what()});
}
}
// ============================================================================
// YGOPRODeck (auto-detect path + last-resort fallback)
// ============================================================================
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=") + rfc3986PercentEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += rfc3986PercentEncode(setName);
}
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseFallbackImageUrl(
const std::string& body, std::string_view name) {
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, "YGOPRODeck response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "YGOPRODeck returned no matching cards."});
}
const std::string wantedNameLower = toLower(trim(std::string(name)));
// Prefer the exact-name match: the fuzzy `fname=` search can mix in
// sibling cards (Dark Magician + Dark Magician Girl), and we don't
// want to land on a sibling's standard art.
for (const auto& card : data) {
const std::string cardName = trim(card.value("name", ""));
if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) {
const std::string image = imageFromCard(card);
if (!image.empty()) return R::ok(image);
}
}
// Failing that, take whatever YGOPRODeck ranked first.
const std::string image = imageFromCard(data.at(0));
if (!image.empty()) {
return R::ok(image);
}
return R::err({K::NotFound, "Card has no image variants."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("YGOPRODeck JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view preferredSetName,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
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 = canonicalizeSetNameForAutoDetect(preferredSetName);
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushPrint = [&collected](const nlohmann::json& print) {
AutoDetectedPrint out;
out.setNo = trim(print.value("set_code", ""));
out.rarity = trim(print.value("set_rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& print : card.at("card_sets")) {
const std::string setName = trim(print.value("set_name", ""));
if (!wantedSet.empty() && setName != wantedSet) continue;
pushPrint(print);
}
}
// Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take
// every print from `data[0]` without filtering by set_name.
//
// When the caller supplied an exact card name (edit-dialog variant
// listing), combining unrelated `card_sets[]` rows after a non-empty
// display-set filter missed would falsely imply multiple printings
// "in one set" (different real-world products share the same card).
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSet.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
const auto& firstCard = j.at("data").at(0);
if (!wantedNameLower.empty()) {
const std::string cardName = trim(firstCard.value("name", ""));
if (toLower(cardName) != wantedNameLower) {
return R::err("Could not auto-detect set print metadata.");
}
}
if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) {
for (const auto& print : firstCard.at("card_sets")) {
pushPrint(print);
}
}
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::parseFirstPrint(
const std::string& body, std::string_view preferredSetName) {
auto list = parsePrintVariants(body, preferredSetName, "");
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());
}
// ============================================================================
// Public ICardPreviewSource API
// ============================================================================
Result<std::string, PreviewLookupError>
YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view /*setId*/,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const ParsedSetNo p = parseSetNoTuple(setNo);
const std::string setCode = extractSetCode(p.setNo);
const std::string rarityCode = rarityCodeFor(p.rarity);
const bool firstEdition = (p.edition == "1E");
// The overall classification needs the worst outcome across the two
// upstreams: NotFound only when *both* answered cleanly with no match,
// Transient as soon as either one couldn't speak. We track Yugipedia's
// outcome here and combine it with YGOPRODeck's below.
bool yugipediaSawTransient = false;
PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."};
// Step 1: Yugipedia per-printing scan. Build a batch of plausible English
// filenames and ask MediaWiki for them all in one call. This is the only
// source we know of that distinguishes art between same-passcode reprints
// (LOB Blue-Eyes vs SDK Blue-Eyes, etc.).
//
// No usable set code (or empty candidate list) is treated as an
// "inapplicable" Yugipedia step rather than a failure - we don't want a
// legitimate metadata gap to taint the final classification as transient.
if (!setCode.empty()) {
const auto candidates = buildCandidateFilenames(
name, setCode, rarityCode, firstEdition);
if (!candidates.empty()) {
const std::string url = buildYugipediaQueryUrl(candidates);
auto resp = http_.get(url);
if (!resp) {
yugipediaSawTransient = true;
yugipediaErr = {K::Transient, resp.error()};
} else {
auto parsed = parseYugipediaResponse(resp.value(), candidates);
if (parsed) return parsed;
yugipediaErr = std::move(parsed).error();
if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true;
}
}
}
// Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has
// no scan we can match (newly-added cards, OCG-only cards without an
// English release, transient Yugipedia errors). Always unfiltered, so
// card_images[0] is the original artwork rather than an alt-art reprint.
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
// YGOPRODeck failed at the network layer => the overall lookup is
// transient regardless of what Yugipedia did. Surface YGOPRODeck's
// error string because it's the most recent failure.
return R::err({K::Transient, fallback.error()});
}
auto parsed = parseFallbackImageUrl(fallback.value(), name);
if (parsed) return parsed;
// Both upstreams answered. If *either* one was transient, the overall
// outcome is transient (we can't conclude the record has no image).
PreviewLookupError fallbackErr = std::move(parsed).error();
if (yugipediaSawTransient || fallbackErr.kind == K::Transient) {
return R::err({K::Transient,
yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message});
}
// Otherwise both confirmed "no image" => safe to remember.
return R::err({K::NotFound, fallbackErr.message});
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::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>> YuGiOhCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
const std::string url = buildSearchUrl(name, canonicalSetName);
auto resp = http_.get(url);
if (resp) {
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(), canonicalSetName, name);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
namespace ccm {
YuGiOhGameModule::YuGiOhGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
+234
View File
@@ -0,0 +1,234 @@
#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) {}
Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"YGOPRODeck response is not an array.");
}
std::vector<Set> out;
out.reserve(j.size());
for (const auto& entry : j) {
Set s;
s.id = entry.value("set_code", "");
s.name = entry.value("set_name", "");
std::string release = entry.value("tcg_date", "");
for (char& ch : release) {
if (ch == '-') ch = '/';
}
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));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("YGOPRODeck set parse error: ") + e.what());
}
}
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());
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
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;
}
}
}
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
+60 -15
View File
@@ -1,30 +1,75 @@
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/util/HttpGetMapping.hpp"
#include <cpr/cpr.h>
#include <string>
#include <utility>
namespace ccm {
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(std::make_unique<cpr::Session>()) {
// Configure session-wide options once; every Get() then only updates
// the URL. libcurl's connection cache lives inside the easy handle, so
// reusing one Session across calls is what gets us TLS keep-alive.
session_->SetTimeout(cpr::Timeout{timeout_});
// `Accept: application/json` breaks some CDNs that refuse non-JSON bodies
// (preview pipeline also GETs raw JPG/PNG). Wildcard keeps JSON APIs happy.
session_->SetHeader(cpr::Header{
{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "*/*"},
});
session_->SetRedirect(cpr::Redirect{/*max_redirects=*/10L,
/*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) {
cpr::Response r = cpr::Get(
cpr::Url{std::string(url)},
cpr::Timeout{timeout_},
// Identify ourselves; some APIs rate-limit unknown agents harshly.
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "application/json"}}
);
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
// libcurl easy handles (and therefore cpr::Session) are not thread-safe.
// We serialize callers here; the preview path is single-flight already
// (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_);
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
+43 -8
View File
@@ -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());
+228
View File
@@ -0,0 +1,228 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <sstream>
#include <system_error>
#include <utility>
#include <vector>
namespace ccm {
namespace fs = std::filesystem;
namespace {
// FNV-1a 64-bit hash, hex-encoded. We don't need cryptographic strength
// here: the `.idx` sidecar file holds the original key and load() rejects
// any mismatch, so a hash collision degrades to a cache miss instead of a
// wrong-image return. FNV-1a was picked to keep this dependency-free
// (no openssl, no extra link).
std::string fnv1a64Hex(std::string_view in) {
constexpr std::uint64_t kOffsetBasis = 0xcbf29ce484222325ULL;
constexpr std::uint64_t kPrime = 0x100000001b3ULL;
std::uint64_t h = kOffsetBasis;
for (unsigned char c : in) {
h ^= c;
h *= kPrime;
}
std::ostringstream oss;
oss << std::hex << std::setw(16) << std::setfill('0') << h;
return oss.str();
}
// Best-effort mtime; returns the epoch on any error so callers can still
// sort consistently (oldest-first eviction stays well-defined).
fs::file_time_type mtimeOrEpoch(const fs::path& p) {
std::error_code ec;
auto t = fs::last_write_time(p, ec);
if (ec) return fs::file_time_type{};
return t;
}
std::uintmax_t fileSizeOrZero(const fs::path& p) {
std::error_code ec;
auto sz = fs::file_size(p, ec);
return ec ? 0u : sz;
}
void touchMtime(const fs::path& p) {
std::error_code ec;
fs::last_write_time(p, fs::file_time_type::clock::now(), ec);
// Ignored: touch is a best-effort hint to the LRU policy.
}
} // namespace
LocalPreviewByteCache::LocalPreviewByteCache(IFileSystem& fs,
fs::path cacheDir,
std::size_t maxBytes)
: fs_(fs), cacheDir_(std::move(cacheDir)), maxBytes_(maxBytes) {}
std::string LocalPreviewByteCache::hashKey(std::string_view key) {
return fnv1a64Hex(key);
}
fs::path LocalPreviewByteCache::payloadPath(const std::string& hash) const {
return cacheDir_ / (hash + ".bin");
}
fs::path LocalPreviewByteCache::negativePath(const std::string& hash) const {
return cacheDir_ / (hash + ".neg");
}
fs::path LocalPreviewByteCache::indexPath(const std::string& hash) const {
return cacheDir_ / (hash + ".idx");
}
IPreviewByteCache::LoadResult LocalPreviewByteCache::load(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
const bool hasBin = fs_.exists(bin);
const bool hasNeg = fs_.exists(neg);
if (!hasBin && !hasNeg) return {HitKind::Miss, {}};
// Sidecar must exist and match exactly. Anything else - missing,
// mismatched, empty - is treated as a miss so the next store() /
// storeNegative() will overwrite cleanly. This is what guarantees that
// a hash collision can never serve another card's bytes or stale
// "no image" verdict.
if (!fs_.exists(idx)) return {HitKind::Miss, {}};
auto idxRead = fs_.readText(idx);
if (!idxRead) return {HitKind::Miss, {}};
if (idxRead.value() != key) return {HitKind::Miss, {}};
if (hasBin) {
auto payload = fs_.readText(bin);
if (!payload) return {HitKind::Miss, {}};
// Touch mtime so this hit moves to the front of the LRU.
touchMtime(bin);
return {HitKind::Hit, std::move(payload).value()};
}
// Negative-only entry. Touch its mtime as well so frequently-checked
// negatives don't get aged out by an arbitrary directory sweep.
touchMtime(neg);
return {HitKind::NegativeHit, {}};
}
void LocalPreviewByteCache::store(std::string_view key, const std::string& payload) {
if (payload.empty()) return;
std::lock_guard<std::mutex> lock(mutex_);
auto ensure = fs_.ensureDirectory(cacheDir_);
if (!ensure) return;
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
// If a negative entry exists for this exact key, drop it before writing
// the positive payload so the two are never co-resident on disk.
if (fs_.exists(neg)) (void)fs_.remove(neg);
// Eviction runs against the *new* payload size, not the post-write
// total, so we make room before writing. If the same key is being
// overwritten the existing payload's bytes are released first.
evictIfNeededLocked(payload.size());
auto wrote = fs_.writeText(bin, payload);
if (!wrote) return;
auto wroteIdx = fs_.writeText(idx, std::string(key));
if (!wroteIdx) {
// Sidecar failure leaves us with bytes we can't safely serve later.
// Roll back the payload write so a future load() doesn't see it.
(void)fs_.remove(bin);
return;
}
}
void LocalPreviewByteCache::storeNegative(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
auto ensure = fs_.ensureDirectory(cacheDir_);
if (!ensure) return;
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
// Replace any existing positive entry: storeNegative is the upstream
// saying "the previous bytes are no longer the correct answer for this
// record". Free the bytes from the size cap immediately.
if (fs_.exists(bin)) (void)fs_.remove(bin);
// Order matters: write the marker first, then the sidecar. If the
// sidecar write fails we delete the marker to avoid a half-written
// entry that load() would treat as a miss anyway but that contributes
// a stray file to the directory listing.
auto wroteNeg = fs_.writeText(neg, std::string{});
if (!wroteNeg) return;
auto wroteIdx = fs_.writeText(idx, std::string(key));
if (!wroteIdx) {
(void)fs_.remove(neg);
}
}
std::size_t LocalPreviewByteCache::currentSizeBytes() {
std::lock_guard<std::mutex> lock(mutex_);
auto entries = fs_.listDirectory(cacheDir_);
if (!entries) return 0;
std::size_t total = 0;
for (const auto& p : entries.value()) {
if (p.extension() == ".bin") total += static_cast<std::size_t>(fileSizeOrZero(p));
}
return total;
}
void LocalPreviewByteCache::evictIfNeededLocked(std::size_t incomingBytes) {
auto entries = fs_.listDirectory(cacheDir_);
if (!entries) return;
struct Entry {
fs::path bin;
fs::path idx;
std::uintmax_t size;
fs::file_time_type mtime;
};
std::vector<Entry> bins;
bins.reserve(entries.value().size());
std::size_t total = 0;
for (const auto& p : entries.value()) {
if (p.extension() != ".bin") continue;
Entry e;
e.bin = p;
e.idx = p;
e.idx.replace_extension(".idx");
e.size = fileSizeOrZero(p);
e.mtime = mtimeOrEpoch(p);
total += static_cast<std::size_t>(e.size);
bins.push_back(std::move(e));
}
if (total + incomingBytes <= maxBytes_) return;
std::sort(bins.begin(), bins.end(),
[](const Entry& a, const Entry& b) { return a.mtime < b.mtime; });
for (const auto& e : bins) {
if (total + incomingBytes <= maxBytes_) break;
// remove() is best-effort; if it fails we still drop our accounting
// for the entry so we don't loop forever on a stuck file.
(void)fs_.remove(e.bin);
(void)fs_.remove(e.idx);
total -= std::min<std::size_t>(static_cast<std::size_t>(e.size), total);
}
}
} // namespace ccm
+51 -15
View File
@@ -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;
}
@@ -53,6 +40,55 @@ bool matchesPokemonFilter(const PokemonCard& 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(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;
if (containsLower(to_string(card.region), needle)) return true;
return false;
}
bool matchesYuGiOhFilter(const YuGiOhCard& 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(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 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;
+267 -8
View File
@@ -1,8 +1,54 @@
#include "ccm/services/CardPreviewService.hpp"
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
namespace ccm {
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
namespace {
// Compose a stable cache key from the four lookup coordinates. Using NUL as
// a separator keeps the key unambiguous even if a card's name happens to
// contain `|` or other punctuation.
std::string makePreviewKey(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo) {
std::string k;
k.reserve(2 + name.size() + setId.size() + setNo.size() + 3);
k.push_back('p');
k.push_back(static_cast<char>(static_cast<int>(game)));
k.push_back('\0');
k.append(name);
k.push_back('\0');
k.append(setId);
k.push_back('\0');
k.append(setNo);
return k;
}
std::string makeUrlKey(std::string_view url) {
std::string k;
k.reserve(url.size() + 1);
k.push_back('u');
k.append(url);
return k;
}
constexpr std::string_view kAssetScheme = "asset:";
} // namespace
CardPreviewService::CardPreviewService(IHttpClient& http,
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) {
@@ -10,6 +56,111 @@ void CardPreviewService::registerModule(IGameModule& module) {
}
}
CardPreviewService::CacheLookupKind CardPreviewService::cacheLookup(
const std::string& key, std::string& outPayload) {
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it == cacheIndex_.end()) {
outPayload.clear();
return CacheLookupKind::Miss;
}
// Move-to-front to mark as most-recently-used.
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
if (it->second->negative) {
outPayload.clear();
return CacheLookupKind::NegativeHit;
}
outPayload = it->second->payload;
return CacheLookupKind::Hit;
}
void CardPreviewService::cacheStore(const std::string& key, std::string payload) {
if (payload.empty()) return;
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it != cacheIndex_.end()) {
// Overwrite existing entry (positive or negative) and bump it to
// the front. Replacing a negative entry is the "we got a real
// image after a previous NotFound" path - rare but valid.
it->second->payload = std::move(payload);
it->second->negative = false;
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
return;
}
cacheList_.push_front({key, std::move(payload), /*negative=*/false});
cacheIndex_.emplace(key, cacheList_.begin());
while (cacheList_.size() > kCacheCapacity) {
cacheIndex_.erase(cacheList_.back().key);
cacheList_.pop_back();
}
}
void CardPreviewService::cacheStoreNegative(const std::string& key) {
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it != cacheIndex_.end()) {
it->second->payload.clear();
it->second->negative = true;
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
return;
}
cacheList_.push_front({key, std::string{}, /*negative=*/true});
cacheIndex_.emplace(key, cacheList_.begin());
while (cacheList_.size() > kCacheCapacity) {
cacheIndex_.erase(cacheList_.back().key);
cacheList_.pop_back();
}
}
Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKey,
std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
std::string payload = std::move(bytes).value();
if (payload.empty()) {
return Result<std::string>::err("Empty response body from " + std::string(url));
}
cacheStore(cacheKey, payload);
// Best-effort persist to disk so the next app launch starts warm.
// The persistent tier is fire-and-forget: any I/O error is swallowed
// by the adapter, the in-memory tier still holds the bytes.
if (persistentCache_ != nullptr) {
persistentCache_->store(cacheKey, payload);
}
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,
@@ -18,17 +169,125 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
if (it == sources_.end() || it->second == nullptr) {
return Result<std::string>::err("No preview source registered for this game.");
}
// Cache check before any HTTP call. The (game, name, setId, setNo) tuple
// uniquely identifies a printing for our purposes - the resolved image
// URL is always a deterministic function of those four inputs, and any
// edit to a lookup-relevant field changes the key automatically.
const std::string key = makePreviewKey(game, name, setId, setNo);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::ok(std::move(cached));
case CacheLookupKind::NegativeHit:
return Result<std::string>::err("No preview available for this card.");
case CacheLookupKind::Miss:
break;
}
// Disk-backed second tier: previews persisted by an earlier app run
// get promoted into the in-memory LRU on first access this session, so
// subsequent re-selections stay fast without re-touching the network.
// Negative entries on disk are likewise promoted - the user already
// knows from a previous session that this record has no upstream image.
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
switch (disk.kind) {
case IPreviewByteCache::HitKind::Hit:
cacheStore(key, disk.payload);
return Result<std::string>::ok(disk.payload);
case IPreviewByteCache::HitKind::NegativeHit:
cacheStoreNegative(key);
return Result<std::string>::err("No preview available for this card.");
case IPreviewByteCache::HitKind::Miss:
break;
}
}
auto url = it->second->fetchImageUrl(name, setId, setNo);
if (!url) return Result<std::string>::err(url.error());
auto bytes = http_.get(url.value());
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
if (!url) {
// The two error kinds split here:
// - NotFound: upstream answered cleanly that this record has no
// image. Persist the verdict so we don't keep retrying.
// - Transient: network/HTTP/parse failure. Surface the error
// unchanged and DO NOT cache anything; the next selection
// retries from scratch.
const auto err = std::move(url).error();
if (err.kind == PreviewLookupError::Kind::NotFound) {
cacheStoreNegative(key);
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
}
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());
}
Result<AutoDetectedPrint> CardPreviewService::detectFirstPrint(Game game,
std::string_view name,
std::string_view setId) {
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->detectFirstPrint(name, setId);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
Game game,
std::string_view name,
std::string_view setId) {
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->detectPrintVariants(name, setId);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
// 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
// there is no negative-cache analogue to worry about; we just look up
// and, if needed, fetch+store.
const std::string key = makeUrlKey(url);
std::string cached;
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) {
cacheStore(key, disk.payload);
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);
}
} // namespace ccm
+208 -15
View File
@@ -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>
@@ -170,4 +157,210 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
}
}
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
bool ascending) {
switch (column) {
case YuGiOhSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case YuGiOhSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
}, ascending));
break;
case YuGiOhSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
}, ascending));
break;
case YuGiOhSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
}, ascending));
break;
case YuGiOhSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
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) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case YuGiOhSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case YuGiOhSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case YuGiOhSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
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 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
@@ -0,0 +1,54 @@
#include "ccm/services/PokemonSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const {
const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json"
: "set-catalog-west.json";
return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file;
}
bool PokemonSetCatalogService::exists(PokemonRegion region) const {
return fs_.exists(catalogPath(region));
}
Result<PokemonSetCatalog> PokemonSetCatalogService::load(PokemonRegion region) const {
const auto p = catalogPath(region);
if (!fs_.exists(p)) {
return Result<PokemonSetCatalog>::err(
region == PokemonRegion::Asia
? "Asia Pokemon set catalog not yet downloaded."
: "West Pokemon set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<PokemonSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<PokemonSetCatalog>::ok(j.get<PokemonSetCatalog>());
} catch (const std::exception& e) {
return Result<PokemonSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> PokemonSetCatalogService::save(PokemonRegion region,
const PokemonSetCatalog& catalog) {
const auto p = catalogPath(region);
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
+190
View File
@@ -0,0 +1,190 @@
#include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.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 PokemonCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
bool passesRegionFilter(const PokemonCard& card, std::optional<PokemonRegion> regionFilter) {
return !regionFilter.has_value() || card.region == *regionFilter;
}
std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
if (region == PokemonRegion::Asia) {
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
}
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
std::vector<PokemonSetCompletionProgress>
computeForCatalog(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& catalog,
PokemonRegion region,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter);
std::vector<PokemonSetCompletionProgress> 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 = normalizeForRegion(region, card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
PokemonSetCompletionProgress row;
row.region = region;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
return out;
}
} // namespace
std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
if (!passesRegionFilter(card, regionFilter)) continue;
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<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog) {
std::vector<PokemonRegion> out;
const auto westRows =
computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt);
if (!westRows.empty()) out.push_back(PokemonRegion::West);
const auto asiaRows =
computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt);
if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia);
return out;
}
std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter,
std::optional<Language> languageFilter) {
std::vector<PokemonSetCompletionProgress> out;
const bool includeWest =
!regionFilter.has_value() || *regionFilter == PokemonRegion::West;
const bool includeAsia =
!regionFilter.has_value() || *regionFilter == PokemonRegion::Asia;
if (includeWest) {
auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West,
languageFilter);
out.insert(out.end(), std::make_move_iterator(west.begin()),
std::make_move_iterator(west.end()));
}
if (includeAsia) {
auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia,
languageFilter);
out.insert(out.end(), std::make_move_iterator(asia.begin()),
std::make_move_iterator(asia.end()));
}
std::sort(out.begin(), out.end(),
[](const PokemonSetCompletionProgress& a,
const PokemonSetCompletionProgress& b) {
if (a.setName != b.setName) return a.setName < b.setName;
return static_cast<int>(a.region) < static_cast<int>(b.region);
});
return out;
}
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) {
const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<PokemonChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
PokemonChecklistEntry entry;
entry.setNo = normalizeForRegion(region, 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 PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+11 -1
View File
@@ -20,8 +20,18 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
return fetched;
}
Result<void> SetService::saveSets(Game game, const std::vector<Set>& sets) {
return repo_.save(game, sets);
}
Result<std::vector<Set>> SetService::getSets(Game game) {
return repo_.load(game);
auto loaded = repo_.load(game);
if (!loaded) return loaded;
auto it = modules_.find(game);
if (it != modules_.end() && it->second != nullptr) {
it->second->setSource().augmentCachedSets(loaded.value());
}
return loaded;
}
} // namespace ccm
@@ -0,0 +1,49 @@
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhSetCatalogService::YuGiOhSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOh) /
"set-catalog.json";
}
bool YuGiOhSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhSetCatalog> YuGiOhSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhSetCatalog>::err("Yu-Gi-Oh! set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhSetCatalog>::ok(j.get<YuGiOhSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhSetCatalogService::save(const YuGiOhSetCatalog& 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
+132
View File
@@ -0,0 +1,132 @@
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include "ccm/util/YuGiOhPrintingSlot.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>>;
[[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;
}
bool passesLanguageFilter(const YuGiOhCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSlotsBySetId(const std::vector<YuGiOhCard>& 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 key = ygoSlotKey(card.setNo);
if (key.empty()) continue;
out[card.set.id].insert(key);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& 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<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSlotsBySetId(collection, languageFilter);
std::vector<YuGiOhSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedSlots] : 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 key = ygoSlotKey(card.setNo);
if (!key.empty() && ownedSlots.count(key) != 0) ++matched;
}
YuGiOhSetCompletionProgress 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 YuGiOhSetCompletionProgress& a,
const YuGiOhSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedSlots;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string key = ygoSlotKey(card.setNo);
if (!key.empty()) ownedSlots.insert(key);
}
std::vector<YuGiOhChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhChecklistEntry entry;
entry.setNo = card.setNo;
entry.name = card.name;
const std::string key = ygoSlotKey(card.setNo);
entry.owned = !key.empty() && ownedSlots.count(key) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhChecklistEntry& a, const YuGiOhChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
-1
View File
@@ -78,7 +78,6 @@ std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
for (std::size_t i = begin; i < end; ++i) {
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
}
if (value > 255) value = 255;
return static_cast<std::uint8_t>(value);
}
+4 -3
View File
@@ -11,12 +11,13 @@ Long-form contributor documentation that lives outside the source tree.
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic and Pokemon modules, plus the runtime flow through `SetService` / `CardPreviewService` and the error-surface conventions.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon (West + Asia backends), Yu-Gi-Oh!, and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching).
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
## Subdirectories
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
## Conventions
@@ -27,7 +28,7 @@ Long-form contributor documentation that lives outside the source tree.
## Required follow-ups
- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`.
- After changing the Magic or Pokemon set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples.
- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete.
- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`).
+5 -1
View File
@@ -22,5 +22,9 @@ This folder contains contributor documentation for Card Collection Manager 3. St
- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`.
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic/Pokemon modules and their runtime purpose.
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, Yu-Gi-Oh!, and Digimon Digi-Battle modules, preview fallback URLs / bundled card-back assets, and shared HTTP behavior (`CprHttpClient`).
## Performance & Caching
- [caching.md](caching.md): preview image caching (in-memory LRU, on-disk byte cache, HTTP connection reuse), lookup order, keys, eviction, and what is intentionally not cached.
+20 -3
View File
@@ -37,6 +37,9 @@ A few traps to plan around now, before you write code:
- **Lookup precision.** Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result.
- **URL encoding.** All query strings must be RFC 3986 percent-encoded before they reach `IHttpClient::get` (`cpr::Url` does **not** re-encode). The Magic/Pokemon implementations have a private `urlEncode` helper you can copy.
- **Collector-number normalization.** Pokemon stores `4/102` but the API only accepts `4`. Whichever convention your domain type uses, normalize it inside `buildSearchUrl` so the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug.
- **Name-matching strictness.** Some APIs reject strict exact-name parameters for real-world card spelling variants (e.g. hyphenation/punctuation differences). If your provider supports fuzzy-name search, prefer that for the first request, then disambiguate in `parseResponse` using set/print metadata.
- **400 fallback strategy.** If adding optional set filters can produce request validation errors (`HTTP 400`), add a second request path that retries without the risky filter and keeps disambiguation local in `parseResponse`.
- **Image variant priority.** If the provider returns both cropped art and full-card images, prefer the full-card URL for selected-card preview. Use cropped variants only as fallback.
### 1.3 Flag icons
@@ -138,6 +141,8 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
- `static Result<std::string> parseResponse(const std::string& body);`
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Keep both optional per game — default behavior should remain an explicit unsupported error.
Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
- Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness).
@@ -190,7 +195,7 @@ private:
Two subtle requirements:
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short.
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short. **Pokemon exception:** a unified Game menu entry may keep one `dirName` (`pokemon`) for collection/images and disambiguate region set caches by filename (`sets-west.json` / `sets-asia.json`) instead of a second data subdirectory.
- `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available".
The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`.
@@ -321,6 +326,12 @@ Derive from `BaseCardEditDialog<<Name>Card>`. Override:
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row):
- `customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo)` — the base wraps the combo in a host panel and calls this so a game can add adjacent controls (Yu-Gi-Oh! adds a **Set code** toggle, a text field, and **Auto detect** beside the combo). The default implementation only does `row.Add(combo, 1, wxEXPAND)`.
- `applySetSelectionByIndex(std::size_t index)` (non-virtual helper on the base) — selects a row in the combo and assigns `card_.set` from `availableSets()[index]` when the combo is enabled.
- `onSetSelectionApplied()` — called after `applySetSelectionByIndex` completes; default no-op. Yu-Gi-Oh! overrides it to clear cached print-variant metadata and reschedule the same follow-up as a manual `wxEVT_COMBOBOX` set change.
In the constructor:
1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add <Display> Card"` or `"Edit <Display> Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::<Name>`, and the optional `preloadedSets` pointer.
@@ -328,6 +339,10 @@ In the constructor:
The reference implementation is `ui_wx/src/PokemonCardEditDialog.cpp`.
When an extra field is from a controlled vocabulary (rarity tiers, print types, etc.), prefer a dropdown (`wxChoice`) over free text to keep list/filter values consistent and reduce user-input variants.
If the external API expects a full print code (e.g. `LOB-001`) but users mostly edit only the numeric suffix, expose a numeric input plus a read-only derived preview (for example `(LOB-001)`) and compose/decompose the stored full value in `readExtraFromCard()` / `writeExtraToCard()`.
### 5.6 `<Name>GameView`
This is the polymorphic glue between the new game's panels and the rest of the app. Create:
@@ -345,10 +360,10 @@ Implement the virtuals:
- `gameId()` returns `Game::<Name>`.
- `displayName()` returns `"<Display>"`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `selectedPanel(parent)` — lazily allocates the selected panel.
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
@@ -452,6 +467,8 @@ Run, in order, from the workspace root. Do not skip any step.
These do not match a single seam in this guide but are worth calling out explicitly.
- **Stale set caches.** Each `IGameView` caches `std::vector<Set> setsCache_`. After `onUpdateSets` succeeds, refresh the cache (assign the new vector). The reference implementations do this.
- **Set ordering drift.** Keep set lists sorted by release date not only in `<Name>SetSource::parseResponse`, but also at UI consumption points (preloaded/cached vectors passed to `BaseCardEditDialog`). Older on-disk cache data or future parser changes can otherwise surface unsorted set pickers.
- **Auto-detect feature scope.** Treat print auto-detect as a per-game capability. Do not assume every game supports it; gate UI affordances behind game-specific dialog logic and source opt-in.
- **`signed_` / `signed`.** The C++ field is `signed_`; the JSON key is `"signed"`. This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key.
- **Spacer column index.** `BaseCardListPanel` reserves index `0` for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index `1`. If you ever need to call into `wxListCtrl` directly from a derived panel (you should not), remember this.
- **Preview-fetch threading.** The async preview fetch in `BaseSelectedCardPanel` uses a `shared_ptr<State>` + `std::atomic alive` + `std::atomic currentGen` triple. Do not capture `this` raw in any background work you add to a new game's selected panel; copy that pattern verbatim.
+283 -9
View File
@@ -18,35 +18,309 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
## Pokemon APIs
Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects the backend below. Collection: `pokemon/collection.json`. West sets: `pokemon/sets-west.json`. Asia sets: `pokemon/sets-asia.json`. Language choices: West → English/German/French/Spanish/Italian/Russian; Asia → Japanese/S-Chinese/T-Chinese/Korean.
### West (`Game::Pokemon`, pokemontcg.io)
**Info API:** `https://api.pokemontcg.io/v2/sets`
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
**Asset API:** `https://api.pokemontcg.io/v2/cards` (by id) and `https://api.pokemontcg.io/v2/cards?q=...` (search)
Used by `PokemonCardPreviewSource` in two ways:
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/cards/{setId}-{number}` (single-card `data` object) — same idea as Asias direct localId fetch — so Lucene `name:``number:` misses cannot blank the preview after Auto-detect fills Set #. On HTTP failure or missing images, falls back to a name-less search `set.id:… number:…` (collector numbers are unique within a set). When Set # or set id is missing, keeps the older `name:"…"` search with optional `set.id` / `number`. The parser takes `images.large` first and falls back to `images.small`.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the search endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query and card-id path expect only the printed-number component (unquoted `number:4` / `number:TG14`; do not wrap alphanumeric numbers in Lucene quotes when combining with other clauses — that has been observed to 500 on the live API).
### Set-completion catalog (West)
**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes:
1. The set list (`pokemon/sets-west.json`) from `/v2/sets` (same as before)
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from a paginated `/v2/cards?select=name,number,set&pageSize=250` dump
Each catalog pack stores `id` (pokemontcg.io set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by the API `number` field (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, matching `card.set.id`, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts.
If `set-catalog-west.json` is missing (and the active region filter is West or All with no Asia catalog either), the Set Completion tab prompts the user to run Update Pokemon.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
Yu-Gi-Oh! splits its remote calls across two upstreams. **Yugipedia** is the primary preview source because it hosts actual per-printing card scans; **YGOPRODeck** continues to drive set listings and the auto-detect-first-print helper, plus a last-resort image fallback.
Upstream documentation:
- [Yugipedia MediaWiki API help](https://yugipedia.com/api.php?action=help) (standard MediaWiki action API; we only need `prop=imageinfo`).
- [Yu-Gi-Oh! API Guide — YGOPRODeck](https://ygoprodeck.com/api-guide/). CCM3 uses **v7** endpoints only.
### Info API: YGOPRODeck `cardsets.php`
`https://db.ygoprodeck.com/api/v7/cardsets.php`
Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object maps `set_code` → internal `Set.id`, `set_name``Set.name`, and `tcg_date``Set.releaseDate` with `-` rewritten to `/` for consistency with other games date strings. Results are sorted ascending by `releaseDate`.
CCM3 also applies a deterministic local patch step in `YuGiOhSetSource::appendMissingSetAliases(...)` after parsing: if upstream omits known 25th Anniversary TCG reprints, the app injects missing aliases for `LOB-25TH`, `MRD-25TH`, `SRL-25TH`, `PSV-25TH`, `DCR-25TH`, and `IOC-25TH` (with fixed release dates) so users can still select those products in the set picker.
**UI note (set code entry, no extra HTTP):** The Yu-Gi-Oh! Add/Edit dialog can resolve a typed **product code** against the **already cached** set vector (same data as the set dropdown). Matching is implemented in `core/include/ccm/util/YuGiOhSetLookup.hpp` as `lookupYuGiOhSetByShorthand(...)`: trim ASCII whitespace, ASCII case-fold, then require an **exact** match on `Set.id` (the YGOPRODeck `set_code`). Zero matches → user error; more than one row with the same normalized id → ambiguous error (defensive). On a unique hit the dialog returns to the dropdown and selects that set.
### Asset API: Yugipedia `api.php` (primary)
`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...`
Used by `YuGiOhCardPreviewSource::fetchImageUrl` for the actual per-printing card scan. Yugipedia is the only public source we have found that distinguishes art between same-passcode reprints (LOB Blue-Eyes vs SDK Blue-Eyes, for example), and uses a **deterministic file-name convention** of the shape `<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>[-Misc].<png|jpg>` per [Yugipedias image policy](https://yugipedia.com/wiki/Yugipedia:Image_policy).
The UI passes a positional tuple in `setNo` of the form `set_code||rarity||edition` (for example `SDK-001||Ultra Rare||UE`); the source splits on `||` before building filenames. Field meanings:
- `set_code` — full code as printed (`LOB-005`, `SDK-001`, `RA04-EN001`). Everything before the first `-` becomes the Yugipedia `<SET>` slot (`LOB`, `SDK`, `RA04`).
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare``UR`, `Quarter Century Secret Rare``QCScR`, …). The canonical short-form mapping lives in `ygoRarityShortCode(...)` (`core/include/ccm/util/YuGiOhPrintingSlot.hpp`) and is reused by both the Yu-Gi-Oh overview-table rarity rendering and preview filename construction (`rarityCodeFor(...)`). Unknown values fall back to the rarity-less filename pattern.
- `edition``1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited).
`buildCandidateFilenames(...)` then produces a priority-ordered list:
1. Printed edition first (`1E` then `UE`, or `UE` then `1E` for non-first), with `LE` last for promo-style prints.
2. English regions only — `EN`, then `NA`, then `EU`, then `AU`. **Yugipedia is queried with English regions regardless of the cards stored Language**, so a German-language card still shows the English scan; this matches the user-visible policy in the edit dialog and avoids querying region-specific scans that are sparser on Yugipedia.
3. Both `.png` and `.jpg` extensions per combo (older LOB-era uploads are `.jpg`, modern reprints are `.png`).
4. A rarity-less fallback round so cards with unknown rarities still resolve in single-rarity sets.
`buildYugipediaQueryUrl(...)` joins all candidates into one MediaWiki **batch query** (`titles=File:A|File:B|...` URL-encoded), so the entire list resolves in a single HTTP call. `parseYugipediaResponse(...)` walks the candidate list in order and returns the URL of the first filename that came back with `imageinfo[0].url`; missing files come back with `"missing": ""` and are skipped.
### Asset API: YGOPRODeck `cardinfo.php` (fallback + auto-detect)
`https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=...`
Used in two situations:
1. **Last-resort preview fallback.** If Yugipedia returns no candidate match (cards without an English scan yet, transient API errors), `fetchImageUrl` falls through to `parseFallbackImageUrl(...)`, which prefers an exact-name match in YGOPRODecks `data[]`, otherwise the first row, and returns the first entry from `card_images[0]`. This is intentionally **not** filtered by `cardset=`: when YGOPRODeck applies that filter, it reorders `card_images` so alt-art passcodes are promoted ahead of the standard art, which would re-introduce the “wrong artwork” bug we fixed by switching to Yugipedia.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted.
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
### Set-completion catalog (`cardinfo.php` all-cards dump)
**Sets → Update Yu-Gi-Oh!** uses `YuGiOhSetSource::fetchAllWithCatalog()` so two HTTP responses write:
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
2. A pack checklist at `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name }` drawn from each cards `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
## Digimon Digi-Battle (1999) APIs (digimoncard.io)
English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse).
### Info API: derived set list from `search.php`
There is **no** dedicated sets endpoint. `DigiBattle99SetSource` calls:
`https://digimoncard.io/api-public/search.php?series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc`
and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with:
- `Set.name` — exact pack display name (used as `pack=` on search / auto-detect)
- `Set.id` — stable slug (`Series 1 Starter Set``series-1-starter-set`); never rename after ship
- `Set.releaseDate` — curated table in the set source (Series 1 Starter = `1999/06/01` verified; other packs use documented year/month anchors)
Unknown future packs get an empty release date and sort last.
Cached on disk as `<dataStorage>/digibattle99/sets.json` via `SetService` / `JsonSetRepository`.
### Set-completion catalog (same `search.php` payload)
**Sets → Update Digimon (Digi-Battle)** uses `DigiBattle99SetSource::fetchAllWithCatalog()` so one HTTP response writes both:
1. The set list (`sets.json`) as above
2. A pack checklist at `<dataStorage>/digibattle99/set-catalog.json`
Each catalog pack stores `id` (slug), `name` (display), and `cards[]` of `{ setNo, name }` (API `id` normalized like preview — alphabetic prefix uppercased). A card listed in multiple `set_name[]` packs appears under **each** pack. The Digimon **Set Completion** tab reads this file offline (no live HTTP while browsing); ownership for a pack requires matching `card.set.id` plus normalized `setNo`.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Digimon (Digi-Battle).
### Asset API: CDN images + `search.php` lookup
Card scans live at:
`https://images.digimoncard.io/images/cards/{id}.jpg`
where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also serves `.webp`, but CCM3 uses `.jpg` because `OnInit` only registers `wxPNGHandler` / `wxJPEGHandler` (WebP bytes would surface as “image decode failed”).
`DigiBattle99CardPreviewSource::fetchImageUrl`:
1. If `setNo` is non-empty → normalize alphabetic prefix to uppercase (**no** invented zero-padding) and return the CDN URL with **no** search round-trip.
2. Otherwise search with `n=` + optional `pack=` (display set name) + `series=`, take the first exact name matchs `id`, then build the CDN URL.
**Preview key:** `(name, set.name, setNo)` — middle slot is the pack **display name** (same idea as Yu-Gi-Oh! passing `set.name` for YGOPRODeck `cardset=`), not the slug id.
**Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity).
Empty search array / `{"error":"..."}``NotFound`; bad JSON / HTTP → `Transient`.
## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend
Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json``sets-west.json` and `pokemonjp/sets.json``sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western pokemontcg.io ids.
### Info API: TCGdex `GET /v2/ja/sets` (+ per-set detail)
`https://api.tcgdex.net/v2/ja/sets` returns a slim array (`id`, `name`, `cardCount`). Release dates require `GET /v2/ja/sets/{id}` (`releaseDate` as `YYYY-MM-DD`, rewritten to `YYYY/MM/DD`). `JapanesePokemonSetSource`:
- Excludes Chinese-region `CS*` junk rows mislabeled on the JA endpoint.
- Applies field overrides (e.g. `SV4a` Japanese name → `シャイニートレジャーex`).
- Prefers English display names and release dates from the bundled EN catalog when present; otherwise keeps the TCGdex Japanese name and fetches detail for the date.
- After parsing the TCGdex list, **injects Original-era / catalog-only products TCGdex omits** (idempotent by set id — skipped if upstream later adds them). The same injection runs when loading a cached Asia set list (`sets-asia.json`) via `ISetSource::augmentCachedSets`, so these products appear without requiring **Update Sets** first. Stable ids and English names:
| Id | English name |
|---|---|
| `UnnumberedPromo` | Unnumbered Promotional cards (Bulbapedia catch-all; synthetic `001`… localIds; preview via catalog `image_url` preferring Japanese / Unnumbered Bulbagarden scans) |
| `ExpSheet1` / `ExpSheet2` / `ExpSheet3` | Expansion Sheet Series 13 |
| `NiviCG` | Nivi City Gym |
| `HanadaCG` | Hanada City Gym |
| `KuchibaCG` | Kuchiba City Gym |
| `TamamushiCG` | Tamamushi City Gym |
| `YamabukiCG` | Yamabuki City Gym |
| `GurenTG` | Guren Town Gym |
| `SouthernIslands` | Southern Islands |
Seed data lives in `tools/pokemon_jp/classic_missing_sets.json` + `classic_missing_prints.json` (merged into the EN catalog via `merge_classic_missing.py`). LocalIds for these products are sequential `001`… within each product (cards were unnumbered in print). Refresh `UnnumberedPromo` prints from Bulbapedia with `python tools/pokemon_jp/harvest_unnumbered_promos.py`, then fill preview images with `python tools/pokemon_jp/enrich_unnumbered_promo_images.py` (prefers Unnumbered / Japanese reprint-gallery scans over English Wizards `|image=` primaries; EN-only Bulbapedia pages leave `image_url` empty), then re-run `merge_classic_missing.py`. Numbered Japanese promo eras (`SV-P`, `S-P`, …) remain out of scope — TCGdex does not expose them, and they are not part of this curated set.
### Set-completion catalog (Asia)
**Sets → Update Pokemon** uses `JapanesePokemonSetSource::fetchAllWithCatalog()` so the Asia path writes:
1. The set list (`pokemon/sets-asia.json`) as above (EN names + classic product injection)
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-asia.json`
For each set, the source `GET`s `/v2/ja/sets/{id}` and builds checklist rows from `cards[]` (`localId``setNo`, display name prefers EN catalog `nameEn`, else TCGdex Japanese `name`). Prints present in the bundled EN catalog but missing from TCGdex `cards[]` are **gap-filled** into the pack (covers UnnumberedPromo / City Gym / Expansion Sheets / Southern Islands and sparse classic sets). Catalog-only products with no TCGdex detail become packs entirely from `JapanesePokemonEnCatalog::printsForSet`.
The Pokemon **Set Completion** tab also loads this file offline; ownership for an Asia pack requires `PokemonRegion::Asia`, matching `card.set.id`, and `normalizeLocalId` on `setNo`. Region and language filters on the tab restrict which packs/cards count. West and Asia never cross-count.
If `set-catalog-asia.json` is missing (and the active region filter needs it), the Set Completion tab prompts the user to run Update Pokemon.
### Sets without printed collector numbers (`UnnumberedPromo`)
Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card.
**Edit dialog (`PokemonCardEditDialog`, Asia region) for set id `UnnumberedPromo`:**
- The **Set #** text field is hidden (row label becomes **Print**). **Auto detect** and **Next** remain.
- Auto-detect / silent Edit prefetch lists catalog prints matching the typed name (exact EN/JA, plus qualified titles such as `Mewtwo``Mewtwo (CoroCoro promo)`). Distinct synthetic localIds form the Next ring.
- **Next** on the edit form shows a position counter (`Next (2/5)`), not the synthetic id. For ordinary numbered JP sets, Next still shows the current collector number (`Next (42)`).
- A modeless **Print preview** popup (`VariantImagePreviewDialog`) opens ~20px to the right of the Add/Edit dialog. It loads the current print via `CardPreviewService::fetchPreviewBytes` and refreshes on each ring step. The popup has its own **`<< Prev` / `Next >>`** controls that drive the same ring as the edit dialog (buttons disabled when fewer than two variants).
- On save, the dialog writes the rings synthetic `setNo` into `PokemonCard::setNo` even though the text field was hidden.
Other classic unnumbered products (City Gyms, Expansion Sheets, Southern Islands) currently keep the normal Set # field; only `UnnumberedPromo` uses the print-preview UX above.
### Asset API: TCGdex card / set-detail images
Preview is **local-id based**. `JapanesePokemonCardPreviewSource`:
1. With `setId` + `setNo` (`localId`), tries `GET /v2/ja/cards/{setId}-{localId}` and reads `image`.
2. Falls back to set-detail `cards[]` (which often already carries `image` on modern sets).
3. Appends `/high.png` to the TCGdex image base URL (PNG — wxImage does not decode webp).
4. If the set-specific card still has no scan (classic sets like `PMCG1`), looks up the bundled EN catalog print for that exact `setId`+`localId` and uses optional `image_url` or a TCGPlayer product image built from `tcgplayer_id` (`https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg`). Gap-fill sources differ by era:
- **PMCG and other data-asia sets with `thirdParty.tcgplayer`**: printing-accurate `tcgplayer_id` harvested offline from [tcgdex/cards-database](https://github.com/tcgdex/cards-database) `data-asia` (the live TCGdex API does not expose them).
- **neo1neo4**: data-asia has no `tcgplayer_id` and TCGdex JA `image` is null; the catalog may carry an ETL-written `image_url` from a **Japanese** [CardIndex](https://www.cardindex.co/) set scan (`enrich_neo_image_urls.py` scrapes the JA neo set pages and matches by English card name). **No English pokemontcg.io fallback** — if CardIndex has no JP image, `image_url` is left empty and the UI shows the card-back. Use `--overwrite` to re-resolve / clear stale EN URLs. Runtime still resolves only by exact JA `setId`+`localId` — no C++ name search across printings.
This is **printing-accurate** gap-fill — not a name search across other Charizard printings at runtime.
5. For **catalog-only products** (Unnumbered Promotional cards, City Gym theme decks, Expansion Sheets, Southern Islands), when TCGdex set/card GETs fail, Auto-detect and preview fall back to the bundled catalog prints for that `setId` (EN/JA name → `localId`; optional `tcgplayer_id` / `image_url` for preview). `UnnumberedPromo` rows typically carry Bulbagarden Archives `image_url` values written by `enrich_unnumbered_promo_images.py`, which prefers Japanese / Unnumbered Promotional reprint scans and omits English-only Wizards Black Star primaries when no JP file is available. Without a catalog image field, preview returns `NotFound` and the UI shows the card-back. Auto-detect matches exact EN/JA names and also qualified English titles (`Mewtwo``Mewtwo (CoroCoro promo)`).
6. City Gym deck exclusives must stay **printing-accurate**. Do **not** reuse Leaders' Stadium / PMCG donor `tcgplayer_id`s for those prints; that shows the wrong set art. Instead, bundle local scans under `assets/pokemon_jp_classic/<setId>/<localId>.jpg` and point the catalog row at `image_url: "asset:pokemon_jp_classic/<setId>/<localId>.jpg"`. `CardPreviewService` loads `asset:` URLs from disk next to the executable, bypassing HTTP entirely.
It does **not** substitute another printing of the same Pokémon when both TCGdex and the catalog lack an image. Then preview returns `NotFound` and the UI shows the Japanese TCG card-back.
Auto-detect / Next uses the same set-detail `cards[]`, matching the typed name against catalog English names or TCGdex Japanese names. Catalog EN aliases are applied only when the catalog `name_ja` agrees with the TCGdex row (stale seed mappings like Charmander→`001` are ignored).
Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ``Erika's Oddish`, `わるいリザードン``Dark Charizard`, `R団のサンダー``Rocket's Zapdos`, neo garbled `輝くセレビ``Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`).
That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1PMCG6, neo1neo4, VS1, web1, E1E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
### Variant Pokémon English titles
Auto-detect for English owner / Rocket's / Dark / Light / Shining Pokémon names requires the bundled catalog's **full** `name_en` for that print (same rule as City Gym manuals that already store `Erika's Oddish`). Typing the Japanese TCGdex name still works when `name_ja` is correct.
To extend variant coverage:
1. Add new JA prefix → English title prefix pairs to `VARIANT_JA_PREFIXES` in [`tools/pokemon_jp/enrich_preview_images.py`](../tools/pokemon_jp/enrich_preview_images.py) (longest prefixes first).
2. Re-run:
```bash
python tools/pokemon_jp/enrich_preview_images.py
```
3. For neo1neo4 Japanese preview images (CardIndex JP scans only; clears EN
pokemontcg.io URLs on miss), run:
```bash
python tools/pokemon_jp/enrich_neo_image_urls.py
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
```
4. Rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated.
Rows with `name_en_source: "manual"` (City Gym theme decks in `classic_missing_prints.json`) are never overwritten. Prefer stable English TCG product names (Bulbapedia / Limitless English titles).
### Extending Trainer/Energy English aliases
Auto-detect for English Trainer/Energy names only works when the bundled catalog has a `name_en` for that print. Pokémon get `name_en` automatically from `dexId` (bare species) or from variant prefix composition (full titles); Trainers and Energy do not. To add more sets or staples:
1. Collect unique Japanese Trainer/Energy names for the sets you care about (from TCGdex set detail `cards[].name`, or from `tools/pokemon_jp/_tcgdex_cards_database/data-asia/<serie>/<setId>/*.ts` after running enrich once).
2. Add each missing `name_ja` → English display name to [`tools/pokemon_jp/non_pokemon_en_by_ja.json`](../tools/pokemon_jp/non_pokemon_en_by_ja.json). One entry covers **every set** that reprints that Japanese title.
3. Re-run:
```bash
python tools/pokemon_jp/enrich_preview_images.py
```
4. Confirm `enrich_preview_images.py` prints `FIRST15 trainer/energy coverage OK` (or extend `FIRST15_SETS` in that script if you raise the coverage baseline). Copy/rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated.
5. Prefer stable English TCG product names (Bulbapedia / Limitless English titles). Do not invent per-set aliases that differ for the same `name_ja`.
### Bundled English catalog
`ui_wx/assets/pokemon_jp_en_catalog.json` is copied next to the exe on build (`assets/pokemon_jp_en_catalog.json`). It supplies English set/card names TCGdex JA cannot provide, plus optional classic-image gap-fill fields (`tcgplayer_id` / `image_url`). Generated offline via `tools/pokemon_jp/` (set EN merge + `enrich_preview_images.py` using species, variant, and trainer/energy tables + optional `enrich_neo_image_urls.py` for neo `image_url`). Missing catalog → Japanese-only labels still work; missing image fields → card-back for unscanned printings. Missing EN aliases for a Trainer still allow Auto-detect when the Japanese name is typed.
Card-back fallback uses the Japanese TCG Bulbagarden scan
(`TCG_Card_Back_Japanese.jpg`), not the Western `Cardback.jpg`.
## Runtime Flow In CCM3
The app uses the same flow for both games:
The app uses the same flow for every game that registers a module:
- `SetService` asks the game's `ISetSource` (info API) for the latest set list.
- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL.
- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer.
- If preview lookup fails (or returns empty bytes), the UI fetches a per-game fallback card-back image URL through `CardPreviewService::fetchImageBytesByUrl(...)` and shows that image in the selected-card preview panel.
- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon / Japanese Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`).
Current fallback image URLs (kept in `BaseSelectedCardPanel` for CCM2 parity):
### Caching And Connection Reuse
See [caching.md](caching.md) for a dedicated reference on preview cache tiers, internal keys, eviction, clearing, and HTTP session reuse.
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon West/Asia backends, Yu-Gi-Oh!, DigiBattle99). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews:
- **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview.
- **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway.
`CardPreviewService` consults the tiers in order **memory → disk → source/HTTP**. On a disk hit (positive *or* negative) the entry is promoted into the in-memory LRU so the next click on the same row never re-touches the disk cache. On HTTP success the bytes are written through to both tiers in one shot. On a `NotFound` source error the **negative** marker is written through to both tiers; on `Transient` source errors nothing is written, so the next selection retries cleanly.
The combined effect on the preview path: first selection of a previously-unseen card pays one TLS handshake per *new* host this session (typically two hops for Yu-Gi-Oh!: `yugipedia.com` for the API, `ms.yugipedia.com` for the image; Digi-Battle often hits `images.digimoncard.io` only when `setNo` is already known), each subsequent fresh card on the same host skips the handshake, any re-selection of an already-viewed card is instant, after the first run with the disk cache populated **even a fresh app launch is decode-only for previously-seen cards** until eviction or a manual cache clear, and **records the upstream cleanly has no image for** stay "instant card-back" across restarts instead of re-paying the lookup every launch. Editing a lookup-relevant field of a record (name, set, setNo, or for Yu-Gi-Oh! the rarity / edition packed into setNo) changes the cache key automatically, so a fresh resolution attempt happens on the next click.
To clear the persistent cache (for example to recover from a bad upstream image), delete the `<exeDir>/.cache/preview-cache/` subdirectory or the umbrella `<exeDir>/.cache/` folder. Note: the in-app "Reset" / data-storage-relocation flow does **not** touch this directory — the cache is install-scoped, not collection-scoped, so it is preserved across data-dir moves and only cleared by deleting the directory above explicitly (or by reinstalling / relocating the executable).
For the full caching design and contributor rules see the dedicated [caching.md](caching.md).
Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match CCM2):
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg`
- Pokemon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
- Pokémon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
- Japanese Pokémon: `https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg`
- Yu-Gi-Oh!: Yugipedia English TCG back — try `https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png`, then `https://ms.yugipedia.com/e/e5/Back-EN.png`; if both fail, load `<exeDir>/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string.
- Digimon (Digi-Battle): no stable public back URL; load `<exeDir>/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time).
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
## Error Surface And Debugging Intent
Both source types return `Result<T, std::string>` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating:
All source types return `Result<T, std::string>` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating:
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions (`data`, `image_uris`, `images.large`/`images.small`). If the fallback fetch succeeds, the panel intentionally shows the card-back image and the inline label `(image preview unavailable)`.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon West (`GET /v2/cards/{setId}-{number}``data` object, or search `data[]`; `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

+167
View File
@@ -0,0 +1,167 @@
#documentation #performance #network #ccm3
# Caching In CCM3
This document describes **what CCM3 caches**, **how lookups are ordered**, and **what is deliberately not cached**. It focuses on the card **preview image** path (remote APIs → raw bytes → UI decode), which is where most explicit caching lives. For upstream URL shapes and API roles, see [assets-and-info-apis.md](assets-and-info-apis.md).
## Scope
| Mechanism | What it stores | Survives restart? |
|-----------|----------------|-------------------|
| In-memory preview LRU (`CardPreviewService`) | Successful preview / fallback-image **bytes** + negative markers for "no upstream image" | No |
| Disk preview byte cache (`LocalPreviewByteCache`) | Same bytes + negative markers, persisted under `<exeDir>/.cache/preview-cache/` | Yes |
| HTTP session reuse (`CprHttpClient`) | libcurl **connections** (TLS + TCP keep-alive), not response bodies | No (process lifetime only) |
Other persistence (for example `JsonSetRepository` after “Update sets”, `JsonCollectionRepository` for card JSON, `LocalImageStore` for **user-attached** scan files) is normal app data storage, not preview caching. Those layers are documented elsewhere via domain services; this page stays centered on **preview latency** and **repeat lookups**.
### Cache directory layout
The umbrella cache root is `<exeDir>/.cache/`, where `exeDir` is the directory containing the running `ccm3` executable — the same scope as `config.json`. It is reserved for any future computed-from-network caches (set-list response snapshots, etc.); the leading dot keeps it out of the way for users poking around the install folder. Today it contains a single subdirectory:
- `<exeDir>/.cache/preview-cache/` — owned by `LocalPreviewByteCache`. Files inside are `<hash>.bin` / `<hash>.neg` / `<hash>.idx` triples (see below).
The cache is deliberately **not** under the user-configurable `Configuration.dataStorage` path. The data-storage path is meant for the user's own data — collection JSON, attached scans, set lists — and is meant to be relocatable, syncable, and backup-friendly. Previews are downloaded artifacts from upstream APIs:
- They must **not** follow the user's collection when the data-storage path is reconfigured at runtime (the cache would otherwise either rebuild from cold every time the user moves the dir, or pollute every chosen target with a recurring `.cache/` directory).
- They must **not** be uploaded together with the user's collection if the user backs up / syncs / version-controls the data dir.
- They are install-scoped, not collection-scoped: a fresh install elsewhere on disk should start cold, and uninstalling / moving the executable should leave nothing stale behind.
Pinning the cache to `exeDir` is what gives all three properties without per-flow plumbing. The trade-off is that the cache is **not** wiped by the in-app data-storage reset / relocation flow; if you genuinely want a clean slate (e.g. you suspect cache corruption), delete `<exeDir>/.cache/` manually.
## Preview lookup order
For `CardPreviewService::fetchPreviewBytes(game, name, setId, setNo)`:
1. **In-memory LRU** — O(1) lookup. A positive entry returns the bytes immediately; a **negative** entry returns an error immediately (no network call). Either kind of hit moves the entry to the most-recently-used position.
2. **Disk cache** (`IPreviewByteCache`, production: `LocalPreviewByteCache`) — on memory miss, look up on disk. A positive disk hit is **promoted** into the in-memory LRU and returned; a negative disk hit is likewise promoted into memory as a negative entry and returned as an error. So the next selection of the same card stays in-memory only.
3. **Network** — ask `ICardPreviewSource` for an image URL, then `IHttpClient::get(url)` for bytes. On success, write through to **both** memory and disk tiers as a positive entry. **`CardPreviewService::fetchAndCache`** treats an HTTP **2xx with an empty body** as an error (no cache write) so empty payloads cannot populate the LRU as false positives.
For `fetchImageBytesByUrl(url)` (used for per-game **card-back fallbacks** when preview lookup fails):
- Same three-tier pattern, but the cache key is derived only from the URL. There is no negative-cache analogue here: the URL is a fixed constant, so any failure is by definition transient. Empty bodies are rejected the same way as on the preview-image GET path.
## Negative caching: transient vs. permanent failures
`fetchPreviewBytes` distinguishes two failure kinds via `PreviewLookupError::Kind`:
- **`NotFound`** — the upstream answered cleanly that this exact record has no preview image. Examples:
- Scryfall search returned `data: []`, or the matched card has no top-level `image_uris`.
- Pokémon TCG search returned `data: []`, or the card has no `images` object / no `large` / `small` URL.
- Yu-Gi-Oh!: **both** Yugipedia and YGOPRODeck answered cleanly with no match (Yugipedia tagged every candidate filename `missing`, *and* YGOPRODeck returned an empty `data` array or no usable image variants).
These are **negative-cached** in both tiers. Subsequent selections of the same record return an error instantly, without any HTTP call. The user-visible effect is that the per-game card-back fallback shows up immediately on every click.
The cache key is `(game, name, setId, setNo)` (with Yu-Gi-Oh! also packing rarity and edition into `setNo`). Any **edit to a lookup-relevant field** of the record changes the key automatically, which means the negative entry no longer matches and a fresh network resolution attempt runs the next time the user clicks the row. So if the user fixes a typo, changes the set, switches a YGO printing's edition or rarity, etc., the new fingerprint guarantees a re-fetch — no manual cache clear needed.
- **`Transient`** — we couldn't tell whether the record has an image because the upstream couldn't speak. Examples:
- HTTP / network / TLS / DNS failure.
- Malformed JSON, missing top-level fields (schema deviation that suggests an outage page rather than a real "no match" response).
- Yu-Gi-Oh!: **either** Yugipedia or YGOPRODeck failed at the network/parse layer. The cautious rule is that as soon as one upstream couldn't speak, the overall outcome is transient — we cannot conclude the record has no image, only that we couldn't reach the place that would tell us.
These are **never cached** (positive or negative). The next selection of the same row retries cleanly. This is the property that keeps a temporary connection drop from semi-permanently breaking previews.
This split is the reason the preview path doesn't keep retrying every click for cards whose printing genuinely has no upstream scan, *and* the reason a brief loss of connectivity doesn't poison the cache with bogus "no image" markers.
## Updating cached entries
There is no explicit "refresh" or "invalidate" API on `CardPreviewService` — by design. Every way an entry's state can change is driven by **what already happens** in the system, so contributors don't have to reason about a side-channel mutation API. The full set of transitions is:
### 1. Edit-driven invalidation (record changed → fresh lookup, automatic)
The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `<setNo>||<rarity>||<1E|UE>`. For Digimon Digi-Battle the middle slot is the pack **display name** (`Set.name`), not the slug id, so `pack=` search and the CDN path stay aligned. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
- Memory and disk lookups for the new key **miss** the old entry (positive or negative).
- A fresh `ICardPreviewSource::fetchImageUrl` call runs.
- The result is cached under the new key, leaving the old key's entry untouched but unreachable from the UI (it ages out via LRU / mtime eviction).
Concretely: fix a typo in the card name → re-fetch. Switch the printing's set → re-fetch. Toggle `1E``UE` on a YGO card → re-fetch the per-printing scan. **No manual cache clear needed**; the test `editing a lookup-relevant field invalidates the negative entry automatically` pins this behavior.
If you add a new disambiguator (say a future "art treatment" flag), the rule is to pack it into one of the existing key slots (`setNo`'s `||`-separated tuple is the established hook) so this auto-invalidation continues to apply. Adding it as a side parameter that the cache key *doesn't* see would silently break the update story.
### 2. Same-key positive ↔ negative state transitions
If a previously cached entry's verdict flips upstream (Yugipedia uploads a missing scan, a Scryfall printing's `image_uris` get fixed, etc.) **and** the user re-encounters it under the same key, the next fetch decides:
- **Positive → negative.** Source returns `NotFound` for a key that previously cached a positive entry: `cacheStoreNegative(key)` overwrites the in-memory entry's bytes with an empty payload and flips `negative=true`; on disk, `LocalPreviewByteCache::storeNegative` removes the existing `<hash>.bin` (releasing its bytes from the size cap) and writes a `<hash>.neg` marker.
- **Negative → positive.** Source returns a real URL, `IHttpClient` returns bytes, `cacheStore(key, payload)` overwrites the existing in-memory entry with the new bytes and flips `negative=false`; on disk, `LocalPreviewByteCache::store` removes the existing `<hash>.neg` and writes the new `<hash>.bin`.
Both paths preserve a key invariant: **`.bin` and `.neg` for the same hash are never co-resident**. `LocalPreviewByteCache` tests pin this down (`a later positive store overwrites an earlier negative entry`, `storeNegative for an existing positive entry replaces the bytes`).
The "trigger" for these transitions in production is one of: the user edits the record back to a previous key (so the still-cached old entry surfaces and a network attempt then re-reaches the upstream), or the in-memory tier was cleared by an app restart and the disk-tier verdict is now stale. There is no time-based revalidation today; the design relies on the upstream answer being stable enough that a stale verdict only hurts until the natural transitions above kick in.
### 3. Eviction-based aging (passive)
- **In-memory LRU.** Capacity is hard-capped at `CardPreviewService::kCacheCapacity = 128` entries (positive and negative share the count). When a new entry is inserted past the cap, the **least-recently-used** entry — the back of the list — is dropped. Any access (positive hit, negative hit, or store) moves the entry to the front, so heavily clicked cards are the last to go.
- **Disk cache.** Capacity is hard-capped by total `.bin` payload bytes (`LocalPreviewByteCache::kDefaultMaxBytes = 64 MiB`). When a `store` would push the total past the cap, oldest-by-mtime `.bin` files (with their `.idx` sidecars) are deleted until the new write fits. A successful `load` touches the entry's mtime, so frequently viewed cards rarely become eviction victims. `.neg` markers are not counted against the cap and are not actively evicted; their count is naturally bounded by the number of records the user has viewed whose upstream cleanly reported "no image".
Eviction is the only way an entry "ages out" without an explicit user action.
### 4. Manual / external invalidation
- **Delete the cache directory.** Removing `<exeDir>/.cache/preview-cache/` (or the umbrella `<exeDir>/.cache/`) is safe: `LocalPreviewByteCache` recreates the directory on the next store. The in-memory tier is unaffected by the disk delete during a running session, but a subsequent app restart starts cold.
- **Reinstall / move the executable.** Because the cache is rooted at `<exeDir>`, a fresh install elsewhere on disk starts cold by construction, and uninstalling / moving the exe leaves no stray cache in the user's data directory. (Note: **resetting or moving the user's data-storage directory does NOT clear the preview cache** — that's intentional; the cache is install-scoped, not collection-scoped.)
- **Tampering with sidecar files.** If a `<hash>.idx` is ever rewritten with a key that doesn't match the requested cache key (corruption, hash collision, filesystem hiccup), `LocalPreviewByteCache::load` reports `Miss` rather than serving the entry. A subsequent `store` / `storeNegative` overwrites the corrupted record cleanly. This is what makes FNV-1a (non-cryptographic) safe to use as the hash: the worst case is a one-time miss, never a wrong answer.
### What does *not* trigger an update
To keep the mental model crisp, the following cases **do not** invalidate or refresh anything:
- **Re-selecting the same row repeatedly.** That's a hit by design — the whole point of the cache. The only thing that changes is the entry's LRU position / mtime.
- **Transient errors on a row that is already negatively cached.** The negative entry is consulted first and short-circuits the call; the network is never touched, so a flaky network can't accidentally turn a `NotFound` verdict into a `Transient` outcome.
- **Restart with a populated disk cache.** This is a *warm start*, not an update. Both positive and negative entries flow back into memory on first re-access via the disk tier. No upstream is consulted, no entries are rewritten.
## In-memory LRU (`CardPreviewService`)
- **Implementation:** Doubly linked list + hash map, guarded by a mutex. Each entry is `{key, payload, negative}`; positive entries hold the bytes, negative entries hold an empty payload and a `negative=true` flag. Hits move the entry to the front regardless of kind.
- **Capacity:** `CardPreviewService::kCacheCapacity` (128 entries — positive and negative entries share this count).
- **Threading:** Preview work can run on a worker thread from the UI layer; all cache access goes through the mutex.
- **Keys:** Internal strings built in `CardPreviewService.cpp`:
- Preview path: prefix `'p'`, then NUL-separated fields: enum `game`, `name`, `setId`, `setNo`. The `setNo` string may embed game-specific disambiguators (for example Yu-Gi-Oh! packs rarity and edition into `setNo` before it reaches the service — see `YuGiOhSelectedCardPanel::previewKey()`).
- URL path: prefix `'u'` plus the full URL string.
Callers should treat the key as opaque; **correctness** depends on passing stable `(game, name, setId, setNo)` (and stable URL for fallback fetches) so the same printing always maps to the same cache entry.
## Disk byte cache (`LocalPreviewByteCache`)
- **Root directory:** `<exeDir>/.cache/preview-cache/` (created on first store). Pinned next to the executable, **not** under the user-configurable `Configuration.dataStorage` path — see "Cache directory layout" above for the rationale.
- **Files per logical entry:** mutually-exclusive `.bin` / `.neg`, plus an always-present `.idx` sidecar:
- `<hash>.bin` — raw image bytes (PNG/JPEG payload). **Positive** entry.
- `<hash>.neg` — zero-byte marker file. **Negative** entry (the upstream cleanly said "no image").
- `<hash>.idx` — text sidecar holding the **exact** cache key string used by `CardPreviewService`. Used to reject hash collisions on load — if `.idx` does not match the requested key, the entry is treated as a miss regardless of which marker file is present.
`store()` removes any existing `.neg` for the same hash; `storeNegative()` removes any existing `.bin`. The two states never co-exist. If they ever somehow did, `load()` prefers the `.bin` (more useful answer).
- **Hash:** FNV-1a 64-bit over the key, rendered as 16 hex digits. Not cryptographic; the `.idx` sidecar is the safety net that prevents collisions from serving the wrong card's bytes or the wrong card's "no image" verdict.
- **Size bound:** Default total payload cap `LocalPreviewByteCache::kDefaultMaxBytes` (64 MiB). Eviction removes **oldest by modification time** among `.bin` files (with their `.idx` sidecars) until the new write fits. **Negative entries** (`.neg` markers) are tiny and are not counted against the cap — their count is naturally bounded by the user's actively-viewed records.
- **Recency on read:** A successful `load` updates the corresponding `.bin` or `.neg` file's mtime (“touch”) so frequently viewed cards are less likely to be evicted.
- **Failure policy:** All adapter I/O failures are swallowed (miss on read, no-op on failed write). Preview still works from network; worst case is “cold” performance.
### Clearing the disk preview cache
- Delete the `<exeDir>/.cache/` folder (or just the `preview-cache/` subfolder inside it). Both options are safe: `LocalPreviewByteCache` recreates the directory on the next store.
- The in-app data-storage reset/relocation flow does **not** touch this directory — the cache is install-scoped (sits next to the exe), not collection-scoped. If you need a clean slate for the cache, delete the directory above explicitly.
## HTTP connection reuse (`CprHttpClient`)
The app constructs **one** `CprHttpClient` and shares it across set sources, preview sources, and image downloads. It owns a single long-lived `cpr::Session` (one libcurl easy handle per process).
- **Benefit:** Repeated HTTPS requests to the **same host** reuse TLS sessions / TCP connections where the server allows keep-alive, which materially reduces latency vs. a fresh session per GET (especially for Yu-Gi-Oh!, where preview resolution and the actual image often hit different hosts).
- **Thread safety:** All `get()` calls are serialized with a mutex because libcurl easy handles are not thread-safe.
This is **not** a response-body cache; it only amortizes connection setup.
## Design constraints (for contributors)
- **Classify source errors honestly.** A new game module's `ICardPreviewSource::fetchImageUrl` must return `PreviewLookupError::Kind::NotFound` only when the upstream answered cleanly (parsed response, no match / no image variants). Anything that could be the network — HTTP error, malformed body, schema deviation, timeout — is `Transient`.
- **Do not cache transient errors.** That's the rule that keeps a flaky connection from permanently disabling previews. If you ever need to record a failure, route it through `IPreviewByteCache::storeNegative` only on a confirmed `NotFound`.
- **Updates flow through the cache key, not a side channel.** Don't add a `clearCache(...)` / `invalidate(...)` API to `CardPreviewService` to "fix" a stale entry. The supported update mechanic is: edit-driven invalidation (key changes), same-key positive/negative replacement on the next successful resolution, and LRU/mtime eviction (see "Updating cached entries" above). A side-channel invalidation API would just be another way for callers to forget to keep the disk tier in sync with the memory tier.
- **Extend cache keys** by packing new disambiguators into existing coordinates (typically `setNo` / tuple encoding) rather than bypassing `CardPreviewService`, so memory and disk tiers stay aligned and editing the record continues to invalidate the negative entry automatically.
- **Tests:**
- `card_preview_service_tests.cpp` pins tier ordering and write-through using an in-memory `IPreviewByteCache` fake; it also exercises the negative-caching behavior end-to-end (NotFound is remembered, Transient is retried, edits invalidate the entry, warm-restart honors the disk negative entry, a later positive overwrites a previous negative).
- `local_preview_byte_cache_tests.cpp` exercises the real-disk adapter in isolated temp directories, including the `.bin`/`.neg`/`.idx` interactions (round-trip, restart, mutual replacement, sidecar collision rejection, eviction).
## Related reading
- [assets-and-info-apis.md](assets-and-info-apis.md) — external APIs and the same preview tiers in **runtime flow** context.
- Root `AGENTS.md` — UI performance guardrails summary.
- `core/AGENTS.md` — conventions for preview caching, source-error classification, and `CprHttpClient` session ownership.
+4
View File
@@ -17,6 +17,10 @@ The repository uses GitHub Actions workflows split by branch intent, with one or
- `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets.
- `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`.
### SonarQube Cloud (coverage quality gate)
Both `feature-ci.yml` and `master-ci.yml` include a Linux job that configures with GCC coverage flags, builds, runs `ctest`, generates `build/sonarqube-coverage.xml` via `gcovr`, and runs the SonarCloud scan. **`sonar.coverage.exclusions`** omit `ui_wx/` and `app/` from the coverage denominator because only `ccm_core` is exercised by automated tests. The scan also sets **`sonar.cpd.exclusions`** for the repeated per-game wx scaffolding files (`*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so intentional parallel UI implementations do not drive the duplication gate. See [Testing Guide And Test Code Of Conduct](testing-and-test-code-of-conduct.md).
## Version Flow
Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases.
+1 -1
View File
@@ -113,7 +113,7 @@ Automated tests primarily cover `core/` and infrastructure adapters. UI testing
`cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`.
For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`).
For MinGW/MSYS2 builds, the `ccm` POST_BUILD step also copies `libstdc++-6.dll`, `libgcc_s_seh-1.dll`, and `libwinpthread-1.dll` from the compilers `bin/` next to `ccm3.exe`. That keeps Explorer / IDE launches on the same UCRT runtime used to build (avoids “Entry Point Not Found” / `__emutls_v._ZSt11__once_call` against `libcpr.dll` when a different `libstdc++` is on `PATH`).
## Troubleshooting
+2
View File
@@ -30,6 +30,8 @@ Windows and Linux use the same logical flow; only generator and compiler setup d
## Coverage Surface
**SonarCloud:** The CI Sonar scan reports coverage against `core/` paths that `ccm_core_tests` can execute. `ui_wx/` and the `app/` composition root are excluded from Sonars **coverage** calculation (`sonar.coverage.exclusions`) because they are not run under the doctest suite; UI behavior is covered by manual validation below. For duplication, the scan excludes intentionally parallel per-game wx scaffolding (`sonar.cpd.exclusions` on `*GameView.cpp`, `*CardEditDialog.cpp`, `*SelectedCardPanel.cpp`) so CPD focuses on shared logic rather than mirrored UI wiring.
Current automated tests cover non-UI behavior, including:
- filesystem naming and parsing behavior

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