Minor: Add Asian Pokemon Card Support (#18)

This commit is contained in:
Sebastian Dine
2026-07-22 11:13:42 +02:00
committed by GitHub
parent e5c830e945
commit c9e6bc2b6b
87 changed files with 78068 additions and 162 deletions
+3
View File
@@ -40,3 +40,6 @@ config.json
configure.log
build.log
test.log
# Offline ETL caches (large third-party extracts)
tools/pokemon_jp/_tcgdex_cards_database/
+1 -1
View File
@@ -84,7 +84,7 @@ Run from the **workspace root**.
- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically.
- `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically.
- `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.cache/preview-cache/`**next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path.
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `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.
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
## Windows UI theming guardrails
+5 -4
View File
@@ -5,14 +5,14 @@ The `ccm` executable — composition root only. The single place where concrete
## File pointers
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png`, `ui_wx/assets/digibattle99_card_back.png`, and `ui_wx/assets/pokemon_jp_en_catalog.json` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks and Japanese Pokémon EN names work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`).
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
@@ -21,7 +21,8 @@ The `ccm` executable — composition root only. The single place where concrete
## Required follow-ups
- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_card_back.png`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_card_back.png` / `pokemon_jp_en_catalog.json`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
- On MinGW-w64 Windows, POST_BUILD also copies `libstdc++-6.dll` / `libgcc_s_seh-1.dll` / `libwinpthread-1.dll` from the compiler directory into `$<TARGET_FILE_DIR:ccm>` so the exe does not load a mismatched runtime from `PATH`.
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
+27 -3
View File
@@ -19,13 +19,37 @@ target_link_libraries(ccm
ccm_warnings
)
# Yu-Gi-Oh! / Digi-Battle preview fallback images (used when network card-back
# URLs fail or no public URL exists).
# 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")
"$<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()
+27 -5
View File
@@ -9,6 +9,8 @@
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
@@ -45,10 +47,11 @@ namespace {
// need for the repositories to know about concrete game module classes.
std::string dirNameForGame(ccm::Game g) {
switch (g) {
case ccm::Game::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
case ccm::Game::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
case ccm::Game::JapanesePokemon: return "pokemon";
}
return "magic";
}
@@ -86,6 +89,17 @@ public:
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
ccm::JapanesePokemonEnCatalog jpCatalog;
{
const auto catalogPath = exeDir / "assets" / "pokemon_jp_en_catalog.json";
if (auto text = fs_->readText(catalogPath); text) {
if (auto parsed = ccm::JapanesePokemonEnCatalog::parse(text.value()); parsed) {
jpCatalog = std::move(parsed).value();
}
}
}
jpPokeMod_ = std::make_unique<ccm::JapanesePokemonGameModule>(*http_, std::move(jpCatalog));
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
*fs_, *config_, &dirNameForGame);
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
@@ -113,6 +127,7 @@ public:
setSvc_->registerModule(pokeMod_.get());
setSvc_->registerModule(ygoMod_.get());
setSvc_->registerModule(digiBattle99Mod_.get());
setSvc_->registerModule(jpPokeMod_.get());
// Disk-backed preview cache lives next to the executable, in the same
// location scope as config.json - NOT inside the user's data-storage
@@ -128,11 +143,16 @@ public:
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
*fs_,
exeDir / ".cache" / "preview-cache");
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_, previewCache_.get());
previewSvc_ = std::make_unique<ccm::CardPreviewService>(
*http_,
previewCache_.get(),
fs_.get(),
exeDir / "assets");
previewSvc_->registerModule(*magicMod_);
previewSvc_->registerModule(*pokeMod_);
previewSvc_->registerModule(*ygoMod_);
previewSvc_->registerModule(*digiBattle99Mod_);
previewSvc_->registerModule(*jpPokeMod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
@@ -154,6 +174,7 @@ public:
*pokeMod_,
*ygoMod_,
*digiBattle99Mod_,
*jpPokeMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
});
@@ -176,6 +197,7 @@ private:
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
std::unique_ptr<ccm::JapanesePokemonGameModule> jpPokeMod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
+3 -3
View File
@@ -4,11 +4,11 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `DigiBattle99Card`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, and `digibattle99/` are the reference implementations — all four expose a fully working set source + card preview source.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `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.
- `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.
@@ -27,7 +27,7 @@
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface.
10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `<exeDir>/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `<hash>.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `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.
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
## Adding a new game
+5
View File
@@ -8,6 +8,7 @@ add_library(ccm_core STATIC
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/DigiBattle99Card.cpp
src/domain/JapanesePokemonCard.cpp
src/domain/Configuration.cpp
src/services/ConfigService.cpp
@@ -35,6 +36,10 @@ add_library(ccm_core STATIC
src/games/digibattle99/DigiBattle99SetSource.cpp
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
src/games/digibattle99/DigiBattle99GameModule.cpp
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
src/games/pokemonjp/JapanesePokemonSetSource.cpp
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
src/games/pokemonjp/JapanesePokemonGameModule.cpp
src/util/FsNames.cpp
)
+25 -6
View File
@@ -10,6 +10,7 @@
#include <array>
#include <optional>
#include <span>
#include <string>
#include <string_view>
@@ -20,6 +21,12 @@ enum class Game {
Pokemon,
YuGiOh,
DigiBattle99,
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
};
enum class PokemonRegion {
West,
Asia,
};
enum class Language {
@@ -28,8 +35,10 @@ enum class Language {
French,
Spanish,
Italian,
Chinese,
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
TraditionalChinese, // JSON / display: "T-Chinese"
Japanese,
Korean,
Russian,
};
@@ -49,24 +58,34 @@ enum class Theme {
};
std::string_view to_string(Game g) noexcept;
std::string_view to_string(PokemonRegion r) noexcept;
std::string_view to_string(Language l) noexcept;
std::string_view to_string(Condition c) noexcept;
std::string_view to_string(Theme t) noexcept;
std::optional<Game> gameFromString(std::string_view s) noexcept;
std::optional<Language> languageFromString(std::string_view s) noexcept;
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
std::optional<Game> gameFromString(std::string_view s) noexcept;
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
std::optional<Language> languageFromString(std::string_view s) noexcept;
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
const std::array<Game, 4>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
const std::array<Language, 10>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
[[nodiscard]] std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept;
[[nodiscard]] Game pokemonBackendGame(PokemonRegion r) noexcept;
[[nodiscard]] Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept;
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
void to_json(nlohmann::json& j, Game v);
void from_json(const nlohmann::json& j, Game& v);
void to_json(nlohmann::json& j, PokemonRegion v);
void from_json(const nlohmann::json& j, PokemonRegion& v);
void to_json(nlohmann::json& j, Language v);
void from_json(const nlohmann::json& j, Language& v);
@@ -0,0 +1,38 @@
#pragma once
// JapanesePokemonCard - Japanese Pokémon TCG collection model.
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct JapanesePokemonCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string note;
std::vector<std::string> images;
Language language{Language::Japanese};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default;
};
void to_json(nlohmann::json& j, const JapanesePokemonCard& c);
void from_json(const nlohmann::json& j, JapanesePokemonCard& c);
} // namespace ccm
+3 -1
View File
@@ -1,7 +1,8 @@
#pragma once
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
// Same established JSON shape (with `setNo` and `firstEdition` aliases),
// plus `region` (West/Asia) for unified West+Asia collections.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
@@ -28,6 +29,7 @@ struct PokemonCard {
bool holo{false};
bool signed_{false};
bool altered{false};
PokemonRegion region{PokemonRegion::West};
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
};
+4
View File
@@ -23,6 +23,10 @@ public:
// Implementations return a vector that has already been filtered
// (e.g. no digital-only sets) and sorted by release date ascending.
virtual Result<std::vector<Set>> fetchAll() = 0;
// Optional post-process for locally cached set lists (e.g. inject products
// the upstream API omits). Default is a no-op. Called by SetService::getSets.
virtual void augmentCachedSets(std::vector<Set>& /*sets*/) const {}
};
class IGameModule {
@@ -0,0 +1,68 @@
#pragma once
// JapanesePokemonCardPreviewSource: TCGdex ja localId-based preview + variants.
// Image URLs use /high.png (wxImage decodes PNG/JPEG, not webp).
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class JapanesePokemonCardPreviewSource final : public ICardPreviewSource {
public:
JapanesePokemonCardPreviewSource(IHttpClient& http,
const JapanesePokemonEnCatalog& catalog);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
static std::string normalizeLocalId(std::string_view setNo);
static std::string buildSetDetailUrl(std::string_view setId);
static std::string buildCardUrl(std::string_view setId, std::string_view localId);
static std::string imageUrlFromBase(std::string_view imageBase);
// Parse set-detail body; optionally filter by name (EN catalog / JA) and/or localId.
struct SetCardRow {
std::string localId;
std::string nameJa;
std::string imageBase; // empty when TCGdex has no scan
std::string rarity;
};
static Result<std::vector<SetCardRow>, PreviewLookupError>
parseSetCards(const std::string& body);
static Result<std::string, PreviewLookupError>
parseCardImageUrl(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog);
// Catalog-only Auto-detect when TCGdex has no set detail (theme decks, etc.).
static Result<std::vector<AutoDetectedPrint>>
detectPrintVariantsFromCatalog(std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog);
private:
IHttpClient& http_;
const JapanesePokemonEnCatalog& catalog_;
};
} // namespace ccm
@@ -0,0 +1,71 @@
#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;
// 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,39 @@
#pragma once
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
// dates. English display names come from JapanesePokemonEnCatalog when present.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/IHttpClient.hpp"
namespace ccm {
class JapanesePokemonSetSource final : public ISetSource {
public:
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
Result<std::vector<Set>> fetchAll() override;
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);
// 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
+5 -1
View File
@@ -1,6 +1,8 @@
#pragma once
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
// JsonSetRepository: persists vector<Set> under `<dataStorage>/<dirName>/`.
// Most games use `sets.json`. Pokemon West/Asia share dir `pokemon` with
// `sets-west.json` / `sets-asia.json` (migrate-on-load from legacy paths).
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IFileSystem.hpp"
@@ -27,6 +29,8 @@ private:
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
[[nodiscard]] std::filesystem::path legacySetsPath(Game game) const;
[[nodiscard]] Result<std::vector<Set>> parseSetsText(const std::string& text) const;
};
} // namespace ccm
+3 -1
View File
@@ -1,6 +1,8 @@
#pragma once
// ISetRepository - persistence port for the cached `sets.json` of a game.
// ISetRepository - persistence port for the cached set list of a game.
// Typical layout: `<dataStorage>/<dirName>/sets.json`. Pokemon West/Asia use
// `sets-west.json` / `sets-asia.json` under the shared `pokemon/` directory.
// Stored as a flat list to mirror the original Rust file layout.
#include "ccm/domain/Enums.hpp"
+6 -1
View File
@@ -20,6 +20,7 @@
// `.includes("")` returns true.
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -35,7 +36,7 @@ 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);
@@ -46,4 +47,8 @@ namespace ccm {
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
std::string_view filter);
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter);
} // namespace ccm
@@ -15,11 +15,13 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include "ccm/util/Result.hpp"
#include <cstddef>
#include <filesystem>
#include <list>
#include <mutex>
#include <string>
@@ -32,7 +34,9 @@ namespace ccm {
class CardPreviewService {
public:
explicit CardPreviewService(IHttpClient& http,
IPreviewByteCache* persistentCache = nullptr);
IPreviewByteCache* persistentCache = nullptr,
IFileSystem* fs = nullptr,
std::filesystem::path assetRoot = {});
// Register a game module's preview source. Calling this with a module
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
@@ -96,6 +100,9 @@ private:
Result<std::string> fetchAndCache(const std::string& cacheKey,
std::string_view url);
Result<std::string, PreviewLookupError> fetchAssetAndCache(
const std::string& cacheKey,
std::string_view assetUrl);
// Returns the kind of in-memory cache entry for `key`. On Hit the
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
@@ -107,6 +114,8 @@ private:
IHttpClient& http_;
IPreviewByteCache* persistentCache_{nullptr};
IFileSystem* fs_{nullptr};
std::filesystem::path assetRoot_;
std::unordered_map<Game, ICardPreviewSource*> sources_;
// LRU: list holds entries in MRU-first order; map points at list nodes
+18
View File
@@ -17,6 +17,7 @@
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -81,6 +82,20 @@ enum class DigiBattle99SortColumn {
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,
@@ -92,5 +107,8 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending);
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending);
} // namespace ccm
+5
View File
@@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) {
void from_json(const nlohmann::json& j, Configuration& c) {
j.at("dataStorage").get_to(c.dataStorage);
j.at("defaultGame").get_to(c.defaultGame);
// JapanesePokemon was folded into Pokemon (West/Asia region). Coerce so
// older config.json files keep a valid user-facing default game.
if (c.defaultGame == Game::JapanesePokemon) {
c.defaultGame = Game::Pokemon;
}
c.theme = j.value("theme", Theme::Light);
}
+83 -31
View File
@@ -13,24 +13,35 @@ namespace ccm {
std::string_view to_string(Game g) noexcept {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
case Game::JapanesePokemon: return "JapanesePokemon";
}
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";
}
CCM_UNREACHABLE();
}
@@ -57,22 +68,33 @@ std::string_view to_string(Theme t) noexcept {
}
std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
if (s == "JapanesePokemon") return Game::JapanesePokemon;
return std::nullopt;
}
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept {
if (s == "West") return PokemonRegion::West;
if (s == "Asia") return PokemonRegion::Asia;
return std::nullopt;
}
std::optional<Language> languageFromString(std::string_view s) noexcept {
if (s == "English") return Language::English;
if (s == "German") return Language::German;
if (s == "French") return Language::French;
if (s == "Spanish") return Language::Spanish;
if (s == "Italian") return Language::Italian;
if (s == "Chinese") return Language::Chinese;
if (s == "Japanese") return Language::Japanese;
if (s == "Russian") return Language::Russian;
if (s == "English") return Language::English;
if (s == "German") return Language::German;
if (s == "French") return Language::French;
if (s == "Spanish") return Language::Spanish;
if (s == "Italian") return Language::Italian;
if (s == "S-Chinese") return Language::SimplifiedChinese;
if (s == "T-Chinese") return Language::TraditionalChinese;
// Legacy single Chinese spelling → Simplified.
if (s == "Chinese") return Language::SimplifiedChinese;
if (s == "Japanese") return Language::Japanese;
if (s == "Korean") return Language::Korean;
if (s == "Russian") return Language::Russian;
return std::nullopt;
}
@@ -99,10 +121,11 @@ const std::array<Game, 4>& allGames() noexcept {
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;
}
@@ -120,16 +143,45 @@ const std::array<Theme, 2>& allThemes() noexcept {
return v;
}
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
static constexpr std::array<Language, 6> kWest{
Language::English, Language::German, Language::French,
Language::Spanish, Language::Italian, Language::Russian};
static constexpr std::array<Language, 4> kAsia{
Language::Japanese, Language::SimplifiedChinese,
Language::TraditionalChinese, Language::Korean};
switch (r) {
case PokemonRegion::West: return kWest;
case PokemonRegion::Asia: return kAsia;
}
CCM_UNREACHABLE();
return kWest;
}
Game pokemonBackendGame(PokemonRegion r) noexcept {
return r == PokemonRegion::Asia ? Game::JapanesePokemon : Game::Pokemon;
}
Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept {
return r == PokemonRegion::Asia ? Language::Japanese : Language::English;
}
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, PokemonRegion v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
void from_json(const nlohmann::json& j, Game& v) {
auto parsed = gameFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, PokemonRegion& v) {
auto parsed = pokemonRegionFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Language& v) {
auto parsed = languageFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const JapanesePokemonCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"setNo", c.setNo},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"firstEdition", c.firstEdition},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, JapanesePokemonCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("setNo").get_to(c.setNo);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("firstEdition").get_to(c.firstEdition);
j.at("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+3
View File
@@ -17,6 +17,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
{"region", c.region},
};
}
@@ -34,6 +35,8 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
j.at("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
// Missing `region` defaults to West so pre-merge West-only files still load.
c.region = j.value("region", PokemonRegion::West);
}
} // namespace ccm
@@ -0,0 +1,442 @@
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <string>
#include <unordered_set>
namespace ccm {
namespace {
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.erase(s.begin());
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
s.pop_back();
}
return s;
}
std::string asciiLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
std::string stripLeadingZeros(std::string_view s) {
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return std::string(s.substr(i));
}
bool localIdsMatch(std::string_view a, std::string_view b) {
if (a == b) return true;
return stripLeadingZeros(a) == stripLeadingZeros(b);
}
bool catalogPrintMatchesRow(const JapanesePokemonPrintEnInfo& print,
const JapanesePokemonCardPreviewSource::SetCardRow& row) {
// Reject stale catalog rows whose Japanese name disagrees with TCGdex.
// Seed data historically mapped Charmander→001 / Charizard→004; those
// localIds are Bulbasaur / Weedle on PMCG1.
if (print.nameJa.empty()) return true;
return asciiLower(print.nameJa) == asciiLower(row.nameJa);
}
bool nameMatchesRow(std::string_view wantedLower,
const JapanesePokemonCardPreviewSource::SetCardRow& row,
std::string_view setId,
const JapanesePokemonEnCatalog& catalog) {
if (wantedLower.empty()) return true;
if (asciiLower(row.nameJa) == wantedLower) return true;
if (auto print = catalog.findPrint(setId, row.localId)) {
if (!catalogPrintMatchesRow(*print, row)) return false;
if (asciiLower(print->nameEn) == wantedLower) return true;
if (asciiLower(print->nameJa) == wantedLower) return true;
}
return false;
}
} // namespace
JapanesePokemonCardPreviewSource::JapanesePokemonCardPreviewSource(
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
: http_(http), catalog_(catalog) {}
std::string JapanesePokemonCardPreviewSource::normalizeLocalId(std::string_view setNo) {
std::string s = trim(std::string(setNo));
const auto slash = s.find('/');
if (slash != std::string::npos) s.erase(slash);
return s;
}
std::string JapanesePokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
rfc3986PercentEncode(setId);
}
std::string JapanesePokemonCardPreviewSource::buildCardUrl(std::string_view setId,
std::string_view localId) {
std::string id = std::string(setId) + "-" + std::string(localId);
return std::string("https://api.tcgdex.net/v2/ja/cards/") +
rfc3986PercentEncode(id);
}
std::string JapanesePokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
if (imageBase.empty()) return {};
std::string url(imageBase);
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
return url + "/high.png";
}
Result<std::vector<JapanesePokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
JapanesePokemonCardPreviewSource::parseSetCards(const std::string& body) {
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
return R::err({K::Transient,
"TCGdex JA set detail missing 'cards' array."});
}
std::vector<SetCardRow> out;
out.reserve(j.at("cards").size());
for (const auto& card : j.at("cards")) {
SetCardRow row;
row.localId = card.value("localId", "");
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
// Fallback: take suffix after last '-' from card id.
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
}
row.nameJa = card.value("name", "");
row.rarity = card.value("rarity", "");
if (card.contains("image") && card.at("image").is_string()) {
row.imageBase = card.at("image").get<std::string>();
}
if (row.localId.empty()) continue;
out.push_back(std::move(row));
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("TCGdex JA set detail JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
JapanesePokemonCardPreviewSource::parseCardImageUrl(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err({K::Transient, "TCGdex JA card response is not a JSON object."});
}
if (!j.contains("image") || j.at("image").is_null()) {
return R::err({K::NotFound, "TCGdex JA card has no image."});
}
if (!j.at("image").is_string()) {
return R::err({K::Transient, "TCGdex JA card image field is not a string."});
}
const std::string base = j.at("image").get<std::string>();
if (base.empty()) {
return R::err({K::NotFound, "TCGdex JA card has no image."});
}
return R::ok(imageUrlFromBase(base));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("TCGdex JA card JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
auto rows = parseSetCards(body);
if (!rows) {
return R::err(rows.error().message);
}
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
std::unordered_set<std::string> seenCatalogUrls;
// Prefer catalog EN matches first so typed English names resolve — but
// only when the catalog localId exists in the set and name_ja agrees
// with TCGdex (guards against stale seed mappings).
std::vector<AutoDetectedPrint> withPreview;
std::vector<AutoDetectedPrint> withoutPreview;
if (!wantedLower.empty()) {
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
const SetCardRow* row = nullptr;
for (const auto& r : rows.value()) {
if (localIdsMatch(r.localId, p.localId)) {
row = &r;
break;
}
}
const std::string previewUrl =
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
if (row == nullptr) {
// Set detail sometimes omits cards[]; keep catalog-only hits
// (UnnumberedPromo). Dedupe only among non-empty preview URLs
// so empty-image prints still appear in the Next ring.
if (!rows.value().empty()) continue;
if (!previewUrl.empty() &&
!seenCatalogUrls.insert(previewUrl).second) {
continue;
}
} else if (!catalogPrintMatchesRow(p, *row)) {
continue;
}
if (!seen.insert(p.localId).second) continue;
AutoDetectedPrint print;
print.setNo = p.localId;
if (row != nullptr) print.rarity = row->rarity;
if (previewUrl.empty()) {
withoutPreview.push_back(std::move(print));
} else {
withPreview.push_back(std::move(print));
}
}
}
for (auto& print : withPreview) out.push_back(std::move(print));
for (auto& print : withoutPreview) out.push_back(std::move(print));
for (const auto& row : rows.value()) {
if (!nameMatchesRow(wantedLower, row, setId, catalog)) continue;
if (!seen.insert(row.localId).second) continue;
AutoDetectedPrint print;
print.setNo = row.localId;
print.rarity = row.rarity;
out.push_back(std::move(print));
}
if (out.empty() && !wantedLower.empty()) {
return R::err("No matching Japanese Pokemon prints for that name in the set.");
}
return R::ok(std::move(out));
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (!catalog.hasPrintsForSet(setId)) {
return R::err("No matching Japanese Pokemon prints for that name in the set.");
}
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
std::unordered_set<std::string> seenUrls;
if (wantedLower.empty()) {
return R::ok(std::move(out));
}
std::vector<AutoDetectedPrint> withPreview;
std::vector<AutoDetectedPrint> withoutPreview;
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
if (!seen.insert(p.localId).second) continue;
// Dedupe only among non-empty preview URLs so identical art is not
// cycled; empty-image prints still join the Next ring (card-back).
// Emit imaged prints first so Auto-detect lands on real art.
const std::string previewUrl =
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
if (!previewUrl.empty() && !seenUrls.insert(previewUrl).second) {
continue;
}
AutoDetectedPrint print;
print.setNo = p.localId;
if (previewUrl.empty()) {
withoutPreview.push_back(std::move(print));
} else {
withPreview.push_back(std::move(print));
}
}
for (auto& print : withPreview) out.push_back(std::move(print));
for (auto& print : withoutPreview) out.push_back(std::move(print));
if (out.empty()) {
return R::err("No matching Japanese Pokemon prints for that name in the set.");
}
return R::ok(std::move(out));
}
Result<std::string, PreviewLookupError>
JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string localId = normalizeLocalId(setNo);
if (setId.empty()) {
return R::err({K::NotFound, "Japanese Pokemon preview requires a set id."});
}
auto catalogPreviewFor = [&](std::string_view lid) -> Result<std::string, PreviewLookupError> {
if (lid.empty()) return R::err({K::NotFound, "No catalog preview for print."});
if (auto print = catalog_.findPrint(setId, lid)) {
const std::string catalogUrl =
JapanesePokemonEnCatalog::previewImageUrlFromPrint(*print);
if (!catalogUrl.empty()) return R::ok(catalogUrl);
}
return R::err({K::NotFound, "TCGdex JA card has no image."});
};
// Prefer direct card fetch when we have a localId.
if (!localId.empty()) {
auto cardResp = http_.get(buildCardUrl(setId, localId));
if (cardResp) {
auto img = parseCardImageUrl(cardResp.value());
if (img) return img;
// NotFound from card object: fall through to set list / catalog.
if (img.error().kind == K::Transient) return img;
} else {
// Synthetic / classic products: try catalog gap-fill before set detail.
auto catalogImg = catalogPreviewFor(localId);
if (catalogImg) return catalogImg;
// Known catalog print with no preview URL: honest miss (do not
// borrow a sibling print's art via name match).
if (catalog_.findPrint(setId, localId)) {
return R::err({K::NotFound, "TCGdex JA card has no image."});
}
}
}
auto setResp = http_.get(buildSetDetailUrl(setId));
if (!setResp) {
// Catalog-only products (City Gym theme decks, etc.) are not on TCGdex.
if (!localId.empty()) {
auto catalogImg = catalogPreviewFor(localId);
if (catalogImg) return catalogImg;
if (catalog_.findPrint(setId, localId)) {
return R::err({K::NotFound, "TCGdex JA card has no image."});
}
// Network failure and no catalog entry: Transient so a brief outage
// is not negative-cached as a permanent miss.
return R::err({K::Transient, setResp.error()});
}
if (catalog_.hasPrintsForSet(setId)) {
const std::string wantedLower = asciiLower(trim(std::string(name)));
if (!wantedLower.empty()) {
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
auto catalogImg = catalogPreviewFor(p.localId);
if (catalogImg) return catalogImg;
}
}
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
}
return R::err({K::Transient, setResp.error()});
}
auto rows = parseSetCards(setResp.value());
if (!rows) return R::err(rows.error());
const std::string wantedLower = asciiLower(trim(std::string(name)));
const SetCardRow* chosen = nullptr;
for (const auto& row : rows.value()) {
if (!localId.empty() && localIdsMatch(row.localId, localId)) {
chosen = &row;
break;
}
}
// Name match only when setNo was not provided — never borrow a sibling
// print's art for a concrete localId.
if (chosen == nullptr && localId.empty() && !wantedLower.empty()) {
for (const auto& row : rows.value()) {
if (nameMatchesRow(wantedLower, row, setId, catalog_)) {
chosen = &row;
break;
}
}
}
if (chosen == nullptr) {
// Empty cards[] with catalog prints: resolve from catalog.
if (rows.value().empty() && catalog_.hasPrintsForSet(setId)) {
if (!localId.empty()) {
auto catalogImg = catalogPreviewFor(localId);
if (catalogImg) return catalogImg;
if (catalog_.findPrint(setId, localId)) {
return R::err({K::NotFound, "TCGdex JA card has no image."});
}
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
}
if (!wantedLower.empty()) {
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
auto catalogImg = catalogPreviewFor(p.localId);
if (catalogImg) return catalogImg;
}
}
}
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
}
if (!chosen->imageBase.empty()) {
return R::ok(imageUrlFromBase(chosen->imageBase));
}
// Try full card object — set résumé sometimes omits image.
auto cardResp = http_.get(buildCardUrl(setId, chosen->localId));
if (cardResp) {
auto img = parseCardImageUrl(cardResp.value());
if (img) return img;
if (img.error().kind == K::Transient) return img;
} else {
// Odd localId padding can 404; still try catalog gap-fill below.
}
// Classic JA sets often have image:null on TCGdex. Prefer a catalog
// printing-accurate TCGPlayer product image for this exact setId+localId
// (never search other printings by Pokémon name).
return catalogPreviewFor(chosen->localId);
}
Result<AutoDetectedPrint>
JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto variants = detectPrintVariants(name, setId);
if (!variants) return Result<AutoDetectedPrint>::err(variants.error());
if (variants.value().empty()) {
return Result<AutoDetectedPrint>::err(
"No matching Japanese Pokemon prints for that name in the set.");
}
return Result<AutoDetectedPrint>::ok(variants.value().front());
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
std::string_view setId) {
if (setId.empty()) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Select a set before auto-detecting Japanese Pokemon prints.");
}
auto setResp = http_.get(buildSetDetailUrl(setId));
if (!setResp) {
if (catalog_.hasPrintsForSet(setId)) {
return detectPrintVariantsFromCatalog(setId, name, catalog_);
}
return Result<std::vector<AutoDetectedPrint>>::err(setResp.error());
}
auto parsed = parsePrintVariants(setResp.value(), setId, name, catalog_);
if (parsed) return parsed;
// Empty/unusable TCGdex detail: fall back to catalog prints when present.
if (catalog_.hasPrintsForSet(setId)) {
return detectPrintVariantsFromCatalog(setId, name, catalog_);
}
return parsed;
}
} // namespace ccm
@@ -0,0 +1,159 @@
#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::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,207 @@
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <string>
#include <unordered_map>
namespace ccm {
namespace {
struct ClassicMissingProduct {
const char* id;
const char* nameEn;
const char* releaseDate; // YYYY/MM/DD
};
// Keep in sync with tools/pokemon_jp/classic_missing_sets.json and
// docs/assets-and-info-apis.md (Japanese Pokémon Info API).
constexpr std::array<ClassicMissingProduct, 11> kMissingClassicProducts{{
// Day after Pokémon Jungle (PMCG2, 1997/03/05) so the set list places
// Unnumbered Promo immediately after Jungle when sorted by releaseDate.
{"UnnumberedPromo", "Unnumbered Promotional cards", "1997/03/06"},
{"ExpSheet1", "Expansion Sheet Series 1", "1998/03/23"},
{"NiviCG", "Nivi City Gym", "1998/04/26"},
{"HanadaCG", "Hanada City Gym", "1998/04/26"},
{"ExpSheet2", "Expansion Sheet Series 2", "1998/06/17"},
{"KuchibaCG", "Kuchiba City Gym", "1998/07/25"},
{"TamamushiCG", "Tamamushi City Gym", "1998/07/25"},
{"ExpSheet3", "Expansion Sheet Series 3", "1998/11/24"},
{"YamabukiCG", "Yamabuki City Gym", "1999/02/26"},
{"GurenTG", "Guren Town Gym", "1999/02/26"},
{"SouthernIslands", "Southern Islands", "1999/07/17"},
}};
const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
// Field-level corrections for known TCGdex JA mislabels (never edit cache).
static const std::unordered_map<std::string, std::string> kOverrides{
{"SV4a", "シャイニートレジャーex"},
};
return kOverrides;
}
[[nodiscard]] bool containsCjk(std::string_view s) noexcept {
// Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE30xE9).
// Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK —
// that used to wipe catalog English names back to the set id.
for (unsigned char ch : s) {
if (ch >= 0xE3 && ch <= 0xE9) return true;
}
return false;
}
} // 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());
}
}
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).
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;
}
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));
}
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) {
if (auto en = catalog_.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty() && s.releaseDate.empty()) {
s.releaseDate = en->releaseDate;
}
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
}
appendMissingClassicProducts(sets);
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
}
} // namespace ccm
+43 -8
View File
@@ -12,24 +12,59 @@ JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, Dir
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path JsonSetRepository::setsPath(Game game) const {
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
const fs::path root = fs::path(config_.current().dataStorage) / dirName_(game);
switch (game) {
case Game::Pokemon: return root / "sets-west.json";
case Game::JapanesePokemon: return root / "sets-asia.json";
default: return root / "sets.json";
}
}
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
const auto p = setsPath(game);
if (!fs_.exists(p)) {
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
fs::path JsonSetRepository::legacySetsPath(Game game) const {
const fs::path dataRoot(config_.current().dataStorage);
switch (game) {
case Game::Pokemon:
// Pre-flatten: pokemon/sets.json
return dataRoot / "pokemon" / "sets.json";
case Game::JapanesePokemon:
// Pre-flatten: pokemonjp/sets.json
return dataRoot / "pokemonjp" / "sets.json";
default:
return {};
}
auto text = fs_.readText(p);
if (!text) return Result<std::vector<Set>>::err(text.error());
}
Result<std::vector<Set>> JsonSetRepository::parseSetsText(const std::string& text) const {
try {
auto j = nlohmann::json::parse(text.value());
auto j = nlohmann::json::parse(text);
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
}
}
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
const auto p = setsPath(game);
if (fs_.exists(p)) {
auto text = fs_.readText(p);
if (!text) return Result<std::vector<Set>>::err(text.error());
return parseSetsText(text.value());
}
const auto legacy = legacySetsPath(game);
if (!legacy.empty() && fs_.exists(legacy)) {
auto text = fs_.readText(legacy);
if (!text) return Result<std::vector<Set>>::err(text.error());
auto parsed = parseSetsText(text.value());
if (!parsed) return parsed;
// Best-effort promote to the new path; UI still gets the sets if write fails.
(void)save(game, parsed.value());
return parsed;
}
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
}
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
const auto p = setsPath(game);
auto dir = fs_.ensureDirectory(p.parent_path());
+17
View File
@@ -47,6 +47,7 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
if (containsLower(to_string(card.region), needle)) return true;
return false;
}
@@ -82,4 +83,20 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi
return false;
}
bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter) {
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
} // namespace ccm
+58 -2
View File
@@ -1,5 +1,6 @@
#include "ccm/services/CardPreviewService.hpp"
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
@@ -36,11 +37,18 @@ std::string makeUrlKey(std::string_view url) {
return k;
}
constexpr std::string_view kAssetScheme = "asset:";
} // namespace
CardPreviewService::CardPreviewService(IHttpClient& http,
IPreviewByteCache* persistentCache)
: http_(http), persistentCache_(persistentCache) {}
IPreviewByteCache* persistentCache,
IFileSystem* fs,
std::filesystem::path assetRoot)
: http_(http),
persistentCache_(persistentCache),
fs_(fs),
assetRoot_(std::move(assetRoot)) {}
void CardPreviewService::registerModule(IGameModule& module) {
if (auto* src = module.cardPreviewSource(); src != nullptr) {
@@ -122,6 +130,37 @@ Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKe
return Result<std::string>::ok(std::move(payload));
}
Result<std::string, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
const std::string& cacheKey,
std::string_view assetUrl) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
if (fs_ == nullptr || assetRoot_.empty()) {
return R::err({K::Transient, "Asset preview path is not configured."});
}
if (!assetUrl.starts_with(kAssetScheme)) {
return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."});
}
std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size())));
const auto fullPath = assetRoot_ / rel;
auto bytes = fs_->readText(fullPath);
if (!bytes) {
return R::err({K::NotFound,
"Bundled preview asset not found: " + fullPath.generic_string()});
}
std::string payload = std::move(bytes).value();
if (payload.empty()) {
return R::err({K::NotFound,
"Bundled preview asset is empty: " + fullPath.generic_string()});
}
cacheStore(cacheKey, payload);
if (persistentCache_ != nullptr) {
persistentCache_->store(cacheKey, payload);
}
return R::ok(std::move(payload));
}
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
@@ -179,6 +218,18 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
}
return Result<std::string>::err(err.message);
}
if (url.value().starts_with(kAssetScheme)) {
auto asset = fetchAssetAndCache(key, url.value());
if (!asset) {
const auto err = std::move(asset).error();
if (err.kind == PreviewLookupError::Kind::NotFound) {
cacheStoreNegative(key);
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
}
return Result<std::string>::err(err.message);
}
return Result<std::string>::ok(std::move(asset).value());
}
return fetchAndCache(key, url.value());
}
@@ -231,6 +282,11 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
return Result<std::string>::ok(disk.payload);
}
}
if (url.starts_with(kAssetScheme)) {
auto asset = fetchAssetAndCache(key, url);
if (!asset) return Result<std::string>::err(asset.error().message);
return Result<std::string>::ok(std::move(asset).value());
}
return fetchAndCache(key, url);
}
+70
View File
@@ -293,4 +293,74 @@ void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
}
}
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending) {
switch (column) {
case JapanesePokemonSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case JapanesePokemonSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case JapanesePokemonSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case JapanesePokemonSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case JapanesePokemonSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case JapanesePokemonSortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return a.holo < b.holo;
}, ascending));
break;
case JapanesePokemonSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case JapanesePokemonSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case JapanesePokemonSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case JapanesePokemonSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
} // namespace ccm
+7 -1
View File
@@ -21,7 +21,13 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
}
Result<std::vector<Set>> SetService::getSets(Game game) {
return repo_.load(game);
auto loaded = repo_.load(game);
if (!loaded) return loaded;
auto it = modules_.find(game);
if (it != modules_.end() && it->second != nullptr) {
it->second->setSource().augmentCachedSets(loaded.value());
}
return loaded;
}
} // namespace ccm
+1 -1
View File
@@ -11,7 +11,7 @@ Long-form contributor documentation that lives outside the source tree.
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, Yu-Gi-Oh!, and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon (West + Asia backends), Yu-Gi-Oh!, and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching).
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
+1 -1
View File
@@ -195,7 +195,7 @@ private:
Two subtle requirements:
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short.
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short. **Pokemon exception:** a unified Game menu entry may keep one `dirName` (`pokemon`) for collection/images and disambiguate region set caches by filename (`sets-west.json` / `sets-asia.json`) instead of a second data subdirectory.
- `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available".
The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`.
+118 -3
View File
@@ -18,6 +18,10 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
## Pokemon APIs
Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects the backend below. Collection: `pokemon/collection.json`. West sets: `pokemon/sets-west.json`. Asia sets: `pokemon/sets-asia.json`. Language choices: West → English/German/French/Spanish/Italian/Russian; Asia → Japanese/S-Chinese/T-Chinese/Korean.
### West (`Game::Pokemon`, pokemontcg.io)
**Info API:** `https://api.pokemontcg.io/v2/sets`
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
@@ -115,6 +119,116 @@ where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also s
Empty search array / `{"error":"..."}``NotFound`; bad JSON / HTTP → `Transient`.
## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend
Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json``sets-west.json` and `pokemonjp/sets.json``sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western pokemontcg.io ids.
### Info API: TCGdex `GET /v2/ja/sets` (+ per-set detail)
`https://api.tcgdex.net/v2/ja/sets` returns a slim array (`id`, `name`, `cardCount`). Release dates require `GET /v2/ja/sets/{id}` (`releaseDate` as `YYYY-MM-DD`, rewritten to `YYYY/MM/DD`). `JapanesePokemonSetSource`:
- Excludes Chinese-region `CS*` junk rows mislabeled on the JA endpoint.
- Applies field overrides (e.g. `SV4a` Japanese name → `シャイニートレジャーex`).
- Prefers English display names and release dates from the bundled EN catalog when present; otherwise keeps the TCGdex Japanese name and fetches detail for the date.
- After parsing the TCGdex list, **injects Original-era / catalog-only products TCGdex omits** (idempotent by set id — skipped if upstream later adds them). The same injection runs when loading a cached Asia set list (`sets-asia.json`) via `ISetSource::augmentCachedSets`, so these products appear without requiring **Update Sets** first. Stable ids and English names:
| Id | English name |
|---|---|
| `UnnumberedPromo` | Unnumbered Promotional cards (Bulbapedia catch-all; synthetic `001`… localIds; preview via catalog `image_url` preferring Japanese / Unnumbered Bulbagarden scans) |
| `ExpSheet1` / `ExpSheet2` / `ExpSheet3` | Expansion Sheet Series 13 |
| `NiviCG` | Nivi City Gym |
| `HanadaCG` | Hanada City Gym |
| `KuchibaCG` | Kuchiba City Gym |
| `TamamushiCG` | Tamamushi City Gym |
| `YamabukiCG` | Yamabuki City Gym |
| `GurenTG` | Guren Town Gym |
| `SouthernIslands` | Southern Islands |
Seed data lives in `tools/pokemon_jp/classic_missing_sets.json` + `classic_missing_prints.json` (merged into the EN catalog via `merge_classic_missing.py`). LocalIds for these products are sequential `001`… within each product (cards were unnumbered in print). Refresh `UnnumberedPromo` prints from Bulbapedia with `python tools/pokemon_jp/harvest_unnumbered_promos.py`, then fill preview images with `python tools/pokemon_jp/enrich_unnumbered_promo_images.py` (prefers Unnumbered / Japanese reprint-gallery scans over English Wizards `|image=` primaries; EN-only Bulbapedia pages leave `image_url` empty), then re-run `merge_classic_missing.py`. Numbered Japanese promo eras (`SV-P`, `S-P`, …) remain out of scope — TCGdex does not expose them, and they are not part of this curated set.
### Sets without printed collector numbers (`UnnumberedPromo`)
Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card.
**Edit dialog (`PokemonCardEditDialog`, Asia region) for set id `UnnumberedPromo`:**
- The **Set #** text field is hidden (row label becomes **Print**). **Auto detect** and **Next** remain.
- Auto-detect / silent Edit prefetch lists catalog prints matching the typed name (exact EN/JA, plus qualified titles such as `Mewtwo``Mewtwo (CoroCoro promo)`). Distinct synthetic localIds form the Next ring.
- **Next** on the edit form shows a position counter (`Next (2/5)`), not the synthetic id. For ordinary numbered JP sets, Next still shows the current collector number (`Next (42)`).
- A modeless **Print preview** popup (`VariantImagePreviewDialog`) opens ~20px to the right of the Add/Edit dialog. It loads the current print via `CardPreviewService::fetchPreviewBytes` and refreshes on each ring step. The popup has its own **`<< Prev` / `Next >>`** controls that drive the same ring as the edit dialog (buttons disabled when fewer than two variants).
- On save, the dialog writes the rings synthetic `setNo` into `PokemonCard::setNo` even though the text field was hidden.
Other classic unnumbered products (City Gyms, Expansion Sheets, Southern Islands) currently keep the normal Set # field; only `UnnumberedPromo` uses the print-preview UX above.
### Asset API: TCGdex card / set-detail images
Preview is **local-id based**. `JapanesePokemonCardPreviewSource`:
1. With `setId` + `setNo` (`localId`), tries `GET /v2/ja/cards/{setId}-{localId}` and reads `image`.
2. Falls back to set-detail `cards[]` (which often already carries `image` on modern sets).
3. Appends `/high.png` to the TCGdex image base URL (PNG — wxImage does not decode webp).
4. If the set-specific card still has no scan (classic sets like `PMCG1`), looks up the bundled EN catalog print for that exact `setId`+`localId` and uses optional `image_url` or a TCGPlayer product image built from `tcgplayer_id` (`https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg`). Gap-fill sources differ by era:
- **PMCG and other data-asia sets with `thirdParty.tcgplayer`**: printing-accurate `tcgplayer_id` harvested offline from [tcgdex/cards-database](https://github.com/tcgdex/cards-database) `data-asia` (the live TCGdex API does not expose them).
- **neo1neo4**: data-asia has no `tcgplayer_id` and TCGdex JA `image` is null; the catalog may carry an ETL-written `image_url` from a **Japanese** [CardIndex](https://www.cardindex.co/) set scan (`enrich_neo_image_urls.py` scrapes the JA neo set pages and matches by English card name). **No English pokemontcg.io fallback** — if CardIndex has no JP image, `image_url` is left empty and the UI shows the card-back. Use `--overwrite` to re-resolve / clear stale EN URLs. Runtime still resolves only by exact JA `setId`+`localId` — no C++ name search across printings.
This is **printing-accurate** gap-fill — not a name search across other Charizard printings at runtime.
5. For **catalog-only products** (Unnumbered Promotional cards, City Gym theme decks, Expansion Sheets, Southern Islands), when TCGdex set/card GETs fail, Auto-detect and preview fall back to the bundled catalog prints for that `setId` (EN/JA name → `localId`; optional `tcgplayer_id` / `image_url` for preview). `UnnumberedPromo` rows typically carry Bulbagarden Archives `image_url` values written by `enrich_unnumbered_promo_images.py`, which prefers Japanese / Unnumbered Promotional reprint scans and omits English-only Wizards Black Star primaries when no JP file is available. Without a catalog image field, preview returns `NotFound` and the UI shows the card-back. Auto-detect matches exact EN/JA names and also qualified English titles (`Mewtwo``Mewtwo (CoroCoro promo)`).
6. City Gym deck exclusives must stay **printing-accurate**. Do **not** reuse Leaders' Stadium / PMCG donor `tcgplayer_id`s for those prints; that shows the wrong set art. Instead, bundle local scans under `assets/pokemon_jp_classic/<setId>/<localId>.jpg` and point the catalog row at `image_url: "asset:pokemon_jp_classic/<setId>/<localId>.jpg"`. `CardPreviewService` loads `asset:` URLs from disk next to the executable, bypassing HTTP entirely.
It does **not** substitute another printing of the same Pokémon when both TCGdex and the catalog lack an image. Then preview returns `NotFound` and the UI shows the Japanese TCG card-back.
Auto-detect / Next uses the same set-detail `cards[]`, matching the typed name against catalog English names or TCGdex Japanese names. Catalog EN aliases are applied only when the catalog `name_ja` agrees with the TCGdex row (stale seed mappings like Charmander→`001` are ignored).
Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ``Erika's Oddish`, `わるいリザードン``Dark Charizard`, `R団のサンダー``Rocket's Zapdos`, neo garbled `輝くセレビ``Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`).
That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1PMCG6, neo1neo4, VS1, web1, E1E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
### Variant Pokémon English titles
Auto-detect for English owner / Rocket's / Dark / Light / Shining Pokémon names requires the bundled catalog's **full** `name_en` for that print (same rule as City Gym manuals that already store `Erika's Oddish`). Typing the Japanese TCGdex name still works when `name_ja` is correct.
To extend variant coverage:
1. Add new JA prefix → English title prefix pairs to `VARIANT_JA_PREFIXES` in [`tools/pokemon_jp/enrich_preview_images.py`](../tools/pokemon_jp/enrich_preview_images.py) (longest prefixes first).
2. Re-run:
```bash
python tools/pokemon_jp/enrich_preview_images.py
```
3. For neo1neo4 Japanese preview images (CardIndex JP scans only; clears EN
pokemontcg.io URLs on miss), run:
```bash
python tools/pokemon_jp/enrich_neo_image_urls.py
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
```
4. Rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated.
Rows with `name_en_source: "manual"` (City Gym theme decks in `classic_missing_prints.json`) are never overwritten. Prefer stable English TCG product names (Bulbapedia / Limitless English titles).
### Extending Trainer/Energy English aliases
Auto-detect for English Trainer/Energy names only works when the bundled catalog has a `name_en` for that print. Pokémon get `name_en` automatically from `dexId` (bare species) or from variant prefix composition (full titles); Trainers and Energy do not. To add more sets or staples:
1. Collect unique Japanese Trainer/Energy names for the sets you care about (from TCGdex set detail `cards[].name`, or from `tools/pokemon_jp/_tcgdex_cards_database/data-asia/<serie>/<setId>/*.ts` after running enrich once).
2. Add each missing `name_ja` → English display name to [`tools/pokemon_jp/non_pokemon_en_by_ja.json`](../tools/pokemon_jp/non_pokemon_en_by_ja.json). One entry covers **every set** that reprints that Japanese title.
3. Re-run:
```bash
python tools/pokemon_jp/enrich_preview_images.py
```
4. Confirm `enrich_preview_images.py` prints `FIRST15 trainer/energy coverage OK` (or extend `FIRST15_SETS` in that script if you raise the coverage baseline). Copy/rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated.
5. Prefer stable English TCG product names (Bulbapedia / Limitless English titles). Do not invent per-set aliases that differ for the same `name_ja`.
### Bundled English catalog
`ui_wx/assets/pokemon_jp_en_catalog.json` is copied next to the exe on build (`assets/pokemon_jp_en_catalog.json`). It supplies English set/card names TCGdex JA cannot provide, plus optional classic-image gap-fill fields (`tcgplayer_id` / `image_url`). Generated offline via `tools/pokemon_jp/` (set EN merge + `enrich_preview_images.py` using species, variant, and trainer/energy tables + optional `enrich_neo_image_urls.py` for neo `image_url`). Missing catalog → Japanese-only labels still work; missing image fields → card-back for unscanned printings. Missing EN aliases for a Trainer still allow Auto-detect when the Japanese name is typed.
Card-back fallback uses the Japanese TCG Bulbagarden scan
(`TCG_Card_Back_Japanese.jpg`), not the Western `Cardback.jpg`.
## Runtime Flow In CCM3
The app uses the same flow for every game that registers a module:
@@ -122,13 +236,13 @@ The app uses the same flow for every game that registers a module:
- `SetService` asks the game's `ISetSource` (info API) for the latest set list.
- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL.
- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer.
- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`).
- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon / Japanese Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`).
### Caching And Connection Reuse
See [caching.md](caching.md) for a dedicated reference on preview cache tiers, internal keys, eviction, clearing, and HTTP session reuse.
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon, Yu-Gi-Oh!, DigiBattle99). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews:
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon West/Asia backends, Yu-Gi-Oh!, DigiBattle99). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews:
- **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview.
- **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
@@ -146,6 +260,7 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg`
- Pokémon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
- Japanese Pokémon: `https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg`
- Yu-Gi-Oh!: Yugipedia English TCG back — try `https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png`, then `https://ms.yugipedia.com/e/e5/Back-EN.png`; if both fail, load `<exeDir>/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string.
- Digimon (Digi-Battle): no stable public back URL; load `<exeDir>/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time).
@@ -158,6 +273,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
+1 -1
View File
@@ -113,7 +113,7 @@ Automated tests primarily cover `core/` and infrastructure adapters. UI testing
`cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`.
For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`).
For MinGW/MSYS2 builds, the `ccm` POST_BUILD step also copies `libstdc++-6.dll`, `libgcc_s_seh-1.dll`, and `libwinpthread-1.dll` from the compilers `bin/` next to `ccm3.exe`. That keeps Explorer / IDE launches on the same UCRT runtime used to build (avoids “Entry Point Not Found” / `__emutls_v._ZSt11__once_call` against `libcpr.dll` when a different `libstdc++` is on `PATH`).
## Troubleshooting
+1 -1
View File
@@ -11,7 +11,7 @@
- `image_service_tests.cpp``ImageService` (uses inline `RecordingImageStore` fake).
- `collection_service_tests.cpp``CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
- `config_service_tests.cpp``ConfigService` against `InMemoryFileSystem`.
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. Set-repo cases also pin Pokemon `sets-west.json` / `sets-asia.json` paths and migrate-on-load from legacy `pokemon/sets.json` / `pokemonjp/sets.json`.
- `local_image_store_tests.cpp``LocalImageStore` against `InMemoryFileSystem` + `ConfigService`: `copyIn` (extension preserved, missing source errors), `remove` (existing file deleted; absent path is a no-op), `resolvePath` layout under `dataStorage/<game>/images/`.
- `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`.
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
+3
View File
@@ -23,6 +23,9 @@ add_executable(ccm_core_tests
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp
icard_preview_source_tests.cpp
yugioh_set_source_tests.cpp
yugioh_set_lookup_tests.cpp
+47
View File
@@ -7,6 +7,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -186,6 +187,15 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
TEST_CASE("empty filter matches everything") {
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
}
TEST_CASE("region is searchable") {
PokemonCard c = pc("Charizard", "Base Set");
c.region = PokemonRegion::Asia;
CHECK(matchesPokemonFilter(c, "asia"));
CHECK_FALSE(matchesPokemonFilter(c, "west"));
c.region = PokemonRegion::West;
CHECK(matchesPokemonFilter(c, "west"));
}
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
@@ -276,3 +286,40 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
CHECK(matchesDigiBattle99Filter(c, "agu"));
}
}
TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") {
TEST_CASE("matches by name and set.name") {
JapanesePokemonCard c;
c.name = "Charmander";
c.set.name = "Expansion Pack";
CHECK(matchesJapanesePokemonFilter(c, "char"));
CHECK(matchesJapanesePokemonFilter(c, "EXPANSION"));
CHECK_FALSE(matchesJapanesePokemonFilter(c, "pikachu"));
}
TEST_CASE("includes setNo in searchable columns") {
JapanesePokemonCard c;
c.name = "Charmander";
c.set.name = "Expansion Pack";
c.setNo = "001";
CHECK(matchesJapanesePokemonFilter(c, "001"));
CHECK(matchesJapanesePokemonFilter(c, "00"));
}
TEST_CASE("empty filter matches everything") {
JapanesePokemonCard c;
c.name = "Charmander";
CHECK(matchesJapanesePokemonFilter(c, ""));
}
TEST_CASE("boolean flag columns are not matched") {
JapanesePokemonCard c;
c.name = "Charmander";
c.holo = true;
c.firstEdition = true;
c.signed_ = true;
c.altered = true;
CHECK_FALSE(matchesJapanesePokemonFilter(c, "true"));
CHECK(matchesJapanesePokemonFilter(c, "char"));
}
}
+66
View File
@@ -2,10 +2,12 @@
#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/services/CardPreviewService.hpp"
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
@@ -130,6 +132,50 @@ public:
void storeNegative(std::string_view) override {}
};
class MemoryFileSystem final : public IFileSystem {
public:
std::unordered_map<std::string, std::string> files;
[[nodiscard]] bool exists(const std::filesystem::path& p) const override {
return files.contains(p.generic_string());
}
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override {
return false;
}
Result<void> ensureDirectory(const std::filesystem::path&) override {
return Result<void>::ok();
}
Result<std::string> readText(const std::filesystem::path& p) override {
auto it = files.find(p.generic_string());
if (it == files.end()) {
return Result<std::string>::err("Unable to open " + p.generic_string());
}
return Result<std::string>::ok(it->second);
}
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
files[p.generic_string()] = std::string(contents);
return Result<void>::ok();
}
Result<void> copyFile(const std::filesystem::path& from,
const std::filesystem::path& to,
bool) override {
auto it = files.find(from.generic_string());
if (it == files.end()) {
return Result<void>::err("missing source");
}
files[to.generic_string()] = it->second;
return Result<void>::ok();
}
Result<void> remove(const std::filesystem::path& p) override {
files.erase(p.generic_string());
return Result<void>::ok();
}
Result<std::vector<std::filesystem::path>> listDirectory(
const std::filesystem::path&) override {
return Result<std::vector<std::filesystem::path>>::ok({});
}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
@@ -232,6 +278,26 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
CHECK(out.isErr());
CHECK(out.error() == "net down");
}
TEST_CASE("asset: preview loads bytes from configured asset root") {
FakeSource source;
source.url = "asset:pokemon_jp_classic/TamamushiCG/016.jpg";
FakeGameModule module;
module.gameId = Game::JapanesePokemon;
module.preview = &source;
FixedHttpClient http;
MemoryFileSystem fs;
fs.files["assets/pokemon_jp_classic/TamamushiCG/016.jpg"] = "JPEG-bytes";
CardPreviewService svc{http, nullptr, &fs, "assets"};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::JapanesePokemon, "Erika", "TamamushiCG", "016");
REQUIRE(out.isOk());
CHECK(out.value() == "JPEG-bytes");
CHECK(http.calls == 0);
}
}
TEST_SUITE("CardPreviewService caching") {
+64
View File
@@ -7,6 +7,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -119,6 +120,30 @@ DigiBattle99Card db(std::uint32_t id, std::string name,
return c;
}
JapanesePokemonCard jp(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::uint8_t amount = 1,
bool holo = false, bool firstEdition = false,
bool sgnd = false, bool altered = false,
Language lang = Language::Japanese,
Condition cond = Condition::NearMint,
std::string note = "") {
JapanesePokemonCard c;
c.id = id;
c.name = std::move(name);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.amount = amount;
c.holo = holo;
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
@@ -147,6 +172,13 @@ std::vector<std::uint32_t> ids(const std::vector<DigiBattle99Card>& v) {
return out;
}
std::vector<std::uint32_t> ids(const std::vector<JapanesePokemonCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
for (const auto& c : v) out.push_back(c.id);
return out;
}
} // namespace
TEST_SUITE("CardSorter - Magic columns") {
@@ -515,3 +547,35 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") {
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
}
TEST_SUITE("CardSorter - JapanesePokemon columns") {
TEST_CASE("Holo and FirstEdition sort false before true") {
std::vector<JapanesePokemonCard> v = {
jp(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false),
jp(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true),
jp(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false),
};
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Holo, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::FirstEdition, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Set column sorts by release date") {
std::vector<JapanesePokemonCard> v = {
jp(1, "x", "Late", "2023/03/10"),
jp(2, "y", "Early", "1996/10/20"),
};
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Name sorts case-insensitively") {
std::vector<JapanesePokemonCard> v = {
jp(1, "charmander", "X", "1996/10/20"),
jp(2, "Bulbasaur", "X", "1996/10/20"),
};
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Name, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
}
+128
View File
@@ -3,6 +3,7 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -27,6 +28,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json jDigi = "DigiBattle99";
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
nlohmann::json jJp = "JapanesePokemon";
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
nlohmann::json j3 = Theme::Dark;
CHECK(j3.get<std::string>() == "Dark");
CHECK(j3.get<Theme>() == Theme::Dark);
@@ -37,11 +41,45 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
CHECK(l.get<std::string>() == "Japanese");
CHECK(l.get<Language>() == Language::Japanese);
nlohmann::json k = Language::Korean;
CHECK(k.get<std::string>() == "Korean");
CHECK(k.get<Language>() == Language::Korean);
nlohmann::json sc = Language::SimplifiedChinese;
CHECK(sc.get<std::string>() == "S-Chinese");
CHECK(sc.get<Language>() == Language::SimplifiedChinese);
nlohmann::json tc = Language::TraditionalChinese;
CHECK(tc.get<std::string>() == "T-Chinese");
CHECK(tc.get<Language>() == Language::TraditionalChinese);
nlohmann::json legacyChinese = "Chinese";
CHECK(legacyChinese.get<Language>() == Language::SimplifiedChinese);
nlohmann::json c = Condition::LightPlayed;
CHECK(c.get<std::string>() == "LightPlayed");
CHECK(c.get<Condition>() == Condition::LightPlayed);
}
TEST_CASE("PokemonRegion") {
nlohmann::json j = PokemonRegion::Asia;
CHECK(j.get<std::string>() == "Asia");
CHECK(j.get<PokemonRegion>() == PokemonRegion::Asia);
nlohmann::json w = "West";
CHECK(w.get<PokemonRegion>() == PokemonRegion::West);
}
TEST_CASE("allGames excludes JapanesePokemon but string mapping remains") {
for (const auto game : allGames()) {
CHECK(game != Game::JapanesePokemon);
}
CHECK(allGames().size() == 4);
CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon);
CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon);
CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon);
}
TEST_CASE("invalid enum string throws") {
nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>());
@@ -146,15 +184,37 @@ TEST_SUITE("PokemonCard JSON") {
c.holo = true;
c.signed_ = false;
c.altered = false;
c.region = PokemonRegion::West;
nlohmann::json j = c;
CHECK(j.at("setNo") == "4/102");
CHECK(j.at("firstEdition") == true);
CHECK(j.at("signed") == false);
CHECK(j.at("region") == "West");
const PokemonCard back = j.get<PokemonCard>();
CHECK(back == c);
}
TEST_CASE("region Asia round-trips and missing region defaults to West") {
PokemonCard c;
c.id = 1;
c.amount = 1;
c.name = "Charmander";
c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"};
c.setNo = "001";
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.region = PokemonRegion::Asia;
nlohmann::json j = c;
CHECK(j.at("region") == "Asia");
CHECK(j.get<PokemonCard>().region == PokemonRegion::Asia);
j.erase("region");
const PokemonCard legacy = j.get<PokemonCard>();
CHECK(legacy.region == PokemonRegion::West);
}
}
TEST_SUITE("DigiBattle99Card JSON") {
@@ -185,6 +245,34 @@ TEST_SUITE("DigiBattle99Card JSON") {
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;
c.id = 9;
c.amount = 1;
c.name = "Charmander";
c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"};
c.setNo = "001";
c.note = "";
c.images = {};
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.firstEdition = true;
c.holo = false;
c.signed_ = false;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "001");
CHECK(j.at("firstEdition") == true);
CHECK(j.at("signed") == false);
CHECK(j.at("language") == "Japanese");
const JapanesePokemonCard back = j.get<JapanesePokemonCard>();
CHECK(back == c);
}
}
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
Configuration cfg;
@@ -201,6 +289,16 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
CHECK(back == cfg);
}
TEST_CASE("legacy defaultGame JapanesePokemon coerces to Pokemon") {
nlohmann::json j = {
{"dataStorage", "/data"},
{"defaultGame", "JapanesePokemon"},
{"theme", "Light"},
};
const auto cfg = j.get<Configuration>();
CHECK(cfg.defaultGame == Game::Pokemon);
}
TEST_CASE("missing theme key defaults to Light") {
const nlohmann::json j = {
{"dataStorage", "/portable/data"},
@@ -559,6 +657,36 @@ TEST_SUITE("Domain JSON required fields") {
}
}
TEST_CASE("JapanesePokemonCard missing each required key throws") {
const nlohmann::json full = {
{"id", 9},
{"amount", 1},
{"name", "Charmander"},
{"set", nlohmann::json{
{"id", "PMCG1"},
{"name", "Expansion Pack"},
{"releaseDate", "1996/10/20"},
}},
{"setNo", "001"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"firstEdition", true},
{"holo", false},
{"signed", false},
{"altered", false},
};
for (const char* key :
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
"firstEdition", "holo", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<JapanesePokemonCard>());
}
}
TEST_CASE("Configuration missing required key throws") {
const nlohmann::json j = {
{"defaultGame", "Magic"},
+12
View File
@@ -3,6 +3,7 @@
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
@@ -63,4 +64,15 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("JapanesePokemon module reports canonical metadata") {
NoopHttpClient http;
JapanesePokemonGameModule module(http);
CHECK(module.id() == Game::JapanesePokemon);
CHECK(module.dirName() == "pokemon");
CHECK(module.displayName() == "Pokemon (Japan)");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
}
@@ -0,0 +1,593 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <unordered_map>
using namespace ccm;
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::unordered_map<std::string, std::string> bodies;
std::string lastUrl;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (!ok) return Result<std::string>::err("offline");
const auto it = bodies.find(lastUrl);
if (it == bodies.end()) return Result<std::string>::err("unknown url: " + lastUrl);
return Result<std::string>::ok(it->second);
}
};
JapanesePokemonEnCatalog sampleCatalog() {
auto c = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート"}
},
"prints": [
{"set_id":"SV1a","local_id":"001","name_en":"Tropius","name_ja":"トロピウス","name_en_source":"bulbapedia"}
]
})");
REQUIRE(c.isOk());
return std::move(c).value();
}
} // namespace
TEST_SUITE("JapanesePokemonCardPreviewSource helpers") {
TEST_CASE("normalizeLocalId strips slash and whitespace") {
CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId(" 001/102 ") == "001");
CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId("4/102") == "4");
}
TEST_CASE("imageUrlFromBase appends high.png") {
CHECK(JapanesePokemonCardPreviewSource::imageUrlFromBase(
"https://assets.tcgdex.net/ja/SV/SV1a/001") ==
"https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
}
TEST_CASE("buildCardUrl encodes set-local id") {
CHECK(JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001") ==
"https://api.tcgdex.net/v2/ja/cards/SV1a-001");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::parseSetCards") {
TEST_CASE("parses localId name and image") {
const std::string json = R"({
"id":"SV1a",
"cards":[
{"id":"SV1a-001","localId":"001","name":"トロピウス",
"image":"https://assets.tcgdex.net/ja/SV/SV1a/001","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parseSetCards(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].localId == "001");
CHECK(out.value()[0].nameJa == "トロピウス");
CHECK(out.value()[0].imageBase == "https://assets.tcgdex.net/ja/SV/SV1a/001");
}
TEST_CASE("missing cards array is Transient") {
const auto out = JapanesePokemonCardPreviewSource::parseSetCards(R"({"id":"X"})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::parseCardImageUrl") {
TEST_CASE("returns high.png URL") {
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl(
R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
}
TEST_CASE("null image is NotFound") {
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl(
R"({"id":"PMCG1-001","image":null})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("malformed JSON is Transient") {
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl("{bad");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::parsePrintVariants") {
TEST_CASE("matches English catalog name") {
const std::string body = R"({
"id":"SV1a",
"cards":[
{"localId":"001","name":"トロピウス","rarity":"Common"},
{"localId":"002","name":"other","rarity":"Common"}
]
})";
const auto catalog = sampleCatalog();
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "SV1a", "Tropius", catalog);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("matches Japanese name without catalog") {
const std::string body = R"({
"id":"SV1a",
"cards":[{"localId":"001","name":"トロピウス"}]
})";
JapanesePokemonEnCatalog empty;
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "SV1a", "トロピウス", empty);
REQUIRE(out.isOk());
CHECK(out.value().front().setNo == "001");
}
TEST_CASE("rejects stale catalog localId when name_ja disagrees with TCGdex") {
// Historical seed bug: Charmander mapped to 001 (actually Bulbasaur).
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ","rarity":"Common"},
{"localId":"014","name":"ヒトカゲ","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Charmander", catalog.value());
REQUIRE(out.isErr());
}
TEST_CASE("accepts corrected catalog localId for Charmander") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ"},
{"localId":"014","name":"ヒトカゲ","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Charmander", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "014");
}
TEST_CASE("English Blastoise and Mewtwo resolve Expansion Pack localIds") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"032","name_en":"Blastoise","name_ja":"カメックス"},
{"set_id":"PMCG1","local_id":"050","name_en":"Mewtwo","name_ja":"ミュウツー"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"032","name":"カメックス","rarity":"Holo Rare"},
{"localId":"050","name":"ミュウツー","rarity":"Holo Rare"}
]
})";
auto blast = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Blastoise", catalog.value());
REQUIRE(blast.isOk());
REQUIRE(blast.value().size() == 1);
CHECK(blast.value()[0].setNo == "032");
auto mew = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Mewtwo", catalog.value());
REQUIRE(mew.isOk());
REQUIRE(mew.value().size() == 1);
CHECK(mew.value()[0].setNo == "050");
}
TEST_CASE("English Switch resolves Expansion Pack localId 073") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"073","name_en":"Switch","name_ja":"ポケモンいれかえ"}
]
})");
REQUIRE(catalog.isOk());
const std::string body = R"({
"id":"PMCG1",
"cards":[
{"localId":"071","name":"きずぐすり","rarity":"Common"},
{"localId":"073","name":"ポケモンいれかえ","rarity":"Common"}
]
})";
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
body, "PMCG1", "Switch", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "073");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("resolves via card endpoint when localId present") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] =
R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})";
auto catalog = sampleCatalog();
JapanesePokemonCardPreviewSource src{http, catalog};
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
REQUIRE(out.isOk());
CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
}
TEST_CASE("resolves via set detail when card has no image but set row does") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] =
R"({"id":"SV1a-001","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("SV1a")] = R"({
"id":"SV1a",
"cards":[{"localId":"001","name":"トロピウス",
"image":"https://assets.tcgdex.net/ja/SV/SV1a/001"}]
})";
auto catalog = sampleCatalog();
JapanesePokemonCardPreviewSource src{http, catalog};
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
REQUIRE(out.isOk());
CHECK(out.value().find("/high.png") != std::string::npos);
}
TEST_CASE("stale catalog does not bind English name to wrong localId image") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"},
{"localId":"014","name":"ヒトカゲ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"}
]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
// Empty setNo forces name match; stale catalog must not pick Bulbasaur's art.
const auto out = src.fetchImageUrl("Charmander", "PMCG1", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("English name resolves correct localId image via catalog") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[
{"localId":"001","name":"フシギダネ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"},
{"localId":"014","name":"ヒトカゲ",
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"}
]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Charmander", "PMCG1", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://assets.tcgdex.net/ja/PMCG/PMCG1/014/high.png");
}
TEST_CASE("null image on set-specific card is NotFound without other-printing fallback") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] =
R"({"id":"PMCG1-021","name":"","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[{"localId":"021","name":"リザードン"}]
})";
JapanesePokemonEnCatalog empty;
JapanesePokemonCardPreviewSource src{http, empty};
const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("catalog tcgplayer_id gap-fills when TCGdex image is null") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] =
R"({"id":"PMCG1-021","name":"","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
"id":"PMCG1",
"cards":[{"localId":"021","name":"リザードン"}]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"021","name_en":"Charizard",
"name_ja":"リザードン","tcgplayer_id":"575604"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021");
REQUIRE(out.isOk());
CHECK(out.value() ==
"https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg");
}
TEST_CASE("HTTP failure is Transient") {
RoutingHttpClient http;
http.ok = false;
JapanesePokemonEnCatalog empty;
JapanesePokemonCardPreviewSource src{http, empty};
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("catalog-only theme deck resolves preview from tcgplayer_id") {
RoutingHttpClient http;
// No TCGdex bodies: card + set detail both miss.
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
"prints": [
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym",
"name_ja":"タマムシシティジム","tcgplayer_id":"12345"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Celadon City Gym", "TamamushiCG", "021");
REQUIRE(out.isOk());
CHECK(out.value() ==
"https://product-images.tcgplayer.com/fit-in/437x437/12345.jpg");
}
TEST_CASE("Tamamushi City Gym Erika uses catalog tcgplayer gap-fill") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
"prints": [
{"set_id":"TamamushiCG","local_id":"016","name_en":"Erika",
"name_ja":"エリカ","name_en_source":"trainer-table",
"tcgplayer_id":"576776"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Erika", "TamamushiCG", "016");
REQUIRE(out.isOk());
CHECK(out.value() ==
"https://product-images.tcgplayer.com/fit-in/437x437/576776.jpg");
}
TEST_CASE("neo catalog image_url gap-fills when TCGdex image is null") {
RoutingHttpClient http;
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("neo4", "106")] =
R"({"id":"neo4-106","name":"","image":null})";
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("neo4")] = R"({
"id":"neo4",
"cards":[{"localId":"106","name":"ラッキースタジアム"}]
})";
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"neo4","local_id":"106","name_en":"Shining Celebi",
"name_ja":"輝くセレビ","name_en_source":"species-table-variant",
"image_url":"https://images.pokemontcg.io/neo4/106_hires.png"}
]
})");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.fetchImageUrl("Shining Celebi", "neo4", "106");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.pokemontcg.io/neo4/106_hires.png");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only") {
TEST_CASE("English trainer name resolves when TCGdex set detail is unavailable") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
"prints": [
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym",
"name_ja":"タマムシシティジム","name_en_source":"trainer-table",
"image_url":"https://example.com/celadon.jpg"},
{"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish",
"name_ja":"エリカのナゾノクサ","name_en_source":"manual",
"image_url":"https://example.com/oddish.jpg"}
]
})json");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto out = src.detectPrintVariants("Celadon City Gym", "TamamushiCG");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "021");
}
TEST_CASE("detectPrintVariantsFromCatalog includes UnnumberedPromo prints without image_url") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)",
"name_ja":"ミュウツー","name_en_source":"manual"},
{"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)",
"name_ja":"ミュウツー","name_en_source":"manual"},
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
"name_ja":"ミュウツー","name_en_source":"manual",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"},
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)",
"name_ja":"","name_en_source":"manual"}
]
})json");
REQUIRE(catalog.isOk());
const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"UnnumberedPromo", "Mewtwo", catalog.value());
REQUIRE(mew.isOk());
REQUIRE(mew.value().size() == 4);
// Imaged prints first, then empty-URL identity rows.
CHECK(mew.value()[0].setNo == "030");
CHECK(mew.value()[1].setNo == "007");
CHECK(mew.value()[2].setNo == "008");
CHECK(mew.value()[3].setNo == "045");
RoutingHttpClient http;
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto img = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030");
REQUIRE(img.isOk());
CHECK(img.value() ==
"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg");
}
TEST_CASE("fetchImageUrl does not borrow sibling UnnumberedPromo image") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)",
"name_ja":"ミュウツー","name_en_source":"manual"},
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
"name_ja":"ミュウツー","name_en_source":"manual",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"}
]
})json");
REQUIRE(catalog.isOk());
JapanesePokemonCardPreviewSource src{http, catalog.value()};
const auto empty = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "007");
REQUIRE(empty.isErr());
CHECK(empty.error().kind == PreviewLookupError::Kind::NotFound);
const auto whf = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030");
REQUIRE(whf.isOk());
CHECK(whf.value() ==
"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg");
}
TEST_CASE("detectPrintVariantsFromCatalog dedupes shared preview URLs") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"},
{"set_id":"UnnumberedPromo","local_id":"073","name_en":"Mewtwo (Song Best Collection promo)",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"},
{"set_id":"UnnumberedPromo","local_id":"197","name_en":"Mewtwo (Wizards Promo 12)",
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"}
]
})json");
REQUIRE(catalog.isOk());
const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"UnnumberedPromo", "Mewtwo", catalog.value());
REQUIRE(mew.isOk());
REQUIRE(mew.value().size() == 1);
CHECK(mew.value()[0].setNo == "030");
}
TEST_CASE("detectPrintVariantsFromCatalog matches owner Pokemon English title") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish",
"name_ja":"エリカのナゾノクサ",
"image_url":"https://example.com/oddish.jpg"}
]
})json");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"TamamushiCG", "Erika's Oddish", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("detectPrintVariantsFromCatalog matches Dark Rocket and Owner PMCG titles") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"PMCG4","local_id":"017","name_en":"Dark Charizard",
"name_ja":"わるいリザードン","name_en_source":"species-table-variant",
"tcgplayer_id":"575744"},
{"set_id":"PMCG6","local_id":"042","name_en":"Rocket's Zapdos",
"name_ja":"R団のサンダー","name_en_source":"species-table-variant",
"tcgplayer_id":"1"},
{"set_id":"PMCG5","local_id":"002","name_en":"Erika's Oddish",
"name_ja":"エリカのナゾノクサ","name_en_source":"species-table-variant",
"tcgplayer_id":"2"}
]
})json");
REQUIRE(catalog.isOk());
const auto dark = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"PMCG4", "Dark Charizard", catalog.value());
REQUIRE(dark.isOk());
REQUIRE(dark.value().size() == 1);
CHECK(dark.value()[0].setNo == "017");
const auto rocket = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"PMCG6", "Rocket's Zapdos", catalog.value());
REQUIRE(rocket.isOk());
REQUIRE(rocket.value().size() == 1);
CHECK(rocket.value()[0].setNo == "042");
const auto owner = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"PMCG5", "Erika's Oddish", catalog.value());
REQUIRE(owner.isOk());
REQUIRE(owner.value().size() == 1);
CHECK(owner.value()[0].setNo == "002");
}
TEST_CASE("detectPrintVariantsFromCatalog matches Light and Shining neo titles") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"neo4","local_id":"004","name_en":"Light Sunflora",
"name_ja":"軽いサンフロラ","name_en_source":"species-table-variant",
"image_url":"https://example.com/sunflora.png"},
{"set_id":"neo4","local_id":"013","name_en":"Shining Celebi",
"name_ja":"輝くセレビ","name_en_source":"species-table-variant",
"image_url":"https://example.com/celebi.png"}
]
})json");
REQUIRE(catalog.isOk());
const auto light = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"neo4", "Light Sunflora", catalog.value());
REQUIRE(light.isOk());
REQUIRE(light.value().size() == 1);
CHECK(light.value()[0].setNo == "004");
const auto shining = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
"neo4", "Shining Celebi", catalog.value());
REQUIRE(shining.isOk());
REQUIRE(shining.value().size() == 1);
CHECK(shining.value()[0].setNo == "013");
}
}
+152
View File
@@ -0,0 +1,152 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
using namespace ccm;
TEST_SUITE("JapanesePokemonEnCatalog") {
TEST_CASE("parses sets and prints") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"PMCG1": {
"name_en": "Expansion Pack",
"name_ja": "拡張パック",
"releaseDate": "1996/10/20"
}
},
"prints": [
{
"set_id": "PMCG1",
"local_id": "001",
"name_en": "Charmander",
"name_ja": "ヒトカゲ",
"name_en_source": "bulbapedia"
}
]
})");
REQUIRE(catalog.isOk());
CHECK_FALSE(catalog.value().empty());
auto set = catalog.value().findSet("PMCG1");
REQUIRE(set.has_value());
CHECK(set->nameEn == "Expansion Pack");
CHECK(set->releaseDate == "1996/10/20");
auto print = catalog.value().findPrint("PMCG1", "001");
REQUIRE(print.has_value());
CHECK(print->nameEn == "Charmander");
CHECK(print->nameEnSource == "bulbapedia");
}
TEST_CASE("parses optional tcgplayer_id and image_url") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{
"set_id": "PMCG1",
"local_id": "021",
"name_en": "Charizard",
"name_ja": "リザードン",
"tcgplayer_id": 575604
},
{
"set_id": "SV1a",
"local_id": "001",
"name_en": "Tropius",
"image_url": "https://example.com/tropius.png"
}
]
})");
REQUIRE(catalog.isOk());
auto charizard = catalog.value().findPrint("PMCG1", "021");
REQUIRE(charizard.has_value());
CHECK(charizard->tcgplayerId == "575604");
CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*charizard) ==
"https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg");
auto tropius = catalog.value().findPrint("SV1a", "001");
REQUIRE(tropius.has_value());
CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*tropius) ==
"https://example.com/tropius.png");
}
TEST_CASE("findPrintsByName is case-insensitive on English") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
]
})");
REQUIRE(catalog.isOk());
const auto hits = catalog.value().findPrintsByName("PMCG1", "charmander");
REQUIRE(hits.size() == 1);
CHECK(hits[0].localId == "001");
}
TEST_CASE("findPrintsByName matches qualified English titles by bare prefix") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)"},
{"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)"},
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)"},
{"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu (CoroCoro promo)"}
]
})json");
REQUIRE(catalog.isOk());
const auto hits = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo");
REQUIRE(hits.size() == 3);
CHECK(hits[0].localId == "007");
CHECK(hits[1].localId == "008");
CHECK(hits[2].localId == "045");
// Exact full title still works.
const auto exact = catalog.value().findPrintsByName(
"UnnumberedPromo", "Mewtwo Strikes Back (Jumbo)");
REQUIRE(exact.size() == 1);
CHECK(exact[0].localId == "045");
}
TEST_CASE("findPrintsByName whole-token matches owner and GR titles") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"227","name_en":"Team GR's Mewtwo (Pokémon Card GB2 promo)"},
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (CoroCoro promo) (Jumbo)"},
{"set_id":"UnnumberedPromo","local_id":"016","name_en":"Mew (CoroCoro promo)"}
]
})json");
REQUIRE(catalog.isOk());
const auto mewtwo = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo");
REQUIRE(mewtwo.size() == 2);
CHECK(mewtwo[0].localId == "227");
CHECK(mewtwo[1].localId == "045");
// "Mew" must not match "Mewtwo …" rows.
const auto mew = catalog.value().findPrintsByName("UnnumberedPromo", "Mew");
REQUIRE(mew.size() == 1);
CHECK(mew[0].localId == "016");
}
TEST_CASE("hasPrintsForSet reports curated classic products") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym"}
]
})");
REQUIRE(catalog.isOk());
CHECK(catalog.value().hasPrintsForSet("TamamushiCG"));
CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1"));
}
TEST_CASE("missing set/print returns nullopt") {
JapanesePokemonEnCatalog empty;
CHECK_FALSE(empty.findSet("X").has_value());
CHECK_FALSE(empty.findPrint("X", "1").has_value());
CHECK(empty.empty());
}
TEST_CASE("malformed JSON is an error") {
CHECK(JapanesePokemonEnCatalog::parse("{not json").isErr());
}
}
+317
View File
@@ -0,0 +1,317 @@
#include <doctest/doctest.h>
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <algorithm>
#include <string>
#include <unordered_map>
using namespace ccm;
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::unordered_map<std::string, std::string> bodies;
std::string lastUrl;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (!ok) return Result<std::string>::err("offline");
const auto it = bodies.find(lastUrl);
if (it == bodies.end()) return Result<std::string>::err("unknown url");
return Result<std::string>::ok(it->second);
}
};
} // namespace
TEST_SUITE("JapanesePokemonSetSource helpers") {
TEST_CASE("excludes CS* set ids") {
CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS1a"));
CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS4a"));
CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("PMCG1"));
CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("SV1a"));
}
TEST_CASE("applies SV4a name override") {
CHECK(JapanesePokemonSetSource::applySetNameOverride("SV4a", "wrong") ==
"シャイニートレジャーex");
CHECK(JapanesePokemonSetSource::applySetNameOverride("PMCG1", "拡張パック") ==
"拡張パック");
}
TEST_CASE("rewrites release date separators") {
CHECK(JapanesePokemonSetSource::rewriteReleaseDate("1996-10-20") == "1996/10/20");
}
TEST_CASE("buildSetDetailUrl percent-encodes id") {
CHECK(JapanesePokemonSetSource::buildSetDetailUrl("SV1a") ==
"https://api.tcgdex.net/v2/ja/sets/SV1a");
}
}
TEST_SUITE("JapanesePokemonSetSource::parseListResponse") {
TEST_CASE("maps id/name and drops CS* entries") {
const std::string json = R"([
{"id":"PMCG1","name":"拡張パック","cardCount":{"total":102,"official":102}},
{"id":"CS1a","name":"トリプレットビート","cardCount":{"total":1,"official":1}},
{"id":"SV4a","name":"レイジングサーフ","cardCount":{"total":320,"official":190}}
])";
const auto out = JapanesePokemonSetSource::parseListResponse(json);
REQUIRE(out.isOk());
// 2 from TCGdex + 11 curated products omitted by TCGdex.
REQUIRE(out.value().size() == 13);
CHECK(out.value()[0].id == "PMCG1");
CHECK(out.value()[0].name == "拡張パック");
CHECK(out.value()[1].id == "SV4a");
CHECK(out.value()[1].name == "シャイニートレジャーex");
}
TEST_CASE("injects classic City Gym and Expansion Sheet products") {
const auto out = JapanesePokemonSetSource::parseListResponse("[]");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 11);
const auto hasId = [&](const char* id) {
return std::any_of(out.value().begin(), out.value().end(),
[&](const Set& s) { return s.id == id; });
};
CHECK(hasId("UnnumberedPromo"));
CHECK(hasId("TamamushiCG"));
CHECK(hasId("NiviCG"));
CHECK(hasId("HanadaCG"));
CHECK(hasId("KuchibaCG"));
CHECK(hasId("YamabukiCG"));
CHECK(hasId("GurenTG"));
CHECK(hasId("ExpSheet1"));
CHECK(hasId("ExpSheet2"));
CHECK(hasId("ExpSheet3"));
CHECK(hasId("SouthernIslands"));
const Set* unnumbered = nullptr;
const Set* tama = nullptr;
for (const auto& s : out.value()) {
if (s.id == "UnnumberedPromo") unnumbered = &s;
if (s.id == "TamamushiCG") tama = &s;
}
REQUIRE(unnumbered != nullptr);
CHECK(unnumbered->name == "Unnumbered Promotional cards");
CHECK(unnumbered->releaseDate == "1997/03/06");
REQUIRE(tama != nullptr);
CHECK(tama->name == "Tamamushi City Gym");
CHECK(tama->releaseDate == "1998/07/25");
}
TEST_CASE("does not duplicate classic products already in the list") {
const std::string json = R"([
{"id":"TamamushiCG","name":"already-present"}
])";
const auto out = JapanesePokemonSetSource::parseListResponse(json);
REQUIRE(out.isOk());
int tamaCount = 0;
for (const auto& s : out.value()) {
if (s.id == "TamamushiCG") ++tamaCount;
}
CHECK(tamaCount == 1);
// Curated EN name / release date overwrite a stale upstream label.
CHECK(out.value().front().name == "Tamamushi City Gym");
CHECK(out.value().front().releaseDate == "1998/07/25");
}
TEST_CASE("empty array still injects classic products") {
const auto out = JapanesePokemonSetSource::parseListResponse("[]");
REQUIRE(out.isOk());
CHECK_FALSE(out.value().empty());
}
TEST_CASE("non-array is an error") {
CHECK(JapanesePokemonSetSource::parseListResponse(R"({"data":[]})").isErr());
}
TEST_CASE("invalid JSON is an error") {
CHECK(JapanesePokemonSetSource::parseListResponse("{not json").isErr());
}
}
TEST_SUITE("JapanesePokemonSetSource::parseReleaseDate") {
TEST_CASE("extracts and rewrites releaseDate") {
const auto out = JapanesePokemonSetSource::parseReleaseDate(
R"({"id":"PMCG1","releaseDate":"1996-10-20"})");
REQUIRE(out.isOk());
CHECK(out.value() == "1996/10/20");
}
TEST_CASE("missing releaseDate yields empty string") {
const auto out = JapanesePokemonSetSource::parseReleaseDate(R"({"id":"X"})");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
}
TEST_SUITE("JapanesePokemonSetSource::fetchAll") {
TEST_CASE("enriches from catalog and sorts by release date") {
RoutingHttpClient http;
http.bodies[JapanesePokemonSetSource::kListEndpoint] = R"([
{"id":"SV1a","name":"トリプレットビート"},
{"id":"PMCG1","name":"拡張パック"},
{"id":"PMCG2","name":"ポケモンジャングル"},
{"id":"CS1a","name":"junk"}
])";
// Catalog supplies dates so detail GETs are skipped.
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック","releaseDate":"1996/10/20"},
"PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"},
"SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート","releaseDate":"2023/03/10"}
},
"prints": []
})");
REQUIRE(catalog.isOk());
JapanesePokemonSetSource src{http, catalog.value()};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
// CS* dropped; 3 TCGdex + 11 curated injections.
REQUIRE(out.value().size() == 14);
// Expansion Pack → Jungle → UnnumberedPromo (day after Jungle).
CHECK(out.value()[0].id == "PMCG1");
CHECK(out.value()[1].id == "PMCG2");
CHECK(out.value()[1].name == "Pokémon Jungle");
CHECK(out.value()[2].id == "UnnumberedPromo");
CHECK(out.value()[2].name == "Unnumbered Promotional cards");
CHECK(out.value()[2].releaseDate == "1997/03/06");
const Set* pmcg1 = nullptr;
bool foundSv = false;
bool foundTama = false;
for (const auto& s : out.value()) {
if (s.id == "PMCG1") {
pmcg1 = &s;
CHECK(s.name == "Expansion Pack");
CHECK(s.releaseDate == "1996/10/20");
}
if (s.id == "SV1a") {
foundSv = true;
CHECK(s.name == "Triplet Beat");
}
if (s.id == "TamamushiCG") {
foundTama = true;
CHECK(s.name == "Tamamushi City Gym");
}
}
REQUIRE(pmcg1 != nullptr);
CHECK(foundSv);
CHECK(foundTama);
}
TEST_CASE("fetches set detail when catalog lacks release date") {
RoutingHttpClient http;
http.bodies[JapanesePokemonSetSource::kListEndpoint] =
R"([{"id":"PMCG1","name":""}])";
http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG1")] =
R"({"id":"PMCG1","releaseDate":"1996-10-20","cards":[]})";
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 12); // PMCG1 + 11 curated
const Set* pmcg1 = nullptr;
for (const auto& s : out.value()) {
if (s.id == "PMCG1") {
pmcg1 = &s;
break;
}
}
REQUIRE(pmcg1 != nullptr);
CHECK(pmcg1->releaseDate == "1996/10/20");
// Without catalog EN, never keep Japanese TCGdex names in Set.name.
CHECK(pmcg1->name == "PMCG1");
}
TEST_CASE("CJK set names fall back to set id even without catalog") {
RoutingHttpClient http;
http.bodies[JapanesePokemonSetSource::kListEndpoint] =
R"([{"id":"PMCG3","name":""}])";
http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG3")] =
R"({"id":"PMCG3","releaseDate":"1997-06-21"})";
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
const Set* pmcg3 = nullptr;
for (const auto& s : out.value()) {
if (s.id == "PMCG3") {
pmcg3 = &s;
break;
}
}
REQUIRE(pmcg3 != nullptr);
CHECK(pmcg3->name == "PMCG3");
}
TEST_CASE("network error on list is surfaced") {
RoutingHttpClient http;
http.ok = false;
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
CHECK(src.fetchAll().isErr());
}
TEST_CASE("augmentCachedSets injects classic products into a cached list") {
RoutingHttpClient http;
JapanesePokemonEnCatalog empty;
JapanesePokemonSetSource src{http, empty};
std::vector<Set> cached;
Set pmcg2;
pmcg2.id = "PMCG2";
pmcg2.name = "Pokémon Jungle";
pmcg2.releaseDate = "1997/03/05";
cached.push_back(std::move(pmcg2));
src.augmentCachedSets(cached);
REQUIRE(cached.size() == 12);
// Jungle stays first; UnnumberedPromo (1997/03/06) is immediately after.
CHECK(cached[0].id == "PMCG2");
CHECK(cached[1].id == "UnnumberedPromo");
CHECK(cached[1].releaseDate == "1997/03/06");
bool foundTama = false;
bool foundUnnumbered = false;
for (const auto& s : cached) {
if (s.id == "TamamushiCG") {
foundTama = true;
CHECK(s.name == "Tamamushi City Gym");
}
if (s.id == "UnnumberedPromo") {
foundUnnumbered = true;
CHECK(s.name == "Unnumbered Promotional cards");
}
}
CHECK(foundTama);
CHECK(foundUnnumbered);
}
TEST_CASE("augmentCachedSets restores English names from catalog") {
RoutingHttpClient http;
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {
"PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"}
},
"prints": []
})");
REQUIRE(catalog.isOk());
JapanesePokemonSetSource src{http, catalog.value()};
std::vector<Set> cached;
Set pmcg2;
pmcg2.id = "PMCG2";
pmcg2.name = "PMCG2"; // stale cache stored the id as the display name
pmcg2.releaseDate = "1997/03/05";
cached.push_back(std::move(pmcg2));
src.augmentCachedSets(cached);
const Set* jungle = nullptr;
for (const auto& s : cached) {
if (s.id == "PMCG2") {
jungle = &s;
break;
}
}
REQUIRE(jungle != nullptr);
CHECK(jungle->name == "Pokémon Jungle");
}
}
+48 -4
View File
@@ -143,13 +143,57 @@ TEST_SUITE("JsonSetRepository") {
CHECK(writeFail.error() == "write failed");
}
TEST_CASE("paths are composed from dataStorage and game dir") {
TEST_CASE("paths use region-specific filenames for Pokemon West and Asia") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
JsonSetRepository repo{fs, cfg, dirNameFn};
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
const std::vector<Set> west = {{"base1", "Base Set", "1999/01/09"}};
const std::vector<Set> asia = {{"SV1a", "Triplet Beat", "2023/01/20"}};
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
REQUIRE(repo.save(Game::Pokemon, west).isOk());
REQUIRE(repo.save(Game::JapanesePokemon, asia).isOk());
CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1);
CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1);
CHECK(fs.files().count("/data/pokemon/sets.json") == 0);
}
TEST_CASE("load migrates legacy pokemon/sets.json to sets-west.json") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(sets).dump(2)).isOk());
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Pokemon);
REQUIRE(loaded.isOk());
CHECK(loaded.value() == sets);
CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1);
}
TEST_CASE("load migrates legacy pokemonjp/sets.json to sets-asia.json") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
const std::vector<Set> sets = {{"SV1a", "Triplet Beat", "2023/01/20"}};
REQUIRE(fs.writeText("/data/pokemonjp/sets.json", nlohmann::json(sets).dump(2)).isOk());
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::JapanesePokemon);
REQUIRE(loaded.isOk());
CHECK(loaded.value() == sets);
CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1);
}
TEST_CASE("load prefers new path over legacy when both exist") {
InMemoryFileSystem fs;
auto cfg = makeConfig(fs, "/data");
const std::vector<Set> legacy = {{"old", "Old", "1999/01/01"}};
const std::vector<Set> neu = {{"new", "New", "2024/01/01"}};
REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(legacy).dump(2)).isOk());
REQUIRE(fs.writeText("/data/pokemon/sets-west.json", nlohmann::json(neu).dump(2)).isOk());
JsonSetRepository repo{fs, cfg, dirNameFn};
const auto loaded = repo.load(Game::Pokemon);
REQUIRE(loaded.isOk());
CHECK(loaded.value() == neu);
}
}
+1
View File
@@ -18,6 +18,7 @@ std::string dirNameForGame(Game g) {
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
case Game::JapanesePokemon: return "pokemon";
}
return "magic";
}
+38
View File
@@ -29,6 +29,9 @@ public:
std::string dirName() const override {
if (gameId == Game::Magic) return "magic";
if (gameId == Game::Pokemon) return "pokemon";
if (gameId == Game::YuGiOh) return "yugioh";
if (gameId == Game::DigiBattle99) return "digibattle99";
if (gameId == Game::JapanesePokemon) return "pokemon";
return "yugioh";
}
std::string displayName() const override { return dirName(); }
@@ -179,6 +182,41 @@ TEST_SUITE("SetService") {
CHECK(digi.source.calls == 1);
}
TEST_CASE("JapanesePokemon module routes independently when all games are registered") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
FakeGameModule pokemon{Game::Pokemon};
pokemon.source.result = Result<std::vector<Set>>::ok({{"base1", "Base", "1999/01/09"}});
FakeGameModule yugioh{Game::YuGiOh};
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
FakeGameModule digi{Game::DigiBattle99};
digi.source.result = Result<std::vector<Set>>::ok(
{{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}});
FakeGameModule jp{Game::JapanesePokemon};
jp.source.result = Result<std::vector<Set>>::ok(
{{"PMCG1", "Expansion Pack", "1996/10/20"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
svc.registerModule(&digi);
svc.registerModule(&jp);
REQUIRE(svc.updateSets(Game::Magic).isOk());
REQUIRE(svc.updateSets(Game::Pokemon).isOk());
REQUIRE(svc.updateSets(Game::YuGiOh).isOk());
REQUIRE(svc.updateSets(Game::DigiBattle99).isOk());
const auto out = svc.updateSets(Game::JapanesePokemon);
REQUIRE(out.isOk());
CHECK(out.value().front().id == "PMCG1");
CHECK(jp.source.calls == 1);
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
}
TEST_CASE("updateSets propagates repository save failures") {
InMemSetRepo repo;
repo.failSave = true;
+147
View File
@@ -0,0 +1,147 @@
# Japanese Pokémon EN catalog ETL
Offline pipeline that builds `ui_wx/assets/pokemon_jp_en_catalog.json` for the
CCM3 Japanese Pokémon module. The C++ app loads this file at startup; it does
**not** scrape Bulbapedia or PokéAPI at runtime.
## Output schema
```json
{
"sets": {
"PMCG1": {
"name_en": "Expansion Pack",
"name_ja": "拡張パック",
"releaseDate": "1996/10/20"
}
},
"prints": [
{
"set_id": "PMCG1",
"local_id": "073",
"name_en": "Switch",
"name_ja": "ポケモンいれかえ",
"name_en_source": "trainer-table",
"tcgplayer_id": "575596"
},
{
"set_id": "PMCG4",
"local_id": "017",
"name_en": "Dark Charizard",
"name_ja": "わるいリザードン",
"name_en_source": "species-table-variant",
"tcgplayer_id": "575744"
}
]
}
```
`name_en_source` is one of `bulbapedia` | `species-table` | `species-table-variant` |
`trainer-table` | `energy-table` | `manual` | `tcgdex-thirdparty`.
Optional gap-fill fields (classic JA when TCGdex has no CDN scan):
- `tcgplayer_id` — TCGPlayer product id from data-asia `thirdParty.tcgplayer`
(PMCG and other classic sets). Runtime builds
`https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg`
- `image_url` — explicit HTTPS URL (wins over `tcgplayer_id` when both set).
Sources: City Gym bundled `asset:pokemon_jp_classic/...` paths, or neo1neo4
Japanese CardIndex scans written by `enrich_neo_image_urls.py` (JP only;
empty `image_url` → card-back when no JP scan exists).
## Suggested steps
1. Snapshot TCGdex `GET /v2/ja/sets` into `_tcgdex_sets.json`.
2. Maintain curated English display names in `set_en_names.json` (set id → EN).
3. Run `merge_set_en_catalog.py` to emit set EN names into the catalog (also
refreshes classic TCGdex-missing products from `classic_missing_sets.json`).
4. For Original-era products TCGdex omits (City Gym theme decks, Expansion
Sheets, Southern Islands), maintain `classic_missing_sets.json` +
`classic_missing_prints.json` and run `merge_classic_missing.py`. Print
`local_id`s are sequential `001`… within each product. Owner Pokémon use
full English titles (e.g. `Erika's Oddish`), not bare species names.
For Bulbapedia **Unnumbered Promotional cards**, run
`harvest_unnumbered_promos.py` to refresh the `UnnumberedPromo` set + prints,
then `enrich_unnumbered_promo_images.py` to write Bulbagarden Archives
`image_url` values that prefer Japanese / Unnumbered Promotional scans
(reprint/gallery) over English Wizards primary `|image=` files. Binding is
print-identity-aware (set/page tokens, no bare-species page fallback) so
unrelated Mewtwo promos do not share one WHF scan. EN-only Bulbapedia pages
leave `image_url` empty (card-back) — never store Wizards/Base Set EN
scans for Pokemon (Japan). Also fills `name_ja` when present. Then run
`merge_classic_missing.py`.
Cardmarket labels some Expansion Sheet / Vending Pokémon as EXP/EXS; those
may still be filed under `UnnumberedPromo` here (e.g. `Mewtwo (Vending S1)` /
`Mewtwo (Vending S3)`). Auto-detect matches bare species names as whole
tokens (`Mewtwo``Team GR's Mewtwo`, `Mewtwo Strikes Back (…)`, not `Mew`).
Runtime UX for this set (no Set # field; Next cycles synthetic localIds;
modeless print-preview popup) is documented under
`docs/assets-and-info-apis.md`**Sets without printed collector numbers**.
For printing-accurate City Gym deck scans, run
`fetch_classic_gym_images.py` and store deck-specific `image_url` values as
`asset:pokemon_jp_classic/<setId>/<localId>.jpg`. Do **not** reuse PMCG
donor `tcgplayer_id`s for City Gym deck exclusives; that shows the wrong
Leaders' Stadium art.
5. Extend `non_pokemon_en_by_ja.json` when new Trainer/Energy English aliases
are needed (JA name → EN display name; covers reprints of the same JA name).
Baseline coverage: **first 15 chronological TCGdex JA main sets**
(`PMCG1``PMCG6`, `neo1``neo4`, `VS1`, `web1`, `E1``E3`). See
`docs/assets-and-info-apis.md`**Extending Trainer/Energy English aliases**.
6. Run `enrich_preview_images.py` to merge from TCGdex cards-database
`data-asia`:
- `tcgplayer_id` for classic-image gap-fill (where data-asia exposes it)
- `name_ja` from card sources
- `name_en` via National Dex → English species table (`species_en.json`)
for ordinary Pokémon with `dexId` (e.g. Blastoise → `032`, Mewtwo → `050`)
- **Variant full titles** when `name_ja` matches a known prefix + `dexId`
(owner gym leaders, Rocket's, Dark, Light, Shining — e.g. `Erika's Oddish`,
`Dark Charizard`, `Shining Celebi`). Upgrades existing bare `species-table`
rows on re-enrich. Tagged `species-table-variant`.
- `name_en` via `non_pokemon_en_by_ja.json` for Trainer/Energy
(e.g. Switch ← `ポケモンいれかえ` → localId `073`)
7. Run `enrich_neo_image_urls.py` after enrich when neo1neo4 previews need
gap-fill. Writes `image_url` from **Japanese** CardIndex set scans (never
English pokemontcg.io). Matching is by English card name within the JA neo
set. Misses clear `image_url` (card-back). Use `--overwrite` to replace
stale EN URLs. See `docs/assets-and-info-apis.md`.
8. Optionally refine `prints[]` name fields via Bulbapedia joins.
At runtime, `JapanesePokemonSetSource` prefers catalog `name_en` and **never**
leaves Japanese TCGdex names in `Set.name` (falls back to the set id). It also
injects the classic missing products listed above. `JapanesePokemonCardPreviewSource`
uses catalog `tcgplayer_id` / `image_url` only for the exact `setId`+`localId`
when TCGdex has no scan, and falls back to catalog-only Auto-detect/preview
when TCGdex has no set detail for a curated classic product.
## Commands
```bash
# After refreshing tools/pokemon_jp/_tcgdex_sets.json and set_en_names.json:
python tools/pokemon_jp/merge_set_en_catalog.py
# After editing classic_missing_sets.json / classic_missing_prints.json:
python tools/pokemon_jp/merge_classic_missing.py
# Refresh UnnumberedPromo prints from Bulbapedia, enrich JP images, then merge:
python tools/pokemon_jp/harvest_unnumbered_promos.py
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --force
python tools/pokemon_jp/merge_classic_missing.py
# After updating bundled City Gym deck scans:
python tools/pokemon_jp/fetch_classic_gym_images.py
# Harvest TCGPlayer ids + species/trainer/variant English aliases:
python tools/pokemon_jp/enrich_preview_images.py
# Fill neo1neo4 image_url from Japanese CardIndex scans (run after enrich):
python tools/pokemon_jp/enrich_neo_image_urls.py
# Replace / clear previously written EN pokemontcg.io neo URLs:
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
```
Seed-only catalog writer (minimal rows):
```bash
python tools/pokemon_jp/build_catalog.py \
--out ui_wx/assets/pokemon_jp_en_catalog.json
```
+1
View File
@@ -0,0 +1 @@
#REDIRECT [[Fossil (TCG)]]
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Scaffold ETL for Japanese Pokémon EN catalog JSON.
Seed mode (default) writes a small valid catalog matching the committed asset
schema. Extend this script to pull TCGdex + Bulbapedia joins for full coverage.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
SEED = {
"sets": {
"PMCG1": {
"name_en": "Expansion Pack",
"name_ja": "拡張パック",
"releaseDate": "1996/10/20",
},
"PMCG2": {
"name_en": "Pokémon Jungle",
"name_ja": "ポケモンジャングル",
"releaseDate": "1997/03/14",
},
"SV1a": {
"name_en": "Triplet Beat",
"name_ja": "トリプレットビート",
"releaseDate": "2023/03/10",
},
"SV4a": {
"name_en": "Shiny Treasure ex",
"name_ja": "シャイニートレジャーex",
"releaseDate": "2023/11/10",
},
},
"prints": [
{
"set_id": "PMCG1",
"local_id": "014",
"name_en": "Charmander",
"name_ja": "ヒトカゲ",
"name_en_source": "bulbapedia",
},
{
"set_id": "PMCG1",
"local_id": "021",
"name_en": "Charizard",
"name_ja": "リザードン",
"name_en_source": "bulbapedia",
},
{
"set_id": "SV1a",
"local_id": "001",
"name_en": "Tropius",
"name_ja": "トロピウス",
"name_en_source": "bulbapedia",
},
],
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
default=Path("ui_wx/assets/pokemon_jp_en_catalog.json"),
help="Output catalog path",
)
args = parser.parse_args()
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(SEED, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"Wrote seed catalog to {args.out}")
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Build a full Japanese Pokémon EN set catalog from TCGdex + Wikipedia.
Strategy:
1. Pull JA set list from TCGdex (exclude CS*).
2. Pull Wikipedia 'List of Pokémon Trading Card Game sets' wikitext and
extract English / Japanese name pairs from {{lang|ja|...}} near bold titles.
3. Match by Japanese name (after TCGdex overrides). Unmatched sets get a
Latin fallback of the set id (never leave Japanese in Set.name).
4. Fetch release dates from TCGdex set detail for catalog completeness.
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.request
from pathlib import Path
UA = "CardCollectionManager3-ETL/0.1 (local; set-catalog)"
JA_OVERRIDES = {
"SV4a": "シャイニートレジャーex",
}
def http_json(url: str):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
def http_text_params(base: str, params: dict) -> dict:
from urllib.parse import urlencode
return http_json(base + "?" + urlencode(params))
def contains_cjk(s: str) -> bool:
return any(
"\u3040" <= ch <= "\u30ff"
or "\u3400" <= ch <= "\u4dbf"
or "\u4e00" <= ch <= "\u9fff"
or "\uf900" <= ch <= "\ufaff"
for ch in s
)
def wiki_en_ja_pairs() -> dict[str, str]:
"""Map Japanese set name -> English display name from Wikipedia."""
data = http_text_params(
"https://en.wikipedia.org/w/api.php",
{
"action": "parse",
"page": "List of Pokémon Trading Card Game sets",
"prop": "wikitext",
"format": "json",
"formatversion": "2",
},
)
wt = data["parse"]["wikitext"]
# '''English Name''' ... lang|ja|Japanese Name
pairs: dict[str, str] = {}
for m in re.finditer(
r"'''([^']+)'''(?P<body>.{0,260}?)\{\{lang\|ja\|(?P<ja>[^}]+)\}\}",
wt,
flags=re.DOTALL,
):
en = m.group(1).strip()
ja = m.group("ja").strip()
# Drop template noise / multi-ja (take first segment before &)
ja = re.split(r"\s*&\s*", ja)[0].strip()
ja = re.sub(r"<[^>]+>", "", ja).strip()
if not ja or not en or not contains_cjk(ja):
continue
# Prefer first English seen for a JA name
pairs.setdefault(ja, en)
return pairs
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
default=Path("ui_wx/assets/pokemon_jp_en_catalog.json"),
)
parser.add_argument(
"--fetch-dates",
action="store_true",
help="Hit TCGdex set detail for each set (slow, ~1 req/s).",
)
args = parser.parse_args()
raw_sets = http_json("https://api.tcgdex.net/v2/ja/sets")
sets = [s for s in raw_sets if not str(s.get("id", "")).startswith("CS")]
print(f"TCGdex JA sets (excl CS*): {len(sets)}")
ja_to_en = wiki_en_ja_pairs()
print(f"Wikipedia JA->EN pairs: {len(ja_to_en)}")
# Keep existing print rows if present
existing_prints = []
if args.out.exists():
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
existing_prints = prev.get("prints", [])
except Exception:
pass
catalog_sets: dict[str, dict] = {}
matched = 0
for entry in sets:
sid = entry["id"]
name_ja = JA_OVERRIDES.get(sid, entry.get("name", ""))
name_en = ja_to_en.get(name_ja, "")
if not name_en:
# Fuzzy: Wikipedia sometimes includes extra spaces / fullwidth
for ja, en in ja_to_en.items():
if ja in name_ja or name_ja in ja:
name_en = en
break
if name_en:
matched += 1
else:
# Never leave CJK in the UI set picker — fall back to set id.
name_en = sid
catalog_sets[sid] = {
"name_en": name_en,
"name_ja": name_ja,
"releaseDate": "",
}
print(f"Matched Wikipedia EN names: {matched}/{len(sets)}")
print(f"Fallback to set id: {len(sets) - matched}")
if args.fetch_dates:
for i, sid in enumerate(catalog_sets):
try:
detail = http_json(f"https://api.tcgdex.net/v2/ja/sets/{sid}")
rd = detail.get("releaseDate") or ""
if rd:
catalog_sets[sid]["releaseDate"] = rd.replace("-", "/")
except Exception as exc:
print(f" date fail {sid}: {exc}")
time.sleep(0.35)
if (i + 1) % 20 == 0:
print(f" dates {i+1}/{len(catalog_sets)}")
out = {"sets": catalog_sets, "prints": existing_prints}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"Wrote {args.out}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
{
"ExpSheet1": {
"name_en": "Expansion Sheet Series 1",
"name_ja": "拡張シート 第1弾",
"releaseDate": "1998/03/23"
},
"NiviCG": {
"name_en": "Nivi City Gym",
"name_ja": "ニビシティジム タケシ",
"releaseDate": "1998/04/26"
},
"HanadaCG": {
"name_en": "Hanada City Gym",
"name_ja": "ハナダシティジム カスミ",
"releaseDate": "1998/04/26"
},
"ExpSheet2": {
"name_en": "Expansion Sheet Series 2",
"name_ja": "拡張シート 第2弾",
"releaseDate": "1998/06/17"
},
"KuchibaCG": {
"name_en": "Kuchiba City Gym",
"name_ja": "クチバシティジム マチス",
"releaseDate": "1998/07/25"
},
"TamamushiCG": {
"name_en": "Tamamushi City Gym",
"name_ja": "タマムシシティジム エリカ",
"releaseDate": "1998/07/25"
},
"ExpSheet3": {
"name_en": "Expansion Sheet Series 3",
"name_ja": "拡張シート 第3弾",
"releaseDate": "1998/11/24"
},
"YamabukiCG": {
"name_en": "Yamabuki City Gym",
"name_ja": "ヤマブキシティジム ナツメ",
"releaseDate": "1999/02/26"
},
"GurenTG": {
"name_en": "Guren Town Gym",
"name_ja": "グレンタウンジム カツラ",
"releaseDate": "1999/02/26"
},
"SouthernIslands": {
"name_en": "Southern Islands",
"name_ja": "サザンアイランド",
"releaseDate": "1999/07/17"
},
"UnnumberedPromo": {
"name_en": "Unnumbered Promotional cards",
"name_ja": "番号なしプロモーションカード",
"releaseDate": "1997/03/06"
}
}
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Fill neo1neo4 catalog image_url from Japanese CardIndex scans only.
TCGdex JA neo sets have image:null. This ETL scrapes CardIndex Japanese set
pages (Awakening Legends, etc.) and writes HTTPS image_url values for exact
JA setId+localId catalog rows.
Policy (UnnumberedPromo parity): Japanese scans only. If CardIndex has no JP
image for a print, image_url is cleared never store English pokemontcg.io
art as a fallback.
Usage:
python tools/pokemon_jp/enrich_neo_image_urls.py
python tools/pokemon_jp/enrich_neo_image_urls.py --dry-run
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite --limit 20
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
NEO_SETS = frozenset({"neo1", "neo2", "neo3", "neo4"})
UA = {
"User-Agent": (
"CCM3-pokemon-jp-etl/1.0 "
"(local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
)
}
# JA neo set id -> (CardIndex set page slug, image CDN folder)
SET_META: dict[str, tuple[str, str]] = {
"neo1": ("gold-silver-to-a-new-world", "neo-jp-gold-silver"),
"neo2": ("crossing-the-ruins", "neo-jp-crossing-ruins"),
"neo3": ("awakening-legends", "neo-jp-awakening-legends"),
"neo4": ("darkness-and-to-light", "neo-jp-darkness-light"),
}
IMG_RE = re.compile(
r"https://images\.cardindex\.co/cardindex-images/cards/"
r"(?P<folder>[^/\"']+)/(?P<file>[^\"'\s>]+)\.(?P<ext>jpe?g|png|webp)",
re.IGNORECASE,
)
def log(msg: str) -> None:
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", errors="replace").decode("ascii"), flush=True)
def http_get(url: str, timeout: float = 60.0) -> str:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", "replace")
def normalize_name(s: str) -> str:
"""Lowercase alnum tokens for matching catalog names to CardIndex slugs."""
s = s.lower().replace("\u2019", "'").replace("'", "")
s = re.sub(r"[^a-z0-9]+", " ", s)
return " ".join(s.split())
def slug_base_name(slug: str) -> str:
"""shining-magikarp-129 / balloon-berry-promo -> shining magikarp / balloon berry."""
s = slug.strip().lower()
s = re.sub(r"-promo$", "", s)
s = re.sub(r"-\d+$", "", s)
return normalize_name(s.replace("-", " "))
def pick_set_image(html: str, image_folder: str, card_slug: str) -> str | None:
"""Prefer full-size JP scan in this set's CDN folder for this card slug."""
folder_l = image_folder.lower()
slug_l = card_slug.lower()
full: list[str] = []
small: list[str] = []
for m in IMG_RE.finditer(html):
if m.group("folder").lower() != folder_l:
continue
file_stem = m.group("file").lower()
# Require the card's own slug (with optional -small).
if not (file_stem == slug_l or file_stem == f"{slug_l}-small"):
# Also accept promo variant files named "{base}-promo".
base = re.sub(r"-\d+$", "", slug_l)
if not (
file_stem == f"{base}-promo"
or file_stem == f"{base}-promo-small"
or file_stem.startswith(f"{slug_l}")
):
continue
url = m.group(0)
if file_stem.endswith("-small"):
small.append(url)
else:
full.append(url)
if full:
# Prefer exact slug match over promo/other.
for u in full:
if f"/{slug_l}." in u.lower():
return u
for u in full:
if f"/{slug_l}-" not in u.lower() or "-promo." in u.lower():
return u
return full[0]
if small:
# Upgrade -small to full-size URL when possible.
u = small[0]
return re.sub(r"-small\.(jpe?g|png|webp)$", r".\1", u, flags=re.I)
return None
def scrape_set_index(
set_id: str, *, sleep_s: float
) -> dict[str, list[tuple[str, str]]]:
"""Return normalize_name -> [(card_slug, image_url), ...] for one neo set."""
page_slug, image_folder = SET_META[set_id]
set_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}"
log(f"scraping set index {set_id}: {set_url}")
html = http_get(set_url)
card_slugs = sorted(
set(
re.findall(
rf"/pokemon-cards/{re.escape(page_slug)}/([a-z0-9\-]+)",
html,
)
)
)
log(f" {len(card_slugs)} card pages")
by_name: dict[str, list[tuple[str, str]]] = {}
for i, slug in enumerate(card_slugs, start=1):
card_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}/{slug}"
try:
time.sleep(sleep_s)
card_html = http_get(card_url)
except (urllib.error.URLError, TimeoutError) as exc:
log(f" [{i}/{len(card_slugs)}] FAIL {slug}: {exc}")
continue
img = pick_set_image(card_html, image_folder, slug)
if not img:
log(f" [{i}/{len(card_slugs)}] no JP image {slug}")
continue
name = slug_base_name(slug)
by_name.setdefault(name, []).append((slug, img))
log(f" [{i}/{len(card_slugs)}] {name!r} <- {img}")
return by_name
def resolve_url_for_print(
name_en: str, index: dict[str, list[tuple[str, str]]]
) -> str | None:
key = normalize_name(name_en)
if not key:
return None
hits = index.get(key) or []
if not hits:
return None
# Unique image only — ambiguous Unown / multi-print names stay empty.
urls = sorted({u for _slug, u in hits})
if len(urls) == 1:
return urls[0]
return None
def enrich_neo_images(
catalog: dict,
*,
dry_run: bool,
overwrite: bool,
sleep_s: float,
limit: int,
out_path: Path,
) -> tuple[int, int, int, int]:
prints = catalog.get("prints", [])
candidates = [
p
for p in prints
if p.get("set_id") in NEO_SETS and (p.get("name_en") or "").strip()
]
if not overwrite:
candidates = [
p for p in candidates if not (p.get("image_url") or "").strip()
]
if limit > 0:
candidates = candidates[:limit]
# Scrape only the sets we need.
needed_sets = sorted({str(p["set_id"]) for p in candidates})
indexes: dict[str, dict[str, list[tuple[str, str]]]] = {}
for sid in needed_sets:
indexes[sid] = scrape_set_index(sid, sleep_s=sleep_s)
filled = 0
changed = 0
missed = 0
updates = 0
def persist() -> None:
if dry_run:
return
out_path.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
for i, p in enumerate(candidates, start=1):
sid = str(p["set_id"])
lid = str(p["local_id"])
name_en = str(p["name_en"]).strip()
prev = (p.get("image_url") or "").strip()
log(f"[{i}/{len(candidates)}] {sid}-{lid} {name_en!r}")
url = resolve_url_for_print(name_en, indexes.get(sid, {}))
if url:
if not dry_run:
p["image_url"] = url
filled += 1
if url != prev:
changed += 1
updates += 1
log(f" -> {url}" + (f" (was {prev})" if prev else ""))
else:
log(f" -> {url} (unchanged)")
else:
missed += 1
if prev:
if not dry_run:
p.pop("image_url", None)
changed += 1
updates += 1
log(f" -> (miss, cleared {prev})")
else:
log(" -> (miss)")
if updates >= 25:
persist()
updates = 0
log(f" checkpoint wrote {out_path}")
persist()
return len(candidates), filled, changed, missed
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument(
"--overwrite",
action="store_true",
help="Re-resolve neo prints that already have image_url (clears EN URLs)",
)
ap.add_argument("--limit", type=int, default=0, help="Max neo prints to process")
ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between HTTP calls")
args = ap.parse_args()
if not args.out.is_file():
raise SystemExit(f"catalog not found: {args.out}")
catalog = json.loads(args.out.read_text(encoding="utf-8"))
total, filled, changed, missed = enrich_neo_images(
catalog,
dry_run=args.dry_run,
overwrite=args.overwrite,
sleep_s=args.sleep,
limit=args.limit,
out_path=args.out,
)
print(
f"neo image_url: candidates={total} filled={filled} changed={changed} "
f"missed={missed}"
+ (" (dry-run)" if args.dry_run else f" wrote {args.out}"),
flush=True,
)
if __name__ == "__main__":
main()
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""Enrich pokemon_jp_en_catalog.json prints from TCGdex data-asia.
Harvests per-card:
- tcgplayer_id (thirdParty.tcgplayer) for classic-image gap-fill
- name_ja from the card source
- name_en via National Dex id English species name (when dexId present)
- name_en for owner / Rocket's / Dark / Light / Shining variants (full titles)
- name_en for Trainer/Energy via tools/pokemon_jp/non_pokemon_en_by_ja.json
English names are required for Auto-detect when the user types "Mewtwo" /
"Switch" / "Erika's Oddish" / "Dark Charizard" etc. TCGdex set résumés only
expose Japanese names.
Usage:
python tools/pokemon_jp/enrich_preview_images.py
python tools/pokemon_jp/enrich_preview_images.py --data-asia path/to/data-asia
"""
from __future__ import annotations
import argparse
import io
import json
import re
import shutil
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
CACHE_DIR = Path(__file__).resolve().parent / "_tcgdex_cards_database"
SPECIES_CACHE = Path(__file__).resolve().parent / "species_en.json"
NON_POKEMON_EN = Path(__file__).resolve().parent / "non_pokemon_en_by_ja.json"
ZIP_URL = "https://github.com/tcgdex/cards-database/archive/refs/heads/master.zip"
# National-dex-ordered English names (index 0 = Bulbasaur / dex 1).
SPECIES_URL = (
"https://raw.githubusercontent.com/sindresorhus/pokemon/main/data/en.json"
)
TCGPLAYER_RE = re.compile(r"tcgplayer\s*:\s*(\d+)")
NAME_JA_RE = re.compile(r"name\s*:\s*\{\s*ja\s*:\s*\"([^\"]+)\"", re.DOTALL)
DEX_RE = re.compile(r"dexId\s*:\s*\[\s*(\d+)")
CATEGORY_RE = re.compile(r'category\s*:\s*"([^"]+)"')
LOCAL_ID_RE = re.compile(r"^[0-9A-Za-z]+$")
# Chronological first 15 main Japanese expansions in TCGdex (for coverage checks).
# Longest JA prefixes first. Maps to English product-title prefix + National Dex species.
VARIANT_JA_PREFIXES: list[tuple[str, str]] = [
("R団の", "Rocket's "),
("エリカの", "Erika's "),
("タケシの", "Brock's "),
("カスミの", "Misty's "),
("マチスの", "Lt. Surge's "),
("ナツメの", "Sabrina's "),
("カツラの", "Blaine's "),
("キョウの", "Koga's "),
("サカキの", "Giovanni's "),
("ヤナギの", "Pryce's "),
("カンナの", "Lorelei's "),
("シバの", "Bruno's "),
("キクコの", "Agatha's "),
("やさしい", "Light "),
("ひかる", "Shining "),
("輝く", "Shining "), # neo Destiny upstream garble
("軽い", "Light "), # neo Destiny upstream garble
("わるい", "Dark "),
("暗い", "Dark "), # neo Destiny upstream garble
("ダーク", "Dark "), # neo Destiny upstream garble (e.g. ダークアリアドス)
]
PROTECTED_NAME_EN_SOURCES = frozenset(
{"manual", "trainer-table", "energy-table", "bulbapedia", "tcgdex-thirdparty"}
)
FIRST15_SETS = [
"PMCG1",
"PMCG2",
"PMCG3",
"PMCG4",
"PMCG5",
"PMCG6",
"neo1",
"neo2",
"neo3",
"neo4",
"VS1",
"web1",
"E1",
"E2",
"E3",
]
def download_data_asia(dest: Path) -> Path:
dest.mkdir(parents=True, exist_ok=True)
marker = dest / "data-asia"
if marker.is_dir() and any(marker.rglob("*.ts")):
return marker
print(f"Downloading {ZIP_URL}")
req = urllib.request.Request(ZIP_URL, headers={"User-Agent": "ccm-pokemonjp-etl"})
with urllib.request.urlopen(req, timeout=180) as resp:
blob = resp.read()
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
members = [n for n in zf.namelist() if "/data-asia/" in n.replace("\\", "/")]
for name in members:
parts = Path(name).parts
if "data-asia" not in parts:
continue
idx = parts.index("data-asia")
rel = Path(*parts[idx:])
target = dest / rel
if name.endswith("/"):
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
if not marker.is_dir():
raise SystemExit("data-asia missing after zip extract")
return marker
def load_species_en() -> dict[int, str]:
"""Map National Dex id -> English species name."""
if SPECIES_CACHE.is_file():
raw = json.loads(SPECIES_CACHE.read_text(encoding="utf-8"))
else:
print(f"Downloading {SPECIES_URL}")
req = urllib.request.Request(
SPECIES_URL, headers={"User-Agent": "ccm-pokemonjp-etl"}
)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = json.loads(resp.read().decode("utf-8"))
SPECIES_CACHE.write_text(
json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
# File is a list: ["Bulbasaur", "Ivysaur", ...]
if isinstance(raw, list):
return {i + 1: name for i, name in enumerate(raw) if isinstance(name, str)}
if isinstance(raw, dict):
return {int(k): str(v) for k, v in raw.items()}
raise SystemExit("unexpected species_en.json shape")
def load_non_pokemon_en() -> dict[str, str]:
"""Map Japanese Trainer/Energy (etc.) names → English display names."""
if not NON_POKEMON_EN.is_file():
return {}
raw = json.loads(NON_POKEMON_EN.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise SystemExit("non_pokemon_en_by_ja.json must be a JSON object")
return {str(k): str(v) for k, v in raw.items() if str(k).strip() and str(v).strip()}
def extract_cards(data_asia: Path) -> dict[tuple[str, str], dict]:
"""Map (setId, localId) -> {tcgplayer_id, name_ja, dex_id}."""
out: dict[tuple[str, str], dict] = {}
for path in data_asia.rglob("*.ts"):
try:
rel = path.relative_to(data_asia)
except ValueError:
continue
parts = rel.parts
if len(parts) != 3:
continue
set_id = parts[1]
local_id = path.stem
if not LOCAL_ID_RE.match(local_id):
continue
text = path.read_text(encoding="utf-8", errors="replace")
entry: dict = {}
m = TCGPLAYER_RE.search(text)
if m:
entry["tcgplayer_id"] = m.group(1)
m = NAME_JA_RE.search(text)
if m:
entry["name_ja"] = m.group(1)
m = DEX_RE.search(text)
if m:
entry["dex_id"] = int(m.group(1))
m = CATEGORY_RE.search(text)
category = m.group(1) if m else ""
# TCGdex PMCG1-102 Fighting Energy has an empty Japanese name in source.
if (
set_id == "PMCG1"
and local_id == "102"
and category == "Energy"
and not entry.get("name_ja")
):
entry["name_ja"] = "基本闘エネルギー"
if not entry:
continue
out[(set_id, local_id)] = entry
return out
def variant_en_prefix(name_ja: str) -> str | None:
"""Return English title prefix for a known JA variant pattern, or None."""
for ja_prefix, en_prefix in VARIANT_JA_PREFIXES:
if name_ja.startswith(ja_prefix):
return en_prefix
return None
def compose_species_name_en(
name_ja: str, dex_id: int | None, species_en: dict[int, str]
) -> tuple[str, str] | None:
"""Return (name_en, name_en_source) from dex + optional variant prefix."""
if dex_id is None or dex_id not in species_en:
return None
species = species_en[dex_id]
prefix = variant_en_prefix(name_ja)
if prefix:
return prefix + species, "species-table-variant"
return species, "species-table"
def upgrade_variant_titles(
prints: list[dict],
cards: dict[tuple[str, str], dict],
species_en: dict[int, str],
) -> int:
"""Upgrade bare species-table rows to full variant English titles."""
upgraded = 0
for p in prints:
if (p.get("name_en_source") or "") in PROTECTED_NAME_EN_SOURCES:
continue
ja = (p.get("name_ja") or "").strip()
if not ja or variant_en_prefix(ja) is None:
continue
sid = str(p.get("set_id", ""))
lid = str(p.get("local_id", ""))
dex = cards.get((sid, lid), {}).get("dex_id")
composed = compose_species_name_en(ja, dex, species_en)
if composed is None:
continue
full_en, src = composed
if p.get("name_en") == full_en and p.get("name_en_source") == src:
continue
p["name_en"] = full_en
p["name_en_source"] = src
upgraded += 1
return upgraded
def verify_first15_trainer_coverage(
data_asia: Path, non_pokemon_en: dict[str, str]
) -> list[str]:
"""Return unique Trainer/Energy JA names in FIRST15 still missing from the map."""
missing: set[str] = set()
for path in data_asia.rglob("*.ts"):
try:
rel = path.relative_to(data_asia)
except ValueError:
continue
parts = rel.parts
if len(parts) != 3 or parts[1] not in FIRST15_SETS:
continue
text = path.read_text(encoding="utf-8", errors="replace")
catm = CATEGORY_RE.search(text)
if not catm or catm.group(1) == "Pokemon":
continue
jam = NAME_JA_RE.search(text)
ja = jam.group(1) if jam else ""
if path.stem == "102" and parts[1] == "PMCG1" and not ja:
ja = "基本闘エネルギー"
if not ja:
missing.add(f"{parts[1]}/{path.stem} <empty name_ja>")
continue
if ja not in non_pokemon_en:
missing.add(ja)
return sorted(missing)
def merge_catalog(
catalog: dict,
cards: dict[tuple[str, str], dict],
species_en: dict[int, str],
non_pokemon_en: dict[str, str],
) -> tuple[int, int, int, int]:
prints = catalog.setdefault("prints", [])
by_key: dict[tuple[str, str], dict] = {}
for p in prints:
sid = str(p.get("set_id", ""))
lid = str(p.get("local_id", ""))
if sid and lid:
by_key[(sid, lid)] = p
updated = 0
added = 0
species_named = 0
table_named = 0
for (sid, lid), meta in sorted(cards.items()):
existing = by_key.get((sid, lid))
if existing is None:
existing = {
"set_id": sid,
"local_id": lid,
"name_en": "",
"name_ja": "",
"name_en_source": "",
}
prints.append(existing)
by_key[(sid, lid)] = existing
added += 1
changed = False
pid = meta.get("tcgplayer_id")
if pid and existing.get("tcgplayer_id") != pid:
existing["tcgplayer_id"] = pid
changed = True
name_ja = meta.get("name_ja", "")
if name_ja and not (existing.get("name_ja") or "").strip():
existing["name_ja"] = name_ja
changed = True
if not (existing.get("name_en") or "").strip():
dex = meta.get("dex_id")
ja_key = (existing.get("name_ja") or name_ja or "").strip()
composed = compose_species_name_en(ja_key, dex, species_en)
if composed is not None:
existing["name_en"], existing["name_en_source"] = composed
species_named += 1
changed = True
else:
ja_key = (existing.get("name_ja") or name_ja or "").strip()
if ja_key and ja_key in non_pokemon_en:
existing["name_en"] = non_pokemon_en[ja_key]
# Energies vs trainers: basic energy names share a pattern.
if "エネルギー" in ja_key and ja_key.startswith("基本"):
existing["name_en_source"] = "energy-table"
elif "エネルギー" in ja_key:
existing["name_en_source"] = "energy-table"
else:
existing["name_en_source"] = "trainer-table"
table_named += 1
changed = True
if changed:
updated += 1
# Also apply the JA→EN table to existing prints that were never in data-asia
# walk (or already present with name_ja but empty name_en).
for p in prints:
if (p.get("name_en") or "").strip():
continue
ja_key = (p.get("name_ja") or "").strip()
if not ja_key or ja_key not in non_pokemon_en:
continue
p["name_en"] = non_pokemon_en[ja_key]
if "エネルギー" in ja_key:
p["name_en_source"] = "energy-table"
else:
p["name_en_source"] = "trainer-table"
table_named += 1
updated += 1
catalog["prints"] = prints
return updated, added, species_named, table_named
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data-asia", type=Path, default=None)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
if args.data_asia:
data_asia = args.data_asia
if not data_asia.is_dir():
raise SystemExit(f"data-asia not found: {data_asia}")
else:
data_asia = download_data_asia(CACHE_DIR)
species_en = load_species_en()
non_pokemon_en = load_non_pokemon_en()
cards = extract_cards(data_asia)
print(f"found {len(cards)} card files under {data_asia}")
print(f"non-pokemon EN map entries: {len(non_pokemon_en)}")
if args.out.exists():
catalog = json.loads(args.out.read_text(encoding="utf-8"))
else:
catalog = {"sets": {}, "prints": []}
updated, added, species_named, table_named = merge_catalog(
catalog, cards, species_en, non_pokemon_en
)
variant_upgraded = upgrade_variant_titles(
catalog["prints"], cards, species_en
)
for lid, expect_en in (
("021", "Charizard"),
("032", "Blastoise"),
("050", "Mewtwo"),
("073", "Switch"),
):
hit = next(
(
p
for p in catalog["prints"]
if p.get("set_id") == "PMCG1" and p.get("local_id") == lid
),
None,
)
if hit:
print(
f"PMCG1/{lid}: name_en={hit.get('name_en')!r} "
f"name_ja={hit.get('name_ja')!r} tp={hit.get('tcgplayer_id')}"
)
if hit.get("name_en") != expect_en:
print(f" WARNING: expected name_en {expect_en!r}")
else:
print(f"WARNING: missing PMCG1/{lid}")
gaps = verify_first15_trainer_coverage(data_asia, non_pokemon_en)
if gaps:
print(f"WARNING: {len(gaps)} FIRST15 trainer/energy JA names still unmapped:")
for ja in gaps[:30]:
print(f" - {ja}")
if len(gaps) > 30:
print(f" ... and {len(gaps) - 30} more")
else:
print(f"FIRST15 trainer/energy coverage OK ({len(FIRST15_SETS)} sets)")
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# Spot-check variant titles on classic sets.
for sid, lid, expect_en in (
("PMCG5", "002", "Erika's Oddish"),
("PMCG4", "017", "Dark Charizard"),
("PMCG6", "042", "Rocket's Zapdos"),
):
hit = next(
(
p
for p in catalog["prints"]
if p.get("set_id") == sid and p.get("local_id") == lid
),
None,
)
if hit:
print(
f"{sid}/{lid}: name_en={hit.get('name_en')!r} "
f"source={hit.get('name_en_source')!r}"
)
if hit.get("name_en") != expect_en:
print(f" WARNING: expected name_en {expect_en!r}")
else:
print(f"WARNING: missing {sid}/{lid}")
print(
f"wrote {args.out}: touched={updated} added={added} "
f"species_named={species_named} table_named={table_named} "
f"variant_upgraded={variant_upgraded} "
f"prints={len(catalog['prints'])}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,567 @@
#!/usr/bin/env python3
"""Fill UnnumberedPromo image_url / name_ja from Bulbapedia card pages.
Reads tools/pokemon_jp/classic_missing_prints.json rows with set_id=UnnumberedPromo,
resolves each `bulbapedia_page` (with redirects), and prefers Japanese /
Unnumbered Promotional scans from reprint/gallery fields over the English
primary `|image=` (often a Wizards Black Star print).
If Bulbapedia only hosts an English scan, image_url is left empty (card-back)
rather than storing a misleading EN preview.
Usage:
python tools/pokemon_jp/enrich_unnumbered_promo_images.py
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --force
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --dry-run
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --limit 20
Then:
python tools/pokemon_jp/merge_classic_missing.py
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
PRINTS = HERE / "classic_missing_prints.json"
SET_ID = "UnnumberedPromo"
UA = "CCM3-pokemon-jp-etl/1.0 (local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
API = "https://bulbapedia.bulbagarden.net/w/api.php"
JNAME_RE = re.compile(r"\|\s*jname\s*=\s*([^\n|]+)", re.IGNORECASE)
# |image= / |image1= / |reprint1= / |caption= / |caption2= / |recaption1=
FIELD_RE = re.compile(
r"\|\s*(image|reprint|caption|recaption)(\d*)\s*=\s*([^\n]+)",
re.IGNORECASE,
)
SKIP_IMAGE_SUBSTR = (
"attack.png",
"card_back",
"cardback",
"project_tcg",
"setsymbol",
"rare_",
"energy.png",
"tcg1_",
"tcg2_",
"misprint",
)
# Filename / caption hints that the scan is the Japanese unnumbered print.
JP_FILENAME_MARKERS = (
"corocoro",
"whf",
"fanbook",
"unnumbered",
"japanese",
"gb2",
"illustrator",
"battleroad",
"movie",
"parentchild",
"vending",
"asobikata",
"jogress",
"pokedude",
"daisuki",
"specialsheet",
"informationpack",
"howibecame",
"newgarura",
"touchgeneration",
"championleague",
"worldofillusions",
"clashatthesummit",
"blackwhitetour",
"warnerbros",
"nintendo64",
"teamgr",
"imakuni",
"tradeplease",
"hungrysnorlax",
"coolporygon", # often still EN — scored only with caption
)
# Captions that mark the Unnumbered / JP print on shared EN+JP articles.
JP_CAPTION_MARKERS = (
"unnumbered promotional",
"unnumbered promo",
"japanese",
"jpexpansion",
)
# English primary prints we must not prefer when a JP candidate exists.
EN_FILENAME_MARKERS = (
"wizardspromo",
"baseset",
"neogenesis",
"fossil",
"teamrocket",
"jungle",
"legendarycollection",
"dppromo",
"mysterious treasures",
"mysterioustreasures",
"diamondpearl",
"exdragon",
"exholon",
"exdelta",
"neodiscovery",
"neorevelations",
"neodestiny",
"gymheroes",
"gymchallenge",
"nintendopromo", # often EN Black Star; allow if also JP-captioned
)
def api(**params: object) -> dict:
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
req = urllib.request.Request(f"{API}?{qs}", headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=90) as resp:
return json.load(resp)
def fetch_wikitext(page: str) -> tuple[str, str] | None:
"""Return (resolved_title, wikitext) or None if missing."""
try:
data = api(
action="parse",
page=page,
prop="wikitext",
format="json",
redirects=1,
)
except urllib.error.HTTPError:
return None
except urllib.error.URLError:
return None
if "error" in data:
return None
parsed = data.get("parse") or {}
wt = (parsed.get("wikitext") or {}).get("*")
title = parsed.get("title") or page
if not wt:
return None
return title, wt
def file_url(filename: str) -> str | None:
fname = filename.strip().replace(" ", "_")
if not fname:
return None
data = api(
action="query",
titles=f"File:{fname}",
prop="imageinfo",
iiprop="url",
format="json",
)
pages = (data.get("query") or {}).get("pages") or {}
for page in pages.values():
infos = page.get("imageinfo") or []
if infos and infos[0].get("url"):
return str(infos[0]["url"])
return None
def normalize_filename(raw: str) -> str | None:
s = raw.strip().split("|", 1)[0].strip()
s = re.sub(r"\[\[(?:File:)?([^\]|]+).*", r"\1", s, flags=re.IGNORECASE)
s = s.strip()
if not s:
return None
low = s.lower().replace(" ", "_")
if any(tok in low for tok in SKIP_IMAGE_SUBSTR):
return None
if not re.search(r"\.(jpe?g|png|gif|webp)$", low):
return None
return s
def score_candidate(filename: str, caption: str) -> int:
"""Higher is better. Score <= 0 means EN-only / reject for UnnumberedPromo."""
fl = filename.lower().replace(" ", "").replace("_", "")
cl = caption.lower()
score = 0
if any(m in cl for m in JP_CAPTION_MARKERS):
score += 100
if any(m.replace(" ", "") in fl for m in JP_FILENAME_MARKERS):
score += 50
if "promo" in fl and not any(m in fl for m in ("wizardspromo", "nintendopromo", "dppromo")):
score += 10
en_hit = any(m.replace(" ", "") in fl for m in EN_FILENAME_MARKERS)
if en_hit:
# EN primary unless caption explicitly marks Unnumbered/JP.
if score < 100:
return -100
score -= 20
return score
def collect_image_candidates(wikitext: str) -> list[tuple[int, str]]:
"""Return (score, filename) for JP-eligible images, best first."""
# Map field key -> value for pairing imageN with captionN / reprintN with recaptionN.
fields: dict[str, str] = {}
for m in FIELD_RE.finditer(wikitext):
kind = m.group(1).lower()
num = m.group(2) or ""
val = m.group(3).strip()
fields[f"{kind}{num}"] = val
candidates: list[tuple[int, str]] = []
seen: set[str] = set()
def add(fname_raw: str, caption: str) -> None:
fname = normalize_filename(fname_raw)
if not fname:
return
key = fname.lower().replace(" ", "_")
if key in seen:
return
score = score_candidate(fname, caption)
if score <= 0:
return
seen.add(key)
candidates.append((score, fname))
# Primary image + caption (usually EN — only kept if JP-scored).
if "image" in fields:
add(fields["image"], fields.get("caption", ""))
# reprintN + recaptionN (common home of Unnumbered JP scans).
for key, val in list(fields.items()):
m = re.fullmatch(r"reprint(\d+)", key)
if not m:
continue
n = m.group(1)
add(val, fields.get(f"recaption{n}", "") or fields.get(f"caption{n}", ""))
# Gallery imageN + captionN.
for key, val in list(fields.items()):
m = re.fullmatch(r"image(\d+)", key)
if not m:
continue
n = m.group(1)
add(val, fields.get(f"caption{n}", "") or fields.get(f"recaption{n}", ""))
candidates.sort(key=lambda t: (-t[0], t[1].lower()))
return candidates
def normalize_token_blob(s: str) -> str:
"""Lowercase alnum-only blob for substring affinity checks."""
return re.sub(r"[^a-z0-9]+", "", s.lower())
def identity_tokens(print_row: dict) -> list[str]:
"""Significant tokens from this print's promo identity (set / page)."""
raw_bits: list[str] = []
for key in ("tcg_set", "bulbapedia_page", "name_en"):
val = str(print_row.get(key) or "").strip()
if val:
raw_bits.append(val)
# Prefer longer set-like phrases first.
tokens: list[str] = []
for bit in raw_bits:
# Drop trailing extras like "(Jumbo)".
bit = re.sub(r"\s*\([^)]*(?:Jumbo|Mini|Silver|Gold)[^)]*\)\s*", " ", bit)
# Pull parenthetical set qualifier: "Mewtwo (WHF Special Sheet promo)".
m = re.search(r"\(([^)]+)\)", bit)
if m:
inner = m.group(1)
inner = re.sub(r"\bpromo\b", "", inner, flags=re.I).strip()
if inner:
tokens.append(inner)
tokens.append(bit)
# Significant wordy tokens (>=3 chars after normalize), longest first.
out: list[str] = []
seen: set[str] = set()
for t in tokens:
norm = normalize_token_blob(t)
if len(norm) < 4:
continue
if norm in seen:
continue
# Skip generic card-name-only blobs when we have set context.
seen.add(norm)
out.append(norm)
out.sort(key=len, reverse=True)
return out
def has_print_affinity(
print_row: dict,
requested_page: str,
resolved_title: str,
filename: str,
caption: str = "",
) -> bool:
"""True if this JP candidate belongs to this print, not a borrowed promo."""
tokens = identity_tokens(print_row)
token_set = set(tokens)
hay = normalize_token_blob(filename + " " + caption + " " + resolved_title)
fl = normalize_token_blob(filename)
req = normalize_token_blob(requested_page)
resolved = normalize_token_blob(resolved_title)
# Filename names a specific JP promo family this print is not part of → reject.
foreign_markers = (
"whf",
"corocoro",
"fanbook",
"gb2",
"specialsheet",
"songbest",
"battleroad",
"teamgr",
"illustrator",
"asobikata",
"vending",
"movie",
)
for marker in foreign_markers:
if marker in fl and not any(marker in tok for tok in token_set):
# e.g. WHF file on a Wizards Promo / Song Best Collection row.
return False
# Resolved title still matches what we asked for (allow mild redirect rename).
if req and (req in resolved or resolved in req):
# Still require filename not foreign (handled above); OK.
if any(m in fl for m in foreign_markers) or "unnumbered" in hay or any(
len(tok) >= 5 and tok in fl for tok in token_set
):
return True
# Requested page matched but image is generic EN — leave to score_candidate.
if any(len(tok) >= 5 and tok in hay for tok in token_set):
return True
tcg_set = str(print_row.get("tcg_set") or "").strip()
tcg_set_norm = normalize_token_blob(tcg_set)
# Wizards Promo rows may use the Wizards article, but only with a
# non-foreign JP file (foreign_markers already rejected WHF/etc.).
if tcg_set_norm.startswith("wizardspromo") and "wizardspromo" in resolved:
if "wizardspromo" in fl or (
any(m in caption.lower() for m in JP_CAPTION_MARKERS)
and not any(m in fl for m in foreign_markers)
):
return True
return False
# Reject borrowing from a generic Wizards Promo dump unless this print is that set.
if "wizardspromo" in resolved and not tcg_set_norm.startswith("wizardspromo"):
for tok in tokens:
if len(tok) >= 5 and tok in hay and "wizardspromo" not in tok:
species = normalize_token_blob(str(print_row.get("tcg_name") or ""))
if species and tok == species:
continue
return True
return False
for tok in tokens:
if len(tok) >= 5 and tok in hay:
species = normalize_token_blob(str(print_row.get("tcg_name") or ""))
if species and tok == species:
continue
return True
if len(tok) >= 3 and tok in ("whf", "gb2") and tok in hay:
return True
for tok in tokens:
if len(tok) >= 5 and tok in fl:
species = normalize_token_blob(str(print_row.get("tcg_name") or ""))
if species and tok == species:
continue
return True
return False
def pick_image_filename_for_print(
print_row: dict,
requested_page: str,
resolved_title: str,
wikitext: str,
) -> str | None:
"""Best JP scan that also has affinity with this print's promo identity."""
fields: dict[str, str] = {}
for m in FIELD_RE.finditer(wikitext):
fields[f"{m.group(1).lower()}{m.group(2) or ''}"] = m.group(3).strip()
def caption_for(fname: str) -> str:
target = fname.lower().replace(" ", "_")
for key, val in fields.items():
nf = normalize_filename(val)
if not nf or nf.lower().replace(" ", "_") != target:
continue
if key == "image":
return fields.get("caption", "")
m = re.fullmatch(r"(reprint|image)(\d+)", key)
if not m:
continue
n = m.group(2)
if m.group(1) == "reprint":
return fields.get(f"recaption{n}", "") or fields.get(f"caption{n}", "")
return fields.get(f"caption{n}", "") or fields.get(f"recaption{n}", "")
return ""
for _score, fname in collect_image_candidates(wikitext):
if has_print_affinity(
print_row, requested_page, resolved_title, fname, caption_for(fname)
):
return fname
return None
def pick_jname(wikitext: str) -> str:
m = JNAME_RE.search(wikitext)
if not m:
return ""
return m.group(1).strip()
def candidate_pages(print_row: dict) -> list[str]:
"""Qualified Bulbapedia titles only — never bare species (avoids shared dumps)."""
out: list[str] = []
page = str(print_row.get("bulbapedia_page") or "").strip()
if page:
out.append(page)
tcg_set = str(print_row.get("tcg_set") or "").strip()
tcg_name = str(print_row.get("tcg_name") or "").strip()
tcg_num = str(print_row.get("tcg_num") or "").strip()
if tcg_name and tcg_set:
if not tcg_num or tcg_num.lower() == "promo":
out.append(f"{tcg_name} ({tcg_set} promo)")
else:
out.append(f"{tcg_name} ({tcg_set} {tcg_num})")
out.append(f"{tcg_name} ({tcg_set} promo)")
# Full qualified English title from harvest (may include Jumbo markers).
name_en = str(print_row.get("name_en") or "").strip()
if name_en and "(" in name_en:
# Strip only trailing variant markers, keep set qualifier.
cleaned = re.sub(
r"\s*\((?:Jumbo|Mini|Silver|Gold|Silver w/Stamp)[^)]*\)\s*$",
"",
name_en,
flags=re.I,
).strip()
if cleaned:
out.append(cleaned)
out.append(name_en)
seen: set[str] = set()
uniq: list[str] = []
for p in out:
if p and p not in seen:
seen.add(p)
uniq.append(p)
return uniq
def enrich_print(print_row: dict, sleep_s: float) -> bool:
"""Mutate print_row with JP image_url / name_ja. Return True if image filled."""
already = str(print_row.get("image_url") or "").strip()
if already:
return False
for page in candidate_pages(print_row):
time.sleep(sleep_s)
resolved = fetch_wikitext(page)
if not resolved:
continue
title, wt = resolved
if not str(print_row.get("name_ja") or "").strip():
jname = pick_jname(wt)
if jname:
print_row["name_ja"] = jname
fname = pick_image_filename_for_print(print_row, page, title, wt)
if not fname:
continue
time.sleep(sleep_s)
url = file_url(fname)
if url:
print_row["image_url"] = url
return True
return False
def log(msg: str) -> None:
try:
print(msg)
except UnicodeEncodeError:
print(msg.encode("ascii", errors="replace").decode("ascii"))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--limit", type=int, default=0, help="Max UnnumberedPromo rows")
ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between API calls")
ap.add_argument("--force", action="store_true", help="Overwrite existing image_url")
ap.add_argument(
"--save-every",
type=int,
default=25,
help="Persist classic_missing_prints.json every N updates",
)
args = ap.parse_args()
all_prints: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8"))
targets = [p for p in all_prints if str(p.get("set_id")) == SET_ID]
if args.limit > 0:
targets = targets[: args.limit]
def persist() -> None:
if args.dry_run:
return
PRINTS.write_text(
json.dumps(all_prints, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
filled = 0
missed = 0
updates_since_save = 0
for i, p in enumerate(targets, start=1):
lid = p.get("local_id")
name = p.get("name_en")
if args.force:
p.pop("image_url", None)
if str(p.get("image_url") or "").strip():
log(f"[{i}/{len(targets)}] skip {lid} {name} (already has image)")
continue
ok = enrich_print(p, sleep_s=args.sleep)
if ok:
filled += 1
updates_since_save += 1
log(f"[{i}/{len(targets)}] OK {lid} {name} -> {p.get('image_url')}")
else:
missed += 1
# Ensure stale EN URLs do not linger after --force.
p.pop("image_url", None)
log(f"[{i}/{len(targets)}] MISS {lid} {name}")
if updates_since_save >= args.save_every:
persist()
updates_since_save = 0
log(f" checkpoint wrote {PRINTS}")
log(f"filled={filled} missed={missed} total={len(targets)}")
if args.dry_run:
log(f"dry-run: not writing {PRINTS}")
return
persist()
log(f"wrote {PRINTS}")
if __name__ == "__main__":
main()
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Download and convert bundled classic Japanese gym-deck scans.
Writes JPEGs under ui_wx/assets/pokemon_jp_classic/<setId>/<localId>.jpg.
Uses dwebp + cjpeg from the local MSYS2 toolchain so we do not need Pillow.
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "ui_wx" / "assets" / "pokemon_jp_classic"
# Curated direct CDN/file URLs for printing-accurate deck scans.
SOURCES: dict[tuple[str, str], str] = {
# TCGCollector static CDN URL for Erika (City Gym Decks No. 061).
("TamamushiCG", "016"): (
"https://static.tcgcollector.com/content/images/9d/33/c6/"
"9d33c6ffe701da03266dd5a65c6ee9537c7043b63b880cf3889f644e1c66aa6f.webp"
),
}
def tool(name: str) -> str:
path = shutil.which(name)
if path is None:
raise SystemExit(f"required tool not found on PATH: {name}")
return path
def download(url: str, dest: Path) -> None:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
dest.write_bytes(resp.read())
def convert_to_jpeg(src: Path, dest: Path) -> None:
suffix = src.suffix.lower()
if suffix in {".jpg", ".jpeg"}:
shutil.copyfile(src, dest)
return
if suffix == ".webp":
dwebp = tool("dwebp")
cjpeg = tool("cjpeg")
with tempfile.NamedTemporaryFile(suffix=".ppm", delete=False) as tmp:
ppm = Path(tmp.name)
try:
subprocess.run([dwebp, str(src), "-ppm", "-o", str(ppm)], check=True)
with open(dest, "wb") as out:
subprocess.run([cjpeg, "-quality", "92", str(ppm)], check=True, stdout=out)
finally:
ppm.unlink(missing_ok=True)
return
raise SystemExit(f"unsupported source format: {src}")
def main() -> None:
for (set_id, local_id), url in SOURCES.items():
out_dir = OUT_DIR / set_id
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / f"{local_id}.jpg"
with tempfile.TemporaryDirectory() as td:
src = Path(td) / Path(url).name
print(f"download {set_id}/{local_id} <- {url}")
download(url, src)
convert_to_jpeg(src, target)
print(f"wrote {target}")
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Build English set names for Japanese Pokémon catalog from Serebii + TCGdex."""
from __future__ import annotations
import json
import re
import urllib.request
from html import unescape
from pathlib import Path
UA = "CardCollectionManager3-ETL/0.1"
OUT = Path("ui_wx/assets/pokemon_jp_en_catalog.json")
SEREBII = "https://www.serebii.net/card/japanese.shtml"
JA_OVERRIDES = {"SV4a": "シャイニートレジャーex"}
def get_bytes(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read()
def contains_cjk(s: str) -> bool:
return any(ord(c) >= 0x80 for c in s)
def parse_serebii_ja_en(html: str) -> dict[str, str]:
"""Extract Japanese -> English set name pairs from Serebii japanese.shtml."""
ja_en: dict[str, str] = {}
# Common patterns on the page:
# English Name<br>Japanese
# English Name (Japanese)
# <a ...>English</a> ... Japanese in nearby cell
for m in re.finditer(
r">([A-Za-z0-9][^<]{1,70}?)</(?:a|b|font|td|span)>\s*<br\s*/?>\s*"
r"([^<]{2,50}?)<",
html,
flags=re.IGNORECASE,
):
en = unescape(re.sub(r"\s+", " ", m.group(1))).strip()
ja = unescape(re.sub(r"\s+", " ", m.group(2))).strip()
if contains_cjk(ja) and not contains_cjk(en) and len(en) > 1:
ja_en.setdefault(ja, en)
for m in re.finditer(
r">([A-Za-z0-9][^<]{1,70}?)\s*\(([^)]{2,50})\)<",
html,
):
en = unescape(re.sub(r"\s+", " ", m.group(1))).strip()
ja = unescape(re.sub(r"\s+", " ", m.group(2))).strip()
if contains_cjk(ja) and not contains_cjk(en) and len(en) > 1:
ja_en.setdefault(ja, en)
return ja_en
def main() -> None:
sets = [
s
for s in json.loads(get_bytes("https://api.tcgdex.net/v2/ja/sets"))
if not str(s.get("id", "")).startswith("CS")
]
print("tcgdex sets", len(sets))
html = get_bytes(SEREBII).decode("utf-8", "replace")
Path("tools/pokemon_jp/_serebii_japanese.html").write_text(html, encoding="utf-8")
print("serebii bytes", len(html))
ja_en = parse_serebii_ja_en(html)
print("ja->en pairs", len(ja_en))
for ja, en in list(ja_en.items())[:12]:
print(f" {en!r} <- {ja!r}")
prints = []
if OUT.exists():
try:
prints = json.loads(OUT.read_text(encoding="utf-8")).get("prints", [])
except Exception:
pass
catalog: dict[str, dict] = {}
matched = 0
for entry in sets:
sid = entry["id"]
name_ja = JA_OVERRIDES.get(sid, entry.get("name", ""))
name_en = ja_en.get(name_ja, "")
if not name_en:
for ja, en in ja_en.items():
if ja == name_ja or ja in name_ja or name_ja in ja:
name_en = en
break
if name_en:
matched += 1
else:
name_en = sid
catalog[sid] = {
"name_en": name_en,
"name_ja": name_ja,
"releaseDate": "",
}
print(f"matched {matched}/{len(sets)}; id fallback {len(sets) - matched}")
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps({"sets": catalog, "prints": prints}, ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
print("wrote", OUT)
if __name__ == "__main__":
main()
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Harvest Bulbapedia Unnumbered Promotional cards into classic_missing seed JSON.
Fetches:
- Unnumbered_Promotional_cards_(TCG)/1996-2005
- Yearly sections on Unnumbered_Promotional_cards_(TCG) (2006+)
Writes/updates:
- tools/pokemon_jp/classic_missing_sets.json (adds UnnumberedPromo)
- tools/pokemon_jp/classic_missing_prints.json (replaces UnnumberedPromo prints)
Synthetic local_ids are sequential 001 (cards are unnumbered in print).
Each print carries a qualified English title when the setlist row has a
{{TCG ID|Set|Name|num}} (e.g. "Mewtwo (CoroCoro promo)") plus a
`bulbapedia_page` hint for enrich_unnumbered_promo_images.py.
Run after harvest:
python tools/pokemon_jp/enrich_unnumbered_promo_images.py
python tools/pokemon_jp/merge_classic_missing.py
"""
from __future__ import annotations
import json
import re
import urllib.parse
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
SETS = HERE / "classic_missing_sets.json"
PRINTS = HERE / "classic_missing_prints.json"
SET_ID = "UnnumberedPromo"
SET_META = {
"name_en": "Unnumbered Promotional cards",
"name_ja": "番号なしプロモーションカード",
"releaseDate": "1997/03/06",
}
UA = "CCM3-pokemon-jp-etl/1.0 (local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
# {{TCG ID|Set|Name|num}} — name may contain δ / &amp; etc.
TCG_ID_FULL_RE = re.compile(
r"\{\{TCG ID\|([^}|]+)\|([^}|]+)(?:\|([^}|]*))?\}\}", re.IGNORECASE
)
TCG_RE = re.compile(r"\{\{TCG\|([^}|]+)(?:\|[^}]*)?\}\}", re.IGNORECASE)
OBP_RE = re.compile(r"\{\{OBP\|([^}|]+)(?:\|[^}]*)?\}\}", re.IGNORECASE)
SMALL_TAG_RE = re.compile(
r"<small>\s*'''?\s*\[([^\]]+)\]\s*'''?\s*</small>", re.IGNORECASE
)
ITALIC_NOTE_RE = re.compile(r"\(''([^']+)''\)")
HTML_TAG_RE = re.compile(r"<[^>]+>")
TEMPLATE_RE = re.compile(r"\{\{[^{}]*\}\}")
def fetch_wikitext(page: str) -> str:
qs = urllib.parse.urlencode(
{
"action": "parse",
"page": page,
"prop": "wikitext",
"format": "json",
}
)
url = f"https://bulbapedia.bulbagarden.net/w/api.php?{qs}"
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=90) as resp:
data = json.load(resp)
return data["parse"]["wikitext"]["*"]
def decode_wiki_text(s: str) -> str:
return (
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#39;", "'")
.strip()
)
def bulbapedia_page_from_tcg_id(set_name: str, card_name: str, num: str) -> str:
"""Best-effort Bulbapedia article title for a TCG ID triple."""
set_name = decode_wiki_text(set_name)
card_name = decode_wiki_text(card_name)
num = decode_wiki_text(num or "").strip()
if not num or num.lower() == "promo":
return f"{card_name} ({set_name} promo)"
return f"{card_name} ({set_name} {num})"
def qualified_name_en(card_name: str, set_name: str, num: str, extras: list[str]) -> str:
"""Distinct English title: Name (Set promo) plus optional [Jumbo]/ markers."""
card_name = decode_wiki_text(card_name)
set_name = decode_wiki_text(set_name)
num = decode_wiki_text(num or "").strip()
if set_name:
if not num or num.lower() == "promo":
base = f"{card_name} ({set_name} promo)"
else:
base = f"{card_name} ({set_name} {num})"
else:
base = card_name
# Avoid duplicating qualifier already present in extras.
remaining = [
e
for e in extras
if e.lower() not in base.lower() and e.lower() not in {"promo"}
]
if remaining:
return f"{base} ({'; '.join(remaining)})"
return base
def collect_extras(raw: str) -> list[str]:
extras: list[str] = []
for m in SMALL_TAG_RE.finditer(raw):
extras.append(m.group(1).strip())
for m in ITALIC_NOTE_RE.finditer(raw):
extras.append(m.group(1).strip())
if "Jumbo" in raw and not any("jumbo" in e.lower() for e in extras):
extras.append("Jumbo")
if "Mini" in raw and not any("mini" in e.lower() for e in extras):
extras.append("Mini")
return extras
def extract_entry(field: str) -> dict | None:
"""Parse one Setlist/nmentry name field into print metadata."""
raw = field.strip()
extras = collect_extras(raw)
tcg = TCG_ID_FULL_RE.search(raw)
if tcg:
set_name = tcg.group(1).strip()
card_name = tcg.group(2).strip()
num = (tcg.group(3) or "").strip()
name_en = qualified_name_en(card_name, set_name, num, extras)
page = bulbapedia_page_from_tcg_id(set_name, card_name, num)
return {
"name_en": name_en,
"bulbapedia_page": page,
"tcg_set": decode_wiki_text(set_name),
"tcg_name": decode_wiki_text(card_name),
"tcg_num": decode_wiki_text(num) if num else "promo",
}
name = None
for rx in (TCG_RE, OBP_RE):
m = rx.search(raw)
if m:
name = decode_wiki_text(m.group(1))
break
if not name:
cleaned = TEMPLATE_RE.sub("", raw)
cleaned = HTML_TAG_RE.sub("", cleaned)
cleaned = cleaned.split("|", 1)[0].strip()
cleaned = re.sub(r"\[\[([^|\]]+)(?:\|[^\]]+)?\]\]", r"\1", cleaned)
name = decode_wiki_text(cleaned.strip(" '\""))
if not name:
return None
if extras:
name = f"{name} ({'; '.join(extras)})"
return {"name_en": name, "bulbapedia_page": name}
def harvest_entries(wikitext: str) -> list[dict]:
entries: list[dict] = []
for m in re.finditer(r"\{\{Setlist/nmentry\|None\|", wikitext):
start = m.end()
depth = 0
i = start
while i < len(wikitext):
if wikitext.startswith("{{", i):
depth += 1
i += 2
continue
if wikitext.startswith("}}", i):
depth = max(0, depth - 1)
i += 2
continue
if wikitext[i] == "|" and depth == 0:
break
i += 1
parsed = extract_entry(wikitext[start:i])
if parsed:
entries.append(parsed)
return entries
def main() -> None:
pages = [
"Unnumbered_Promotional_cards_(TCG)/1996-2005",
"Unnumbered_Promotional_cards_(TCG)",
]
all_entries: list[dict] = []
for page in pages:
wt = fetch_wikitext(page)
got = harvest_entries(wt)
print(f"{page}: {len(got)} setlist rows")
all_entries.extend(got)
prints: list[dict] = []
for i, entry in enumerate(all_entries, start=1):
row = {
"set_id": SET_ID,
"local_id": f"{i:03d}",
"name_en": entry["name_en"],
"name_ja": "",
"name_en_source": "manual",
"bulbapedia_page": entry.get("bulbapedia_page") or entry["name_en"],
}
for key in ("tcg_set", "tcg_name", "tcg_num"):
if entry.get(key):
row[key] = entry[key]
prints.append(row)
sets_obj: dict = json.loads(SETS.read_text(encoding="utf-8"))
sets_obj[SET_ID] = SET_META
SETS.write_text(
json.dumps(sets_obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
existing: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8"))
kept = [p for p in existing if str(p.get("set_id")) != SET_ID]
kept.extend(prints)
PRINTS.write_text(
json.dumps(kept, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
f"wrote {SET_ID}: {len(prints)} prints "
f"(classic_missing_prints total={len(kept)})"
)
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Merge classic TCGdex-missing JA products into pokemon_jp_en_catalog.json.
Reads:
tools/pokemon_jp/classic_missing_sets.json
tools/pokemon_jp/classic_missing_prints.json
tools/pokemon_jp/set_en_names.json (updated with EN display names)
Writes set metadata + prints into ui_wx/assets/pokemon_jp_en_catalog.json
without dropping existing TCGdex-backed entries. Replaces prior prints for
the same (set_id, local_id) keys from classic_missing_prints.json.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SETS = HERE / "classic_missing_sets.json"
PRINTS = HERE / "classic_missing_prints.json"
EN_MAP = HERE / "set_en_names.json"
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
# Classic gym-deck / sheet products use synthetic set ids (not on TCGdex).
CLASSIC_SET_IDS = frozenset(
{
"UnnumberedPromo",
"ExpSheet1",
"ExpSheet2",
"ExpSheet3",
"NiviCG",
"HanadaCG",
"KuchibaCG",
"TamamushiCG",
"YamabukiCG",
"GurenTG",
"SouthernIslands",
}
)
GYM_DECK_SET_IDS = frozenset(
{"NiviCG", "HanadaCG", "KuchibaCG", "TamamushiCG", "YamabukiCG", "GurenTG"}
)
def strip_gym_deck_donor_ids(classic_prints: list[dict]) -> int:
"""Remove PMCG donor ids; gym-deck exclusives need printing-accurate art."""
stripped = 0
for p in classic_prints:
sid = str(p.get("set_id") or "")
if sid not in GYM_DECK_SET_IDS:
continue
if p.get("image_url"):
p.pop("tcgplayer_id", None)
continue
if p.pop("tcgplayer_id", None) is not None:
stripped += 1
return stripped
def main() -> None:
missing_sets: dict[str, dict] = json.loads(SETS.read_text(encoding="utf-8"))
missing_prints: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8"))
en_map: dict[str, str] = json.loads(EN_MAP.read_text(encoding="utf-8"))
catalog: dict = {"sets": {}, "prints": []}
if OUT.exists():
catalog = json.loads(OUT.read_text(encoding="utf-8"))
sets_obj: dict = catalog.setdefault("sets", {})
for sid, meta in missing_sets.items():
name_en = str(meta.get("name_en") or "").strip() or sid
en_map[sid] = name_en
sets_obj[sid] = {
"name_en": name_en,
"name_ja": str(meta.get("name_ja") or ""),
"releaseDate": str(meta.get("releaseDate") or ""),
}
EN_MAP.write_text(
json.dumps(dict(sorted(en_map.items())), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
classic_ids = {s for s in missing_sets}
classic_keys = {
(str(p.get("set_id")), str(p.get("local_id"))) for p in missing_prints
}
existing: list[dict] = catalog.get("prints") or []
kept = [
p
for p in existing
if (str(p.get("set_id")), str(p.get("local_id"))) not in classic_keys
or str(p.get("set_id")) not in classic_ids
]
# Drop all prints for classic set ids, then append the curated list.
kept = [p for p in kept if str(p.get("set_id")) not in classic_ids]
stripped = strip_gym_deck_donor_ids(missing_prints)
kept.extend(missing_prints)
catalog["prints"] = kept
OUT.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
f"merged {len(missing_sets)} classic sets and {len(missing_prints)} prints "
f"into {OUT} (total prints={len(kept)}, gym donor ids stripped={stripped})"
)
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Merge curated English set names into pokemon_jp_en_catalog.json.
Reads tools/pokemon_jp/_tcgdex_sets.json (TCGdex JA list snapshot) and
tools/pokemon_jp/set_en_names.json (curated id -> English display name).
Also preserves / refreshes classic TCGdex-missing products from
classic_missing_sets.json (UnnumberedPromo, City Gym decks, Expansion Sheets,
Southern Islands).
Never writes Japanese into name_en.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SETS_SNAP = HERE / "_tcgdex_sets.json"
EN_MAP = HERE / "set_en_names.json"
CLASSIC = HERE / "classic_missing_sets.json"
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
JA_OVERRIDES = {"SV4a": "シャイニートレジャーex"}
def main() -> None:
sets = json.loads(SETS_SNAP.read_text(encoding="utf-8"))
en_map: dict[str, str] = json.loads(EN_MAP.read_text(encoding="utf-8"))
classic: dict[str, dict] = {}
if CLASSIC.exists():
classic = json.loads(CLASSIC.read_text(encoding="utf-8"))
prints = []
if OUT.exists():
try:
prints = json.loads(OUT.read_text(encoding="utf-8")).get("prints", [])
except Exception:
pass
catalog: dict[str, dict] = {}
matched = 0
for entry in sets:
sid = entry["id"]
if sid.startswith("CS"):
continue
name_ja = JA_OVERRIDES.get(sid, entry.get("name", ""))
name_en = en_map.get(sid, "").strip()
if name_en:
matched += 1
else:
name_en = sid
catalog[sid] = {
"name_en": name_en,
"name_ja": name_ja,
"releaseDate": "",
}
for sid, meta in classic.items():
name_en = str(meta.get("name_en") or en_map.get(sid) or sid).strip()
catalog[sid] = {
"name_en": name_en,
"name_ja": str(meta.get("name_ja") or ""),
"releaseDate": str(meta.get("releaseDate") or ""),
}
if name_en and name_en != sid:
matched += 1
OUT.write_text(
json.dumps({"sets": catalog, "prints": prints}, ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
print(f"wrote {OUT}: {matched}/{len(catalog)} curated EN names")
if __name__ == "__main__":
main()
+263
View File
@@ -0,0 +1,263 @@
{
"Bugsyのテクニカルマシン01": "Bugsy's Technical Machine 01",
"Bugsyのテクニカルマシン02": "Bugsy's Technical Machine 02",
"Clair's Technical Machine 01": "Clair's Technical Machine 01",
"Clair's Technical Machine 02": "Clair's Technical Machine 02",
"Max Revive": "Max Revive",
"Moomooミルク": "Moomoo Milk",
"Morty's Technical Machine 01": "Morty's Technical Machine 01",
"Morty's Technical Machine 02": "Morty's Technical Machine 02",
"ecogym": "Eco Gym",
"exp。共有": "EXP. Share",
"いいきずぐすり": "Super Potion",
"お上品攻撃": "Refined Attack",
"きずぐすり": "Potion",
"きずぐすり配合エネルギー": "Potion Energy",
"くすぐりマシーン": "Tickling Machine",
"げんきのかけら": "Revive",
"せまいジム": "Narrow Gym",
"たたきつけろ!挑戦状": "Challenge!",
"なかよしポフィン": "Buddy-Buddy Poffin",
"なにかの化石": "Mysterious Fossil",
"なんでもなおし": "Full Heal",
"なんでもなおし配合エネルギー": "Full Heal Energy",
"にせオーキドの逆襲": "Impostor Professor Oak's Revenge",
"ねむれ!ねむれ!": "Sleep! Sleep!",
"ふうせん": "Air Balloon",
"ふしぎなアメ": "Rare Candy",
"まきちらせ!ベトベトガス": "Goop Gas Attack",
"アカマツ": "Carmine",
"アプリコーンの森": "Apricorn Forest",
"アーケードゲーム": "Arcade Game",
"ウィルのテクニカルマシン01": "Will's Technical Machine 01",
"ウィルのテクニカルマシン02": "Will's Technical Machine 02",
"ウォーターキューブ01": "Water Cube 01",
"エネルギーの流れ": "Energy Flow",
"エネルギーをリサイクルします": "Recycle Energy",
"エネルギーを高めます": "Boost Energy",
"エネルギーアーク": "Energy Ark",
"エネルギーサーキュレート": "Energy Circulate",
"エネルギースイッチ": "Energy Switch",
"エネルギースタジアム": "Energy Stadium",
"エネルギー・リムーブ": "Energy Removal",
"エネルギー回収": "Energy Retrieval",
"エネルギー回復": "Energy Restore",
"エネルギー増幅器": "Energy Amplifier",
"エネルギー検索": "Energy Search",
"エネルギー転送": "Energy Transfer",
"エネルギー除去2": "Energy Removal 2",
"エネルギー電荷": "Energy Charge",
"エリカ": "Erika",
"エリカのお付き": "Erika's Maids",
"エリカの親切": "Erika's Kindness",
"エリカの香水": "Erika's Perfume",
"エルム教授": "Professor Elm",
"エルム教授のトレーニング方法": "Professor Elm's Training Method",
"オーキドはかせ": "Professor Oak",
"オーク教授の研究": "Professor Oak's Research",
"カウンターゲイン": "Counter Gain",
"カスミ": "Misty",
"カスミのいかり": "Misty's Wrath",
"カスミのなみだ": "Misty's Tears",
"カスミのわがまま": "Misty's Wish",
"カスミの勝負": "Misty's Duel",
"カツラ": "Blaine",
"カツラのギャンブル": "Blaine's Gamble",
"カツラのクイズ その3": "Blaine's Quiz #3",
"カツラの奥の手": "Blaine's Last Resort",
"カレンのテクニカルマシン01": "Karen's Technical Machine 01",
"カレンのテクニカルマシン02": "Karen's Technical Machine 02",
"カードフリップゲーム": "Card Flip Game",
"キョウ": "Koga",
"キョウ秘伝, 変わり身の術": "Koga's Ninja Trick",
"ギャンブラー": "Gambler",
"クイックボール": "Quick Ball",
"クチバシティジム": "Vermilion City Gym",
"クリスタルエネルギー": "Crystal Energy",
"グッドマナー": "Good Manners",
"グラスキューブ01": "Grass Cube 01",
"グレンタウンジム": "Cinnabar Island Gym",
"ゴールドベリー": "Gold Berry",
"サイキックキューブ01": "Psychic Cube 01",
"サカキ": "Giovanni",
"サカキの切り札": "Giovanni's Last Resort",
"ジャグラー": "Juggler",
"ジャスミンのテクニカルマシン01": "Jasmine's Technical Machine 01",
"ジャスミンのテクニカルマシン02": "Jasmine's Technical Machine 02",
"ジャニーンのテクニカルマシン01": "Janine's Technical Machine 01",
"ジャニーンのテクニカルマシン02": "Janine's Technical Machine 02",
"スイッチ": "Switch",
"スイレンのお世話": "Lana's Aid",
"スパイ作戦": "Spy Work",
"スプラウトタワー": "Sprout Tower",
"スーパーエネルギー検索": "Super Energy Retrieval",
"スーパーエネルギー除去2": "Super Energy Removal 2",
"スーパースクープアップ": "Super Scoop Up",
"スーパーボール": "Great Ball",
"スーパーロッド": "Super Rod",
"セキチクシティジム": "Fuchsia City Gym",
"タイムカプセル": "Time Capsule",
"タイムシャード": "Time Shard",
"タケシ": "Brock",
"タケシの保護": "Brock's Protection",
"タケシの育て方": "Brock's Training Method",
"タマムシシティジム": "Celadon City Gym",
"ダウジングマシーン": "Item Finder",
"ダブル無色エネルギー": "Double Colorless Energy",
"チャックのテクニカルマシン01": "Chuck's Technical Machine 01",
"チャックのテクニカルマシン02": "Chuck's Technical Machine 02",
"チャリティ": "Charity",
"チームロケットの邪悪な行為": "Team Rocket's Evil Deeds",
"テクノレーダー": "Technical Machine: Evolution",
"ディフェンダー": "Defender",
"デュアルボール": "Dual Ball",
"トウコ": "Hilda",
"トキワシティジム": "Viridian City Gym",
"トラッシュ交換": "Trash Exchange",
"ナツメ": "Sabrina",
"ナツメのESP": "Sabrina's ESP",
"ナツメのサイキックコントロール": "Sabrina's Psychic Control",
"ナツメの眼": "Sabrina's Gaze",
"ナンジャモ": "Iono",
"ニビシティジム": "Pewter City Gym",
"ネストボール": "Nest Ball",
"ハイパーデボルブスプレー": "Hyper Devolution Spray",
"ハイパーボール": "Ultra Ball",
"ハナダシティジム": "Cerulean City Gym",
"バトルVIPパス": "Battle VIP Pass",
"バトル場は穴だらけ!": "The Field is Full of Holes!",
"バルーンベリー": "Balloon Berry",
"パソコン通信": "Computer Search",
"パワープロテイン": "Power Protein",
"ヒーリングフィールド": "Healing Field",
"ビルからのメール": "Mail from Bill",
"ビルのテレポーター": "Bill's Teleporter",
"ビルのメンテナンス": "Bill's Maintenance",
"ピッピ人形": "Clefairy Doll",
"ファイアキューブ01": "Fire Cube 01",
"フォーカスバンド": "Focus Band",
"フォークナーのテクニカルマシン01": "Falkner's Technical Machine 01",
"フォークナーのテクニカルマシン02": "Falkner's Technical Machine 02",
"フジろうじん": "Mr. Fuji",
"フルヒール": "Full Heal",
"ブルーノのテクニカルマシン01": "Bruno's Technical Machine 01",
"ブルーノのテクニカルマシン02": "Bruno's Technical Machine 02",
"ブレイブバングル": "Brave Bangle",
"プライスのテクニカルマシン01": "Pryce's Technical Machine 01",
"プライスのテクニカルマシン02": "Pryce's Technical Machine 02",
"プライムキャッチャー": "Prime Catcher",
"プラスパワー": "PlusPower",
"ベリー": "Berry",
"ペパー": "Arven",
"ホイットニーのテクニカルマシン01": "Whitney's Technical Machine 01",
"ホイットニーのテクニカルマシン02": "Whitney's Technical Machine 02",
"ボスのやりかた": "The Boss's Way",
"ボスの指令": "Boss's Orders",
"ポクギア": "Pokégear",
"ポケギア3.0": "Pokégear 3.0",
"ポケモンいれかえ": "Switch",
"ポケモンの笛": "Pokémon Flute",
"ポケモンキャッチャー": "Pokémon Catcher",
"ポケモンセンター": "Pokémon Center",
"ポケモンナース": "Pokémon Nurse",
"ポケモンパーク": "Pokémon Park",
"ポケモンパーソナリティテスト": "Pokémon Personality Test",
"ポケモンファンクラブ": "Pokémon Fan Club",
"ポケモンブリーダーフィールド": "Pokémon Breeder Fields",
"ポケモンマーチ": "Pokémon March",
"ポケモン交換おじさん": "Pokémon Trader",
"ポケモン再送信": "Pokémon Retransmit",
"ポケモン反転": "Pokémon Reversal",
"ポケモン回収": "Pokémon Retrieval",
"ポケモン図鑑": "Pokédex",
"ポケモン育て屋さん": "Pokémon Breeder",
"ポケモン通信": "Pokémon Communication",
"ポーション": "Potion",
"マサキ": "Bill",
"マスターボール": "Master Ball",
"マチス": "Lt. Surge",
"マチスの交渉": "Lt. Surge's Treaty",
"マチスの秘策": "Lt. Surge's Secret Plan",
"マルチテクニカルマシン01": "Multi Technical Machine 01",
"ミニスカート": "Lass",
"ミラクルベリー": "Miracle Berry",
"メアリー": "Mary",
"メアリーの衝動": "Mary's Impulse",
"メモリベリー": "Memory Berry",
"メンテナンス": "Maintenance",
"モンスターボール": "Poké Ball",
"ヤマブキシティジム": "Saffron City Gym",
"ライトニングキューブ01": "Lightning Cube 01",
"ラジオタワー": "Radio Tower",
"ラッキースタジアム": "Lucky Stadium",
"ランスのテクニカルマシン01": "Lance's Technical Machine 01",
"ランスのテクニカルマシン02": "Lance's Technical Machine 02",
"リコール": "Recall",
"リサイクル": "Recycle",
"リムーブ禁止ジム": "No Removal Gym",
"リーリエの決心": "Lillie's Determination",
"レインボーエネルギー": "Rainbow Energy",
"ロケットのスニーク攻撃": "Rocket's Sneak Attack",
"ロケットのテクニカルマシン01": "Rocket's Technical Machine 01",
"ロケットの隠れ家": "Rocket Hideout",
"ロケット団のおねーさん": "Rocket's Admin.",
"ロケット団のワナ": "Team Rocket's Trap",
"ロケット団の実験": "Team Rocket's Experiment",
"ロケット団の爆発ジム": "Explosion Gym",
"ロケット団の特訓ジム": "Training Center",
"ロケット団参上!": "Here Comes Team Rocket!",
"ロケット団員": "Team Rocket Grunt",
"ワープエネルギー": "Warp Energy",
"ワープポイント": "Warp Point",
"二重突風": "Double Gust",
"先見者": "Oracle",
"化石卵": "Fossil Egg",
"博士の研究": "Professor's Research",
"反撃の爪": "Counterattack Claws",
"古い棒": "Old Rod",
"基本ドラゴンエネルギー": "Dragon Energy",
"基本フェアリーエネルギー": "Fairy Energy",
"基本悪エネルギー": "Darkness Energy",
"基本水エネルギー": "Water Energy",
"基本炎エネルギー": "Fire Energy",
"基本草エネルギー": "Grass Energy",
"基本超エネルギー": "Psychic Energy",
"基本鋼エネルギー": "Metal Energy",
"基本闘エネルギー": "Fighting Energy",
"基本雷エネルギー": "Lightning Energy",
"壁を台無しにする[aerodactyl]": "Ruin Wall",
"壁を台無しにする[カブト]": "Ruin Wall",
"壊れた地上ジム": "Broken Ground Gym",
"夜のタンカ": "Night Stretcher",
"夜の廃品回収": "Nightly Garbage Run",
"大地の器": "Earthen Vessel",
"奇跡のエネルギー": "Miracle Energy",
"強さの魅力": "Power Charge",
"思い出させる": "Reminder",
"思考ウェーブマシン": "Thought Wave Machine",
"戦いキューブ01": "Fighting Cube 01",
"抵抗力低下ジム": "Resistance Gym",
"拡大鏡": "Magnifier",
"新しいpokedex": "New Pokédex",
"旅行セールスマン": "Traveling Salesman",
"森林保護者": "Forest Guardian",
"模倣": "Copycat",
"海底遺跡": "Undersea Ruins",
"町のボランティア": "Town Volunteers",
"癒しベリー": "Heal Berry",
"発電所": "Power Plant",
"礼儀作法": "Etiquette",
"突風": "Gust of Wind",
"粉末を癒します": "Heal Powder",
"脱力感ガード": "Weakness Guard",
"見えない壁": "Invisible Wall",
"詐欺師オーク教授": "Impostor Professor Oak",
"詐欺師オーク教授の発明": "Impostor Professor Oak's Invention",
"超エネルギーリムーブ": "Super Energy Removal",
"退化スプレー": "Devolution Spray",
"金属エネルギー": "Metal Energy",
"金属キューブ01": "Metal Cube 01",
"錯乱ジム": "Chaos Gym",
"闇のエネルギー": "Darkness Energy",
"闇キューブ01": "Darkness Cube 01"
}
+175
View File
@@ -0,0 +1,175 @@
{
"ADV1": "Expansion Pack ADV",
"ADV2": "Miracle of the Desert",
"ADV3": "Rulers of the Heavens",
"ADV4": "Flight of the Skies",
"ADV5": "Undone Seal",
"CP1": "Magma Gang VS Aqua Gang: Double Crisis",
"CP2": "Legendary Shine Collection",
"CP3": "PokéKyun Collection",
"CP4": "Premium Champion Pack",
"CP5": "Mythical & Legendary Dream Shine Collection",
"CP6": "Expansion Pack 20th Anniversary",
"E1": "Base Expansion Pack",
"E2": "The Town on No Map",
"E3": "Wind from the Sea",
"E4": "Split Earth",
"E5": "Mysterious Mountains",
"ExpSheet1": "Expansion Sheet Series 1",
"ExpSheet2": "Expansion Sheet Series 2",
"ExpSheet3": "Expansion Sheet Series 3",
"GurenTG": "Guren Town Gym",
"HanadaCG": "Hanada City Gym",
"KuchibaCG": "Kuchiba City Gym",
"L1a": "HeartGold Collection",
"L1b": "SoulSilver Collection",
"L2": "Reviving Legends",
"L3": "Clash at the Summit",
"LL": "Lost Link",
"M-P": "MEGA Promo",
"M1L": "Mega Symphonia",
"M1S": "Mega Symphonia",
"M2": "Inferno X",
"M2a": "MEGA Dream ex",
"M3": "Munikeith Zero",
"M4": "Ninja Spinner",
"M5": "MEGA Expansion",
"MC": "McDonald's Collection",
"NiviCG": "Nivi City Gym",
"PCG1": "Venusaur/Charizard/Blastoise Half Deck",
"PCG10": "Offense and Defense of the Furthest Ends",
"PCG2": "Flight of Fire",
"PCG3": "Clash of the Blue Sky",
"PCG4": "Rocket Gang Strikes Back",
"PCG5": "Golden Sky, Silvery Ocean",
"PCG6": "Mirage Forest",
"PCG7": "Holon Research Tower",
"PCG8": "Holon Phantom",
"PCG9": "Miracle Crystal",
"PMCG1": "Expansion Pack",
"PMCG2": "Pokémon Jungle",
"PMCG3": "Mystery of the Fossils",
"PMCG4": "Rocket Gang",
"PMCG5": "Leaders' Stadium",
"PMCG6": "Challenge from the Darkness",
"S10D": "Time Gazer",
"S10P": "Space Juggler",
"S10a": "Dark Abyss",
"S10b": "Pokémon GO",
"S11": "Lost Abyss",
"S11a": "Incandescent Arcana",
"S12": "Paradigm Trigger",
"S12a": "VSTAR Universe",
"S1H": "Shield",
"S1W": "Sword",
"S1a": "VMAX Rising",
"S2": "Rebellion Crash",
"S2a": "Explosive Walker",
"S3": "Infinity Zone",
"S3a": "Legendary Pulse",
"S4": "Amazing Volt Tackle",
"S4a": "Shiny Star V",
"S5I": "Single Strike Master",
"S5R": "Rapid Strike Master",
"S5a": "Matchless Fighters",
"S6H": "Silver Lance",
"S6K": "Jet-Black Spirit",
"S6a": "Eevee Heroes",
"S7D": "Skyscraping Perfection",
"S7R": "Blue Sky Stream",
"S8": "Fusion Arts",
"S8a": "Dark Phantasma",
"S8b": "VMAX Climax",
"S9": "Star Birth",
"S9a": "Battle Region",
"SM0": "Generation",
"SM1+": "Strength Expansion Pack Sun & Moon",
"SM10": "Double Blaze",
"SM10b": "Sky Legend",
"SM11a": "Remix Bout",
"SM11b": "Dream League",
"SM12": "Alter Genesis",
"SM12a": "Tag All Stars",
"SM1M": "Collection Moon",
"SM1S": "Collection Sun",
"SM2K": "Islands Await You",
"SM2L": "Alolan Moonlight",
"SM3+": "Shining Legends",
"SM3H": "Fighting Rainbow",
"SM3N": "Darkness that Consumes Light",
"SM4+": "GX Battle Boost",
"SM4A": "Ultradimensional Beasts",
"SM4S": "Awakened Heroes",
"SM5+": "Ultra Force Ultra Sun Ultra Moon",
"SM5M": "Ultra Force",
"SM5S": "Ultra Moon",
"SM6": "Forbidden Light",
"SM6a": "Dragon Storm",
"SM6b": "Champion Road",
"SM7": "Fairy Rise",
"SM7a": "Thunderclap Spark",
"SM7b": "Fairy Rise",
"SM8": "Explosive Impact",
"SM8a": "Dark Order",
"SM8b": "GX Ultra Shiny",
"SM9": "Tag Bolt",
"SM9a": "Night Unison",
"SM9b": "Full Metal Wall",
"SMP2": "Detective Pikachu",
"SV10": "Glory of Team Rocket",
"SV11B": "Black Bolt",
"SV11W": "White Flare",
"SV1S": "Scarlet ex",
"SV1V": "Violet ex",
"SV1a": "Triplet Beat",
"SV2D": "Clay Burst",
"SV2P": "Snow Hazard",
"SV2a": "Pokémon Card 151",
"SV3": "Ruler of the Black Flame",
"SV3a": "Raging Surf",
"SV4K": "Ancient Roar",
"SV4M": "Future Flash",
"SV4a": "Shiny Treasure ex",
"SV5K": "Wild Force",
"SV5M": "Cyber Judge",
"SV5a": "Crimson Haze",
"SV6": "Mask of Change",
"SV6a": "Night Wanderer",
"SV7": "Stellar Miracle",
"SV7a": "Paradise Dragona",
"SV8": "Super Electric Breaker",
"SV8a": "Terastal Festival ex",
"SV9": "Battle Partners",
"SV9a": "Heat Wave Arena",
"SVK": "Starter Set / Construction Dec",
"SVLN": "Starter Set Terastal Charizard ex",
"SVLS": "Starter Set Lucario & Roaring Moon",
"SouthernIslands": "Southern Islands",
"TamamushiCG": "Tamamushi City Gym",
"UnnumberedPromo": "Unnumbered Promotional cards",
"VS1": "Pokémon VS",
"XY10": "Awakening Psychic King",
"XY11a": "Cruel Traitor",
"XY11b": "Fever-Burst Fight",
"XY1a": "Collection X",
"XY1b": "Collection Y",
"XY2": "Wild Blaze",
"XY3": "Rising Fist",
"XY4": "Phantom Gate",
"XY5a": "Gaia Volcano",
"XY5b": "Tidal Storm",
"XY6": "Emerald Break",
"XY7": "Bandit Ring",
"XY8a": "Blue Shock",
"XY8b": "Red Flash",
"XY9": "Rage of the Broken Heavens",
"YamabukiCG": "Yamabuki City Gym",
"neo1": "Gold, Silver, to a New World...",
"neo2": "Crossing the Ruins...",
"neo3": "Awakening Legends",
"neo4": "Darkness, and to Light...",
"sm2+": "Facing a New Trial",
"sn10a": "GG End",
"sn11": "Miracle Twin",
"web1": "Pokémon Web"
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -9,8 +9,8 @@
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)``Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
- `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`.
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 lines of hook overrides on top of the matching base template.
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
@@ -67,7 +67,7 @@
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
15. **Preview fallback behavior (CCM2 parity where applicable):**
- Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap.
- Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains working when touching preview code.
- Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Japanese Pokémon uses the Japanese TCG Bulbagarden back (`TCG_Card_Back_Japanese.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains working when touching preview code.
- If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews.
16. **Per-game auto-detect controls:**
- Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game.
+1
View File
@@ -27,6 +27,7 @@ add_library(ccm_ui_wx STATIC
src/SettingsDialog.cpp
src/SwitchCtrl.cpp
src/ImageViewerDialog.cpp
src/VariantImagePreviewDialog.cpp
src/IconListCtrl.cpp
src/SvgIcons.cpp
src/Theme.cpp
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because it is too large Load Diff
+2
View File
@@ -27,6 +27,8 @@ struct AppContext {
IGameModule& pokemonModule;
IGameModule& yuGiOhModule;
IGameModule& digiBattle99Module;
// Asia Pokemon sets/preview backend (not a separate Game menu entry).
IGameModule& japanesePokemonModule;
// Active per-game UI bundles. The order is the order shown in the
// Game menu; the composition root constructs them and hands raw
// pointers in. `MainFrame` does not own these — `app/main.cpp` does.
+50 -7
View File
@@ -3,7 +3,8 @@
// BaseCardEditDialog<TCard>
//
// Header-only template for the modal create/edit form. Owns the parts every
// game shares — Name, Set picker (read-only combo with prefix typeahead),
// game shares — Name, Set picker (read-only combo with typeahead: prefix first,
// then substring, with ASCII-fold so "Pokemon"/"Jungle" match "Pokémon Jungle"),
// Amount spin, Language and Condition choices, Note, Image list with
// Add/Remove/double-click-to-view, OK/Cancel — and exposes hooks the
// subclass uses to:
@@ -48,6 +49,7 @@
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <span>
#include <string>
#include <utility>
#include <vector>
@@ -102,6 +104,9 @@ protected:
// wants; the base only owns the surrounding label.
virtual void buildFlagsRow(wxBoxSizer* flagsBox) = 0;
// Subclass adds any labelled rows between Name and Set. Default does nothing.
virtual void appendPreSetRows(wxFlexGridSizer* /*grid*/) {}
// Subclass adds any extra game-specific labelled rows just below the
// standard rows but above the Note row, by calling `appendRow(label, ctrl)`
// (provided as a parameter). Default does nothing.
@@ -114,6 +119,11 @@ protected:
// Subclass copies the extra fields it owns from its widgets back into `card_`.
virtual void writeExtraToCard() {}
// Languages offered in the Language choice. Default: allLanguages().
[[nodiscard]] virtual std::span<const Language> languagesForChoice() const {
return allLanguages();
}
[[nodiscard]] virtual std::string updateMenuName() const { return "Update Sets"; }
// Display name passed into errors and the dialog title hints.
@@ -151,6 +161,12 @@ protected:
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
}
void setPreloadedSetsPointer(const std::vector<Set>* sets) noexcept {
preloadedSets_ = sets;
}
void refreshSetAndLanguageChoices() { populateChoices(); }
// Default: combo only. Yu-Gi-Oh! overrides to add set-code entry + toggle.
virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) {
row.Add(combo, 1, wxEXPAND);
@@ -193,6 +209,9 @@ private:
});
appendRow(grid, "Name", nameCtrl_);
// Optional rows between Name and Set (e.g. Pokemon West/Asia region).
appendPreSetRows(grid);
// `setCombo_` must be parented to `setHost` so every control in the Set row
// shares the same `wxPanel`; otherwise the combo stays a direct child of the
// dialog while the sizer lives on `setHost`, which corrupts layout on MSW.
@@ -311,9 +330,10 @@ private:
languageChoice_->Clear();
int langIdx = 0;
int i = 0;
const auto langsForChoice = languagesForChoice();
wxArrayString langs;
langs.Alloc(allLanguages().size());
for (auto l : allLanguages()) {
langs.Alloc(langsForChoice.size());
for (auto l : langsForChoice) {
const std::string lang = std::string(to_string(l));
langs.Add(wxString::FromUTF8(lang.c_str()));
if (l == card_.language) langIdx = i;
@@ -486,20 +506,43 @@ private:
#endif
}
// Fold a few Latin-1 diacritics so typing ASCII "Pokemon" matches "Pokémon".
[[nodiscard]] static wxString foldAsciiForTypeahead(wxString s) {
s.MakeLower();
s.Replace(wxString::FromUTF8("\xc3\xa9"), wxT("e")); // é
s.Replace(wxString::FromUTF8("\xc3\x89"), wxT("e")); // É (after lower: é)
s.Replace(wxString::FromUTF8("\xc3\xa8"), wxT("e")); // è
s.Replace(wxString::FromUTF8("\xc3\xaa"), wxT("e")); // ê
s.Replace(wxString::FromUTF8("\xc3\xa0"), wxT("a")); // à
s.Replace(wxString::FromUTF8("\xc3\xa1"), wxT("a")); // á
s.Replace(wxString::FromUTF8("\xc3\xb1"), wxT("n")); // ñ
s.Replace(wxString::FromUTF8("\xc3\xbc"), wxT("u")); // ü
s.Replace(wxString::FromUTF8("\xc3\xb6"), wxT("o")); // ö
return s;
}
void applySetTypeaheadSelection() {
const auto& available = availableSets();
if (!setCombo_ || available.empty()) return;
wxString pref = setTypeaheadPrefix_;
pref.MakeLower();
const wxString pref = foldAsciiForTypeahead(setTypeaheadPrefix_);
if (pref.empty()) return;
// Prefer prefix matches, then substring (so "Jungle" finds "Pokémon Jungle").
for (std::size_t i = 0; i < available.size(); ++i) {
wxString name(wxString::FromUTF8(available[i].name));
name.MakeLower();
const wxString name =
foldAsciiForTypeahead(wxString::FromUTF8(available[i].name));
if (name.StartsWith(pref)) {
setCombo_->SetSelection(static_cast<int>(i));
return;
}
}
for (std::size_t i = 0; i < available.size(); ++i) {
const wxString name =
foldAsciiForTypeahead(wxString::FromUTF8(available[i].name));
if (name.Contains(pref)) {
setCombo_->SetSelection(static_cast<int>(i));
return;
}
}
}
void onSetComboChar(wxKeyEvent& ev) {
+10 -1
View File
@@ -217,6 +217,12 @@ protected:
[[nodiscard]] virtual Game gameId() const noexcept = 0;
// Preview / card-back routing. Defaults to `gameId()`; Pokemon overrides
// so Asia cards use the JapanesePokemon preview source + card back.
[[nodiscard]] virtual Game previewGameFor(const TCard& /*card*/) const noexcept {
return gameId();
}
// Construction ------------------------------------------------------------
BaseSelectedCardPanel(wxWindow* parent,
@@ -292,6 +298,9 @@ private:
case Game::Pokemon:
// Mirrors CCM2's unresolved-preview fallback image.
return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg";
case Game::JapanesePokemon:
// Japanese TCG back (distinct from the Western Cardback.jpg).
return "https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg";
case Game::YuGiOh:
// Yugipedia English TCG backing (thumbnail — smaller than full scan).
return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png";
@@ -379,7 +388,7 @@ private:
auto state = state_;
CardPreviewService* svcPtr = &cardPreview_;
auto [name, setId, setNo] = previewKey(card);
const Game game = gameId();
const Game game = previewGameFor(card);
const std::string exeDirCopy = exeDirForBundledAssets_;
std::thread([state, gen, svcPtr, name = std::move(name),
+39 -6
View File
@@ -1,24 +1,28 @@
#pragma once
// PokemonCardEditDialog: typed Add/Edit form for a `PokemonCard`. Inherits
// the shared layout, set picker, and image management from
// `BaseCardEditDialog<PokemonCard>` and adds:
// - a `Set #` text input (between the Set picker and the Amount spin)
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
// PokemonCardEditDialog: Add/Edit form for a unified West/Asia PokemonCard.
// West/Asia switch drives set lists, language choices, preview APIs, and the
// Asia-only UnnumberedPromo print UX (from the former Japanese dialog).
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include "ccm/ui/SwitchCtrl.hpp"
#include <wx/button.h>
#include <wx/stattext.h>
#include <atomic>
#include <memory>
#include <span>
#include <string>
#include <vector>
namespace ccm::ui {
class VariantImagePreviewDialog;
class PokemonCardEditDialog final : public BaseCardEditDialog<PokemonCard> {
public:
PokemonCardEditDialog(wxWindow* parent,
@@ -27,22 +31,27 @@ public:
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets = nullptr);
const std::vector<Set>* westSets = nullptr,
const std::vector<Set>* asiaSets = nullptr);
~PokemonCardEditDialog() override;
protected:
void appendPreSetRows(wxFlexGridSizer* grid) override;
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void appendExtraRows(wxFlexGridSizer* grid) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
void onCardLookupContextChanged() override;
[[nodiscard]] std::span<const Language> languagesForChoice() const override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onRegionSwitch(wxCommandEvent&);
void applyRegion(PokemonRegion region, bool clearSetIfMissing);
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
@@ -60,15 +69,37 @@ private:
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void refreshSetNoRowMode();
void applySelectedSetNo(std::string setNo);
void stepVariantRing(int delta);
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
void closeUnnumberedPreview();
void ensureUnnumberedPreviewOpen();
void refreshUnnumberedPreview();
void requestUnnumberedPreviewAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
std::string setNo,
std::size_t ringIndex,
std::size_t ringCount);
[[nodiscard]] bool isUnnumberedPromoSelected() const;
[[nodiscard]] std::string currentRingSetNo() const;
[[nodiscard]] Game backendGame() const noexcept;
[[nodiscard]] PokemonRegion currentRegion() const noexcept;
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
unsigned previewFetchEpoch_{0};
CardPreviewService& cardPreview_;
const std::vector<Set>* westSets_{nullptr};
const std::vector<Set>* asiaSets_{nullptr};
std::shared_ptr<VariantFetchState> variantFetchState_;
SwitchCtrl* regionSwitch_{nullptr};
wxStaticText* setNoLabel_{nullptr};
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
@@ -76,7 +107,9 @@ private:
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
VariantImagePreviewDialog* unnumberedPreview_{nullptr};
std::string selectedSetNo_;
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
+5 -6
View File
@@ -1,9 +1,7 @@
#pragma once
// PokemonGameView: IGameView for the Pokemon TCG. Mirrors `MagicGameView` —
// owns the Pokemon-typed list, selected, and edit-dialog widgets and
// delegates persistence to a `CollectionService<PokemonCard>` reference
// supplied by the composition root.
// PokemonGameView: unified West + Asia Pokemon UI. One collection file;
// separate West/Asia set caches; Sets > Update Pokemon refreshes both.
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/IGameModule.hpp"
@@ -49,7 +47,7 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
const std::vector<Set>& setsForDialog(PokemonRegion region);
ConfigService& config_;
CollectionService<PokemonCard>& collection_;
@@ -60,7 +58,8 @@ private:
PokemonCardListPanel* listPanel_{nullptr};
PokemonSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
std::vector<Set> setsCacheWest_;
std::vector<Set> setsCacheAsia_;
bool attemptedInitialSetLoad_{false};
};
@@ -25,6 +25,9 @@ protected:
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const PokemonCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
[[nodiscard]] Game previewGameFor(const PokemonCard& card) const noexcept override {
return pokemonBackendGame(card.region);
}
};
} // namespace ccm::ui
@@ -0,0 +1,45 @@
#pragma once
// VariantImagePreviewDialog: small modeless popup that shows a single card
// preview image (bytes decoded as wxImage). Used by Japanese Pokémon Add/Edit
// when cycling UnnumberedPromo prints. Prev/Next fire custom events so the
// edit dialog owns the variant ring.
#include <wx/button.h>
#include <wx/dialog.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/panel.h>
#include <wx/stattext.h>
#include <wx/string.h>
#include <string_view>
namespace ccm::ui {
wxDECLARE_EVENT(EVT_VARIANT_PREVIEW_PREV, wxCommandEvent);
wxDECLARE_EVENT(EVT_VARIANT_PREVIEW_NEXT, wxCommandEvent);
class VariantImagePreviewDialog : public wxDialog {
public:
explicit VariantImagePreviewDialog(wxWindow* parent);
void setCaption(const wxString& caption);
void setImageBytes(std::string_view bytes);
void clearImage();
void setNavigationEnabled(bool enabled);
void repositionBesideParent();
private:
class ImageCanvas;
void onPrev(wxCommandEvent&);
void onNext(wxCommandEvent&);
ImageCanvas* imageHost_{nullptr};
wxStaticText* caption_{nullptr};
wxButton* prevButton_{nullptr};
wxButton* nextButton_{nullptr};
};
} // namespace ccm::ui
+5 -4
View File
@@ -41,10 +41,11 @@ constexpr const char kFilterInputHint[] = "Filter";
std::string dirNameForGame(Game g) {
switch (g) {
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
case Game::JapanesePokemon: return "pokemon";
}
return "magic";
}
+345 -26
View File
@@ -1,12 +1,34 @@
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/ui/VariantImagePreviewDialog.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
namespace {
constexpr const char* kUnnumberedPromoSetId = "UnnumberedPromo";
constexpr const char* kJapanesePokemonCardBackUrl =
"https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg";
const std::vector<Set> kEmptySets;
bool languageAllowedForRegion(Language lang, PokemonRegion region) {
for (const auto l : languagesForPokemonRegion(region)) {
if (l == lang) return true;
}
return false;
}
} // namespace
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
@@ -14,15 +36,22 @@ PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
CardPreviewService& cardPreview,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets)
const std::vector<Set>* westSets,
const std::vector<Set>* asiaSets)
: BaseCardEditDialog<PokemonCard>(
parent,
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets),
imageService, setService, mode, PokemonCard{}, Game::Pokemon, nullptr),
dialogMode_(mode),
cardPreview_(cardPreview),
westSets_(westSets),
asiaSets_(asiaSets),
variantFetchState_(std::make_shared<VariantFetchState>()) {
const PokemonRegion region = initial.region;
mutableCard() = std::move(initial);
setPreloadedSetsPointer(region == PokemonRegion::Asia ? asiaSets_ : westSets_);
buildAndPopulate();
refreshSetNoRowMode();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
@@ -32,12 +61,40 @@ PokemonCardEditDialog::~PokemonCardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
closeUnnumberedPreview();
}
PokemonRegion PokemonCardEditDialog::currentRegion() const noexcept {
return constCard().region;
}
Game PokemonCardEditDialog::backendGame() const noexcept {
return pokemonBackendGame(currentRegion());
}
std::span<const Language> PokemonCardEditDialog::languagesForChoice() const {
return languagesForPokemonRegion(currentRegion());
}
void PokemonCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void PokemonCardEditDialog::appendPreSetRows(wxFlexGridSizer* grid) {
auto* regionPanel = new wxPanel(this, wxID_ANY);
auto* row = new wxBoxSizer(wxHORIZONTAL);
regionSwitch_ = new SwitchCtrl(regionPanel, wxID_ANY,
constCard().region == PokemonRegion::Asia);
row->Add(new wxStaticText(regionPanel, wxID_ANY, wxString::FromUTF8("West")),
0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
row->Add(regionSwitch_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
row->Add(new wxStaticText(regionPanel, wxID_ANY, wxString::FromUTF8("Asia")),
0, wxALIGN_CENTER_VERTICAL);
regionPanel->SetSizer(row);
regionSwitch_->Bind(EVT_CCM_SWITCH, &PokemonCardEditDialog::onRegionSwitch, this);
appendRow(grid, "Region", regionPanel);
}
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
@@ -50,6 +107,7 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
}
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
setNoLabel_ = new wxStaticText(this, wxID_ANY, "Set #");
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
@@ -63,20 +121,63 @@ void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
grid->Add(setNoLabel_, 0, wxALIGN_CENTER_VERTICAL);
grid->Add(setNoPanel, 1, wxEXPAND);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
}
}
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
std::string out(setNo);
const auto slash = out.find('/');
if (slash != std::string::npos) {
out.resize(slash);
void PokemonCardEditDialog::onRegionSwitch(wxCommandEvent&) {
const PokemonRegion next =
(regionSwitch_ && regionSwitch_->GetValue()) ? PokemonRegion::Asia
: PokemonRegion::West;
applyRegion(next, true);
}
void PokemonCardEditDialog::applyRegion(PokemonRegion region, bool clearSetIfMissing) {
mutableCard().region = region;
if (regionSwitch_ && regionSwitch_->GetValue() != (region == PokemonRegion::Asia)) {
regionSwitch_->SetValue(region == PokemonRegion::Asia, false);
}
return out;
if (!languageAllowedForRegion(mutableCard().language, region)) {
mutableCard().language = defaultLanguageForPokemonRegion(region);
}
const std::vector<Set>* sets =
region == PokemonRegion::Asia
? (asiaSets_ != nullptr ? asiaSets_ : &kEmptySets)
: (westSets_ != nullptr ? westSets_ : &kEmptySets);
setPreloadedSetsPointer(sets);
const std::string prevSetId = mutableCard().set.id;
bool setStillValid = false;
for (const auto& s : availableSets()) {
if (s.id == prevSetId) {
setStillValid = true;
mutableCard().set = s;
break;
}
}
if (clearSetIfMissing && !setStillValid) {
mutableCard().set = Set{};
mutableCard().setNo.clear();
selectedSetNo_.clear();
if (setNoCtrl_) setNoCtrl_->ChangeValue(wxEmptyString);
}
refreshSetAndLanguageChoices();
clearCachedPrintVariants();
refreshSetNoRowMode();
scheduleDeferredVariantPrefetch();
Layout();
if (GetSizer()) Fit();
}
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
}
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
@@ -84,20 +185,78 @@ std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctr
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
bool PokemonCardEditDialog::isUnnumberedPromoSelected() const {
if (currentRegion() != PokemonRegion::Asia) return false;
if (const auto* set = selectedSetFromControls()) {
return set->id == kUnnumberedPromoSetId;
}
return constCard().set.id == kUnnumberedPromoSetId;
}
std::string PokemonCardEditDialog::currentRingSetNo() const {
if (!uniqueSetNos_.empty() && setNoRingPos_ < uniqueSetNos_.size()) {
return uniqueSetNos_[setNoRingPos_];
}
return selectedSetNo_;
}
void PokemonCardEditDialog::applySelectedSetNo(std::string setNo) {
selectedSetNo_ = normalizedStoredSetNo(setNo);
if (setNoCtrl_ && setNoCtrl_->IsShown()) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str()));
}
}
void PokemonCardEditDialog::refreshSetNoRowMode() {
const bool unnumbered = isUnnumberedPromoSelected();
if (setNoLabel_) {
setNoLabel_->SetLabelText(unnumbered ? wxString::FromUTF8("Print")
: wxString::FromUTF8("Set #"));
}
if (setNoCtrl_) {
setNoCtrl_->Show(!unnumbered);
if (!unnumbered && !selectedSetNo_.empty()) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str()));
}
if (auto* parent = setNoCtrl_->GetParent()) {
parent->Layout();
}
}
if (!unnumbered) {
closeUnnumberedPreview();
}
Layout();
if (GetSizer()) Fit();
}
void PokemonCardEditDialog::readExtraFromCard() {
clearCachedPrintVariants();
selectedSetNo_ = normalizedStoredSetNo(constCard().setNo);
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
if (regionSwitch_) {
regionSwitch_->SetValue(constCard().region == PokemonRegion::Asia, false);
}
refreshSetNoRowMode();
}
void PokemonCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
mutableCard().region =
(regionSwitch_ && regionSwitch_->GetValue()) ? PokemonRegion::Asia
: PokemonRegion::West;
if (isUnnumberedPromoSelected()) {
mutableCard().setNo = normalizedStoredSetNo(selectedSetNo_);
} else if (setNoCtrl_) {
selectedSetNo_ = storedSetNoFromControls(setNoCtrl_);
mutableCard().setNo = selectedSetNo_;
} else {
mutableCard().setNo = normalizedStoredSetNo(selectedSetNo_);
}
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
@@ -106,9 +265,11 @@ void PokemonCardEditDialog::writeExtraToCard() {
void PokemonCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
++previewFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
closeUnnumberedPreview();
refreshVariantNextControls();
}
@@ -142,9 +303,10 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
const Game game = backendGame();
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog, game]() {
auto detected = svc->detectPrintVariants(game, name, setId);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
@@ -173,14 +335,20 @@ void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
if (fillSetNoOnSuccess && !cachedVariants_.empty()) {
applySelectedSetNo(cachedVariants_.front().setNo);
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
if (isUnnumberedPromoSelected() && !uniqueSetNos_.empty()) {
ensureUnnumberedPreviewOpen();
refreshUnnumberedPreview();
if (unnumberedPreview_ != nullptr) {
unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1);
}
}
}
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
@@ -197,8 +365,12 @@ void PokemonCardEditDialog::rebuildVariantRingFromCache() {
}
void PokemonCardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
const std::string current = isUnnumberedPromoSelected()
? normalizedStoredSetNo(selectedSetNo_)
: storedSetNoFromControls(setNoCtrl_);
if (!isUnnumberedPromoSelected() && setNoCtrl_) {
selectedSetNo_ = current;
}
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
@@ -206,26 +378,66 @@ void PokemonCardEditDialog::syncRingPositionToControls() {
break;
}
}
if (!uniqueSetNos_.empty() && selectedSetNo_.empty()) {
applySelectedSetNo(uniqueSetNos_[setNoRingPos_]);
}
}
void PokemonCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
const bool showNext = uniqueSetNos_.size() > 1;
nextSetNoBtn_->Show(showNext);
if (showNext) {
if (isUnnumberedPromoSelected()) {
const std::size_t i = setNoRingPos_ + 1;
const std::size_t n = uniqueSetNos_.size();
nextSetNoBtn_->SetLabel(wxString::Format("Next (%zu/%zu)", i, n));
} else {
const std::string setNo = currentRingSetNo();
if (setNo.empty()) {
nextSetNoBtn_->SetLabel("Next");
} else {
nextSetNoBtn_->SetLabel(
wxString::Format("Next (%s)", wxString::FromUTF8(setNo.c_str())));
}
}
} else {
nextSetNoBtn_->SetLabel("Next");
}
if (auto* parent = nextSetNoBtn_->GetParent()) {
parent->Layout();
}
Layout();
if (GetSizer()) Fit();
if (unnumberedPreview_ != nullptr) {
unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1);
}
}
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
void PokemonCardEditDialog::stepVariantRing(int delta) {
if (uniqueSetNos_.size() <= 1 || delta == 0) return;
const auto n = static_cast<int>(uniqueSetNos_.size());
auto pos = static_cast<int>(setNoRingPos_) + delta;
pos %= n;
if (pos < 0) pos += n;
setNoRingPos_ = static_cast<std::size_t>(pos);
applySelectedSetNo(uniqueSetNos_[setNoRingPos_]);
refreshVariantNextControls();
if (isUnnumberedPromoSelected()) {
ensureUnnumberedPreviewOpen();
refreshUnnumberedPreview();
if (unnumberedPreview_ != nullptr) {
unnumberedPreview_->setNavigationEnabled(true);
}
}
}
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
stepVariantRing(1);
}
void PokemonCardEditDialog::autoDetectFromApi() {
@@ -248,8 +460,115 @@ void PokemonCardEditDialog::autoDetectFromApi() {
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
refreshSetNoRowMode();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
void PokemonCardEditDialog::closeUnnumberedPreview() {
++previewFetchEpoch_;
if (unnumberedPreview_ != nullptr) {
unnumberedPreview_->Destroy();
unnumberedPreview_ = nullptr;
}
}
void PokemonCardEditDialog::ensureUnnumberedPreviewOpen() {
if (!isUnnumberedPromoSelected()) {
closeUnnumberedPreview();
return;
}
if (unnumberedPreview_ != nullptr) {
unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1);
unnumberedPreview_->repositionBesideParent();
return;
}
unnumberedPreview_ = new VariantImagePreviewDialog(this);
const Theme theme = inferThemeFromWindow(this);
applyThemeToWindowTree(unnumberedPreview_, paletteForTheme(theme), theme);
unnumberedPreview_->Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& ev) {
unnumberedPreview_ = nullptr;
ev.Skip();
});
unnumberedPreview_->Bind(EVT_VARIANT_PREVIEW_PREV, [this](wxCommandEvent&) {
stepVariantRing(-1);
});
unnumberedPreview_->Bind(EVT_VARIANT_PREVIEW_NEXT, [this](wxCommandEvent&) {
stepVariantRing(1);
});
unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1);
unnumberedPreview_->Show(true);
unnumberedPreview_->repositionBesideParent();
}
void PokemonCardEditDialog::refreshUnnumberedPreview() {
if (!isUnnumberedPromoSelected() || uniqueSetNos_.empty()) {
closeUnnumberedPreview();
return;
}
ensureUnnumberedPreviewOpen();
if (unnumberedPreview_ == nullptr) return;
syncCardFromControls();
const auto& card = constCard();
const std::string setNo = currentRingSetNo();
if (card.name.empty() || setNo.empty()) {
unnumberedPreview_->clearImage();
unnumberedPreview_->setCaption(wxString::FromUTF8("Enter a card name"));
return;
}
const std::size_t i = setNoRingPos_ + 1;
const std::size_t n = uniqueSetNos_.size();
unnumberedPreview_->setCaption(
wxString::Format("%s (%zu/%zu)",
wxString::FromUTF8(card.name.c_str()), i, n));
const unsigned epoch = ++previewFetchEpoch_;
requestUnnumberedPreviewAsync(epoch, card.name, kUnnumberedPromoSetId, setNo, setNoRingPos_,
uniqueSetNos_.size());
}
void PokemonCardEditDialog::requestUnnumberedPreviewAsync(unsigned capturedEpoch,
std::string name,
std::string setId,
std::string setNo,
std::size_t ringIndex,
std::size_t ringCount) {
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), setNo = std::move(setNo), ringIndex,
ringCount]() {
auto bytes = svc->fetchPreviewBytes(Game::JapanesePokemon, name, setId, setNo);
std::string payload;
bool usedFallback = false;
if (bytes) {
payload = std::move(bytes).value();
} else {
auto fallback = svc->fetchImageBytesByUrl(kJapanesePokemonCardBackUrl);
if (fallback) {
payload = std::move(fallback).value();
usedFallback = true;
}
}
wxTheApp->CallAfter([state, self, capturedEpoch, payload = std::move(payload),
name, ringIndex, ringCount, usedFallback]() mutable {
if (!state->alive.load()) return;
if (capturedEpoch != self->previewFetchEpoch_) return;
if (self->unnumberedPreview_ == nullptr) return;
self->unnumberedPreview_->setImageBytes(payload);
if (usedFallback) {
self->unnumberedPreview_->setCaption(
wxString::Format("%s (%zu/%zu) — preview unavailable",
wxString::FromUTF8(name.c_str()),
ringIndex + 1, ringCount));
}
});
}).detach();
}
} // namespace ccm::ui
+70 -25
View File
@@ -31,18 +31,22 @@ void PokemonGameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
attemptedInitialSetLoad_ = true;
auto cached = sets_.getSets(Game::Pokemon);
if (cached) {
setsCache_ = std::move(cached).value();
if (!setsCache_.empty()) return;
} else {
setsCache_.clear();
}
auto loadOrRefresh = [this](Game game, std::vector<Set>& cache) {
auto cached = sets_.getSets(game);
if (cached) {
cache = std::move(cached).value();
if (!cache.empty()) return;
} else {
cache.clear();
}
auto refreshed = sets_.updateSets(game);
if (refreshed) {
cache = std::move(refreshed).value();
}
};
auto refreshed = sets_.updateSets(Game::Pokemon);
if (refreshed) {
setsCache_ = std::move(refreshed).value();
}
loadOrRefresh(Game::Pokemon, setsCacheWest_);
loadOrRefresh(Game::JapanesePokemon, setsCacheAsia_);
}
wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
@@ -81,13 +85,20 @@ void PokemonGameView::refreshCollection() {
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
const std::vector<Set>& PokemonGameView::setsForDialog() {
const std::vector<Set>& PokemonGameView::setsForDialog(PokemonRegion region) {
ensureSetsLoaded();
if (!setsCache_.empty()) return setsCache_;
if (region == PokemonRegion::Asia) {
if (!setsCacheAsia_.empty()) return setsCacheAsia_;
auto loaded = sets_.getSets(Game::JapanesePokemon);
if (loaded) setsCacheAsia_ = std::move(loaded).value();
else setsCacheAsia_.clear();
return setsCacheAsia_;
}
if (!setsCacheWest_.empty()) return setsCacheWest_;
auto loaded = sets_.getSets(Game::Pokemon);
if (loaded) setsCache_ = std::move(loaded).value();
else setsCache_.clear();
return setsCache_;
if (loaded) setsCacheWest_ = std::move(loaded).value();
else setsCacheWest_.clear();
return setsCacheWest_;
}
void PokemonGameView::onAddCard(wxWindow* parentWindow) {
@@ -98,11 +109,13 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
}
PokemonCard fresh;
fresh.amount = 1;
fresh.region = PokemonRegion::West;
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
&setsForDialog());
&setsForDialog(PokemonRegion::West),
&setsForDialog(PokemonRegion::Asia));
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
@@ -147,7 +160,8 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
return;
}
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
&setsForDialog());
&setsForDialog(PokemonRegion::West),
&setsForDialog(PokemonRegion::Asia));
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
@@ -181,15 +195,46 @@ void PokemonGameView::onDeleteCard(wxWindow* parentWindow) {
}
std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::Pokemon);
if (!out) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
"Error", wxOK | wxICON_ERROR);
auto westOut = sets_.updateSets(Game::Pokemon);
auto asiaOut = sets_.updateSets(Game::JapanesePokemon);
if (westOut) {
setsCacheWest_ = westOut.value();
}
if (asiaOut) {
setsCacheAsia_ = asiaOut.value();
}
if (!westOut && !asiaOut) {
showThemedMessageDialog(
parentWindow,
"Failed to update West sets: " + westOut.error() +
"\nFailed to update Asia sets: " + asiaOut.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
setsCache_ = out.value();
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Pokemon sets.",
"Sets updated", wxOK | wxICON_INFORMATION);
if (!westOut) {
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(asiaOut.value().size()) +
" Asia Pokemon sets, but West failed: " + westOut.error(),
"Sets partially updated", wxOK | wxICON_WARNING);
return "Pokemon sets partially updated.";
}
if (!asiaOut) {
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(westOut.value().size()) +
" West Pokemon sets, but Asia failed: " + asiaOut.error(),
"Sets partially updated", wxOK | wxICON_WARNING);
return "Pokemon sets partially updated.";
}
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(westOut.value().size()) + " West and " +
std::to_string(asiaOut.value().size()) + " Asia Pokemon sets.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Pokemon sets updated.";
}
+3
View File
@@ -11,6 +11,7 @@ enum PokemonDetailKey : int {
kName = 0,
kSet,
kSetNo,
kRegion,
kLanguage,
kCondition,
kAmount,
@@ -34,6 +35,7 @@ PokemonSelectedCardPanel::declareDetailRows() const {
{"Name", kName, "(no card selected)"},
{"Set", kSet, ""},
{"Set #", kSetNo, ""},
{"Region", kRegion, ""},
{"Language", kLanguage, ""},
{"Condition", kCondition, ""},
{"Amount", kAmount, ""},
@@ -56,6 +58,7 @@ std::string PokemonSelectedCardPanel::detailValueFor(const PokemonCard& card,
case kName: return card.name;
case kSet: return card.set.name;
case kSetNo: return card.setNo;
case kRegion: return std::string(to_string(card.region));
case kLanguage: return std::string(to_string(card.language));
case kCondition: return std::string(to_string(card.condition));
case kAmount: return std::to_string(card.amount);
+5 -4
View File
@@ -14,10 +14,11 @@ namespace {
wxString displayLabelForGame(Game g) {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "Yu-Gi-Oh!";
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "Yu-Gi-Oh!";
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
case Game::JapanesePokemon: return "Pokemon"; // internal; not in allGames()
}
return wxString::FromUTF8(to_string(g).data());
}
+176
View File
@@ -0,0 +1,176 @@
#include "ccm/ui/VariantImagePreviewDialog.hpp"
#include <wx/bitmap.h>
#include <wx/dcclient.h>
#include <wx/display.h>
#include <wx/log.h>
#include <wx/mstream.h>
#include <wx/sizer.h>
#include <algorithm>
namespace ccm::ui {
wxDEFINE_EVENT(EVT_VARIANT_PREVIEW_PREV, wxCommandEvent);
wxDEFINE_EVENT(EVT_VARIANT_PREVIEW_NEXT, wxCommandEvent);
class VariantImagePreviewDialog::ImageCanvas : public wxPanel {
public:
explicit ImageCanvas(wxWindow* parent) : wxPanel(parent, wxID_ANY) {
SetBackgroundStyle(wxBG_STYLE_PAINT);
Bind(wxEVT_PAINT, &ImageCanvas::onPaint, this);
Bind(wxEVT_SIZE, [this](wxSizeEvent& ev) {
Refresh();
ev.Skip();
});
}
void setImage(const wxImage& img) {
original_ = img;
cachedScaled_ = wxBitmap();
cachedScaledFor_ = wxSize(-1, -1);
Refresh();
}
void clear() {
original_ = wxImage();
cachedScaled_ = wxBitmap();
cachedScaledFor_ = wxSize(-1, -1);
Refresh();
}
private:
void onPaint(wxPaintEvent&) {
wxPaintDC dc(this);
dc.Clear();
if (!original_.IsOk()) return;
const wxSize ws = GetClientSize();
if (ws.GetWidth() <= 0 || ws.GetHeight() <= 0) return;
const double scale = std::min(
static_cast<double>(ws.GetWidth()) / original_.GetWidth(),
static_cast<double>(ws.GetHeight()) / original_.GetHeight());
const int w = std::max(1, static_cast<int>(original_.GetWidth() * scale));
const int h = std::max(1, static_cast<int>(original_.GetHeight() * scale));
const wxSize scaledSize(w, h);
if (!cachedScaled_.IsOk() || cachedScaledFor_ != scaledSize) {
wxImage scaled = original_.Scale(w, h, wxIMAGE_QUALITY_HIGH);
cachedScaled_ = wxBitmap(scaled);
cachedScaledFor_ = scaledSize;
}
dc.DrawBitmap(cachedScaled_,
(ws.GetWidth() - w) / 2,
(ws.GetHeight() - h) / 2,
true);
}
wxImage original_;
wxBitmap cachedScaled_;
wxSize cachedScaledFor_{-1, -1};
};
VariantImagePreviewDialog::VariantImagePreviewDialog(wxWindow* parent)
: wxDialog(parent, wxID_ANY, "Print preview",
wxDefaultPosition, wxSize(280, 420),
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER | wxSTAY_ON_TOP) {
auto* root = new wxBoxSizer(wxVERTICAL);
imageHost_ = new ImageCanvas(this);
root->Add(imageHost_, 1, wxEXPAND | wxALL, 6);
caption_ = new wxStaticText(this, wxID_ANY, "");
root->Add(caption_, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 6);
auto* nav = new wxBoxSizer(wxHORIZONTAL);
prevButton_ = new wxButton(this, wxID_ANY, "<< Prev");
nextButton_ = new wxButton(this, wxID_ANY, "Next >>");
nav->Add(prevButton_, 0, wxRIGHT, 6);
nav->Add(nextButton_, 0);
root->Add(nav, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxBOTTOM, 6);
prevButton_->Bind(wxEVT_BUTTON, &VariantImagePreviewDialog::onPrev, this);
nextButton_->Bind(wxEVT_BUTTON, &VariantImagePreviewDialog::onNext, this);
SetSizer(root);
Layout();
repositionBesideParent();
}
void VariantImagePreviewDialog::repositionBesideParent() {
wxWindow* parent = GetParent();
if (parent == nullptr) return;
const wxRect parentScreen = parent->GetScreenRect();
const wxSize size = GetSize();
int x = parentScreen.GetRight() + 20;
int y = parentScreen.GetTop();
const int displayIdx = wxDisplay::GetFromWindow(parent);
if (displayIdx != wxNOT_FOUND) {
const wxRect work = wxDisplay(displayIdx).GetClientArea();
if (x + size.GetWidth() > work.GetRight()) {
x = std::max(work.GetLeft(), work.GetRight() - size.GetWidth());
}
if (y + size.GetHeight() > work.GetBottom()) {
y = std::max(work.GetTop(), work.GetBottom() - size.GetHeight());
}
if (x < work.GetLeft()) x = work.GetLeft();
if (y < work.GetTop()) y = work.GetTop();
}
SetPosition(wxPoint(x, y));
}
void VariantImagePreviewDialog::setCaption(const wxString& caption) {
if (caption_) {
caption_->SetLabelText(caption);
Layout();
}
}
void VariantImagePreviewDialog::setImageBytes(std::string_view bytes) {
if (!imageHost_) return;
if (bytes.empty()) {
clearImage();
return;
}
wxMemoryInputStream stream(bytes.data(), bytes.size());
wxImage img;
bool decoded = false;
{
wxLogNull suppressPngWarnings;
decoded = img.LoadFile(stream, wxBITMAP_TYPE_ANY);
}
if (!decoded || !img.IsOk()) {
clearImage();
return;
}
imageHost_->setImage(img);
}
void VariantImagePreviewDialog::clearImage() {
if (imageHost_) {
imageHost_->clear();
}
}
void VariantImagePreviewDialog::setNavigationEnabled(bool enabled) {
if (prevButton_) prevButton_->Enable(enabled);
if (nextButton_) nextButton_->Enable(enabled);
}
void VariantImagePreviewDialog::onPrev(wxCommandEvent&) {
wxCommandEvent ev(EVT_VARIANT_PREVIEW_PREV, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
}
void VariantImagePreviewDialog::onNext(wxCommandEvent&) {
wxCommandEvent ev(EVT_VARIANT_PREVIEW_NEXT, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
}
} // namespace ccm::ui