mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 09:01:11 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3b4762b76 | |||
| 2eb7c59f78 | |||
| 7cf25d671f | |||
| ab0e3c5ae2 | |||
| 9917e364c1 | |||
| c9e6bc2b6b | |||
| e5c830e945 | |||
| 42926f2fb5 | |||
| 8a50e8daba | |||
| 98f2575b5a | |||
| d6c7f60aee | |||
| 7935f2b18e | |||
| 5805101d24 | |||
| 8b7d45fdac | |||
| c1d42bdadd | |||
| 6ff4406638 | |||
| 6f575f4cec | |||
| 7807192ecb | |||
| a691291413 | |||
| 108863b795 | |||
| c434ee51a7 |
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -40,3 +40,6 @@ config.json
|
||||
configure.log
|
||||
build.log
|
||||
test.log
|
||||
|
||||
# Offline ETL caches (large third-party extracts)
|
||||
tools/pokemon_jp/_tcgdex_cards_database/
|
||||
|
||||
@@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin
|
||||
## Project structure
|
||||
|
||||
- `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`.
|
||||
- `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.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
|
||||
|
||||
@@ -78,22 +92,31 @@ Run from the **workspace root**.
|
||||
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
|
||||
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
|
||||
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
|
||||
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
|
||||
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme` in `Theme.cpp` (opt out of immersive dark mode + parent `WM_CTLCOLOREDIT` subclass — that message goes to the EDIT's parent, not `MainFrame`).
|
||||
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
|
||||
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
|
||||
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
|
||||
- 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).
|
||||
|
||||
@@ -1,19 +1,55 @@
|
||||
# Card Collection Manager 3
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
|
||||
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!
|
||||
- Yu-Gi-Oh! (Bandai)
|
||||
- Digimon (Digi-Battle)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Magic The Gathering
|
||||
<details open>
|
||||
<summary>Magic The Gathering</summary>
|
||||
|
||||

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

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

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

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

|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Migrating From CCM1 And CCM2
|
||||
|
||||
CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory.
|
||||
@@ -68,3 +104,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
@@ -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>`, `JsonCollectionRepository<YuGiOhBandaiCard>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `YuGiOhBandaiSetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `YuGiOhBandaiGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, `YuGiOhBandaiGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
|
||||
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
|
||||
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
|
||||
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`).
|
||||
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
|
||||
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
|
||||
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
|
||||
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".
|
||||
|
||||
@@ -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()
|
||||
|
||||
+123
-5
@@ -2,24 +2,40 @@
|
||||
// it to the wxWidgets UI layer. This is the only place where concrete adapter
|
||||
// types are mentioned - everything downstream depends on interfaces.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
#include "ccm/infra/JsonCollectionRepository.hpp"
|
||||
#include "ccm/infra/JsonSetRepository.hpp"
|
||||
#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/YuGiOhBandaiSetCatalogService.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/AppContext.hpp"
|
||||
#include "ccm/ui/DigiBattle99GameView.hpp"
|
||||
#include "ccm/ui/MagicGameView.hpp"
|
||||
#include "ccm/ui/MainFrame.hpp"
|
||||
#include "ccm/ui/PokemonGameView.hpp"
|
||||
#include "ccm/ui/YuGiOhBandaiGameView.hpp"
|
||||
#include "ccm/ui/YuGiOhGameView.hpp"
|
||||
|
||||
#include <wx/app.h>
|
||||
#include <wx/icon.h>
|
||||
@@ -38,8 +54,12 @@ namespace {
|
||||
// need for the repositories to know about concrete game module classes.
|
||||
std::string dirNameForGame(ccm::Game g) {
|
||||
switch (g) {
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
case ccm::Game::YuGiOh: return "yugioh";
|
||||
case ccm::Game::DigiBattle99: return "digibattle99";
|
||||
case ccm::Game::YuGiOhBandai: return "yugiohbandai";
|
||||
case ccm::Game::JapanesePokemon: return "pokemon";
|
||||
}
|
||||
return "magic";
|
||||
}
|
||||
@@ -74,12 +94,43 @@ 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_);
|
||||
ygoBandaiMod_ = std::make_unique<ccm::YuGiOhBandaiGameModule>(*http_);
|
||||
|
||||
ccm::JapanesePokemonEnCatalog jpCatalog;
|
||||
{
|
||||
const auto catalogPath = exeDir / "assets" / "pokemon_jp_en_catalog.json";
|
||||
if (auto text = fs_->readText(catalogPath); text) {
|
||||
if (auto parsed = ccm::JapanesePokemonEnCatalog::parse(text.value()); parsed) {
|
||||
jpCatalog = std::move(parsed).value();
|
||||
}
|
||||
}
|
||||
}
|
||||
jpPokeMod_ = std::make_unique<ccm::JapanesePokemonGameModule>(*http_, std::move(jpCatalog));
|
||||
|
||||
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
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);
|
||||
ygoBandaiRepo_ =
|
||||
std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
|
||||
digiBattle99CatalogStore_ =
|
||||
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
ygoCatalogStore_ =
|
||||
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
ygoMod_->setCatalogService(ygoCatalogStore_.get());
|
||||
ygoBandaiCatalogStore_ =
|
||||
std::make_unique<ccm::YuGiOhBandaiSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
pokeCatalogStore_ =
|
||||
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
|
||||
|
||||
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
|
||||
@@ -87,19 +138,63 @@ 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_);
|
||||
ygoBandaiCollSvc_ =
|
||||
std::make_unique<ccm::CollectionService<ccm::YuGiOhBandaiCard>>(
|
||||
*ygoBandaiRepo_, *imgStore_);
|
||||
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
|
||||
setSvc_->registerModule(magicMod_.get());
|
||||
setSvc_->registerModule(pokeMod_.get());
|
||||
setSvc_->registerModule(ygoMod_.get());
|
||||
setSvc_->registerModule(digiBattle99Mod_.get());
|
||||
setSvc_->registerModule(ygoBandaiMod_.get());
|
||||
setSvc_->registerModule(jpPokeMod_.get());
|
||||
|
||||
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(*ygoBandaiMod_);
|
||||
previewSvc_->registerModule(*jpPokeMod_);
|
||||
|
||||
// Per-game UI bundles. Order here is the order shown in the Game menu.
|
||||
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
|
||||
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
|
||||
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_,
|
||||
*pokeCatalogStore_);
|
||||
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
|
||||
*ygoCatalogStore_);
|
||||
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
|
||||
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*digiBattle99Mod_, *digiBattle99CatalogStore_);
|
||||
ygoBandaiView_ = std::make_unique<ccm::ui::YuGiOhBandaiGameView>(
|
||||
*config_, *ygoBandaiCollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*ygoBandaiMod_, *ygoBandaiCatalogStore_);
|
||||
|
||||
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
|
||||
*config_,
|
||||
@@ -108,7 +203,12 @@ public:
|
||||
*previewSvc_,
|
||||
*magicMod_,
|
||||
*pokeMod_,
|
||||
{ magicView_.get(), pokeView_.get() },
|
||||
*ygoMod_,
|
||||
*digiBattle99Mod_,
|
||||
*ygoBandaiMod_,
|
||||
*jpPokeMod_,
|
||||
{ magicView_.get(), pokeView_.get(), ygoView_.get(), ygoBandaiView_.get(),
|
||||
digiBattle99View_.get() },
|
||||
});
|
||||
|
||||
auto* frame = new ccm::ui::MainFrame(*ctx_);
|
||||
@@ -128,17 +228,35 @@ 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::YuGiOhBandaiGameModule> ygoBandaiMod_;
|
||||
std::unique_ptr<ccm::JapanesePokemonGameModule> jpPokeMod_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>> ygoBandaiRepo_;
|
||||
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
|
||||
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
|
||||
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
|
||||
std::unique_ptr<ccm::YuGiOhBandaiSetCatalogService> ygoBandaiCatalogStore_;
|
||||
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
|
||||
std::unique_ptr<ccm::LocalImageStore> imgStore_;
|
||||
std::unique_ptr<ccm::ImageService> imgSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhBandaiCard>> ygoBandaiCollSvc_;
|
||||
std::unique_ptr<ccm::SetService> setSvc_;
|
||||
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
|
||||
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
|
||||
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
|
||||
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
|
||||
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
|
||||
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
|
||||
std::unique_ptr<ccm::ui::YuGiOhBandaiGameView> ygoBandaiView_;
|
||||
std::unique_ptr<ccm::ui::AppContext> ctx_;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ FetchContent_MakeAvailable(nlohmann_json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need
|
||||
# a system libcurl. Used for Scryfall + pokemontcg.io REST calls.
|
||||
# a system libcurl. Used for Scryfall + TCGdex REST calls.
|
||||
#
|
||||
# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)`
|
||||
# rule that references `libcurl_shared`, which isn't in any export set when
|
||||
|
||||
+9
-6
@@ -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), `YuGiOhBandaiCard`, `YuGiOhBandaiSetCatalog` (Bandai pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/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` / `YuGiOhBandaiSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `yugiohbandai/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all expose a fully working set source + card preview source. `YuGiOhSetSource`, `YuGiOhBandaiSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
|
||||
- `src/` mirrors `include/ccm/` for non-template implementations.
|
||||
|
||||
## Conventions
|
||||
@@ -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.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
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ add_library(ccm_core STATIC
|
||||
src/domain/Set.cpp
|
||||
src/domain/MagicCard.cpp
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/YuGiOhCard.cpp
|
||||
src/domain/YuGiOhBandaiCard.cpp
|
||||
src/domain/YuGiOhBandaiSetCatalog.cpp
|
||||
src/domain/DigiBattle99Card.cpp
|
||||
src/domain/DigiBattle99SetCatalog.cpp
|
||||
src/domain/YuGiOhSetCatalog.cpp
|
||||
src/domain/PokemonSetCatalog.cpp
|
||||
src/domain/JapanesePokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
src/services/ConfigService.cpp
|
||||
@@ -14,20 +22,45 @@ add_library(ccm_core STATIC
|
||||
src/services/CardPreviewService.cpp
|
||||
src/services/CardSorter.cpp
|
||||
src/services/CardFilter.cpp
|
||||
src/services/DigiBattle99SetCompletion.cpp
|
||||
src/services/DigiBattle99SetCatalogService.cpp
|
||||
src/services/YuGiOhSetCompletion.cpp
|
||||
src/services/YuGiOhSetCatalogService.cpp
|
||||
src/services/YuGiOhBandaiSetCompletion.cpp
|
||||
src/services/YuGiOhBandaiSetCatalogService.cpp
|
||||
src/services/PokemonSetCompletion.cpp
|
||||
src/services/PokemonSetCatalogService.cpp
|
||||
|
||||
src/infra/CprHttpClient.cpp
|
||||
src/infra/StdFileSystem.cpp
|
||||
src/infra/JsonSetRepository.cpp
|
||||
src/infra/LocalImageStore.cpp
|
||||
src/infra/LocalPreviewByteCache.cpp
|
||||
|
||||
src/games/magic/MagicSetSource.cpp
|
||||
src/games/magic/MagicCardPreviewSource.cpp
|
||||
src/games/magic/MagicGameModule.cpp
|
||||
src/games/pokemon/PokemonWestSetId.cpp
|
||||
src/games/pokemon/PokemonCollectionSetSync.cpp
|
||||
src/games/pokemon/PokemonSetSource.cpp
|
||||
src/games/pokemon/PokemonCardPreviewSource.cpp
|
||||
src/games/pokemon/PokemonGameModule.cpp
|
||||
src/games/yugioh/YuGiOhSetSource.cpp
|
||||
src/games/yugioh/YuGiOhCardPreviewSource.cpp
|
||||
src/games/yugioh/YuGiOhGameModule.cpp
|
||||
src/games/digibattle99/DigiBattle99SetSource.cpp
|
||||
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
|
||||
src/games/digibattle99/DigiBattle99GameModule.cpp
|
||||
src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp
|
||||
src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp
|
||||
src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp
|
||||
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
|
||||
src/games/pokemonjp/JapanesePokemonSetSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonGameModule.cpp
|
||||
|
||||
src/util/FsNames.cpp
|
||||
src/util/SetNoNatural.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99Card - Digimon Digi-Battle (1999 English) card model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99Card {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const DigiBattle99Card&, const DigiBattle99Card&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
|
||||
// completion. Filled from digimoncard.io bulk search.php (same payload as the
|
||||
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99CatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const DigiBattle99CatalogCard&,
|
||||
const DigiBattle99CatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalogPack&,
|
||||
const DigiBattle99SetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalog {
|
||||
std::vector<DigiBattle99SetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalog&,
|
||||
const DigiBattle99SetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -18,6 +19,15 @@ namespace ccm {
|
||||
enum class Game {
|
||||
Magic,
|
||||
Pokemon,
|
||||
YuGiOh,
|
||||
DigiBattle99,
|
||||
YuGiOhBandai,
|
||||
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
|
||||
};
|
||||
|
||||
enum class PokemonRegion {
|
||||
West,
|
||||
Asia,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
@@ -26,8 +36,10 @@ enum class Language {
|
||||
French,
|
||||
Spanish,
|
||||
Italian,
|
||||
Chinese,
|
||||
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
|
||||
TraditionalChinese, // JSON / display: "T-Chinese"
|
||||
Japanese,
|
||||
Korean,
|
||||
Russian,
|
||||
};
|
||||
|
||||
@@ -47,24 +59,34 @@ enum class Theme {
|
||||
};
|
||||
|
||||
std::string_view to_string(Game g) noexcept;
|
||||
std::string_view to_string(PokemonRegion r) noexcept;
|
||||
std::string_view to_string(Language l) noexcept;
|
||||
std::string_view to_string(Condition c) noexcept;
|
||||
std::string_view to_string(Theme t) noexcept;
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
const std::array<Game, 2>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
|
||||
const std::array<Game, 5>& allGames() noexcept;
|
||||
const std::array<Language, 10>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
[[nodiscard]] std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Game pokemonBackendGame(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept;
|
||||
|
||||
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
|
||||
void to_json(nlohmann::json& j, Game v);
|
||||
void from_json(const nlohmann::json& j, Game& v);
|
||||
|
||||
void to_json(nlohmann::json& j, PokemonRegion v);
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Language v);
|
||||
void from_json(const nlohmann::json& j, Language& v);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCard - Japanese Pokémon TCG collection model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::Japanese};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c);
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases),
|
||||
// plus `region` (West/Asia) for unified West+Asia collections.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
@@ -28,6 +29,7 @@ struct PokemonCard {
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
|
||||
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
|
||||
// completion. West and Asia each persist their own file under
|
||||
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const PokemonCatalogCard&,
|
||||
const PokemonCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct PokemonSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<PokemonCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const PokemonSetCatalogPack&,
|
||||
const PokemonSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct PokemonSetCatalog {
|
||||
std::vector<PokemonSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const PokemonSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const PokemonSetCatalog&,
|
||||
const PokemonSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiCard - Bandai Carddass (pre-Konami) card model.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhBandaiCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::Japanese};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiCard&, const YuGiOhBandaiCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiSetCatalog: offline pack → card checklist for Bandai set
|
||||
// completion. Filled from Yugipedia set-gallery wikitext and persisted at
|
||||
// `<dataStorage>/yugiohbandai/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhBandaiCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
std::string rarity;
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiCatalogCard&,
|
||||
const YuGiOhBandaiCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhBandaiSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<YuGiOhBandaiCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiSetCatalogPack&,
|
||||
const YuGiOhBandaiSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhBandaiSetCatalog {
|
||||
std::vector<YuGiOhBandaiSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const YuGiOhBandaiSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiSetCatalog&,
|
||||
const YuGiOhBandaiSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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,55 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
|
||||
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
|
||||
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
/// YGOPRODeck `set_rarity` for this printing when known (optional;
|
||||
/// older `set-catalog.json` files omit it).
|
||||
std::string rarity{};
|
||||
|
||||
friend bool operator==(const YuGiOhCatalogCard&,
|
||||
const YuGiOhCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<YuGiOhCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const YuGiOhSetCatalogPack&,
|
||||
const YuGiOhSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhSetCatalog {
|
||||
std::vector<YuGiOhSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const YuGiOhSetCatalog&,
|
||||
const YuGiOhSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -23,6 +23,10 @@ public:
|
||||
// Implementations return a vector that has already been filtered
|
||||
// (e.g. no digital-only sets) and sorted by release date ascending.
|
||||
virtual Result<std::vector<Set>> fetchAll() = 0;
|
||||
|
||||
// Optional post-process for locally cached set lists (e.g. inject products
|
||||
// the upstream API omits). Default is a no-op. Called by SetService::getSets.
|
||||
virtual void augmentCachedSets(std::vector<Set>& /*sets*/) const {}
|
||||
};
|
||||
|
||||
class IGameModule {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99CardPreviewSource: digimoncard.io search + CDN card images for
|
||||
// Digimon Digi-Battle (1999 English).
|
||||
//
|
||||
// Preview key middle slot is Set.name (pack display name) so search.php?pack=
|
||||
// works without a reverse slug map. When setNo is present, the CDN URL is
|
||||
// built directly — no search round-trip.
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99CardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
static constexpr const char* kImageBase =
|
||||
"https://images.digimoncard.io/images/cards/";
|
||||
|
||||
explicit DigiBattle99CardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
|
||||
// Does not invent zero-padding — CDN keys match API ids literally.
|
||||
static std::string normalizeCardNumber(std::string_view setNo);
|
||||
|
||||
// CDN preview URL for a normalized card id (.jpg — wxImage registers
|
||||
// JPEG/PNG only; digimoncard.io also serves .webp but we cannot decode it).
|
||||
static std::string buildImageUrl(std::string_view setNo);
|
||||
|
||||
// digimoncard.io search URL: n= / pack= / series= / optional card=.
|
||||
// setName is the pack display name (Set.name), not the slug id.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo);
|
||||
|
||||
// Parse a digimoncard.io search.php body into a CDN image URL for the
|
||||
// first exact name match (optional pack filter applied by the request).
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName,
|
||||
std::string_view wantedSetNo = {});
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99GameModule: Digimon Digi-Battle (1999 English) via digimoncard.io.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99GameModule final : public IGameModule {
|
||||
public:
|
||||
explicit DigiBattle99GameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::DigiBattle99; }
|
||||
[[nodiscard]] std::string dirName() const override { return "digibattle99"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
DigiBattle99SetSource setSource_;
|
||||
DigiBattle99CardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
|
||||
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
|
||||
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
|
||||
// The same payload also builds the set-completion catalog (parseCatalog).
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint =
|
||||
"https://digimoncard.io/api-public/search.php?"
|
||||
"series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc";
|
||||
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
DigiBattle99SetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit DigiBattle99SetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// One HTTP round-trip producing both the set list and the pack catalog.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
|
||||
|
||||
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
|
||||
static std::string slugifyPackName(std::string_view packName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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,16 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
|
||||
// TCG. Calls the Pokemon TCG search endpoint at
|
||||
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
|
||||
// and returns `data[0].images.large` (with `images.small` as a graceful
|
||||
// fallback). Mirrors the established `getImage` flow in
|
||||
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
|
||||
// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
|
||||
// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
|
||||
// set-detail name match for auto-detect. Image URLs append /high.png (wxImage
|
||||
// decodes PNG, not webp).
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
@@ -19,21 +18,57 @@ 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; }
|
||||
|
||||
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Strip everything after the first '/' (e.g. "4/102" -> "4").
|
||||
static std::string normalizeCollectorNumber(std::string_view setNo);
|
||||
|
||||
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
// 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);
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string name;
|
||||
std::string imageBase;
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseCardByIdResponse(const std::string& body);
|
||||
|
||||
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseSearchResponse(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
// Parse TCGdex card-by-id JSON into print metadata (name + localId + rarity).
|
||||
static Result<AutoDetectedPrint> parsePrintFromCardById(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
// Sync Pokemon collection cards against freshly fetched set lists:
|
||||
// - West: canonicalize legacy pokemontcg set ids, then refresh name/date
|
||||
// - Asia: refresh name/date when the set id is present in the Asia list
|
||||
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Mutates cards in place. Returns how many cards changed at least one set field.
|
||||
[[nodiscard]] std::size_t syncPokemonCollectionSets(
|
||||
std::vector<PokemonCard>& cards,
|
||||
const std::vector<Set>& westSets,
|
||||
const std::vector<Set>& asiaSets);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
|
||||
// and card preview source, both backed by api.pokemontcg.io/v2.
|
||||
// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en).
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
|
||||
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
|
||||
// response into our `Set` domain type, and sorts by release date ascending.
|
||||
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
|
||||
// so no rewriting is needed (unlike Scryfall's `released_at`).
|
||||
// Behavior matches `pokemon/set_services.rs::update_sets`.
|
||||
// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
|
||||
// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
|
||||
// dates and set-completion checklists come from per-set detail GETs.
|
||||
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
PokemonSetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit PokemonSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
// List + per-set detail (cards + release date) for the offline checklist.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids.
|
||||
// Identity when the id is already TCGdex (or unknown). Asia set ids must not
|
||||
// be passed through this helper.
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids
|
||||
// and ids that already match TCGdex are returned unchanged.
|
||||
[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCardPreviewSource: TCGdex ja localId-based preview + variants.
|
||||
// Image URLs use /high.png (wxImage decodes PNG/JPEG, not webp).
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
JapanesePokemonCardPreviewSource(IHttpClient& http,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
static std::string normalizeLocalId(std::string_view setNo);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
static std::string buildCardUrl(std::string_view setId, std::string_view localId);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
// Parse set-detail body; optionally filter by name (EN catalog / JA) and/or localId.
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string nameJa;
|
||||
std::string imageBase; // empty when TCGdex has no scan
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseCardImageUrl(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Catalog-only Auto-detect when TCGdex has no set detail (theme decks, etc.).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectPrintVariantsFromCatalog(std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Reverse lookup: set + localId → name via catalog (no HTTP).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNoFromCatalog(std::string_view setId,
|
||||
std::string_view localId,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Parse TCGdex JA card-by-id JSON into print metadata.
|
||||
static Result<AutoDetectedPrint> parsePrintFromCardResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonEnCatalog - bundled English name layer for Japanese Pokémon.
|
||||
// Loaded from assets/pokemon_jp_en_catalog.json (generated offline). Missing
|
||||
// entries fall through to TCGdex Japanese names at runtime.
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonSetEnInfo {
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string releaseDate; // YYYY/MM/DD when known; may be empty
|
||||
};
|
||||
|
||||
struct JapanesePokemonPrintEnInfo {
|
||||
std::string setId;
|
||||
std::string localId;
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string nameEnSource; // bulbapedia | species-table | manual
|
||||
// Classic JA gap-fill when TCGdex has no CDN scan (optional).
|
||||
std::string imageUrl; // explicit HTTPS URL, preferred when set
|
||||
std::string tcgplayerId; // TCGPlayer product id → product-images CDN
|
||||
};
|
||||
|
||||
class JapanesePokemonEnCatalog {
|
||||
public:
|
||||
[[nodiscard]] static Result<JapanesePokemonEnCatalog>
|
||||
parse(const std::string& jsonBody);
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept {
|
||||
return sets_.empty() && printsByKey_.empty();
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonSetEnInfo>
|
||||
findSet(std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonPrintEnInfo>
|
||||
findPrint(std::string_view setId, std::string_view localId) const;
|
||||
|
||||
// Case-insensitive match of nameEn or nameJa within a set.
|
||||
// Also matches qualified English titles: wanted "Mewtwo" hits
|
||||
// "Mewtwo (CoroCoro promo)" (prefix + " (").
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
findPrintsByName(std::string_view setId, std::string_view cardName) const;
|
||||
|
||||
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
|
||||
|
||||
// All prints for a set (catalog gap-fill / set-completion checklists).
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
printsForSet(std::string_view setId) const;
|
||||
|
||||
// TCGPlayer product-image CDN URL for classic JA gap-fill.
|
||||
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
|
||||
|
||||
// Prefer imageUrl; else build from tcgplayerId; else empty.
|
||||
[[nodiscard]] static std::string previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print);
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, JapanesePokemonSetEnInfo> sets_;
|
||||
std::unordered_map<std::string, JapanesePokemonPrintEnInfo> printsByKey_;
|
||||
// setId -> print keys for name scans
|
||||
std::unordered_map<std::string, std::vector<std::string>> printKeysBySet_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonGameModule: Japanese Pokémon TCG via TCGdex ja + EN catalog.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog = {});
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::JapanesePokemon; }
|
||||
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Pokemon (Japan)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
[[nodiscard]] const JapanesePokemonEnCatalog& catalog() const noexcept {
|
||||
return catalog_;
|
||||
}
|
||||
|
||||
private:
|
||||
JapanesePokemonEnCatalog catalog_;
|
||||
JapanesePokemonSetSource setSource_;
|
||||
JapanesePokemonCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
|
||||
// dates and set-completion checklists. English display names come from
|
||||
// JapanesePokemonEnCatalog when present.
|
||||
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
PokemonSetCatalog catalog;
|
||||
};
|
||||
|
||||
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// List + per-set detail (cards + release date) + EN catalog gap-fill.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
void augmentCachedSets(std::vector<Set>& sets) const override;
|
||||
|
||||
// Pure parsers for hermetic tests.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static bool shouldExcludeSetId(std::string_view setId) noexcept;
|
||||
static std::string applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
// Build one pack checklist from a set-detail body, then gap-fill from catalog.
|
||||
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set,
|
||||
const JapanesePokemonEnCatalog& enCatalog);
|
||||
|
||||
// Catalog-only pack (classic products with no TCGdex detail).
|
||||
static PokemonSetCatalogPack catalogPackFromEnCatalog(
|
||||
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
|
||||
|
||||
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
|
||||
static void appendMissingClassicProducts(std::vector<Set>& sets);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,162 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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.
|
||||
//
|
||||
// Reverse lookup (set + setNo → name) uses the offline set-completion catalog
|
||||
// written by Sets → Update Yu-Gi-Oh! (`YuGiOhSetCatalogService`).
|
||||
//
|
||||
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
|
||||
// stored Language. Localized scans are intentionally not queried so the user
|
||||
// sees a consistent, well-stocked gallery (EN scans are the most complete).
|
||||
class YuGiOhCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit YuGiOhCardPreviewSource(IHttpClient& http);
|
||||
|
||||
// Optional offline catalog for set+setNo → name reverse lookup. When null
|
||||
// or empty, detectVariantsBySetNo returns a clear "Update Sets" error.
|
||||
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
|
||||
catalogStore_ = catalogStore;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// ---- 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);
|
||||
|
||||
// Offline reverse lookup against a set-completion catalog. `setId` is the
|
||||
// pack's set code (e.g. "LOB"); `setNo` may be digits ("005") or a full
|
||||
// collector code ("LOB-005" / "LOB-EN005").
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNoFromCatalog(const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// YGOPRODeck cardset= dump filtered by collector digits (HTTP fallback when
|
||||
// the offline catalog is missing or has no match).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNoFromCardset(const std::string& body,
|
||||
std::string_view preferredSetName,
|
||||
std::string_view setNo);
|
||||
|
||||
static std::string buildCardsetOnlyUrl(std::string_view setName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
YuGiOhSetCatalogService* catalogStore_{nullptr};
|
||||
// Cached offline catalog so reverse auto-detect does not re-parse a
|
||||
// multi-MB JSON file on every button click.
|
||||
mutable std::optional<YuGiOhSetCatalog> catalogCache_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.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_; }
|
||||
|
||||
// Wire offline set catalog for set+setNo → name reverse auto-detect.
|
||||
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
|
||||
previewSource_.setCatalogService(catalogStore);
|
||||
}
|
||||
|
||||
[[nodiscard]] YuGiOhCardPreviewSource& previewSource() noexcept {
|
||||
return previewSource_;
|
||||
}
|
||||
|
||||
private:
|
||||
YuGiOhSetSource setSource_;
|
||||
YuGiOhCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // 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
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiCardPreviewSource: Yugipedia pageimages + SMW ask for Bandai
|
||||
// Carddass previews and auto-detect (by English name or Bandai number).
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit YuGiOhBandaiCardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Prefer "<Name> (Bandai)" / English / Sealdass page depending on setId.
|
||||
static std::string preferredPageTitle(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
static std::string buildPageImagesUrl(std::string_view pageTitle);
|
||||
|
||||
static std::string buildAskByNameUrl(std::string_view englishName);
|
||||
|
||||
static std::string buildAskByNumberUrl(std::string_view setNo);
|
||||
|
||||
// True for Jump/Toei promo codes (J1, TA2, …). Yugipedia's SMW
|
||||
// `Bandai number` property is numeric-only, so these must use the
|
||||
// promotional gallery instead of `action=ask`.
|
||||
[[nodiscard]] static bool isAlphanumericPromoNumber(std::string_view setNo);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePromoGalleryResponse(const std::string& body,
|
||||
std::string_view wantedSetNo);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parsePageImagesResponse(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parseAskResponse(const std::string& body,
|
||||
std::string_view preferredSetId,
|
||||
std::string_view wantedSetNo = {});
|
||||
|
||||
static AutoDetectedPrint enrichPrint(AutoDetectedPrint print,
|
||||
std::string_view pageTitle);
|
||||
|
||||
private:
|
||||
Result<std::string, PreviewLookupError> fetchPageImage(std::string_view pageTitle);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> askByName(std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiGameModule: Bandai Carddass via Yugipedia.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit YuGiOhBandaiGameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOhBandai; }
|
||||
[[nodiscard]] std::string dirName() const override { return "yugiohbandai"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
YuGiOhBandaiSetSource& bandaiSetSource() noexcept { return setSource_; }
|
||||
|
||||
private:
|
||||
YuGiOhBandaiSetSource setSource_;
|
||||
YuGiOhBandaiCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiSetSource: hardcoded Bandai set manifest + Yugipedia gallery
|
||||
// wikitext catalogs for set completion.
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiSetSource final : public ISetSource {
|
||||
public:
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
};
|
||||
|
||||
struct SetManifestEntry {
|
||||
const char* id;
|
||||
const char* name;
|
||||
const char* releaseDate; // YYYY/MM/DD
|
||||
const char* galleryPage; // Yugipedia page title (may be shared)
|
||||
// For the shared promo gallery: keep cards whose setNo starts with
|
||||
// this prefix (empty = keep all from that page into this pack).
|
||||
const char* setNoPrefix;
|
||||
};
|
||||
|
||||
explicit YuGiOhBandaiSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
[[nodiscard]] static const std::vector<SetManifestEntry>& setManifest();
|
||||
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& /*unused*/);
|
||||
|
||||
// Parse one gallery wikitext body into checklist cards.
|
||||
static Result<std::vector<YuGiOhBandaiCatalogCard>>
|
||||
parseGalleryWikitext(const std::string& wikitext);
|
||||
|
||||
// Map a Bandai number string to a set id (ban1/ban2/ban3/promos/sealdass).
|
||||
static std::string setIdForNumber(std::string_view setNo);
|
||||
|
||||
static std::string setNameForId(std::string_view setId);
|
||||
|
||||
// Normalize printed numbers: strip leading zeros on pure-decimal values;
|
||||
// uppercase letter prefixes (j1 → J1). Sealdass stays unpadded decimal.
|
||||
static std::string normalizeCardNumber(std::string_view setNo);
|
||||
|
||||
static std::string expandRarityCode(std::string_view code);
|
||||
|
||||
static std::string buildGalleryParseUrl(std::string_view pageTitle);
|
||||
|
||||
static std::string englishNameFromGalleryTitle(std::string_view pageTitle);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -7,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
|
||||
|
||||
@@ -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
|
||||
@@ -12,9 +12,44 @@
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct AutoDetectedPrint {
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
// Optional fields used by games that resolve set/name/language during
|
||||
// auto-detect (e.g. Yu-Gi-Oh! Bandai). Existing games leave them empty.
|
||||
std::string name;
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::string language; // Language enum spelling when known ("Japanese" / "English")
|
||||
};
|
||||
|
||||
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
|
||||
// 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 +57,51 @@ 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.");
|
||||
}
|
||||
|
||||
// Optional lookup by set + collector / Bandai number (fills name + rarity).
|
||||
// `setId` uses the same meaning as detectPrintVariants for the game
|
||||
// (set id for Pokémon/Bandai; set display name for Digi-Battle; set code
|
||||
// id for Yu-Gi-Oh! catalog reverse lookup).
|
||||
virtual Result<AutoDetectedPrint> detectBySetNo(std::string_view /*setId*/,
|
||||
std::string_view /*setNo*/) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Detect-by-number not supported by this game.");
|
||||
}
|
||||
|
||||
virtual Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNo(std::string_view /*setId*/,
|
||||
std::string_view /*setNo*/) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Detect-by-number variants not supported by this game.");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// ISetRepository - persistence port for the cached `sets.json` of a game.
|
||||
// ISetRepository - persistence port for the cached set list of a game.
|
||||
// Typical layout: `<dataStorage>/<dirName>/sets.json`. Pokemon West/Asia use
|
||||
// `sets-west.json` / `sets-asia.json` under the shared `pokemon/` directory.
|
||||
// Stored as a flat list to mirror the original Rust file layout.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
@@ -19,8 +19,12 @@
|
||||
// * An empty filter matches every row, exactly as in JS where every string
|
||||
// `.includes("")` returns true.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
#include <string_view>
|
||||
|
||||
@@ -33,9 +37,23 @@ namespace ccm {
|
||||
std::string_view filter);
|
||||
|
||||
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
|
||||
// name, set.name, setNo, language, condition, amount, note.
|
||||
// name, set.name, setNo, language, condition, amount, note, region.
|
||||
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||
std::string_view filter);
|
||||
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Digi-Battle mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Bandai: name, set.name, setNo, rarity, language, condition, amount, note.
|
||||
[[nodiscard]] bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -15,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,101 @@ 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);
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(Game game,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
Game game,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// Download image bytes from a fully-qualified URL without going through
|
||||
// per-game preview-source resolution.
|
||||
// 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
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
// UI relies on it so successive clicks on different columns compose predictably
|
||||
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -52,11 +56,77 @@ 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,
|
||||
};
|
||||
|
||||
enum class YuGiOhBandaiSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
SetNo,
|
||||
Rarity,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon columns.
|
||||
enum class JapanesePokemonSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
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 sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
|
||||
YuGiOhBandaiSortColumn column,
|
||||
bool ascending);
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -80,6 +80,15 @@ public:
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
|
||||
Result<void> saveAll(Game game, std::vector<TCard> cards) {
|
||||
Map map;
|
||||
for (auto& card : cards) {
|
||||
map.insert_or_assign(card.id, std::move(card));
|
||||
}
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Remove the card with the given id. Also deletes any associated images
|
||||
// via the IImageStore (best-effort - image removal failures are logged in
|
||||
// the error string but the card itself is still purged from the JSON).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
|
||||
// the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<DigiBattle99SetCatalog> load() const;
|
||||
Result<void> save(const DigiBattle99SetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
|
||||
// inflate the numerator. An optional languageFilter restricts ownership to
|
||||
// cards of that language (packs with zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99SetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct DigiBattle99ChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection, in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped. When languageFilter is
|
||||
// set, only cards of that language count toward ownership.
|
||||
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
// When languageFilter is set, only cards of that language count as owned.
|
||||
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
|
||||
// pokemon/set-catalog-asia.json under the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<PokemonSetCatalog> load(PokemonRegion region) const;
|
||||
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists(PokemonRegion region) const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Pokemon set-completion progress and per-set checklists.
|
||||
// Ownership requires matching PokemonRegion for the pack (West vs Asia),
|
||||
// matching set.id, and a normalized collector number / localId. Duplicates /
|
||||
// amount / holo / firstEdition do not inflate the numerator. Optional
|
||||
// regionFilter and languageFilter restrict which cards count (packs with
|
||||
// zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonSetCompletionProgress {
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::string releaseDate; // YYYY/MM/DD from owned cards; may be empty
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct PokemonChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection (optionally region-scoped),
|
||||
// in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
|
||||
std::optional<PokemonRegion> regionFilter = std::nullopt);
|
||||
|
||||
// Distinct regions that have ≥1 owned card matching a catalog pack.
|
||||
[[nodiscard]] std::vector<PokemonRegion>
|
||||
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog);
|
||||
|
||||
// Packs where the collection owns ≥1 matching card, ordered by releaseDate
|
||||
// then setName then region. When regionFilter is set, only that region's
|
||||
// catalog/cards count.
|
||||
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
|
||||
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
std::optional<PokemonRegion> regionFilter = std::nullopt,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
[[nodiscard]] std::vector<PokemonChecklistEntry>
|
||||
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
PokemonRegion region,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -27,6 +27,10 @@ public:
|
||||
// repository, and return the new list.
|
||||
Result<std::vector<Set>> updateSets(Game game);
|
||||
|
||||
// Persist an already-fetched set list (no HTTP). Used when a game-specific
|
||||
// Update Sets path fetches sets + side payloads in one round-trip.
|
||||
Result<void> saveSets(Game game, const std::vector<Set>& sets);
|
||||
|
||||
// Cached read; returns an error if no local data exists yet.
|
||||
Result<std::vector<Set>> getSets(Game game);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiSetCatalogService: load/save yugiohbandai/set-catalog.json.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
YuGiOhBandaiSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<YuGiOhBandaiSetCatalog> load() const;
|
||||
Result<void> save(const YuGiOhBandaiSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Bandai set-completion progress and per-set checklists.
|
||||
// Ownership keys on (set.id, normalized setNo). Never name-only.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhBandaiSetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct YuGiOhBandaiChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
std::string rarity;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
[[nodiscard]] std::vector<Language>
|
||||
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection);
|
||||
|
||||
[[nodiscard]] std::vector<YuGiOhBandaiSetCompletionProgress>
|
||||
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
|
||||
const YuGiOhBandaiSetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
[[nodiscard]] std::vector<YuGiOhBandaiChecklistEntry>
|
||||
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
|
||||
const YuGiOhBandaiSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
|
||||
// configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<YuGiOhSetCatalog> load() const;
|
||||
Result<void> save(const YuGiOhSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
|
||||
// amount / rarity / firstEdition do not inflate the numerator. An optional
|
||||
// languageFilter restricts ownership to cards of that language (packs with
|
||||
// zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhSetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct YuGiOhChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection, in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped. When languageFilter is
|
||||
// set, only cards of that language count toward ownership.
|
||||
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
|
||||
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
// When languageFilter is set, only cards of that language count as owned.
|
||||
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
|
||||
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// ASCII-only tolower for sort/filter parity with the legacy TS path:
|
||||
// String.prototype.toLowerCase() on English/German/etc. card metadata behaves
|
||||
// identically for this byte range.
|
||||
[[nodiscard]] inline std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
// Shared rule for bidirectional Set # Auto detect (name ↔ set number):
|
||||
// when both fields are filled, the field the user last edited is the lookup key.
|
||||
|
||||
namespace ccm {
|
||||
|
||||
enum class CardLookupEditField { None, Name, SetNo };
|
||||
|
||||
// Returns true when Auto detect should run setNo → name (reverse).
|
||||
// `nameEmpty` / `setNoEmpty` are already trimmed/normalized by the caller.
|
||||
// When both are empty the result is false (caller shows a validation message).
|
||||
// When only one is filled, that direction wins. When both are filled, SetNo
|
||||
// wins only if it was the last edited lookup field; otherwise Name wins
|
||||
// (including `None`, matching the historical default).
|
||||
[[nodiscard]] inline bool preferDetectBySetNo(bool nameEmpty,
|
||||
bool setNoEmpty,
|
||||
CardLookupEditField lastEdited) noexcept {
|
||||
if (nameEmpty) return !setNoEmpty;
|
||||
if (setNoEmpty) return false;
|
||||
return lastEdited == CardLookupEditField::SetNo;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Shared classification for raw HTTP GET outcomes (transport vs status vs OK).
|
||||
// `CprHttpClient::get` delegates here so doctest can exercise the branches
|
||||
// without touching libcpr or the network stack.
|
||||
[[nodiscard]] inline Result<std::string> mapHttpGetResponse(bool curlTransportError,
|
||||
std::string_view curlErrorMessage,
|
||||
long httpStatusCode,
|
||||
std::string responseBody,
|
||||
std::string_view requestUrl) {
|
||||
if (curlTransportError) {
|
||||
return Result<std::string>::err(std::string("HTTP error: ") +
|
||||
std::string(curlErrorMessage));
|
||||
}
|
||||
if (httpStatusCode < 200 || httpStatusCode >= 300) {
|
||||
return Result<std::string>::err(
|
||||
"HTTP " + std::to_string(httpStatusCode) + " from " +
|
||||
std::string(requestUrl));
|
||||
}
|
||||
return Result<std::string>::ok(std::move(responseBody));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Needed because cpr does not encode the URL
|
||||
// string passed to IHttpClient::get.
|
||||
[[nodiscard]] inline std::string rfc3986PercentEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
// Natural (alphanumeric) ordering for collector / set numbers.
|
||||
// Digit runs compare as integers so "2" < "10" < "100"; non-digit runs use
|
||||
// ordinary string order (e.g. "SWSH001" < "SWSH002").
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// strcmp-style: <0 if a < b, 0 if equal (after natural + lex tie-break), >0 if a > b.
|
||||
[[nodiscard]] int compareSetNoNatural(std::string_view a, std::string_view b) noexcept;
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,126 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
// Digits from a full set code (after '-') or from a digits-only Set # field.
|
||||
[[nodiscard]] inline std::string ygoCollectorDigitsFromInput(std::string_view raw) {
|
||||
const std::string_view s = trimAsciiSpaces(raw);
|
||||
if (s.find('-') != std::string_view::npos) return ygoCollectorDigitsOnly(s);
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string ygoDigitsStripLeadingZeros(std::string digits) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < digits.size() && digits[i] == '0') ++i;
|
||||
if (i > 0) digits.erase(0, i);
|
||||
return digits;
|
||||
}
|
||||
|
||||
// True when both designate the same collector number, ignoring leading zeros
|
||||
// ("5" == "005") and accepting either a full set code or digits-only input.
|
||||
[[nodiscard]] inline bool ygoCollectorDigitsEqual(std::string_view a,
|
||||
std::string_view b) {
|
||||
return ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(a)) ==
|
||||
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(b));
|
||||
}
|
||||
|
||||
// True when both strings designate the same printed slot: same abbreviation
|
||||
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
|
||||
// extracted from everything after that dash.
|
||||
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
|
||||
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
|
||||
return ygoCollectorDigitsEqual(a, 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
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// Resolves a Yu-Gi-Oh! product code (YGOPRODeck `set_code`, stored as `Set.id`)
|
||||
// against a cached set list. Used by the Yu-Gi-Oh! edit dialog "set code" mode.
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhSetShorthandLookup {
|
||||
enum class Kind { Unique, NotFound, Ambiguous };
|
||||
|
||||
Kind kind{Kind::NotFound};
|
||||
std::size_t index{0};
|
||||
};
|
||||
|
||||
[[nodiscard]] inline std::string normalizeYuGiOhSetIdForLookup(std::string_view id) {
|
||||
std::string out;
|
||||
out.reserve(id.size());
|
||||
for (unsigned char uch : id) {
|
||||
out.push_back(static_cast<char>(std::tolower(uch)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string_view trimAsciiWhitespace(std::string_view s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline YuGiOhSetShorthandLookup lookupYuGiOhSetByShorthand(
|
||||
std::string_view query, const std::vector<Set>& sets) {
|
||||
const std::string_view trimmed = trimAsciiWhitespace(query);
|
||||
if (trimmed.empty()) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
|
||||
}
|
||||
const std::string qNorm = normalizeYuGiOhSetIdForLookup(trimmed);
|
||||
|
||||
std::size_t firstIdx = 0;
|
||||
int matchCount = 0;
|
||||
for (std::size_t i = 0; i < sets.size(); ++i) {
|
||||
if (normalizeYuGiOhSetIdForLookup(sets[i].id) == qNorm) {
|
||||
if (matchCount == 0) firstIdx = i;
|
||||
++matchCount;
|
||||
if (matchCount > 1) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::Ambiguous, 0};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchCount == 1) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::Unique, firstIdx};
|
||||
}
|
||||
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) {
|
||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||
j.at("dataStorage").get_to(c.dataStorage);
|
||||
j.at("defaultGame").get_to(c.defaultGame);
|
||||
// JapanesePokemon was folded into Pokemon (West/Asia region). Coerce so
|
||||
// older config.json files keep a valid user-facing default game.
|
||||
if (c.defaultGame == Game::JapanesePokemon) {
|
||||
c.defaultGame = Game::Pokemon;
|
||||
}
|
||||
c.theme = j.value("theme", Theme::Light);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
|
||||
std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
+99
-33
@@ -3,28 +3,48 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#define CCM_UNREACHABLE() __builtin_unreachable()
|
||||
#else
|
||||
#define CCM_UNREACHABLE() ((void)0)
|
||||
#endif
|
||||
|
||||
namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::DigiBattle99: return "DigiBattle99";
|
||||
case Game::YuGiOhBandai: return "YuGiOhBandai";
|
||||
case Game::JapanesePokemon: return "JapanesePokemon";
|
||||
}
|
||||
return "Magic";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(PokemonRegion r) noexcept {
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return "West";
|
||||
case PokemonRegion::Asia: return "Asia";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
switch (l) {
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::Chinese: return "Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::SimplifiedChinese: return "S-Chinese";
|
||||
case Language::TraditionalChinese: return "T-Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Korean: return "Korean";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
return "English";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Condition c) noexcept {
|
||||
@@ -37,7 +57,7 @@ std::string_view to_string(Condition c) noexcept {
|
||||
case Condition::Played: return "Played";
|
||||
case Condition::Poor: return "Poor";
|
||||
}
|
||||
return "Mint";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Theme t) noexcept {
|
||||
@@ -45,24 +65,38 @@ std::string_view to_string(Theme t) noexcept {
|
||||
case Theme::Light: return "Light";
|
||||
case Theme::Dark: return "Dark";
|
||||
}
|
||||
return "Light";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
||||
if (s == "YuGiOhBandai") return Game::YuGiOhBandai;
|
||||
if (s == "JapanesePokemon") return Game::JapanesePokemon;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept {
|
||||
if (s == "West") return PokemonRegion::West;
|
||||
if (s == "Asia") return PokemonRegion::Asia;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "Chinese") return Language::Chinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "S-Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "T-Chinese") return Language::TraditionalChinese;
|
||||
// Legacy single Chinese spelling → Simplified.
|
||||
if (s == "Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Korean") return Language::Korean;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -83,15 +117,18 @@ 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, 5>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 5> v{
|
||||
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::YuGiOhBandai,
|
||||
Game::DigiBattle99};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Language, 8>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 8> v{
|
||||
const std::array<Language, 10>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 10> v{
|
||||
Language::English, Language::German, Language::French, Language::Spanish,
|
||||
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
|
||||
Language::Italian, Language::SimplifiedChinese, Language::TraditionalChinese,
|
||||
Language::Japanese, Language::Korean, Language::Russian
|
||||
};
|
||||
return v;
|
||||
}
|
||||
@@ -109,16 +146,45 @@ const std::array<Theme, 2>& allThemes() noexcept {
|
||||
return v;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
|
||||
static constexpr std::array<Language, 6> kWest{
|
||||
Language::English, Language::German, Language::French,
|
||||
Language::Spanish, Language::Italian, Language::Russian};
|
||||
static constexpr std::array<Language, 4> kAsia{
|
||||
Language::Japanese, Language::SimplifiedChinese,
|
||||
Language::TraditionalChinese, Language::Korean};
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return kWest;
|
||||
case PokemonRegion::Asia: return kAsia;
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
return kWest;
|
||||
}
|
||||
|
||||
Game pokemonBackendGame(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Game::JapanesePokemon : Game::Pokemon;
|
||||
}
|
||||
|
||||
Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Language::Japanese : Language::English;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, PokemonRegion v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
|
||||
void from_json(const nlohmann::json& j, Game& v) {
|
||||
auto parsed = gameFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v) {
|
||||
auto parsed = pokemonRegionFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Language& v) {
|
||||
auto parsed = languageFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
@@ -17,6 +19,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
{"region", c.region},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +37,13 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
// Missing `region` defaults to West so pre-merge West-only files still load.
|
||||
c.region = j.value("region", PokemonRegion::West);
|
||||
// Migrate legacy pokemontcg.io West set ids to TCGdex EN on load so the
|
||||
// next collection save persists canonical ids. Asia ids are untouched.
|
||||
if (c.region == PokemonRegion::West && !c.set.id.empty()) {
|
||||
c.set.id = canonicalizeWestSetId(c.set.id);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"rarity", c.rarity},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("rarity").get_to(c.rarity);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const YuGiOhBandaiSetCatalogPack* YuGiOhBandaiSetCatalog::findPack(
|
||||
std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}, {"rarity", c.rarity}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
if (j.contains("rarity")) {
|
||||
j.at("rarity").get_to(c.rarity);
|
||||
} else {
|
||||
c.rarity.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
if (!c.rarity.empty()) j["rarity"] = c.rarity;
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
c.rarity = j.value("rarity", "");
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,305 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool cardInPack(const nlohmann::json& card, std::string_view packName) {
|
||||
if (packName.empty()) return true;
|
||||
if (!card.contains("set_name") || !card.at("set_name").is_array()) return false;
|
||||
for (const auto& pack : card.at("set_name")) {
|
||||
if (pack.is_string() && pack.get<std::string>() == packName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric collector suffix: "ST-01" → "01", "01" → "01", "BO-115" → "115".
|
||||
std::string numericSuffix(std::string_view setNo) {
|
||||
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
|
||||
const auto dash = n.find('-');
|
||||
const std::string_view tail =
|
||||
dash == std::string::npos ? std::string_view{n} : std::string_view{n}.substr(dash + 1);
|
||||
std::string out;
|
||||
out.reserve(tail.size());
|
||||
for (unsigned char c : tail) {
|
||||
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string digits) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < digits.size() && digits[i] == '0') ++i;
|
||||
if (i > 0) digits.erase(0, i);
|
||||
return digits;
|
||||
}
|
||||
|
||||
bool hasAlphabeticPrefix(std::string_view setNo) {
|
||||
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
|
||||
return !n.empty() && std::isalpha(static_cast<unsigned char>(n.front())) != 0;
|
||||
}
|
||||
|
||||
// Exact id match, or digits-only input matched to the numeric suffix with
|
||||
// leading zeros ignored ("1" ↔ "ST-01", but not "ST-11").
|
||||
bool cardNumbersMatch(std::string_view wanted, std::string_view actual) {
|
||||
const std::string a = DigiBattle99CardPreviewSource::normalizeCardNumber(wanted);
|
||||
const std::string b = DigiBattle99CardPreviewSource::normalizeCardNumber(actual);
|
||||
if (a.empty() || b.empty()) return false;
|
||||
if (a == b) return true;
|
||||
// Full id typed (ST-01): require exact normalized equality only.
|
||||
if (hasAlphabeticPrefix(a)) return false;
|
||||
return stripLeadingZeros(numericSuffix(a)) == stripLeadingZeros(numericSuffix(b));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
|
||||
: http_(http) {}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::normalizeCardNumber(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
if (s.empty()) return s;
|
||||
// Uppercase leading alphabetic prefix (ST / BO / MO / Fx-style).
|
||||
std::size_t i = 0;
|
||||
while (i < s.size() && std::isalpha(static_cast<unsigned char>(s[i]))) {
|
||||
s[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
|
||||
++i;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildImageUrl(std::string_view setNo) {
|
||||
const std::string id = normalizeCardNumber(setNo);
|
||||
return std::string(kImageBase) + id + ".jpg";
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
std::string url = "https://digimoncard.io/api-public/search.php?series=";
|
||||
url += rfc3986PercentEncode(kSeries);
|
||||
if (!name.empty()) {
|
||||
url += "&n=";
|
||||
url += rfc3986PercentEncode(name);
|
||||
}
|
||||
if (!setName.empty()) {
|
||||
url += "&pack=";
|
||||
url += rfc3986PercentEncode(setName);
|
||||
}
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
url += "&card=";
|
||||
url += rfc3986PercentEncode(num);
|
||||
}
|
||||
url += "&sort=name&sortdirection=asc";
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err({K::NotFound, j.value("error", std::string{"No cards found."})});
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "digimoncard.io Digi-Battle response is not a JSON array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
const nlohmann::json* chosen = nullptr;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedLower) continue;
|
||||
}
|
||||
chosen = &card;
|
||||
break;
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
const std::string id = normalizeCardNumber(chosen->value("id", ""));
|
||||
if (id.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle card has no id / card number."});
|
||||
}
|
||||
return R::ok(buildImageUrl(id));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
return R::ok(buildImageUrl(num));
|
||||
}
|
||||
if (name.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle preview requires a card name or set number."});
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseImageUrlFromSearch(resp.value(), name);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName,
|
||||
std::string_view wantedSetNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err(j.value("error", std::string{"No cards found."}));
|
||||
}
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
const std::string wantedPack = trim(std::string(setName));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
const std::string wantedNo = normalizeCardNumber(wantedSetNo);
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!cardInPack(card, wantedPack)) continue;
|
||||
AutoDetectedPrint out;
|
||||
out.name = trim(card.value("name", ""));
|
||||
out.setNo = normalizeCardNumber(card.value("id", ""));
|
||||
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
|
||||
if (out.setNo.empty()) continue;
|
||||
// digimoncard.io `card=` is fuzzy (card=1 can return ST-01 and ST-11).
|
||||
// When the user typed a number, keep only exact / zero-padded matches.
|
||||
if (!wantedNo.empty() && !cardNumbersMatch(wantedNo, out.setNo)) continue;
|
||||
collected.push_back(std::move(out));
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
if (!wantedNo.empty()) {
|
||||
return R::err("Could not auto-detect Digi-Battle card name from set number.");
|
||||
}
|
||||
if (!wantedNameLower.empty() && !wantedPack.empty()) {
|
||||
return R::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
if (seen.insert(p.setNo).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectFirstPrint(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
auto list = detectPrintVariants(name, setName);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setName, name);
|
||||
}
|
||||
// Retry name-only; still filter by pack in parsePrintVariants.
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "", "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setName, name);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectBySetNo(
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setName, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect Digi-Battle card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectVariantsBySetNo(
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (trim(std::string(setName)).empty()) return R::err("Select a set first.");
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (num.empty()) return R::err("Card number is empty.");
|
||||
|
||||
const std::string url = buildSearchUrl("", setName, num);
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
auto parsed = parsePrintVariants(resp.value(), setName, "", num);
|
||||
if (parsed && !parsed.value().empty()) return parsed;
|
||||
}
|
||||
// Retry number-only; still filter by pack + exact/padded number.
|
||||
const std::string fallbackUrl = buildSearchUrl("", "", num);
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) {
|
||||
if (resp) return R::err("Could not auto-detect Digi-Battle card name from set number.");
|
||||
return R::err(fallback.error());
|
||||
}
|
||||
return parsePrintVariants(fallback.value(), setName, "", num);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,206 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Curated EN release dates for the vintage Digi-Battle product line.
|
||||
// Series 1 Starter is verified 1999-06-01; other entries use digimoncard.io /
|
||||
// checklist years (day unknown -> YYYY/01/01 or mid-year anchors for ordering).
|
||||
const std::unordered_map<std::string, std::string>& curatedReleaseDates() {
|
||||
static const std::unordered_map<std::string, std::string> kDates{
|
||||
{"Series 1 Starter Set", "1999/06/01"},
|
||||
{"Series 1 Booster Pack", "1999/06/01"},
|
||||
{"Series 2 Booster Pack", "1999/09/01"},
|
||||
{"Series 3 Booster Pack", "2000/01/01"},
|
||||
{"Series 4 Booster Pack", "2000/06/01"},
|
||||
{"Series 5 Booster Pack", "2000/10/01"},
|
||||
{"Series 6 Booster Pack", "2001/01/01"},
|
||||
{"Street Starter Set 1", "2001/01/01"},
|
||||
{"Street Starter Set 2", "2001/02/01"},
|
||||
{"Street Starter Set 3", "2001/03/01"},
|
||||
{"Street Starter Set 4", "2001/04/01"},
|
||||
{"Digimon The Movie Promo Cards", "2000/10/01"},
|
||||
};
|
||||
return kDates;
|
||||
}
|
||||
|
||||
std::string releaseDateForPack(const std::string& packName) {
|
||||
const auto& dates = curatedReleaseDates();
|
||||
const auto it = dates.find(packName);
|
||||
if (it != dates.end()) return it->second;
|
||||
return {};
|
||||
}
|
||||
|
||||
Result<nlohmann::json> parseSearchArray(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return Result<nlohmann::json>::err(
|
||||
j.value("error", std::string{"digimoncard.io set search error"}));
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return Result<nlohmann::json>::err(
|
||||
"digimoncard.io Digi-Battle response is not a JSON array.");
|
||||
}
|
||||
return Result<nlohmann::json>::ok(j);
|
||||
} catch (const std::exception& e) {
|
||||
return Result<nlohmann::json>::err(
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
|
||||
std::string out;
|
||||
out.reserve(packName.size());
|
||||
bool pendingHyphen = false;
|
||||
for (unsigned char ch : packName) {
|
||||
if (std::isalnum(ch)) {
|
||||
if (pendingHyphen && !out.empty()) out.push_back('-');
|
||||
pendingHyphen = false;
|
||||
out.push_back(static_cast<char>(std::tolower(ch)));
|
||||
} else {
|
||||
pendingHyphen = !out.empty();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<std::vector<Set>>::err(arr.error());
|
||||
|
||||
// Preserve first-seen order of pack names, then sort by release date.
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<std::string> packNames;
|
||||
packNames.reserve(16);
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string name = pack.get<std::string>();
|
||||
if (name.empty()) continue;
|
||||
if (seen.insert(name).second) packNames.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Set> out;
|
||||
out.reserve(packNames.size());
|
||||
for (const auto& name : packNames) {
|
||||
Set s;
|
||||
s.id = slugifyPackName(name);
|
||||
s.name = name;
|
||||
s.releaseDate = releaseDateForPack(name);
|
||||
if (s.id.empty()) continue;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
|
||||
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
|
||||
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
|
||||
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
|
||||
|
||||
// pack display name -> (setId, ordered unique cards by first-seen setNo)
|
||||
struct PackBuild {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::unordered_set<std::string> seenNos;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
};
|
||||
std::unordered_map<std::string, PackBuild> byName;
|
||||
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
|
||||
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
|
||||
DigiBattle99CatalogCard card;
|
||||
card.name = entry.at("name").get<std::string>();
|
||||
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
|
||||
entry.at("id").get<std::string>());
|
||||
if (card.setNo.empty()) continue;
|
||||
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string packName = pack.get<std::string>();
|
||||
if (packName.empty()) continue;
|
||||
|
||||
auto& build = byName[packName];
|
||||
if (build.setName.empty()) {
|
||||
build.setName = packName;
|
||||
build.setId = slugifyPackName(packName);
|
||||
}
|
||||
if (build.setId.empty()) continue;
|
||||
if (!build.seenNos.insert(card.setNo).second) continue;
|
||||
build.cards.push_back(card);
|
||||
}
|
||||
}
|
||||
|
||||
DigiBattle99SetCatalog catalog;
|
||||
catalog.packs.reserve(byName.size());
|
||||
for (auto& [_, build] : byName) {
|
||||
if (build.setId.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
DigiBattle99SetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetSource::FetchWithCatalog>
|
||||
DigiBattle99SetSource::fetchAllWithCatalog() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
|
||||
|
||||
auto sets = parseResponse(resp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
auto catalog = parseCatalog(resp.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
|
||||
auto both = fetchAllWithCatalog();
|
||||
if (!both) return Result<std::vector<Set>>::err(both.error());
|
||||
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,40 +1,16 @@
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
|
||||
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
|
||||
// which does not URL-encode the URL string we hand it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Apply the same name massaging as the legacy query path before sending.
|
||||
std::string sanitizeName(std::string_view name) {
|
||||
std::string s(name);
|
||||
@@ -59,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,53 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
|
||||
// Magic implementation; kept private so the two can drift independently if a
|
||||
// future API requires it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Strip everything after the first '/' in a Pokemon collector number.
|
||||
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
|
||||
// `4/102`. Without this, no API match is found.
|
||||
std::string normalizeNumber(std::string_view setNo) {
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string_view s) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return std::string(s.substr(i));
|
||||
}
|
||||
|
||||
// Exact localId match after slash-normalization, or leading-zero-insensitive
|
||||
// equality ("4" ↔ "04", not "4" ↔ "14").
|
||||
bool localIdsMatch(std::string_view a, std::string_view b) {
|
||||
const std::string na = PokemonCardPreviewSource::normalizeCollectorNumber(a);
|
||||
const std::string nb = PokemonCardPreviewSource::normalizeCollectorNumber(b);
|
||||
if (na.empty() || nb.empty()) return false;
|
||||
if (na == nb) return true;
|
||||
return stripLeadingZeros(na) == stripLeadingZeros(nb);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
@@ -46,65 +56,360 @@ std::string normalizeNumber(std::string_view setNo) {
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
|
||||
if (imageBase.empty()) return {};
|
||||
std::string url(imageBase);
|
||||
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
|
||||
return url + "/high.png";
|
||||
}
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
std::string id = idCanon + "-" + num;
|
||||
return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/en/sets/") +
|
||||
rfc3986PercentEncode(canonicalizeWestSetId(setId));
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
// Build the unencoded query first so the output matches what the Pokemon
|
||||
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
|
||||
std::string query = "name:\"";
|
||||
query += std::string(name);
|
||||
query += "\"";
|
||||
if (!setId.empty()) {
|
||||
query += " set.id:";
|
||||
query += std::string(setId);
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
std::string url = "https://api.tcgdex.net/v2/en/cards?";
|
||||
bool first = true;
|
||||
auto append = [&](std::string_view key, std::string_view value) {
|
||||
if (value.empty()) return;
|
||||
if (!first) url += '&';
|
||||
first = false;
|
||||
url += std::string(key);
|
||||
url += "=eq:";
|
||||
url += rfc3986PercentEncode(value);
|
||||
};
|
||||
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
append("set.id", idCanon);
|
||||
append("localId", num);
|
||||
} else {
|
||||
append("name", name);
|
||||
append("set.id", idCanon);
|
||||
append("localId", num);
|
||||
}
|
||||
const std::string num = normalizeNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
query += " number:";
|
||||
query += num;
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseSetCards(const std::string& body) {
|
||||
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex EN set detail missing 'cards' array."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
|
||||
std::vector<SetCardRow> out;
|
||||
out.reserve(j.at("cards").size());
|
||||
for (const auto& card : j.at("cards")) {
|
||||
SetCardRow row;
|
||||
row.localId = card.value("localId", "");
|
||||
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
|
||||
}
|
||||
row.name = card.value("name", "");
|
||||
row.rarity = card.value("rarity", "");
|
||||
if (card.contains("image") && card.at("image").is_string()) {
|
||||
row.imageBase = card.at("image").get<std::string>();
|
||||
}
|
||||
if (row.localId.empty()) continue;
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
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 R::ok(std::move(out));
|
||||
} 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("TCGdex EN set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string url = buildSearchUrl(name, setId, 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.is_object()) {
|
||||
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
|
||||
}
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex EN card has no image."});
|
||||
}
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex EN 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 EN card has no image."});
|
||||
}
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex EN card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "TCGdex EN cards search response is not an array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex EN returned no matching cards."});
|
||||
}
|
||||
for (const auto& card : j) {
|
||||
if (!card.contains("image") || !card.at("image").is_string()) continue;
|
||||
const std::string base = card.at("image").get<std::string>();
|
||||
if (base.empty()) continue;
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex EN matching cards have no image."});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex EN cards search JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
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 idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
|
||||
if (byId) {
|
||||
auto img = parseCardByIdResponse(byId.value());
|
||||
if (img) return img;
|
||||
// NotFound / Transient schema: fall through to search.
|
||||
}
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, idCanon, num);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseSearchResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view /*setId*/,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!wantedLower.empty()) {
|
||||
if (toLower(trim(row.name)) != wantedLower) continue;
|
||||
}
|
||||
const std::string localId = normalizeCollectorNumber(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto list = detectPrintVariants(name, setId);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
if (!idCanon.empty()) {
|
||||
auto detail = http_.get(buildSetDetailUrl(idCanon));
|
||||
if (detail) {
|
||||
auto parsed = parsePrintVariants(detail.value(), idCanon, name);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: filtered cards search by name (+ optional set).
|
||||
const std::string url = buildSearchUrl(name, idCanon, "");
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(resp.value());
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("TCGdex EN returned no matching cards.");
|
||||
}
|
||||
const std::string wantedLower = toLower(trim(std::string(name)));
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedLower) continue;
|
||||
}
|
||||
if (!idCanon.empty()) {
|
||||
std::string cardSetId;
|
||||
if (card.contains("set") && card.at("set").is_object()) {
|
||||
cardSetId = trim(card.at("set").value("id", ""));
|
||||
} else if (card.contains("id") && card.at("id").is_string()) {
|
||||
// Slim search hits are "setId-localId".
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) cardSetId = id.substr(0, dash);
|
||||
}
|
||||
if (cardSetId != idCanon) continue;
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
|
||||
print.rarity = trim(card.value("rarity", ""));
|
||||
if (print.setNo.empty() && print.rarity.empty()) continue;
|
||||
const std::string key = print.setNo + '\0' + print.rarity;
|
||||
if (!seen.insert(key).second) continue;
|
||||
collected.push_back(std::move(print));
|
||||
}
|
||||
if (collected.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::ok(std::move(collected));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::parsePrintFromCardById(
|
||||
const std::string& body) {
|
||||
using R = Result<AutoDetectedPrint>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object()) {
|
||||
return R::err("TCGdex EN card response is not a JSON object.");
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.name = trim(j.value("name", ""));
|
||||
print.setNo = normalizeCollectorNumber(j.value("localId", ""));
|
||||
print.rarity = trim(j.value("rarity", ""));
|
||||
if (print.name.empty()) {
|
||||
return R::err("TCGdex EN card has no name.");
|
||||
}
|
||||
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
|
||||
const std::string id = j.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) {
|
||||
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
|
||||
}
|
||||
}
|
||||
return R::ok(std::move(print));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex EN card JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
if (idCanon.empty()) return R::err("Select a set first.");
|
||||
if (num.empty()) return R::err("Card number is empty.");
|
||||
|
||||
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
|
||||
if (byId) {
|
||||
auto parsed = parsePrintFromCardById(byId.value());
|
||||
if (parsed && localIdsMatch(parsed.value().setNo, num)) {
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.push_back(std::move(parsed).value());
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: filtered search by set.id + localId.
|
||||
const std::string url = buildSearchUrl("", idCanon, num);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(resp.value());
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : j) {
|
||||
AutoDetectedPrint print;
|
||||
print.name = trim(card.value("name", ""));
|
||||
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
|
||||
print.rarity = trim(card.value("rarity", ""));
|
||||
if (print.name.empty()) continue;
|
||||
if (print.setNo.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) {
|
||||
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: TCGdex search can be fuzzy; never accept a
|
||||
// different localId (e.g. "14" when the user asked for "4").
|
||||
if (!localIdsMatch(print.setNo, num)) continue;
|
||||
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
|
||||
if (!seen.insert(key).second) continue;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::unordered_map<std::string, const Set*> indexById(const std::vector<Set>& sets) {
|
||||
std::unordered_map<std::string, const Set*> out;
|
||||
out.reserve(sets.size());
|
||||
for (const auto& s : sets) {
|
||||
if (s.id.empty()) continue;
|
||||
out.emplace(s.id, &s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool applySetMetadata(PokemonCard& card, const Set& upstream) {
|
||||
bool changed = false;
|
||||
if (card.set.name != upstream.name) {
|
||||
card.set.name = upstream.name;
|
||||
changed = true;
|
||||
}
|
||||
if (card.set.releaseDate != upstream.releaseDate) {
|
||||
card.set.releaseDate = upstream.releaseDate;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::size_t syncPokemonCollectionSets(std::vector<PokemonCard>& cards,
|
||||
const std::vector<Set>& westSets,
|
||||
const std::vector<Set>& asiaSets) {
|
||||
const auto westById = indexById(westSets);
|
||||
const auto asiaById = indexById(asiaSets);
|
||||
|
||||
std::size_t touched = 0;
|
||||
for (auto& card : cards) {
|
||||
bool changed = false;
|
||||
if (card.region == PokemonRegion::West) {
|
||||
if (!card.set.id.empty()) {
|
||||
const std::string canon = canonicalizeWestSetId(card.set.id);
|
||||
if (canon != card.set.id) {
|
||||
card.set.id = canon;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!card.set.id.empty()) {
|
||||
if (const auto it = westById.find(card.set.id); it != westById.end()) {
|
||||
if (applySetMetadata(card, *it->second)) changed = true;
|
||||
}
|
||||
}
|
||||
} else if (card.region == PokemonRegion::Asia) {
|
||||
if (!card.set.id.empty()) {
|
||||
if (const auto it = asiaById.find(card.set.id); it != asiaById.end()) {
|
||||
if (applySetMetadata(card, *it->second)) changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) ++touched;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,45 +1,170 @@
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
|
||||
std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/en/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"Pokemon TCG API response missing 'data' array.");
|
||||
"TCGdex EN sets response is not a JSON array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
|
||||
// no separator rewrite needed (cf. Scryfall's "released_at").
|
||||
s.releaseDate = entry.value("releaseDate", "");
|
||||
s.id = entry.value("id", "");
|
||||
if (s.id.empty()) continue;
|
||||
s.name = entry.value("name", "");
|
||||
s.releaseDate = {}; // filled from set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
std::string("TCGdex EN sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex EN set detail response is not a JSON object.");
|
||||
}
|
||||
const std::string raw = j.value("releaseDate", "");
|
||||
if (raw.empty()) {
|
||||
return Result<std::string>::ok(std::string{});
|
||||
}
|
||||
return Result<std::string>::ok(rewriteReleaseDate(raw));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set) {
|
||||
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
|
||||
if (!rows) {
|
||||
return Result<PokemonSetCatalogPack>::err(rows.error().message);
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& row : rows.value()) {
|
||||
const std::string localId =
|
||||
PokemonCardPreviewSource::normalizeCollectorNumber(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
std::string name = row.name;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return parsed;
|
||||
|
||||
std::vector<Set> out = std::move(parsed).value();
|
||||
for (auto& s : out) {
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue; // keep set with empty date rather than fail all
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
|
||||
|
||||
std::vector<Set> sets = std::move(parsed).value();
|
||||
PokemonSetCatalog catalog;
|
||||
catalog.packs.reserve(sets.size());
|
||||
|
||||
for (auto& s : sets) {
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue;
|
||||
|
||||
if (s.releaseDate.empty()) {
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
auto pack = parseCatalogPackFromSetDetail(detail.value(), s);
|
||||
if (pack) {
|
||||
catalog.packs.push_back(std::move(pack).value());
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against
|
||||
// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids
|
||||
// (base1, swsh1, sv10, sve, …) pass through unchanged.
|
||||
const std::unordered_map<std::string, std::string>& legacyAliases() {
|
||||
static const std::unordered_map<std::string, std::string> kMap{
|
||||
// Classic / EX / HGSS renames
|
||||
{"base6", "lc"},
|
||||
{"bp", "bog"},
|
||||
{"tk1a", "tk-ex-latia"},
|
||||
{"tk1b", "tk-ex-latio"},
|
||||
{"tk2a", "tk-ex-p"},
|
||||
{"tk2b", "tk-ex-m"},
|
||||
{"hsp", "hgssp"},
|
||||
|
||||
// McDonald's Collections
|
||||
{"mcd11", "2011bw"},
|
||||
{"mcd12", "2012bw"},
|
||||
{"mcd14", "2014xy"},
|
||||
{"mcd15", "2015xy"},
|
||||
{"mcd16", "2016xy"},
|
||||
{"mcd17", "2017sm"},
|
||||
{"mcd18", "2018sm"},
|
||||
{"mcd19", "2019sm"},
|
||||
{"mcd21", "2021swsh"},
|
||||
{"mcd22", "2022swsh"},
|
||||
{"mcd23", "2023sv"},
|
||||
{"mcd24", "2024sv"},
|
||||
|
||||
// SM specials
|
||||
{"sm35", "sm3.5"},
|
||||
{"sm75", "sm7.5"},
|
||||
|
||||
// SWSH specials / galleries
|
||||
{"swsh35", "swsh3.5"},
|
||||
{"swsh45", "swsh4.5"},
|
||||
{"swsh45sv", "swsh4.5sv"},
|
||||
{"cel25c", "cel25cc"},
|
||||
{"swsh9tg", "swsh9.5tg"},
|
||||
{"swsh10tg", "swsh10.5tg"},
|
||||
{"pgo", "swsh10.5"},
|
||||
{"swsh11tg", "swsh11.5tg"},
|
||||
{"swsh12tg", "swsh12.5tg"},
|
||||
{"swsh12pt5", "swsh12.5"},
|
||||
{"swsh12pt5gg", "swsh12.5gg"},
|
||||
|
||||
// Scarlet & Violet (pokemontcg used unpadded / pt5 forms)
|
||||
{"sv1", "sv01"},
|
||||
{"sv2", "sv02"},
|
||||
{"sv3", "sv03"},
|
||||
{"sv3pt5", "sv03.5"},
|
||||
{"sv4", "sv04"},
|
||||
{"sv4pt5", "sv04.5"},
|
||||
{"sv5", "sv05"},
|
||||
{"sv6", "sv06"},
|
||||
{"sv6pt5", "sv06.5"},
|
||||
{"sv7", "sv07"},
|
||||
{"sv8", "sv08"},
|
||||
{"sv8pt5", "sv08.5"},
|
||||
{"sv9", "sv09"},
|
||||
{"zsv10pt5", "sv10.5b"},
|
||||
{"rsv10pt5", "sv10.5w"},
|
||||
|
||||
// Mega Evolution era
|
||||
{"me1", "me01"},
|
||||
{"me2", "me02"},
|
||||
{"me2pt5", "me02.5"},
|
||||
{"me3", "me03"},
|
||||
{"me4", "me04"},
|
||||
{"me5", "me05"},
|
||||
};
|
||||
return kMap;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string canonicalizeWestSetId(std::string_view setId) {
|
||||
if (setId.empty()) return {};
|
||||
const auto& map = legacyAliases();
|
||||
const auto it = map.find(std::string(setId));
|
||||
if (it != map.end()) return it->second;
|
||||
return std::string(setId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,551 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string_view s) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return std::string(s.substr(i));
|
||||
}
|
||||
|
||||
bool localIdsMatch(std::string_view a, std::string_view b) {
|
||||
if (a == b) return true;
|
||||
return stripLeadingZeros(a) == stripLeadingZeros(b);
|
||||
}
|
||||
|
||||
bool catalogPrintMatchesRow(const JapanesePokemonPrintEnInfo& print,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row) {
|
||||
// Reject stale catalog rows whose Japanese name disagrees with TCGdex.
|
||||
// Seed data historically mapped Charmander→001 / Charizard→004; those
|
||||
// localIds are Bulbasaur / Weedle on PMCG1.
|
||||
if (print.nameJa.empty()) return true;
|
||||
return asciiLower(print.nameJa) == asciiLower(row.nameJa);
|
||||
}
|
||||
|
||||
bool nameMatchesRow(std::string_view wantedLower,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row,
|
||||
std::string_view setId,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
if (wantedLower.empty()) return true;
|
||||
if (asciiLower(row.nameJa) == wantedLower) return true;
|
||||
if (auto print = catalog.findPrint(setId, row.localId)) {
|
||||
if (!catalogPrintMatchesRow(*print, row)) return false;
|
||||
if (asciiLower(print->nameEn) == wantedLower) return true;
|
||||
if (asciiLower(print->nameJa) == wantedLower) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonCardPreviewSource::JapanesePokemonCardPreviewSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::normalizeLocalId(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) s.erase(slash);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildCardUrl(std::string_view setId,
|
||||
std::string_view localId) {
|
||||
std::string id = std::string(setId) + "-" + std::string(localId);
|
||||
return std::string("https://api.tcgdex.net/v2/ja/cards/") +
|
||||
rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
|
||||
if (imageBase.empty()) return {};
|
||||
std::string url(imageBase);
|
||||
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
|
||||
return url + "/high.png";
|
||||
}
|
||||
|
||||
Result<std::vector<JapanesePokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseSetCards(const std::string& body) {
|
||||
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex JA set detail missing 'cards' array."});
|
||||
}
|
||||
std::vector<SetCardRow> out;
|
||||
out.reserve(j.at("cards").size());
|
||||
for (const auto& card : j.at("cards")) {
|
||||
SetCardRow row;
|
||||
row.localId = card.value("localId", "");
|
||||
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
// Fallback: take suffix after last '-' from card id.
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
|
||||
}
|
||||
row.nameJa = card.value("name", "");
|
||||
row.rarity = card.value("rarity", "");
|
||||
if (card.contains("image") && card.at("image").is_string()) {
|
||||
row.imageBase = card.at("image").get<std::string>();
|
||||
}
|
||||
if (row.localId.empty()) continue;
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseCardImageUrl(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object()) {
|
||||
return R::err({K::Transient, "TCGdex JA card response is not a JSON object."});
|
||||
}
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex JA card image field is not a string."});
|
||||
}
|
||||
const std::string base = j.at("image").get<std::string>();
|
||||
if (base.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenCatalogUrls;
|
||||
|
||||
// Prefer catalog EN matches first so typed English names resolve — but
|
||||
// only when the catalog localId exists in the set and name_ja agrees
|
||||
// with TCGdex (guards against stale seed mappings).
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
const SetCardRow* row = nullptr;
|
||||
for (const auto& r : rows.value()) {
|
||||
if (localIdsMatch(r.localId, p.localId)) {
|
||||
row = &r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (row == nullptr) {
|
||||
// Set detail sometimes omits cards[]; keep catalog-only hits
|
||||
// (UnnumberedPromo). Dedupe only among non-empty preview URLs
|
||||
// so empty-image prints still appear in the Next ring.
|
||||
if (!rows.value().empty()) continue;
|
||||
if (!previewUrl.empty() &&
|
||||
!seenCatalogUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
} else if (!catalogPrintMatchesRow(p, *row)) {
|
||||
continue;
|
||||
}
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (row != nullptr) print.rarity = row->rarity;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!nameMatchesRow(wantedLower, row, setId, catalog)) continue;
|
||||
if (!seen.insert(row.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = row.localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty() && !wantedLower.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (!catalog.hasPrintsForSet(setId)) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenUrls;
|
||||
if (wantedLower.empty()) {
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
// Dedupe only among non-empty preview URLs so identical art is not
|
||||
// cycled; empty-image prints still join the Next ring (card-back).
|
||||
// Emit imaged prints first so Auto-detect lands on real art.
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (!previewUrl.empty() && !seenUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
if (out.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string localId = normalizeLocalId(setNo);
|
||||
if (setId.empty()) {
|
||||
return R::err({K::NotFound, "Japanese Pokemon preview requires a set id."});
|
||||
}
|
||||
|
||||
auto catalogPreviewFor = [&](std::string_view lid) -> Result<std::string, PreviewLookupError> {
|
||||
if (lid.empty()) return R::err({K::NotFound, "No catalog preview for print."});
|
||||
if (auto print = catalog_.findPrint(setId, lid)) {
|
||||
const std::string catalogUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(*print);
|
||||
if (!catalogUrl.empty()) return R::ok(catalogUrl);
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
};
|
||||
|
||||
// Prefer direct card fetch when we have a localId.
|
||||
if (!localId.empty()) {
|
||||
auto cardResp = http_.get(buildCardUrl(setId, localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
// NotFound from card object: fall through to set list / catalog.
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Synthetic / classic products: try catalog gap-fill before set detail.
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
// Known catalog print with no preview URL: honest miss (do not
|
||||
// borrow a sibling print's art via name match).
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
// Catalog-only products (City Gym theme decks, etc.) are not on TCGdex.
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
// Network failure and no catalog entry: Transient so a brief outage
|
||||
// is not negative-cached as a permanent miss.
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
auto rows = parseSetCards(setResp.value());
|
||||
if (!rows) return R::err(rows.error());
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
const SetCardRow* chosen = nullptr;
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!localId.empty() && localIdsMatch(row.localId, localId)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Name match only when setNo was not provided — never borrow a sibling
|
||||
// print's art for a concrete localId.
|
||||
if (chosen == nullptr && localId.empty() && !wantedLower.empty()) {
|
||||
for (const auto& row : rows.value()) {
|
||||
if (nameMatchesRow(wantedLower, row, setId, catalog_)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
// Empty cards[] with catalog prints: resolve from catalog.
|
||||
if (rows.value().empty() && catalog_.hasPrintsForSet(setId)) {
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!chosen->imageBase.empty()) {
|
||||
return R::ok(imageUrlFromBase(chosen->imageBase));
|
||||
}
|
||||
|
||||
// Try full card object — set résumé sometimes omits image.
|
||||
auto cardResp = http_.get(buildCardUrl(setId, chosen->localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Odd localId padding can 404; still try catalog gap-fill below.
|
||||
}
|
||||
|
||||
// Classic JA sets often have image:null on TCGdex. Prefer a catalog
|
||||
// printing-accurate TCGPlayer product image for this exact setId+localId
|
||||
// (never search other printings by Pokémon name).
|
||||
return catalogPreviewFor(chosen->localId);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint>
|
||||
JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto variants = detectPrintVariants(name, setId);
|
||||
if (!variants) return Result<AutoDetectedPrint>::err(variants.error());
|
||||
if (variants.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(variants.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) {
|
||||
if (setId.empty()) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Select a set before auto-detecting Japanese Pokemon prints.");
|
||||
}
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(setResp.error());
|
||||
}
|
||||
auto parsed = parsePrintVariants(setResp.value(), setId, name, catalog_);
|
||||
if (parsed) return parsed;
|
||||
// Empty/unusable TCGdex detail: fall back to catalog prints when present.
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::parsePrintFromCardResponse(
|
||||
const std::string& body) {
|
||||
using R = Result<AutoDetectedPrint>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object()) {
|
||||
return R::err("TCGdex JA card response is not a JSON object.");
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.name = trim(j.value("name", ""));
|
||||
print.setNo = normalizeLocalId(j.value("localId", ""));
|
||||
print.rarity = trim(j.value("rarity", ""));
|
||||
if (print.name.empty()) {
|
||||
return R::err("TCGdex JA card has no name.");
|
||||
}
|
||||
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
|
||||
const std::string id = j.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) {
|
||||
print.setNo = normalizeLocalId(id.substr(dash + 1));
|
||||
}
|
||||
}
|
||||
return R::ok(std::move(print));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex JA card JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
|
||||
std::string_view setId,
|
||||
std::string_view localId,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string id = normalizeLocalId(localId);
|
||||
if (setId.empty()) return R::err("Select a set first.");
|
||||
if (id.empty()) return R::err("Card number is empty.");
|
||||
|
||||
// Prefer exact key, then leading-zero-insensitive scan ("1" ↔ "001").
|
||||
auto found = catalog.findPrint(setId, id);
|
||||
if (!found) {
|
||||
for (const auto& print : catalog.printsForSet(setId)) {
|
||||
if (localIdsMatch(print.localId, id)) {
|
||||
found = print;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.name = !found->nameEn.empty() ? found->nameEn : found->nameJa;
|
||||
print.setNo = found->localId.empty() ? id : found->localId;
|
||||
if (print.name.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.push_back(std::move(print));
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::detectBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (setId.empty()) return R::err("Select a set first.");
|
||||
const std::string id = normalizeLocalId(setNo);
|
||||
if (id.empty()) return R::err("Card number is empty.");
|
||||
|
||||
auto cardResp = http_.get(buildCardUrl(setId, id));
|
||||
if (cardResp) {
|
||||
auto parsed = parsePrintFromCardResponse(cardResp.value());
|
||||
if (parsed && localIdsMatch(parsed.value().setNo, id)) {
|
||||
// Prefer EN catalog name when available (exact or zero-insensitive).
|
||||
if (auto cat = catalog_.findPrint(setId, id); cat && !cat->nameEn.empty()) {
|
||||
parsed.value().name = cat->nameEn;
|
||||
} else {
|
||||
for (const auto& p : catalog_.printsForSet(setId)) {
|
||||
if (localIdsMatch(p.localId, id) && !p.nameEn.empty()) {
|
||||
parsed.value().name = p.nameEn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.push_back(std::move(parsed).value());
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectVariantsBySetNoFromCatalog(setId, id, catalog_);
|
||||
}
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string printKey(std::string_view setId, std::string_view localId) {
|
||||
return std::string(setId) + '\0' + std::string(localId);
|
||||
}
|
||||
|
||||
bool isAsciiAlnumToken(std::string_view s) {
|
||||
if (s.empty()) return false;
|
||||
for (unsigned char ch : s) {
|
||||
if (!std::isalnum(ch)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True when `needle` appears in `hay` as a whole alphanumeric token
|
||||
/// (e.g. "mewtwo" in "team gr's mewtwo" / "mewtwo strikes back", but not
|
||||
/// "mew" inside "mewtwo"). ASCII needles only.
|
||||
bool containsWholeAsciiToken(std::string_view hay, std::string_view needle) {
|
||||
if (!isAsciiAlnumToken(needle)) return false;
|
||||
const std::size_t n = needle.size();
|
||||
for (std::size_t i = 0; i + n <= hay.size(); ++i) {
|
||||
if (hay.compare(i, n, needle) != 0) continue;
|
||||
const bool leftOk = i == 0 || !std::isalnum(static_cast<unsigned char>(hay[i - 1]));
|
||||
const bool rightOk =
|
||||
i + n == hay.size() ||
|
||||
!std::isalnum(static_cast<unsigned char>(hay[i + n]));
|
||||
if (leftOk && rightOk) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Result<JapanesePokemonEnCatalog>
|
||||
JapanesePokemonEnCatalog::parse(const std::string& jsonBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(jsonBody);
|
||||
JapanesePokemonEnCatalog out;
|
||||
|
||||
if (j.contains("sets") && j.at("sets").is_object()) {
|
||||
for (auto it = j.at("sets").begin(); it != j.at("sets").end(); ++it) {
|
||||
JapanesePokemonSetEnInfo info;
|
||||
info.nameEn = it.value().value("name_en", "");
|
||||
info.nameJa = it.value().value("name_ja", "");
|
||||
info.releaseDate = it.value().value("releaseDate", "");
|
||||
out.sets_[it.key()] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (j.contains("prints") && j.at("prints").is_array()) {
|
||||
for (const auto& entry : j.at("prints")) {
|
||||
JapanesePokemonPrintEnInfo info;
|
||||
info.setId = entry.value("set_id", "");
|
||||
info.localId = entry.value("local_id", "");
|
||||
info.nameEn = entry.value("name_en", "");
|
||||
info.nameJa = entry.value("name_ja", "");
|
||||
info.nameEnSource = entry.value("name_en_source", "");
|
||||
info.imageUrl = entry.value("image_url", "");
|
||||
if (entry.contains("tcgplayer_id")) {
|
||||
const auto& tp = entry.at("tcgplayer_id");
|
||||
if (tp.is_string()) {
|
||||
info.tcgplayerId = tp.get<std::string>();
|
||||
} else if (tp.is_number_integer()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::int64_t>());
|
||||
} else if (tp.is_number_unsigned()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::uint64_t>());
|
||||
}
|
||||
}
|
||||
if (info.setId.empty() || info.localId.empty()) continue;
|
||||
const std::string key = printKey(info.setId, info.localId);
|
||||
out.printKeysBySet_[info.setId].push_back(key);
|
||||
out.printsByKey_[key] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
return Result<JapanesePokemonEnCatalog>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<JapanesePokemonEnCatalog>::err(
|
||||
std::string("Japanese Pokemon EN catalog JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonSetEnInfo>
|
||||
JapanesePokemonEnCatalog::findSet(std::string_view setId) const {
|
||||
const auto it = sets_.find(std::string(setId));
|
||||
if (it == sets_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrint(std::string_view setId,
|
||||
std::string_view localId) const {
|
||||
const auto it = printsByKey_.find(printKey(setId, localId));
|
||||
if (it == printsByKey_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrintsByName(std::string_view setId,
|
||||
std::string_view cardName) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
if (cardName.empty()) return out;
|
||||
const std::string wanted = asciiLower(std::string(cardName));
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
const auto& p = pit->second;
|
||||
const std::string enLower = asciiLower(p.nameEn);
|
||||
const std::string jaLower = asciiLower(p.nameJa);
|
||||
// Exact, qualified "Mewtwo (...)", or whole-token in a longer title
|
||||
// ("Team GR's Mewtwo", "Mewtwo Strikes Back (...)").
|
||||
if (enLower == wanted || jaLower == wanted ||
|
||||
enLower.starts_with(wanted + " (") ||
|
||||
containsWholeAsciiToken(enLower, wanted) ||
|
||||
containsWholeAsciiToken(jaLower, wanted)) {
|
||||
out.push_back(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noexcept {
|
||||
const auto it = printKeysBySet_.find(std::string(setId));
|
||||
return it != printKeysBySet_.end() && !it->second.empty();
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
out.reserve(keysIt->second.size());
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
out.push_back(pit->second);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
|
||||
if (productId.empty()) return {};
|
||||
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
|
||||
std::string(productId) + ".jpg";
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print) {
|
||||
if (!print.imageUrl.empty()) return print.imageUrl;
|
||||
return tcgplayerImageUrl(print.tcgplayerId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
JapanesePokemonGameModule::JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog)
|
||||
: catalog_(std::move(catalog)),
|
||||
setSource_(http, catalog_),
|
||||
previewSource_(http, catalog_) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,353 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
struct ClassicMissingProduct {
|
||||
const char* id;
|
||||
const char* nameEn;
|
||||
const char* releaseDate; // YYYY/MM/DD
|
||||
};
|
||||
|
||||
// Keep in sync with tools/pokemon_jp/classic_missing_sets.json and
|
||||
// docs/assets-and-info-apis.md (Japanese Pokémon Info API).
|
||||
constexpr std::array<ClassicMissingProduct, 11> kMissingClassicProducts{{
|
||||
// Day after Pokémon Jungle (PMCG2, 1997/03/05) so the set list places
|
||||
// Unnumbered Promo immediately after Jungle when sorted by releaseDate.
|
||||
{"UnnumberedPromo", "Unnumbered Promotional cards", "1997/03/06"},
|
||||
{"ExpSheet1", "Expansion Sheet Series 1", "1998/03/23"},
|
||||
{"NiviCG", "Nivi City Gym", "1998/04/26"},
|
||||
{"HanadaCG", "Hanada City Gym", "1998/04/26"},
|
||||
{"ExpSheet2", "Expansion Sheet Series 2", "1998/06/17"},
|
||||
{"KuchibaCG", "Kuchiba City Gym", "1998/07/25"},
|
||||
{"TamamushiCG", "Tamamushi City Gym", "1998/07/25"},
|
||||
{"ExpSheet3", "Expansion Sheet Series 3", "1998/11/24"},
|
||||
{"YamabukiCG", "Yamabuki City Gym", "1999/02/26"},
|
||||
{"GurenTG", "Guren Town Gym", "1999/02/26"},
|
||||
{"SouthernIslands", "Southern Islands", "1999/07/17"},
|
||||
}};
|
||||
|
||||
const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
|
||||
// Field-level corrections for known TCGdex JA mislabels (never edit cache).
|
||||
static const std::unordered_map<std::string, std::string> kOverrides{
|
||||
{"SV4a", "シャイニートレジャーex"},
|
||||
};
|
||||
return kOverrides;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool containsCjk(std::string_view s) noexcept {
|
||||
// Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE3–0xE9).
|
||||
// Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK —
|
||||
// that used to wipe catalog English names back to the set id.
|
||||
for (unsigned char ch : s) {
|
||||
if (ch >= 0xE3 && ch <= 0xE9) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
|
||||
const JapanesePokemonEnCatalog& enCatalog) {
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : pack.cards) {
|
||||
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
|
||||
}
|
||||
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
std::string name = print.nameEn;
|
||||
if (name.empty()) name = print.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
}
|
||||
|
||||
void sortPackCards(PokemonSetCatalogPack& pack) {
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
}
|
||||
|
||||
void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) {
|
||||
if (auto en = catalog.findSet(s.id)) {
|
||||
if (!en->nameEn.empty()) s.name = en->nameEn;
|
||||
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
|
||||
}
|
||||
if (s.name.empty() || containsCjk(s.name)) {
|
||||
s.name = s.id;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonSetSource::JapanesePokemonSetSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
bool JapanesePokemonSetSource::shouldExcludeSetId(std::string_view setId) noexcept {
|
||||
// Chinese-region CS* entries are mislabeled on the JA endpoint.
|
||||
return setId.size() >= 2 && setId[0] == 'C' && setId[1] == 'S';
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa) {
|
||||
const auto& overrides = setNameJaOverrides();
|
||||
const auto it = overrides.find(std::string(setId));
|
||||
if (it != overrides.end()) return it->second;
|
||||
return nameJa;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>>
|
||||
JapanesePokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"TCGdex JA sets response is not a JSON array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
if (s.id.empty() || shouldExcludeSetId(s.id)) continue;
|
||||
s.name = applySetNameOverride(s.id, entry.value("name", ""));
|
||||
s.releaseDate = {}; // filled from catalog or set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
appendMissingClassicProducts(out);
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("TCGdex JA sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::appendMissingClassicProducts(std::vector<Set>& sets) {
|
||||
for (const auto& product : kMissingClassicProducts) {
|
||||
auto it = std::find_if(sets.begin(), sets.end(), [&](const Set& s) {
|
||||
return s.id == product.id;
|
||||
});
|
||||
if (it != sets.end()) {
|
||||
// Keep curated display name / sort date in sync (e.g. UnnumberedPromo
|
||||
// placement after Pokémon Jungle) even when the id was already cached.
|
||||
it->name = product.nameEn;
|
||||
it->releaseDate = product.releaseDate;
|
||||
continue;
|
||||
}
|
||||
Set s;
|
||||
s.id = product.id;
|
||||
s.name = product.nameEn;
|
||||
s.releaseDate = product.releaseDate;
|
||||
sets.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string>
|
||||
JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex JA set detail response is not a JSON object.");
|
||||
}
|
||||
const std::string raw = j.value("releaseDate", "");
|
||||
if (raw.empty()) {
|
||||
return Result<std::string>::ok(std::string{});
|
||||
}
|
||||
return Result<std::string>::ok(rewriteReleaseDate(raw));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("TCGdex JA set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog(
|
||||
const Set& set, const JapanesePokemonEnCatalog& enCatalog) {
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
for (const auto& print : enCatalog.printsForSet(set.id)) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
|
||||
if (localId.empty()) continue;
|
||||
std::string name = print.nameEn;
|
||||
if (name.empty()) name = print.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
sortPackCards(pack);
|
||||
return pack;
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set,
|
||||
const JapanesePokemonEnCatalog& enCatalog) {
|
||||
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
|
||||
if (!rows) {
|
||||
// Transient/NotFound from parse — treat empty cards as catalog-only.
|
||||
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
|
||||
auto pack = catalogPackFromEnCatalog(set, enCatalog);
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err(
|
||||
"No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::err(rows.error().message);
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& row : rows.value()) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
|
||||
std::string name;
|
||||
if (auto print = enCatalog.findPrint(set.id, localId)) {
|
||||
name = print->nameEn;
|
||||
if (name.empty()) name = print->nameJa;
|
||||
}
|
||||
if (name.empty()) name = row.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
|
||||
gapFillFromEnCatalog(pack, enCatalog);
|
||||
sortPackCards(pack);
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return parsed;
|
||||
|
||||
std::vector<Set> out = std::move(parsed).value();
|
||||
for (auto& s : out) {
|
||||
// Prefer catalog English; never leave Japanese TCGdex names in Set.name
|
||||
// (the set picker must stay English-only).
|
||||
applyEnglishSetName(s, catalog_);
|
||||
if (!s.releaseDate.empty()) continue;
|
||||
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue; // keep set with empty date rather than fail all
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<JapanesePokemonSetSource::FetchWithCatalog>
|
||||
JapanesePokemonSetSource::fetchAllWithCatalog() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
|
||||
|
||||
std::vector<Set> sets = std::move(parsed).value();
|
||||
PokemonSetCatalog catalog;
|
||||
catalog.packs.reserve(sets.size());
|
||||
|
||||
for (auto& s : sets) {
|
||||
applyEnglishSetName(s, catalog_);
|
||||
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) {
|
||||
// Classic / catalog-only products often have no TCGdex detail.
|
||||
auto pack = catalogPackFromEnCatalog(s, catalog_);
|
||||
if (!pack.cards.empty()) {
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.releaseDate.empty()) {
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
|
||||
if (pack) {
|
||||
catalog.packs.push_back(std::move(pack).value());
|
||||
} else {
|
||||
auto fallback = catalogPackFromEnCatalog(s, catalog_);
|
||||
if (!fallback.cards.empty()) {
|
||||
catalog.packs.push_back(std::move(fallback));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
|
||||
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
|
||||
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
|
||||
for (auto& s : sets) {
|
||||
applyEnglishSetName(s, catalog_);
|
||||
}
|
||||
appendMissingClassicProducts(sets);
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,730 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string packId = std::string(trimAsciiSpaces(setId));
|
||||
if (packId.empty()) return R::err("Select a set first.");
|
||||
|
||||
const std::string rawNo = std::string(trimAsciiSpaces(setNo));
|
||||
if (rawNo.empty()) return R::err("Card number is empty.");
|
||||
|
||||
const std::string wantDigits =
|
||||
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo));
|
||||
if (wantDigits.empty()) return R::err("Card number is empty.");
|
||||
|
||||
const YuGiOhSetCatalogPack* pack = catalog.findPack(packId);
|
||||
if (pack == nullptr) {
|
||||
// Allow callers to pass the display set name (HTTP fallback path).
|
||||
for (const auto& candidate : catalog.packs) {
|
||||
if (candidate.setName == packId) {
|
||||
pack = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pack == nullptr) {
|
||||
return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seenNames;
|
||||
for (const auto& card : pack->cards) {
|
||||
if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue;
|
||||
if (card.name.empty()) continue;
|
||||
if (!seenNames.insert(card.name).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.name = card.name;
|
||||
print.setNo = card.setNo;
|
||||
print.rarity = card.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) {
|
||||
return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") +
|
||||
rfc3986PercentEncode(setName);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
|
||||
const std::string& body,
|
||||
std::string_view preferredSetName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string wantDigits =
|
||||
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo));
|
||||
if (wantDigits.empty()) return R::err("Card number is empty.");
|
||||
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return R::err("YGOPRODeck response missing 'data' array.");
|
||||
}
|
||||
const std::string preferredLower = toLower(trim(std::string(preferredSetName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : j.at("data")) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (cardName.empty()) continue;
|
||||
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
|
||||
for (const auto& printing : card.at("card_sets")) {
|
||||
const std::string setName = trim(printing.value("set_name", ""));
|
||||
const std::string setCode = trim(printing.value("set_code", ""));
|
||||
if (setCode.empty()) continue;
|
||||
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
|
||||
if (!preferredLower.empty() && toLower(setName) != preferredLower) continue;
|
||||
if (!ygoCollectorDigitsEqual(setCode, setNo)) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.name = cardName;
|
||||
print.setNo = setCode;
|
||||
print.rarity = trim(printing.value("set_rarity", ""));
|
||||
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
|
||||
if (!seen.insert(key).second) continue;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string setKey = std::string(trimAsciiSpaces(setId));
|
||||
if (setKey.empty()) return R::err("Select a set first.");
|
||||
if (ygoCollectorDigitsFromInput(setNo).empty()) {
|
||||
return R::err("Card number is empty.");
|
||||
}
|
||||
|
||||
// 1) Offline catalog (preferred — fast once cached).
|
||||
if (catalogStore_ != nullptr) {
|
||||
if (!catalogCache_) {
|
||||
auto loaded = catalogStore_->load();
|
||||
if (loaded) catalogCache_ = std::move(loaded).value();
|
||||
}
|
||||
if (catalogCache_ && !catalogCache_->empty()) {
|
||||
auto fromCatalog =
|
||||
detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo);
|
||||
|
||||
// Prefer YGOPRODeck when reachable so rarity (and multi-rarity
|
||||
// variants) come through — the offline catalog may predate the
|
||||
// rarity field or only keep one rarity per printing slot.
|
||||
const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey);
|
||||
std::string setName = setKey;
|
||||
if (pack != nullptr) {
|
||||
setName = pack->setName;
|
||||
} else {
|
||||
for (const auto& candidate : catalogCache_->packs) {
|
||||
if (candidate.setName == setKey) {
|
||||
setName = candidate.setName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!setName.empty()) {
|
||||
auto resp = http_.get(buildCardsetOnlyUrl(setName));
|
||||
if (resp) {
|
||||
auto fromHttp =
|
||||
detectVariantsBySetNoFromCardset(resp.value(), setName, setNo);
|
||||
if (fromHttp) return fromHttp;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromCatalog) return fromCatalog;
|
||||
// Prefer catalog miss text when HTTP also missed / was unreachable.
|
||||
return fromCatalog;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) No catalog: treat setKey as display set name and query YGOPRODeck.
|
||||
auto resp = http_.get(buildCardsetOnlyUrl(setKey));
|
||||
if (!resp) {
|
||||
return R::err(
|
||||
"Set catalog missing and YGOPRODeck lookup failed. "
|
||||
"Run Sets → Update Yu-Gi-Oh! or check your network.");
|
||||
}
|
||||
return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
YuGiOhGameModule::YuGiOhGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,242 @@
|
||||
#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());
|
||||
const std::string setRarity(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer an EN-embedded code over a bare / other-region equivalent.
|
||||
auto& prev = build.cards[existing->second];
|
||||
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
|
||||
prev.setNo = setCode;
|
||||
if (!cardName.empty()) prev.name = cardName;
|
||||
const std::string setRarity(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
if (!setRarity.empty()) prev.rarity = setRarity;
|
||||
} else if (prev.rarity.empty()) {
|
||||
prev.rarity = std::string(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
YuGiOhSetCatalog catalog;
|
||||
catalog.packs.reserve(byId.size());
|
||||
for (auto& [_, build] : byId) {
|
||||
if (build.setId.empty() || build.cards.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
YuGiOhSetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<YuGiOhSetCatalog>::err(
|
||||
std::string("YGOPRODeck catalog parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::fetchAllWithCatalog() {
|
||||
auto setsResp = http_.get(kEndpoint);
|
||||
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
|
||||
|
||||
auto sets = parseResponse(setsResp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
|
||||
auto infoResp = http_.get(kCardInfoEndpoint);
|
||||
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
|
||||
|
||||
auto catalog = parseCatalog(infoResp.value(), sets.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,453 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
std::string trimCopy(std::string_view s) {
|
||||
while (!s.empty() &&
|
||||
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
|
||||
s.front() == '\r')) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() &&
|
||||
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
|
||||
s.back() == '\r')) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return std::string(s);
|
||||
}
|
||||
|
||||
std::string urlEncode(std::string_view s) {
|
||||
static constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size() * 3);
|
||||
for (unsigned char c : s) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else if (c == ' ') {
|
||||
out.push_back('+');
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(hex[c >> 4]);
|
||||
out.push_back(hex[c & 0xF]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string wikiTitleEncode(std::string_view title) {
|
||||
// MediaWiki titles use underscores for spaces in the titles= parameter.
|
||||
std::string s;
|
||||
s.reserve(title.size());
|
||||
for (char c : title) {
|
||||
s.push_back(c == ' ' ? '_' : c);
|
||||
}
|
||||
return urlEncode(s);
|
||||
}
|
||||
|
||||
bool endsWith(std::string_view s, std::string_view suffix) {
|
||||
return s.size() >= suffix.size() &&
|
||||
s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
|
||||
}
|
||||
|
||||
int askMatchRank(std::string_view pageTitle, std::string_view preferredSetId) {
|
||||
// Lower is better.
|
||||
if (preferredSetId == "bansealdass") {
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 0;
|
||||
if (endsWith(pageTitle, " (Bandai)")) return 1;
|
||||
return 5;
|
||||
}
|
||||
if (preferredSetId == "ban3") {
|
||||
if (endsWith(pageTitle, " (Bandai)")) return 0;
|
||||
if (endsWith(pageTitle, " (English Bandai)")) return 1;
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 4;
|
||||
return 5;
|
||||
}
|
||||
if (endsWith(pageTitle, " (Bandai)")) return 0;
|
||||
if (endsWith(pageTitle, " (English Bandai)")) return 1;
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 3;
|
||||
return 5;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhBandaiCardPreviewSource::YuGiOhBandaiCardPreviewSource(IHttpClient& http)
|
||||
: http_(http) {}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::preferredPageTitle(
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string n = trimCopy(name);
|
||||
if (n.empty()) return {};
|
||||
|
||||
const std::string num = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
if (setId == "bansealdass") {
|
||||
return n + " (Bandai Sealdass)";
|
||||
}
|
||||
// Promo pages on Yugipedia often omit the "(Bandai)" disambiguator
|
||||
// (e.g. Blue-Eyes White Dragon's 3-Body Connection for TA2).
|
||||
if (setId == "banpromo-j" || setId == "banpromo-ta" ||
|
||||
isAlphanumericPromoNumber(num)) {
|
||||
return n;
|
||||
}
|
||||
if (num == "118" || setId == "ban3") {
|
||||
// Prefer JP Bandai page for most ban3 cards; English #118 uses the
|
||||
// English Bandai title when setNo is 118.
|
||||
if (num == "118") return n + " (English Bandai)";
|
||||
}
|
||||
return n + " (Bandai)";
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::buildPageImagesUrl(
|
||||
std::string_view pageTitle) {
|
||||
return std::string(
|
||||
"https://yugipedia.com/api.php?action=query&format=json"
|
||||
"&prop=pageimages&piprop=original&titles=") +
|
||||
wikiTitleEncode(pageTitle);
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::buildAskByNameUrl(
|
||||
std::string_view englishName) {
|
||||
// [[Category:Bandai cards]][[English name::<name>]]|?English name|?Bandai number|?Rarity|limit=20
|
||||
std::ostringstream q;
|
||||
q << "[[Category:Bandai cards]][[English name::" << englishName
|
||||
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
|
||||
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
|
||||
urlEncode(q.str());
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::buildAskByNumberUrl(
|
||||
std::string_view setNo) {
|
||||
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
std::ostringstream q;
|
||||
q << "[[Category:Bandai cards]][[Bandai number::" << n
|
||||
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
|
||||
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
|
||||
urlEncode(q.str());
|
||||
}
|
||||
|
||||
bool YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber(
|
||||
std::string_view setNo) {
|
||||
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
for (unsigned char c : n) {
|
||||
if (std::isalpha(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::parsePromoGalleryResponse(
|
||||
const std::string& body,
|
||||
std::string_view wantedSetNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string want = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
|
||||
if (want.empty()) return R::err("Card number is empty.");
|
||||
|
||||
std::string wikitext;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
|
||||
return R::err("Yugipedia promo gallery response missing parse.wikitext");
|
||||
}
|
||||
wikitext = j.at("parse").at("wikitext").get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Yugipedia promo gallery JSON parse error: ") +
|
||||
e.what());
|
||||
}
|
||||
|
||||
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wikitext);
|
||||
if (!cards) return R::err(cards.error());
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
for (const auto& card : cards.value()) {
|
||||
if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo) != want) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.name = card.name;
|
||||
print.setNo = card.setNo;
|
||||
print.rarity = card.rarity;
|
||||
print.setId = YuGiOhBandaiSetSource::setIdForNumber(card.setNo);
|
||||
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
|
||||
print.language = "Japanese";
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
AutoDetectedPrint YuGiOhBandaiCardPreviewSource::enrichPrint(
|
||||
AutoDetectedPrint print,
|
||||
std::string_view pageTitle) {
|
||||
print.name = YuGiOhBandaiSetSource::englishNameFromGalleryTitle(pageTitle);
|
||||
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) {
|
||||
print.setId = "bansealdass";
|
||||
print.language = "Japanese";
|
||||
} else if (endsWith(pageTitle, " (English Bandai)")) {
|
||||
print.setId = "ban3";
|
||||
print.language = "English";
|
||||
} else {
|
||||
if (print.setId.empty() && !print.setNo.empty()) {
|
||||
print.setId = YuGiOhBandaiSetSource::setIdForNumber(print.setNo);
|
||||
}
|
||||
print.language = "Japanese";
|
||||
}
|
||||
if (!print.setId.empty()) {
|
||||
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
|
||||
}
|
||||
return print;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("query") || !j.at("query").contains("pages")) {
|
||||
return R::err({K::Transient, "Yugipedia pageimages: missing query.pages"});
|
||||
}
|
||||
const auto& pages = j.at("query").at("pages");
|
||||
for (auto it = pages.begin(); it != pages.end(); ++it) {
|
||||
const auto& page = it.value();
|
||||
if (page.contains("missing") || page.contains("invalid")) continue;
|
||||
if (page.contains("original") && page.at("original").contains("source")) {
|
||||
const auto url = page.at("original").at("source").get<std::string>();
|
||||
if (!url.empty()) return R::ok(url);
|
||||
}
|
||||
if (page.contains("thumbnail") && page.at("thumbnail").contains("original")) {
|
||||
const auto url = page.at("thumbnail").at("original").get<std::string>();
|
||||
if (!url.empty()) return R::ok(url);
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "Yugipedia pageimages: no image for page"});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("Yugipedia pageimages JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body,
|
||||
std::string_view preferredSetId,
|
||||
std::string_view wantedSetNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("query") || !j.at("query").contains("results")) {
|
||||
return R::err("Yugipedia ask: missing query.results");
|
||||
}
|
||||
const auto& results = j.at("query").at("results");
|
||||
if (!results.is_object() || results.empty()) {
|
||||
return R::ok({});
|
||||
}
|
||||
|
||||
const std::string wantNo = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
|
||||
|
||||
std::vector<std::pair<int, AutoDetectedPrint>> ranked;
|
||||
for (auto it = results.begin(); it != results.end(); ++it) {
|
||||
const std::string pageTitle = it.key();
|
||||
const auto& printouts = it.value().value("printouts", nlohmann::json::object());
|
||||
|
||||
AutoDetectedPrint print;
|
||||
if (printouts.contains("Bandai number") &&
|
||||
printouts.at("Bandai number").is_array() &&
|
||||
!printouts.at("Bandai number").empty()) {
|
||||
const auto& num = printouts.at("Bandai number").at(0);
|
||||
if (num.is_number_integer()) {
|
||||
print.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(
|
||||
std::to_string(num.get<int>()));
|
||||
} else if (num.is_string()) {
|
||||
print.setNo =
|
||||
YuGiOhBandaiSetSource::normalizeCardNumber(num.get<std::string>());
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: SMW ask should be exact, but never accept a
|
||||
// different Bandai number (e.g. #11 when the user asked for #1).
|
||||
if (!wantNo.empty() &&
|
||||
YuGiOhBandaiSetSource::normalizeCardNumber(print.setNo) != wantNo) {
|
||||
continue;
|
||||
}
|
||||
if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() &&
|
||||
!printouts.at("Rarity").empty()) {
|
||||
const auto& rar = printouts.at("Rarity").at(0);
|
||||
if (rar.is_object() && rar.contains("fulltext")) {
|
||||
print.rarity = rar.at("fulltext").get<std::string>();
|
||||
} else if (rar.is_string()) {
|
||||
print.rarity = rar.get<std::string>();
|
||||
}
|
||||
}
|
||||
if (printouts.contains("English name") &&
|
||||
printouts.at("English name").is_array() &&
|
||||
!printouts.at("English name").empty()) {
|
||||
print.name = printouts.at("English name").at(0).get<std::string>();
|
||||
}
|
||||
|
||||
print = enrichPrint(std::move(print), pageTitle);
|
||||
if (print.name.empty()) continue;
|
||||
ranked.emplace_back(askMatchRank(pageTitle, preferredSetId), std::move(print));
|
||||
}
|
||||
|
||||
std::sort(ranked.begin(), ranked.end(),
|
||||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.reserve(ranked.size());
|
||||
for (auto& [rank, print] : ranked) {
|
||||
(void)rank;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Yugipedia ask JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhBandaiCardPreviewSource::fetchPageImage(std::string_view pageTitle) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
if (pageTitle.empty()) {
|
||||
return R::err({K::NotFound, "Empty Bandai page title"});
|
||||
}
|
||||
const std::string url = buildPageImagesUrl(pageTitle);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parsePageImagesResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByName(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string n = trimCopy(name);
|
||||
if (n.empty()) return R::err("Card name is empty.");
|
||||
const std::string url = buildAskByNameUrl(n);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
return parseAskResponse(resp.value(), setId);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByNumber(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
if (n.empty()) return R::err("Card number is empty.");
|
||||
|
||||
// Promo codes (J1, TA2, …) are not valid values for SMW's numeric
|
||||
// `Bandai number` property — ask returns a type error. Resolve them from
|
||||
// the promotional set gallery instead.
|
||||
R list = [&]() -> R {
|
||||
if (isAlphanumericPromoNumber(n)) {
|
||||
static constexpr const char* kPromoGallery =
|
||||
"Set Card Galleries:Promotional Cards (Bandai)";
|
||||
const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
return parsePromoGalleryResponse(resp.value(), n);
|
||||
}
|
||||
const std::string url = buildAskByNumberUrl(n);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
return parseAskResponse(resp.value(), setId, n);
|
||||
}();
|
||||
if (!list) return list;
|
||||
|
||||
const std::string wantSet = trimCopy(setId);
|
||||
if (wantSet.empty()) return list;
|
||||
|
||||
std::vector<AutoDetectedPrint> filtered;
|
||||
filtered.reserve(list.value().size());
|
||||
for (auto& print : list.value()) {
|
||||
if (print.setId == wantSet) filtered.push_back(std::move(print));
|
||||
}
|
||||
if (filtered.empty()) {
|
||||
return R::err("No Bandai card matched that number in the selected set.");
|
||||
}
|
||||
return R::ok(std::move(filtered));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect Bandai card from number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
return askByNumber(setId, setNo);
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhBandaiCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
|
||||
const std::string title = preferredPageTitle(name, setId, setNo);
|
||||
auto direct = fetchPageImage(title);
|
||||
if (direct) return direct;
|
||||
// Try English Bandai if JP page missed for #118.
|
||||
if (YuGiOhBandaiSetSource::normalizeCardNumber(setNo) == "118") {
|
||||
auto en = fetchPageImage(trimCopy(name) + " (English Bandai)");
|
||||
if (en) return en;
|
||||
}
|
||||
|
||||
// Fall back to SMW ask by name, then pageimages on the best hit.
|
||||
auto variants = askByName(name, setId);
|
||||
if (!variants) {
|
||||
// Prefer the original NotFound if ask also failed transiently only
|
||||
// after a clean miss; otherwise surface ask error as Transient.
|
||||
if (direct.error().kind == K::NotFound) {
|
||||
return R::err({K::Transient, variants.error()});
|
||||
}
|
||||
return direct;
|
||||
}
|
||||
if (variants.value().empty()) {
|
||||
return R::err({K::NotFound, "No Bandai card matched the name"});
|
||||
}
|
||||
|
||||
const auto& best = variants.value().front();
|
||||
std::string askTitle = preferredPageTitle(best.name, best.setId, best.setNo);
|
||||
if (best.language == "English") {
|
||||
askTitle = best.name + " (English Bandai)";
|
||||
} else if (best.setId == "bansealdass") {
|
||||
askTitle = best.name + " (Bandai Sealdass)";
|
||||
}
|
||||
return fetchPageImage(askTitle);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectFirstPrint(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto list = detectPrintVariants(name, setId);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect Bandai print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) {
|
||||
return askByName(name, setId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
YuGiOhBandaiGameModule::YuGiOhBandaiGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,267 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <regex>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trimCopy(std::string_view s) {
|
||||
while (!s.empty() &&
|
||||
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
|
||||
s.front() == '\r')) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() &&
|
||||
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
|
||||
s.back() == '\r')) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return std::string(s);
|
||||
}
|
||||
|
||||
std::string urlEncode(std::string_view s) {
|
||||
static constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size() * 3);
|
||||
for (unsigned char c : s) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else if (c == ' ') {
|
||||
out.push_back('+');
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(hex[c >> 4]);
|
||||
out.push_back(hex[c & 0xF]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhBandaiSetSource::YuGiOhBandaiSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
const std::vector<YuGiOhBandaiSetSource::SetManifestEntry>&
|
||||
YuGiOhBandaiSetSource::setManifest() {
|
||||
static const std::vector<SetManifestEntry> kManifest{
|
||||
{"ban1", "1st Generation", "1998/09/01",
|
||||
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation", ""},
|
||||
{"ban2", "2nd Generation", "1998/11/01",
|
||||
"Set Card Galleries:2nd Generation (Bandai)", ""},
|
||||
{"ban3", "3rd Generation", "1999/03/06",
|
||||
"Set Card Galleries:3rd Generation (Bandai)", ""},
|
||||
{"banpromo-j", "Jump Promos", "1998/01/01",
|
||||
"Set Card Galleries:Promotional Cards (Bandai)", "J"},
|
||||
{"banpromo-ta", "Toei Promos", "1999/03/06",
|
||||
"Set Card Galleries:Promotional Cards (Bandai)", "TA"},
|
||||
{"bansealdass", "Sealdass", "1999/06/01",
|
||||
"Set Card Galleries:Yu-Gi-Oh! Bandai Sealdass", ""},
|
||||
};
|
||||
return kManifest;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhBandaiSetSource::parseResponse(
|
||||
const std::string& /*unused*/) {
|
||||
std::vector<Set> out;
|
||||
for (const auto& e : setManifest()) {
|
||||
out.push_back(Set{e.id, e.name, e.releaseDate});
|
||||
}
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhBandaiSetSource::fetchAll() {
|
||||
return parseResponse({});
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::buildGalleryParseUrl(std::string_view pageTitle) {
|
||||
return std::string(
|
||||
"https://yugipedia.com/api.php?action=parse&format=json&formatversion=2"
|
||||
"&prop=wikitext&page=") +
|
||||
urlEncode(pageTitle);
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::normalizeCardNumber(std::string_view setNo) {
|
||||
std::string s = trimCopy(setNo);
|
||||
if (s.empty()) return {};
|
||||
|
||||
// Strip a leading '#' if present.
|
||||
if (s.front() == '#') s.erase(s.begin());
|
||||
|
||||
// Uppercase letter prefix forms: j1 / ta2.
|
||||
bool hasAlpha = false;
|
||||
for (char& c : s) {
|
||||
if (std::isalpha(static_cast<unsigned char>(c))) {
|
||||
hasAlpha = true;
|
||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
}
|
||||
if (hasAlpha) return s;
|
||||
|
||||
// Pure decimal: strip leading zeros but keep a single zero.
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return s.substr(i);
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::expandRarityCode(std::string_view code) {
|
||||
const std::string c = trimCopy(code);
|
||||
if (c == "C") return "Common";
|
||||
if (c == "R") return "Rare";
|
||||
if (c == "SR") return "Super Rare";
|
||||
if (c == "UR") return "Ultra Rare";
|
||||
if (c == "HFR" || c == "Holo Seal" || c == "HS") return "Holo Seal";
|
||||
if (c.empty()) return {};
|
||||
return c;
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::englishNameFromGalleryTitle(
|
||||
std::string_view pageTitle) {
|
||||
std::string name = trimCopy(pageTitle);
|
||||
const auto stripSuffix = [&](std::string_view suffix) {
|
||||
if (name.size() > suffix.size() &&
|
||||
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||
name.resize(name.size() - suffix.size());
|
||||
name = trimCopy(name);
|
||||
}
|
||||
};
|
||||
stripSuffix(" (Bandai Sealdass)");
|
||||
stripSuffix(" (English Bandai)");
|
||||
stripSuffix(" (Bandai)");
|
||||
return name;
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::setIdForNumber(std::string_view setNo) {
|
||||
const std::string n = normalizeCardNumber(setNo);
|
||||
if (n.empty()) return {};
|
||||
if (!n.empty() && (n[0] == 'J' || n[0] == 'j')) return "banpromo-j";
|
||||
if (n.size() >= 2 && (n[0] == 'T' || n[0] == 't') &&
|
||||
(n[1] == 'A' || n[1] == 'a')) {
|
||||
return "banpromo-ta";
|
||||
}
|
||||
|
||||
// Pure decimal → generation by range. Callers that need Sealdass must
|
||||
// pass set context; number alone cannot disambiguate 1–42 vs Sealdass.
|
||||
bool pureDecimal = true;
|
||||
for (char c : n) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(c))) {
|
||||
pureDecimal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pureDecimal) return {};
|
||||
|
||||
const int v = std::stoi(n);
|
||||
if (v >= 1 && v <= 42) return "ban1";
|
||||
if (v >= 43 && v <= 88) return "ban2";
|
||||
if (v >= 89 && v <= 118) return "ban3";
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::setNameForId(std::string_view setId) {
|
||||
for (const auto& e : setManifest()) {
|
||||
if (e.id == setId) return e.name;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Result<std::vector<YuGiOhBandaiCatalogCard>>
|
||||
YuGiOhBandaiSetSource::parseGalleryWikitext(const std::string& wikitext) {
|
||||
using R = Result<std::vector<YuGiOhBandaiCatalogCard>>;
|
||||
std::vector<YuGiOhBandaiCatalogCard> out;
|
||||
|
||||
// Generation galleries (raw):
|
||||
// … | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|ja}}
|
||||
// Promo galleries (often expanded with <br />):
|
||||
// … | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}
|
||||
static const std::regex kLine(
|
||||
R"((?:\{\{pound\}\}|\[\[)([A-Za-z0-9]+)(?:\]\])?(?:\s*\(\[\[([A-Za-z0-9]+)\]\]\))?[^\n]*?\{\{Gallery card names\|([^}|]+))",
|
||||
std::regex::ECMAScript);
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (std::sregex_iterator it(wikitext.begin(), wikitext.end(), kLine), end;
|
||||
it != end; ++it) {
|
||||
const std::smatch& m = *it;
|
||||
YuGiOhBandaiCatalogCard card;
|
||||
card.setNo = normalizeCardNumber(m[1].str());
|
||||
if (card.setNo.empty()) continue;
|
||||
if (m[2].matched) {
|
||||
card.rarity = expandRarityCode(m[2].str());
|
||||
}
|
||||
card.name = englishNameFromGalleryTitle(m[3].str());
|
||||
if (card.name.empty()) continue;
|
||||
if (!seen.insert(card.setNo).second) continue;
|
||||
out.push_back(std::move(card));
|
||||
}
|
||||
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<YuGiOhBandaiSetSource::FetchWithCatalog>
|
||||
YuGiOhBandaiSetSource::fetchAllWithCatalog() {
|
||||
using R = Result<FetchWithCatalog>;
|
||||
|
||||
auto sets = parseResponse({});
|
||||
if (!sets) return R::err(sets.error());
|
||||
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
std::unordered_map<std::string, std::string> pageCache;
|
||||
|
||||
for (const auto& entry : setManifest()) {
|
||||
const std::string page = entry.galleryPage;
|
||||
std::string body;
|
||||
auto cached = pageCache.find(page);
|
||||
if (cached != pageCache.end()) {
|
||||
body = cached->second;
|
||||
} else {
|
||||
const std::string url = buildGalleryParseUrl(page);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
body = std::move(resp).value();
|
||||
pageCache.emplace(page, body);
|
||||
}
|
||||
|
||||
std::string wikitext;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
|
||||
return R::err("Yugipedia gallery response missing parse.wikitext");
|
||||
}
|
||||
wikitext = j.at("parse").at("wikitext").get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Yugipedia gallery JSON parse error: ") +
|
||||
e.what());
|
||||
}
|
||||
|
||||
auto cards = parseGalleryWikitext(wikitext);
|
||||
if (!cards) return R::err(cards.error());
|
||||
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = entry.id;
|
||||
pack.setName = entry.name;
|
||||
const std::string prefix = entry.setNoPrefix;
|
||||
for (const auto& card : cards.value()) {
|
||||
if (!prefix.empty()) {
|
||||
if (card.setNo.size() < prefix.size() ||
|
||||
card.setNo.compare(0, prefix.size(), prefix) != 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pack.cards.push_back(card);
|
||||
}
|
||||
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog);
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user