mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-09-04 17:23:29 +00:00
Minor: Add Asian Pokemon Card Support (#18)
This commit is contained in:
@@ -40,3 +40,6 @@ config.json
|
|||||||
configure.log
|
configure.log
|
||||||
build.log
|
build.log
|
||||||
test.log
|
test.log
|
||||||
|
|
||||||
|
# Offline ETL caches (large third-party extracts)
|
||||||
|
tools/pokemon_jp/_tcgdex_cards_database/
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- `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.
|
- `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
|
## Windows UI theming guardrails
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -5,14 +5,14 @@ The `ccm` executable — composition root only. The single place where concrete
|
|||||||
## File pointers
|
## File pointers
|
||||||
|
|
||||||
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
|
- `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
|
## 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.
|
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).
|
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.
|
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.
|
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).
|
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
|
## 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 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 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.
|
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
|
||||||
|
|||||||
+27
-3
@@ -19,13 +19,37 @@ target_link_libraries(ccm
|
|||||||
ccm_warnings
|
ccm_warnings
|
||||||
)
|
)
|
||||||
|
|
||||||
# Yu-Gi-Oh! / Digi-Battle preview fallback images (used when network card-back
|
# Yu-Gi-Oh! / Digi-Battle preview fallback images and the Japanese Pokémon
|
||||||
# URLs fail or no public URL exists).
|
# 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
|
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"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_classic"
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
|
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
|
||||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
|
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png"
|
"${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
@@ -9,6 +9,8 @@
|
|||||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||||
|
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||||
|
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||||
#include "ccm/infra/CprHttpClient.hpp"
|
#include "ccm/infra/CprHttpClient.hpp"
|
||||||
#include "ccm/infra/JsonCollectionRepository.hpp"
|
#include "ccm/infra/JsonCollectionRepository.hpp"
|
||||||
@@ -45,10 +47,11 @@ namespace {
|
|||||||
// need for the repositories to know about concrete game module classes.
|
// need for the repositories to know about concrete game module classes.
|
||||||
std::string dirNameForGame(ccm::Game g) {
|
std::string dirNameForGame(ccm::Game g) {
|
||||||
switch (g) {
|
switch (g) {
|
||||||
case ccm::Game::Magic: return "magic";
|
case ccm::Game::Magic: return "magic";
|
||||||
case ccm::Game::Pokemon: return "pokemon";
|
case ccm::Game::Pokemon: return "pokemon";
|
||||||
case ccm::Game::YuGiOh: return "yugioh";
|
case ccm::Game::YuGiOh: return "yugioh";
|
||||||
case ccm::Game::DigiBattle99: return "digibattle99";
|
case ccm::Game::DigiBattle99: return "digibattle99";
|
||||||
|
case ccm::Game::JapanesePokemon: return "pokemon";
|
||||||
}
|
}
|
||||||
return "magic";
|
return "magic";
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,17 @@ public:
|
|||||||
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
|
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
|
||||||
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*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>>(
|
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
|
||||||
*fs_, *config_, &dirNameForGame);
|
*fs_, *config_, &dirNameForGame);
|
||||||
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
|
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
|
||||||
@@ -113,6 +127,7 @@ public:
|
|||||||
setSvc_->registerModule(pokeMod_.get());
|
setSvc_->registerModule(pokeMod_.get());
|
||||||
setSvc_->registerModule(ygoMod_.get());
|
setSvc_->registerModule(ygoMod_.get());
|
||||||
setSvc_->registerModule(digiBattle99Mod_.get());
|
setSvc_->registerModule(digiBattle99Mod_.get());
|
||||||
|
setSvc_->registerModule(jpPokeMod_.get());
|
||||||
|
|
||||||
// Disk-backed preview cache lives next to the executable, in the same
|
// Disk-backed preview cache lives next to the executable, in the same
|
||||||
// location scope as config.json - NOT inside the user's data-storage
|
// location scope as config.json - NOT inside the user's data-storage
|
||||||
@@ -128,11 +143,16 @@ public:
|
|||||||
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
|
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
|
||||||
*fs_,
|
*fs_,
|
||||||
exeDir / ".cache" / "preview-cache");
|
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(*magicMod_);
|
||||||
previewSvc_->registerModule(*pokeMod_);
|
previewSvc_->registerModule(*pokeMod_);
|
||||||
previewSvc_->registerModule(*ygoMod_);
|
previewSvc_->registerModule(*ygoMod_);
|
||||||
previewSvc_->registerModule(*digiBattle99Mod_);
|
previewSvc_->registerModule(*digiBattle99Mod_);
|
||||||
|
previewSvc_->registerModule(*jpPokeMod_);
|
||||||
|
|
||||||
// Per-game UI bundles. Order here is the order shown in the Game menu.
|
// Per-game UI bundles. Order here is the order shown in the Game menu.
|
||||||
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
|
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
|
||||||
@@ -154,6 +174,7 @@ public:
|
|||||||
*pokeMod_,
|
*pokeMod_,
|
||||||
*ygoMod_,
|
*ygoMod_,
|
||||||
*digiBattle99Mod_,
|
*digiBattle99Mod_,
|
||||||
|
*jpPokeMod_,
|
||||||
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
|
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,6 +197,7 @@ private:
|
|||||||
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
|
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
|
||||||
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
|
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
|
||||||
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
|
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::MagicCard>> magicRepo_;
|
||||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
|
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
|
||||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
|
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
|
||||||
|
|||||||
+3
-3
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
## Layer pointers
|
## 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/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/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/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).
|
- `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.
|
- `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.
|
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.
|
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.
|
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
|
## Adding a new game
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ add_library(ccm_core STATIC
|
|||||||
src/domain/PokemonCard.cpp
|
src/domain/PokemonCard.cpp
|
||||||
src/domain/YuGiOhCard.cpp
|
src/domain/YuGiOhCard.cpp
|
||||||
src/domain/DigiBattle99Card.cpp
|
src/domain/DigiBattle99Card.cpp
|
||||||
|
src/domain/JapanesePokemonCard.cpp
|
||||||
src/domain/Configuration.cpp
|
src/domain/Configuration.cpp
|
||||||
|
|
||||||
src/services/ConfigService.cpp
|
src/services/ConfigService.cpp
|
||||||
@@ -35,6 +36,10 @@ add_library(ccm_core STATIC
|
|||||||
src/games/digibattle99/DigiBattle99SetSource.cpp
|
src/games/digibattle99/DigiBattle99SetSource.cpp
|
||||||
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
|
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
|
||||||
src/games/digibattle99/DigiBattle99GameModule.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
|
src/util/FsNames.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
@@ -20,6 +21,12 @@ enum class Game {
|
|||||||
Pokemon,
|
Pokemon,
|
||||||
YuGiOh,
|
YuGiOh,
|
||||||
DigiBattle99,
|
DigiBattle99,
|
||||||
|
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class PokemonRegion {
|
||||||
|
West,
|
||||||
|
Asia,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class Language {
|
enum class Language {
|
||||||
@@ -28,8 +35,10 @@ enum class Language {
|
|||||||
French,
|
French,
|
||||||
Spanish,
|
Spanish,
|
||||||
Italian,
|
Italian,
|
||||||
Chinese,
|
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
|
||||||
|
TraditionalChinese, // JSON / display: "T-Chinese"
|
||||||
Japanese,
|
Japanese,
|
||||||
|
Korean,
|
||||||
Russian,
|
Russian,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,24 +58,34 @@ enum class Theme {
|
|||||||
};
|
};
|
||||||
|
|
||||||
std::string_view to_string(Game g) noexcept;
|
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(Language l) noexcept;
|
||||||
std::string_view to_string(Condition c) noexcept;
|
std::string_view to_string(Condition c) noexcept;
|
||||||
std::string_view to_string(Theme t) noexcept;
|
std::string_view to_string(Theme t) noexcept;
|
||||||
|
|
||||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
|
||||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||||
std::optional<Theme> themeFromString(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<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<Condition, 7>& allConditions() noexcept;
|
||||||
const std::array<Theme, 2>& allThemes() 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.
|
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
|
||||||
void to_json(nlohmann::json& j, Game v);
|
void to_json(nlohmann::json& j, Game v);
|
||||||
void from_json(const 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 to_json(nlohmann::json& j, Language v);
|
||||||
void from_json(const nlohmann::json& j, Language& v);
|
void from_json(const nlohmann::json& j, Language& v);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// JapanesePokemonCard - Japanese Pokémon TCG collection model.
|
||||||
|
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||||
|
|
||||||
|
#include "ccm/domain/Enums.hpp"
|
||||||
|
#include "ccm/domain/Set.hpp"
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ccm {
|
||||||
|
|
||||||
|
struct JapanesePokemonCard {
|
||||||
|
std::uint32_t id{0};
|
||||||
|
std::uint8_t amount{1};
|
||||||
|
std::string name;
|
||||||
|
Set set;
|
||||||
|
std::string setNo;
|
||||||
|
std::string note;
|
||||||
|
std::vector<std::string> images;
|
||||||
|
Language language{Language::Japanese};
|
||||||
|
Condition condition{Condition::NearMint};
|
||||||
|
bool firstEdition{false};
|
||||||
|
bool holo{false};
|
||||||
|
bool signed_{false};
|
||||||
|
bool altered{false};
|
||||||
|
|
||||||
|
friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
void to_json(nlohmann::json& j, const JapanesePokemonCard& c);
|
||||||
|
void from_json(const nlohmann::json& j, JapanesePokemonCard& c);
|
||||||
|
|
||||||
|
} // namespace ccm
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
|
// 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/Enums.hpp"
|
||||||
#include "ccm/domain/Set.hpp"
|
#include "ccm/domain/Set.hpp"
|
||||||
@@ -28,6 +29,7 @@ struct PokemonCard {
|
|||||||
bool holo{false};
|
bool holo{false};
|
||||||
bool signed_{false};
|
bool signed_{false};
|
||||||
bool altered{false};
|
bool altered{false};
|
||||||
|
PokemonRegion region{PokemonRegion::West};
|
||||||
|
|
||||||
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public:
|
|||||||
// Implementations return a vector that has already been filtered
|
// Implementations return a vector that has already been filtered
|
||||||
// (e.g. no digital-only sets) and sorted by release date ascending.
|
// (e.g. no digital-only sets) and sorted by release date ascending.
|
||||||
virtual Result<std::vector<Set>> fetchAll() = 0;
|
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 {
|
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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#pragma once
|
#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/games/IGameModule.hpp"
|
||||||
#include "ccm/ports/IFileSystem.hpp"
|
#include "ccm/ports/IFileSystem.hpp"
|
||||||
@@ -27,6 +29,8 @@ private:
|
|||||||
DirNameFn dirName_;
|
DirNameFn dirName_;
|
||||||
|
|
||||||
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
|
[[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
|
} // namespace ccm
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#pragma once
|
#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.
|
// Stored as a flat list to mirror the original Rust file layout.
|
||||||
|
|
||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
// `.includes("")` returns true.
|
// `.includes("")` returns true.
|
||||||
|
|
||||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||||
|
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||||
#include "ccm/domain/MagicCard.hpp"
|
#include "ccm/domain/MagicCard.hpp"
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/domain/YuGiOhCard.hpp"
|
#include "ccm/domain/YuGiOhCard.hpp"
|
||||||
@@ -35,7 +36,7 @@ namespace ccm {
|
|||||||
std::string_view filter);
|
std::string_view filter);
|
||||||
|
|
||||||
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
|
// 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.
|
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||||
std::string_view filter);
|
std::string_view filter);
|
||||||
@@ -46,4 +47,8 @@ namespace ccm {
|
|||||||
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
|
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
|
||||||
std::string_view filter);
|
std::string_view filter);
|
||||||
|
|
||||||
|
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
|
||||||
|
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||||
|
std::string_view filter);
|
||||||
|
|
||||||
} // namespace ccm
|
} // namespace ccm
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
#include "ccm/games/IGameModule.hpp"
|
#include "ccm/games/IGameModule.hpp"
|
||||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||||
|
#include "ccm/ports/IFileSystem.hpp"
|
||||||
#include "ccm/ports/IHttpClient.hpp"
|
#include "ccm/ports/IHttpClient.hpp"
|
||||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||||
#include "ccm/util/Result.hpp"
|
#include "ccm/util/Result.hpp"
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <filesystem>
|
||||||
#include <list>
|
#include <list>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -32,7 +34,9 @@ namespace ccm {
|
|||||||
class CardPreviewService {
|
class CardPreviewService {
|
||||||
public:
|
public:
|
||||||
explicit CardPreviewService(IHttpClient& http,
|
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
|
// Register a game module's preview source. Calling this with a module
|
||||||
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
||||||
@@ -96,6 +100,9 @@ private:
|
|||||||
|
|
||||||
Result<std::string> fetchAndCache(const std::string& cacheKey,
|
Result<std::string> fetchAndCache(const std::string& cacheKey,
|
||||||
std::string_view url);
|
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
|
// Returns the kind of in-memory cache entry for `key`. On Hit the
|
||||||
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
|
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
|
||||||
@@ -107,6 +114,8 @@ private:
|
|||||||
|
|
||||||
IHttpClient& http_;
|
IHttpClient& http_;
|
||||||
IPreviewByteCache* persistentCache_{nullptr};
|
IPreviewByteCache* persistentCache_{nullptr};
|
||||||
|
IFileSystem* fs_{nullptr};
|
||||||
|
std::filesystem::path assetRoot_;
|
||||||
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
||||||
|
|
||||||
// LRU: list holds entries in MRU-first order; map points at list nodes
|
// LRU: list holds entries in MRU-first order; map points at list nodes
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
||||||
|
|
||||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||||
|
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||||
#include "ccm/domain/MagicCard.hpp"
|
#include "ccm/domain/MagicCard.hpp"
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/domain/YuGiOhCard.hpp"
|
#include "ccm/domain/YuGiOhCard.hpp"
|
||||||
@@ -81,6 +82,20 @@ enum class DigiBattle99SortColumn {
|
|||||||
Note,
|
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
|
// Stable in-place sort. `ascending=false` runs the same comparator with
|
||||||
// inverted sign, matching `byField(field, asc)` semantics.
|
// inverted sign, matching `byField(field, asc)` semantics.
|
||||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
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,
|
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||||
DigiBattle99SortColumn column,
|
DigiBattle99SortColumn column,
|
||||||
bool ascending);
|
bool ascending);
|
||||||
|
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||||
|
JapanesePokemonSortColumn column,
|
||||||
|
bool ascending);
|
||||||
|
|
||||||
} // namespace ccm
|
} // namespace ccm
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) {
|
|||||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||||
j.at("dataStorage").get_to(c.dataStorage);
|
j.at("dataStorage").get_to(c.dataStorage);
|
||||||
j.at("defaultGame").get_to(c.defaultGame);
|
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);
|
c.theme = j.value("theme", Theme::Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-31
@@ -13,24 +13,35 @@ namespace ccm {
|
|||||||
|
|
||||||
std::string_view to_string(Game g) noexcept {
|
std::string_view to_string(Game g) noexcept {
|
||||||
switch (g) {
|
switch (g) {
|
||||||
case Game::Magic: return "Magic";
|
case Game::Magic: return "Magic";
|
||||||
case Game::Pokemon: return "Pokemon";
|
case Game::Pokemon: return "Pokemon";
|
||||||
case Game::YuGiOh: return "YuGiOh";
|
case Game::YuGiOh: return "YuGiOh";
|
||||||
case Game::DigiBattle99: return "DigiBattle99";
|
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();
|
CCM_UNREACHABLE();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string_view to_string(Language l) noexcept {
|
std::string_view to_string(Language l) noexcept {
|
||||||
switch (l) {
|
switch (l) {
|
||||||
case Language::English: return "English";
|
case Language::English: return "English";
|
||||||
case Language::German: return "German";
|
case Language::German: return "German";
|
||||||
case Language::French: return "French";
|
case Language::French: return "French";
|
||||||
case Language::Spanish: return "Spanish";
|
case Language::Spanish: return "Spanish";
|
||||||
case Language::Italian: return "Italian";
|
case Language::Italian: return "Italian";
|
||||||
case Language::Chinese: return "Chinese";
|
case Language::SimplifiedChinese: return "S-Chinese";
|
||||||
case Language::Japanese: return "Japanese";
|
case Language::TraditionalChinese: return "T-Chinese";
|
||||||
case Language::Russian: return "Russian";
|
case Language::Japanese: return "Japanese";
|
||||||
|
case Language::Korean: return "Korean";
|
||||||
|
case Language::Russian: return "Russian";
|
||||||
}
|
}
|
||||||
CCM_UNREACHABLE();
|
CCM_UNREACHABLE();
|
||||||
}
|
}
|
||||||
@@ -57,22 +68,33 @@ std::string_view to_string(Theme t) noexcept {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||||
if (s == "Magic") return Game::Magic;
|
if (s == "Magic") return Game::Magic;
|
||||||
if (s == "Pokemon") return Game::Pokemon;
|
if (s == "Pokemon") return Game::Pokemon;
|
||||||
if (s == "YuGiOh") return Game::YuGiOh;
|
if (s == "YuGiOh") return Game::YuGiOh;
|
||||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
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;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||||
if (s == "English") return Language::English;
|
if (s == "English") return Language::English;
|
||||||
if (s == "German") return Language::German;
|
if (s == "German") return Language::German;
|
||||||
if (s == "French") return Language::French;
|
if (s == "French") return Language::French;
|
||||||
if (s == "Spanish") return Language::Spanish;
|
if (s == "Spanish") return Language::Spanish;
|
||||||
if (s == "Italian") return Language::Italian;
|
if (s == "Italian") return Language::Italian;
|
||||||
if (s == "Chinese") return Language::Chinese;
|
if (s == "S-Chinese") return Language::SimplifiedChinese;
|
||||||
if (s == "Japanese") return Language::Japanese;
|
if (s == "T-Chinese") return Language::TraditionalChinese;
|
||||||
if (s == "Russian") return Language::Russian;
|
// 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;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +121,11 @@ const std::array<Game, 4>& allGames() noexcept {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::array<Language, 8>& allLanguages() noexcept {
|
const std::array<Language, 10>& allLanguages() noexcept {
|
||||||
static constexpr std::array<Language, 8> v{
|
static constexpr std::array<Language, 10> v{
|
||||||
Language::English, Language::German, Language::French, Language::Spanish,
|
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;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -120,16 +143,45 @@ const std::array<Theme, 2>& allThemes() noexcept {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
|
||||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
static constexpr std::array<Language, 6> kWest{
|
||||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
Language::English, Language::German, Language::French,
|
||||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
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) {
|
void from_json(const nlohmann::json& j, Game& v) {
|
||||||
auto parsed = gameFromString(j.get<std::string>());
|
auto parsed = gameFromString(j.get<std::string>());
|
||||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||||
v = *parsed;
|
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) {
|
void from_json(const nlohmann::json& j, Language& v) {
|
||||||
auto parsed = languageFromString(j.get<std::string>());
|
auto parsed = languageFromString(j.get<std::string>());
|
||||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||||
|
|
||||||
|
namespace ccm {
|
||||||
|
|
||||||
|
void to_json(nlohmann::json& j, const JapanesePokemonCard& c) {
|
||||||
|
j = nlohmann::json{
|
||||||
|
{"id", c.id},
|
||||||
|
{"amount", c.amount},
|
||||||
|
{"name", c.name},
|
||||||
|
{"set", c.set},
|
||||||
|
{"setNo", c.setNo},
|
||||||
|
{"note", c.note},
|
||||||
|
{"images", c.images},
|
||||||
|
{"language", c.language},
|
||||||
|
{"condition", c.condition},
|
||||||
|
{"firstEdition", c.firstEdition},
|
||||||
|
{"holo", c.holo},
|
||||||
|
{"signed", c.signed_},
|
||||||
|
{"altered", c.altered},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void from_json(const nlohmann::json& j, JapanesePokemonCard& c) {
|
||||||
|
j.at("id").get_to(c.id);
|
||||||
|
j.at("amount").get_to(c.amount);
|
||||||
|
j.at("name").get_to(c.name);
|
||||||
|
j.at("set").get_to(c.set);
|
||||||
|
j.at("setNo").get_to(c.setNo);
|
||||||
|
j.at("note").get_to(c.note);
|
||||||
|
j.at("images").get_to(c.images);
|
||||||
|
j.at("language").get_to(c.language);
|
||||||
|
j.at("condition").get_to(c.condition);
|
||||||
|
j.at("firstEdition").get_to(c.firstEdition);
|
||||||
|
j.at("holo").get_to(c.holo);
|
||||||
|
j.at("signed").get_to(c.signed_);
|
||||||
|
j.at("altered").get_to(c.altered);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ccm
|
||||||
@@ -17,6 +17,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
|
|||||||
{"holo", c.holo},
|
{"holo", c.holo},
|
||||||
{"signed", c.signed_},
|
{"signed", c.signed_},
|
||||||
{"altered", c.altered},
|
{"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("holo").get_to(c.holo);
|
||||||
j.at("signed").get_to(c.signed_);
|
j.at("signed").get_to(c.signed_);
|
||||||
j.at("altered").get_to(c.altered);
|
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
|
} // 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 0xE3–0xE9).
|
||||||
|
// Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK —
|
||||||
|
// that used to wipe catalog English names back to the set id.
|
||||||
|
for (unsigned char ch : s) {
|
||||||
|
if (ch >= 0xE3 && ch <= 0xE9) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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
|
||||||
@@ -12,24 +12,59 @@ JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, Dir
|
|||||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||||
|
|
||||||
fs::path JsonSetRepository::setsPath(Game game) const {
|
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) {
|
fs::path JsonSetRepository::legacySetsPath(Game game) const {
|
||||||
const auto p = setsPath(game);
|
const fs::path dataRoot(config_.current().dataStorage);
|
||||||
if (!fs_.exists(p)) {
|
switch (game) {
|
||||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this 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 {
|
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>>());
|
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
|
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) {
|
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
|
||||||
const auto p = setsPath(game);
|
const auto p = setsPath(game);
|
||||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
|||||||
if (containsLower(to_string(card.condition), needle)) return true;
|
if (containsLower(to_string(card.condition), needle)) return true;
|
||||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||||
if (containsLower(card.note, needle)) return true;
|
if (containsLower(card.note, needle)) return true;
|
||||||
|
if (containsLower(to_string(card.region), needle)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +83,20 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi
|
|||||||
return false;
|
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
|
} // namespace ccm
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "ccm/services/CardPreviewService.hpp"
|
#include "ccm/services/CardPreviewService.hpp"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -36,11 +37,18 @@ std::string makeUrlKey(std::string_view url) {
|
|||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constexpr std::string_view kAssetScheme = "asset:";
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
CardPreviewService::CardPreviewService(IHttpClient& http,
|
CardPreviewService::CardPreviewService(IHttpClient& http,
|
||||||
IPreviewByteCache* persistentCache)
|
IPreviewByteCache* persistentCache,
|
||||||
: http_(http), persistentCache_(persistentCache) {}
|
IFileSystem* fs,
|
||||||
|
std::filesystem::path assetRoot)
|
||||||
|
: http_(http),
|
||||||
|
persistentCache_(persistentCache),
|
||||||
|
fs_(fs),
|
||||||
|
assetRoot_(std::move(assetRoot)) {}
|
||||||
|
|
||||||
void CardPreviewService::registerModule(IGameModule& module) {
|
void CardPreviewService::registerModule(IGameModule& module) {
|
||||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
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));
|
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,
|
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||||
std::string_view name,
|
std::string_view name,
|
||||||
std::string_view setId,
|
std::string_view setId,
|
||||||
@@ -179,6 +218,18 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
|||||||
}
|
}
|
||||||
return Result<std::string>::err(err.message);
|
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());
|
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);
|
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);
|
return fetchAndCache(key, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
} // namespace ccm
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Result<std::vector<Set>> SetService::getSets(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
|
} // namespace ccm
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- `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.
|
- `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.
|
- `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).
|
- `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.
|
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ private:
|
|||||||
|
|
||||||
Two subtle requirements:
|
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".
|
- `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`.
|
The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
|
|||||||
|
|
||||||
## Pokemon APIs
|
## 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`
|
**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.
|
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`.
|
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 1–3 |
|
||||||
|
| `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 ring’s 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).
|
||||||
|
- **neo1–neo4**: 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 (PMCG1–PMCG6, neo1–neo4, VS1, web1, E1–E3). 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 neo1–neo4 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
|
## Runtime Flow In CCM3
|
||||||
|
|
||||||
The app uses the same flow for every game that registers a module:
|
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.
|
- `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` 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.
|
- `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
|
### 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.
|
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.
|
- **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview.
|
||||||
- **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
|
- **Persistent 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`
|
- 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`
|
- 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.
|
- 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).
|
- 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
|
- 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).
|
- 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 Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `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.
|
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `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.
|
||||||
|
|||||||
@@ -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`.
|
`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 compiler’s `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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
- `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake).
|
- `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake).
|
||||||
- `collection_service_tests.cpp` — `CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
|
- `collection_service_tests.cpp` — `CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
|
||||||
- `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`.
|
- `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/`.
|
- `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`.
|
- `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`.
|
||||||
- `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
|
- `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ add_executable(ccm_core_tests
|
|||||||
pokemon_card_preview_source_tests.cpp
|
pokemon_card_preview_source_tests.cpp
|
||||||
digibattle99_set_source_tests.cpp
|
digibattle99_set_source_tests.cpp
|
||||||
digibattle99_card_preview_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
|
icard_preview_source_tests.cpp
|
||||||
yugioh_set_source_tests.cpp
|
yugioh_set_source_tests.cpp
|
||||||
yugioh_set_lookup_tests.cpp
|
yugioh_set_lookup_tests.cpp
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||||
|
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||||
#include "ccm/domain/MagicCard.hpp"
|
#include "ccm/domain/MagicCard.hpp"
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/domain/YuGiOhCard.hpp"
|
#include "ccm/domain/YuGiOhCard.hpp"
|
||||||
@@ -186,6 +187,15 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
|
|||||||
TEST_CASE("empty filter matches everything") {
|
TEST_CASE("empty filter matches everything") {
|
||||||
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
|
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") {
|
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
|
||||||
@@ -276,3 +286,40 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
|
|||||||
CHECK(matchesDigiBattle99Filter(c, "agu"));
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
#include "ccm/games/IGameModule.hpp"
|
#include "ccm/games/IGameModule.hpp"
|
||||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||||
|
#include "ccm/ports/IFileSystem.hpp"
|
||||||
#include "ccm/ports/IHttpClient.hpp"
|
#include "ccm/ports/IHttpClient.hpp"
|
||||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||||
#include "ccm/services/CardPreviewService.hpp"
|
#include "ccm/services/CardPreviewService.hpp"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -130,6 +132,50 @@ public:
|
|||||||
void storeNegative(std::string_view) override {}
|
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.
|
// Minimal IGameModule fake that exposes a configurable preview source.
|
||||||
class FakeGameModule final : public IGameModule {
|
class FakeGameModule final : public IGameModule {
|
||||||
public:
|
public:
|
||||||
@@ -232,6 +278,26 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
|
|||||||
CHECK(out.isErr());
|
CHECK(out.isErr());
|
||||||
CHECK(out.error() == "net down");
|
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") {
|
TEST_SUITE("CardPreviewService caching") {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||||
|
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||||
#include "ccm/domain/MagicCard.hpp"
|
#include "ccm/domain/MagicCard.hpp"
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/domain/YuGiOhCard.hpp"
|
#include "ccm/domain/YuGiOhCard.hpp"
|
||||||
@@ -119,6 +120,30 @@ DigiBattle99Card db(std::uint32_t id, std::string name,
|
|||||||
return c;
|
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> ids(const std::vector<MagicCard>& v) {
|
||||||
std::vector<std::uint32_t> out;
|
std::vector<std::uint32_t> out;
|
||||||
out.reserve(v.size());
|
out.reserve(v.size());
|
||||||
@@ -147,6 +172,13 @@ std::vector<std::uint32_t> ids(const std::vector<DigiBattle99Card>& v) {
|
|||||||
return out;
|
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
|
} // namespace
|
||||||
|
|
||||||
TEST_SUITE("CardSorter - Magic columns") {
|
TEST_SUITE("CardSorter - Magic columns") {
|
||||||
@@ -515,3 +547,35 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") {
|
|||||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "ccm/domain/Configuration.hpp"
|
#include "ccm/domain/Configuration.hpp"
|
||||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||||
#include "ccm/domain/Enums.hpp"
|
#include "ccm/domain/Enums.hpp"
|
||||||
|
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||||
#include "ccm/domain/MagicCard.hpp"
|
#include "ccm/domain/MagicCard.hpp"
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/domain/YuGiOhCard.hpp"
|
#include "ccm/domain/YuGiOhCard.hpp"
|
||||||
@@ -27,6 +28,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
|
|||||||
nlohmann::json jDigi = "DigiBattle99";
|
nlohmann::json jDigi = "DigiBattle99";
|
||||||
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
|
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
|
||||||
|
|
||||||
|
nlohmann::json jJp = "JapanesePokemon";
|
||||||
|
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
|
||||||
|
|
||||||
nlohmann::json j3 = Theme::Dark;
|
nlohmann::json j3 = Theme::Dark;
|
||||||
CHECK(j3.get<std::string>() == "Dark");
|
CHECK(j3.get<std::string>() == "Dark");
|
||||||
CHECK(j3.get<Theme>() == Theme::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<std::string>() == "Japanese");
|
||||||
CHECK(l.get<Language>() == Language::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;
|
nlohmann::json c = Condition::LightPlayed;
|
||||||
CHECK(c.get<std::string>() == "LightPlayed");
|
CHECK(c.get<std::string>() == "LightPlayed");
|
||||||
CHECK(c.get<Condition>() == Condition::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") {
|
TEST_CASE("invalid enum string throws") {
|
||||||
nlohmann::json bad = "Spanglish";
|
nlohmann::json bad = "Spanglish";
|
||||||
CHECK_THROWS(bad.get<Language>());
|
CHECK_THROWS(bad.get<Language>());
|
||||||
@@ -146,15 +184,37 @@ TEST_SUITE("PokemonCard JSON") {
|
|||||||
c.holo = true;
|
c.holo = true;
|
||||||
c.signed_ = false;
|
c.signed_ = false;
|
||||||
c.altered = false;
|
c.altered = false;
|
||||||
|
c.region = PokemonRegion::West;
|
||||||
|
|
||||||
nlohmann::json j = c;
|
nlohmann::json j = c;
|
||||||
CHECK(j.at("setNo") == "4/102");
|
CHECK(j.at("setNo") == "4/102");
|
||||||
CHECK(j.at("firstEdition") == true);
|
CHECK(j.at("firstEdition") == true);
|
||||||
CHECK(j.at("signed") == false);
|
CHECK(j.at("signed") == false);
|
||||||
|
CHECK(j.at("region") == "West");
|
||||||
|
|
||||||
const PokemonCard back = j.get<PokemonCard>();
|
const PokemonCard back = j.get<PokemonCard>();
|
||||||
CHECK(back == c);
|
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") {
|
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_SUITE("Configuration JSON matches Rust serde aliases") {
|
||||||
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
|
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
|
||||||
Configuration cfg;
|
Configuration cfg;
|
||||||
@@ -201,6 +289,16 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
|
|||||||
CHECK(back == cfg);
|
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") {
|
TEST_CASE("missing theme key defaults to Light") {
|
||||||
const nlohmann::json j = {
|
const nlohmann::json j = {
|
||||||
{"dataStorage", "/portable/data"},
|
{"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") {
|
TEST_CASE("Configuration missing required key throws") {
|
||||||
const nlohmann::json j = {
|
const nlohmann::json j = {
|
||||||
{"defaultGame", "Magic"},
|
{"defaultGame", "Magic"},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||||
|
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||||
#include "ccm/ports/IHttpClient.hpp"
|
#include "ccm/ports/IHttpClient.hpp"
|
||||||
|
|
||||||
@@ -63,4 +64,15 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
|||||||
CHECK(module.cardPreviewSource() != nullptr);
|
CHECK(module.cardPreviewSource() != nullptr);
|
||||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,13 +143,57 @@ TEST_SUITE("JsonSetRepository") {
|
|||||||
CHECK(writeFail.error() == "write failed");
|
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;
|
InMemoryFileSystem fs;
|
||||||
auto cfg = makeConfig(fs, "/data");
|
auto cfg = makeConfig(fs, "/data");
|
||||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
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());
|
REQUIRE(repo.save(Game::Pokemon, west).isOk());
|
||||||
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ std::string dirNameForGame(Game g) {
|
|||||||
case Game::Pokemon: return "pokemon";
|
case Game::Pokemon: return "pokemon";
|
||||||
case Game::YuGiOh: return "yugioh";
|
case Game::YuGiOh: return "yugioh";
|
||||||
case Game::DigiBattle99: return "digibattle99";
|
case Game::DigiBattle99: return "digibattle99";
|
||||||
|
case Game::JapanesePokemon: return "pokemon";
|
||||||
}
|
}
|
||||||
return "magic";
|
return "magic";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public:
|
|||||||
std::string dirName() const override {
|
std::string dirName() const override {
|
||||||
if (gameId == Game::Magic) return "magic";
|
if (gameId == Game::Magic) return "magic";
|
||||||
if (gameId == Game::Pokemon) return "pokemon";
|
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";
|
return "yugioh";
|
||||||
}
|
}
|
||||||
std::string displayName() const override { return dirName(); }
|
std::string displayName() const override { return dirName(); }
|
||||||
@@ -179,6 +182,41 @@ TEST_SUITE("SetService") {
|
|||||||
CHECK(digi.source.calls == 1);
|
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") {
|
TEST_CASE("updateSets propagates repository save failures") {
|
||||||
InMemSetRepo repo;
|
InMemSetRepo repo;
|
||||||
repo.failSave = true;
|
repo.failSave = true;
|
||||||
|
|||||||
@@ -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 neo1–neo4
|
||||||
|
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 neo1–neo4 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 neo1–neo4 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
|
||||||
|
```
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
#REDIRECT [[Fossil (TCG)]]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fill neo1–neo4 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()
|
||||||
@@ -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()
|
||||||
@@ -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 δ / & 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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("'", "'")
|
||||||
|
.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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -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/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/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/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/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, 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/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/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 ~50–100 lines of hook overrides on top of the matching base template.
|
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 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`).
|
- `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`.
|
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
|
||||||
15. **Preview fallback behavior (CCM2 parity where applicable):**
|
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.
|
- 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.
|
- 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:**
|
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.
|
- Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ add_library(ccm_ui_wx STATIC
|
|||||||
src/SettingsDialog.cpp
|
src/SettingsDialog.cpp
|
||||||
src/SwitchCtrl.cpp
|
src/SwitchCtrl.cpp
|
||||||
src/ImageViewerDialog.cpp
|
src/ImageViewerDialog.cpp
|
||||||
|
src/VariantImagePreviewDialog.cpp
|
||||||
src/IconListCtrl.cpp
|
src/IconListCtrl.cpp
|
||||||
src/SvgIcons.cpp
|
src/SvgIcons.cpp
|
||||||
src/Theme.cpp
|
src/Theme.cpp
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ struct AppContext {
|
|||||||
IGameModule& pokemonModule;
|
IGameModule& pokemonModule;
|
||||||
IGameModule& yuGiOhModule;
|
IGameModule& yuGiOhModule;
|
||||||
IGameModule& digiBattle99Module;
|
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
|
// Active per-game UI bundles. The order is the order shown in the
|
||||||
// Game menu; the composition root constructs them and hands raw
|
// Game menu; the composition root constructs them and hands raw
|
||||||
// pointers in. `MainFrame` does not own these — `app/main.cpp` does.
|
// pointers in. `MainFrame` does not own these — `app/main.cpp` does.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// BaseCardEditDialog<TCard>
|
// BaseCardEditDialog<TCard>
|
||||||
//
|
//
|
||||||
// Header-only template for the modal create/edit form. Owns the parts every
|
// 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
|
// Amount spin, Language and Condition choices, Note, Image list with
|
||||||
// Add/Remove/double-click-to-view, OK/Cancel — and exposes hooks the
|
// Add/Remove/double-click-to-view, OK/Cancel — and exposes hooks the
|
||||||
// subclass uses to:
|
// subclass uses to:
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -102,6 +104,9 @@ protected:
|
|||||||
// wants; the base only owns the surrounding label.
|
// wants; the base only owns the surrounding label.
|
||||||
virtual void buildFlagsRow(wxBoxSizer* flagsBox) = 0;
|
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
|
// Subclass adds any extra game-specific labelled rows just below the
|
||||||
// standard rows but above the Note row, by calling `appendRow(label, ctrl)`
|
// standard rows but above the Note row, by calling `appendRow(label, ctrl)`
|
||||||
// (provided as a parameter). Default does nothing.
|
// (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_`.
|
// Subclass copies the extra fields it owns from its widgets back into `card_`.
|
||||||
virtual void writeExtraToCard() {}
|
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"; }
|
[[nodiscard]] virtual std::string updateMenuName() const { return "Update Sets"; }
|
||||||
|
|
||||||
// Display name passed into errors and the dialog title hints.
|
// Display name passed into errors and the dialog title hints.
|
||||||
@@ -151,6 +161,12 @@ protected:
|
|||||||
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
|
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.
|
// Default: combo only. Yu-Gi-Oh! overrides to add set-code entry + toggle.
|
||||||
virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) {
|
virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) {
|
||||||
row.Add(combo, 1, wxEXPAND);
|
row.Add(combo, 1, wxEXPAND);
|
||||||
@@ -193,6 +209,9 @@ private:
|
|||||||
});
|
});
|
||||||
appendRow(grid, "Name", nameCtrl_);
|
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
|
// `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
|
// 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.
|
// dialog while the sizer lives on `setHost`, which corrupts layout on MSW.
|
||||||
@@ -311,9 +330,10 @@ private:
|
|||||||
languageChoice_->Clear();
|
languageChoice_->Clear();
|
||||||
int langIdx = 0;
|
int langIdx = 0;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
const auto langsForChoice = languagesForChoice();
|
||||||
wxArrayString langs;
|
wxArrayString langs;
|
||||||
langs.Alloc(allLanguages().size());
|
langs.Alloc(langsForChoice.size());
|
||||||
for (auto l : allLanguages()) {
|
for (auto l : langsForChoice) {
|
||||||
const std::string lang = std::string(to_string(l));
|
const std::string lang = std::string(to_string(l));
|
||||||
langs.Add(wxString::FromUTF8(lang.c_str()));
|
langs.Add(wxString::FromUTF8(lang.c_str()));
|
||||||
if (l == card_.language) langIdx = i;
|
if (l == card_.language) langIdx = i;
|
||||||
@@ -486,20 +506,43 @@ private:
|
|||||||
#endif
|
#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() {
|
void applySetTypeaheadSelection() {
|
||||||
const auto& available = availableSets();
|
const auto& available = availableSets();
|
||||||
if (!setCombo_ || available.empty()) return;
|
if (!setCombo_ || available.empty()) return;
|
||||||
wxString pref = setTypeaheadPrefix_;
|
const wxString pref = foldAsciiForTypeahead(setTypeaheadPrefix_);
|
||||||
pref.MakeLower();
|
|
||||||
if (pref.empty()) return;
|
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) {
|
for (std::size_t i = 0; i < available.size(); ++i) {
|
||||||
wxString name(wxString::FromUTF8(available[i].name));
|
const wxString name =
|
||||||
name.MakeLower();
|
foldAsciiForTypeahead(wxString::FromUTF8(available[i].name));
|
||||||
if (name.StartsWith(pref)) {
|
if (name.StartsWith(pref)) {
|
||||||
setCombo_->SetSelection(static_cast<int>(i));
|
setCombo_->SetSelection(static_cast<int>(i));
|
||||||
return;
|
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) {
|
void onSetComboChar(wxKeyEvent& ev) {
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ protected:
|
|||||||
|
|
||||||
[[nodiscard]] virtual Game gameId() const noexcept = 0;
|
[[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 ------------------------------------------------------------
|
// Construction ------------------------------------------------------------
|
||||||
|
|
||||||
BaseSelectedCardPanel(wxWindow* parent,
|
BaseSelectedCardPanel(wxWindow* parent,
|
||||||
@@ -292,6 +298,9 @@ private:
|
|||||||
case Game::Pokemon:
|
case Game::Pokemon:
|
||||||
// Mirrors CCM2's unresolved-preview fallback image.
|
// Mirrors CCM2's unresolved-preview fallback image.
|
||||||
return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg";
|
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:
|
case Game::YuGiOh:
|
||||||
// Yugipedia English TCG backing (thumbnail — smaller than full scan).
|
// Yugipedia English TCG backing (thumbnail — smaller than full scan).
|
||||||
return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png";
|
return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png";
|
||||||
@@ -379,7 +388,7 @@ private:
|
|||||||
auto state = state_;
|
auto state = state_;
|
||||||
CardPreviewService* svcPtr = &cardPreview_;
|
CardPreviewService* svcPtr = &cardPreview_;
|
||||||
auto [name, setId, setNo] = previewKey(card);
|
auto [name, setId, setNo] = previewKey(card);
|
||||||
const Game game = gameId();
|
const Game game = previewGameFor(card);
|
||||||
const std::string exeDirCopy = exeDirForBundledAssets_;
|
const std::string exeDirCopy = exeDirForBundledAssets_;
|
||||||
|
|
||||||
std::thread([state, gen, svcPtr, name = std::move(name),
|
std::thread([state, gen, svcPtr, name = std::move(name),
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// PokemonCardEditDialog: typed Add/Edit form for a `PokemonCard`. Inherits
|
// PokemonCardEditDialog: Add/Edit form for a unified West/Asia PokemonCard.
|
||||||
// the shared layout, set picker, and image management from
|
// West/Asia switch drives set lists, language choices, preview APIs, and the
|
||||||
// `BaseCardEditDialog<PokemonCard>` and adds:
|
// Asia-only UnnumberedPromo print UX (from the former Japanese dialog).
|
||||||
// - a `Set #` text input (between the Set picker and the Amount spin)
|
|
||||||
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
|
|
||||||
|
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||||
#include "ccm/services/CardPreviewService.hpp"
|
#include "ccm/services/CardPreviewService.hpp"
|
||||||
#include "ccm/ui/BaseCardEditDialog.hpp"
|
#include "ccm/ui/BaseCardEditDialog.hpp"
|
||||||
|
#include "ccm/ui/SwitchCtrl.hpp"
|
||||||
|
|
||||||
#include <wx/button.h>
|
#include <wx/button.h>
|
||||||
|
#include <wx/stattext.h>
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace ccm::ui {
|
namespace ccm::ui {
|
||||||
|
|
||||||
|
class VariantImagePreviewDialog;
|
||||||
|
|
||||||
class PokemonCardEditDialog final : public BaseCardEditDialog<PokemonCard> {
|
class PokemonCardEditDialog final : public BaseCardEditDialog<PokemonCard> {
|
||||||
public:
|
public:
|
||||||
PokemonCardEditDialog(wxWindow* parent,
|
PokemonCardEditDialog(wxWindow* parent,
|
||||||
@@ -27,22 +31,27 @@ public:
|
|||||||
CardPreviewService& cardPreview,
|
CardPreviewService& cardPreview,
|
||||||
EditMode mode,
|
EditMode mode,
|
||||||
PokemonCard initial,
|
PokemonCard initial,
|
||||||
const std::vector<Set>* preloadedSets = nullptr);
|
const std::vector<Set>* westSets = nullptr,
|
||||||
|
const std::vector<Set>* asiaSets = nullptr);
|
||||||
~PokemonCardEditDialog() override;
|
~PokemonCardEditDialog() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
void appendPreSetRows(wxFlexGridSizer* grid) override;
|
||||||
void buildFlagsRow(wxBoxSizer* flagsBox) override;
|
void buildFlagsRow(wxBoxSizer* flagsBox) override;
|
||||||
void appendExtraRows(wxFlexGridSizer* grid) override;
|
void appendExtraRows(wxFlexGridSizer* grid) override;
|
||||||
void readExtraFromCard() override;
|
void readExtraFromCard() override;
|
||||||
void writeExtraToCard() override;
|
void writeExtraToCard() override;
|
||||||
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
|
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
|
||||||
void onCardLookupContextChanged() override;
|
void onCardLookupContextChanged() override;
|
||||||
|
[[nodiscard]] std::span<const Language> languagesForChoice() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct VariantFetchState {
|
struct VariantFetchState {
|
||||||
std::atomic<bool> alive{true};
|
std::atomic<bool> alive{true};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void onRegionSwitch(wxCommandEvent&);
|
||||||
|
void applyRegion(PokemonRegion region, bool clearSetIfMissing);
|
||||||
void onAutoDetectSetNo(wxCommandEvent&);
|
void onAutoDetectSetNo(wxCommandEvent&);
|
||||||
void onNextSetNo(wxCommandEvent&);
|
void onNextSetNo(wxCommandEvent&);
|
||||||
void onSetSelectionChanged(wxCommandEvent&);
|
void onSetSelectionChanged(wxCommandEvent&);
|
||||||
@@ -60,15 +69,37 @@ private:
|
|||||||
void rebuildVariantRingFromCache();
|
void rebuildVariantRingFromCache();
|
||||||
void syncRingPositionToControls();
|
void syncRingPositionToControls();
|
||||||
void refreshVariantNextControls();
|
void refreshVariantNextControls();
|
||||||
|
void refreshSetNoRowMode();
|
||||||
|
void applySelectedSetNo(std::string setNo);
|
||||||
|
void stepVariantRing(int delta);
|
||||||
void scheduleDeferredVariantPrefetch();
|
void scheduleDeferredVariantPrefetch();
|
||||||
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
|
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 storedSetNoFromControls(const wxTextCtrl* ctrl);
|
||||||
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
|
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
|
||||||
|
|
||||||
EditMode dialogMode_;
|
EditMode dialogMode_;
|
||||||
unsigned variantFetchEpoch_{0};
|
unsigned variantFetchEpoch_{0};
|
||||||
|
unsigned previewFetchEpoch_{0};
|
||||||
CardPreviewService& cardPreview_;
|
CardPreviewService& cardPreview_;
|
||||||
|
const std::vector<Set>* westSets_{nullptr};
|
||||||
|
const std::vector<Set>* asiaSets_{nullptr};
|
||||||
std::shared_ptr<VariantFetchState> variantFetchState_;
|
std::shared_ptr<VariantFetchState> variantFetchState_;
|
||||||
|
|
||||||
|
SwitchCtrl* regionSwitch_{nullptr};
|
||||||
|
wxStaticText* setNoLabel_{nullptr};
|
||||||
wxTextCtrl* setNoCtrl_{nullptr};
|
wxTextCtrl* setNoCtrl_{nullptr};
|
||||||
wxButton* autoSetNoBtn_{nullptr};
|
wxButton* autoSetNoBtn_{nullptr};
|
||||||
wxButton* nextSetNoBtn_{nullptr};
|
wxButton* nextSetNoBtn_{nullptr};
|
||||||
@@ -76,7 +107,9 @@ private:
|
|||||||
wxCheckBox* firstEditionCheck_{nullptr};
|
wxCheckBox* firstEditionCheck_{nullptr};
|
||||||
wxCheckBox* signedCheck_{nullptr};
|
wxCheckBox* signedCheck_{nullptr};
|
||||||
wxCheckBox* alteredCheck_{nullptr};
|
wxCheckBox* alteredCheck_{nullptr};
|
||||||
|
VariantImagePreviewDialog* unnumberedPreview_{nullptr};
|
||||||
|
|
||||||
|
std::string selectedSetNo_;
|
||||||
std::vector<AutoDetectedPrint> cachedVariants_;
|
std::vector<AutoDetectedPrint> cachedVariants_;
|
||||||
std::vector<std::string> uniqueSetNos_;
|
std::vector<std::string> uniqueSetNos_;
|
||||||
std::size_t setNoRingPos_{0};
|
std::size_t setNoRingPos_{0};
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// PokemonGameView: IGameView for the Pokemon TCG. Mirrors `MagicGameView` —
|
// PokemonGameView: unified West + Asia Pokemon UI. One collection file;
|
||||||
// owns the Pokemon-typed list, selected, and edit-dialog widgets and
|
// separate West/Asia set caches; Sets > Update Pokemon refreshes both.
|
||||||
// delegates persistence to a `CollectionService<PokemonCard>` reference
|
|
||||||
// supplied by the composition root.
|
|
||||||
|
|
||||||
#include "ccm/domain/PokemonCard.hpp"
|
#include "ccm/domain/PokemonCard.hpp"
|
||||||
#include "ccm/games/IGameModule.hpp"
|
#include "ccm/games/IGameModule.hpp"
|
||||||
@@ -49,7 +47,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void ensureSetsLoaded();
|
void ensureSetsLoaded();
|
||||||
const std::vector<Set>& setsForDialog();
|
const std::vector<Set>& setsForDialog(PokemonRegion region);
|
||||||
|
|
||||||
ConfigService& config_;
|
ConfigService& config_;
|
||||||
CollectionService<PokemonCard>& collection_;
|
CollectionService<PokemonCard>& collection_;
|
||||||
@@ -60,7 +58,8 @@ private:
|
|||||||
|
|
||||||
PokemonCardListPanel* listPanel_{nullptr};
|
PokemonCardListPanel* listPanel_{nullptr};
|
||||||
PokemonSelectedCardPanel* selectedPanel_{nullptr};
|
PokemonSelectedCardPanel* selectedPanel_{nullptr};
|
||||||
std::vector<Set> setsCache_;
|
std::vector<Set> setsCacheWest_;
|
||||||
|
std::vector<Set> setsCacheAsia_;
|
||||||
bool attemptedInitialSetLoad_{false};
|
bool attemptedInitialSetLoad_{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ protected:
|
|||||||
[[nodiscard]] std::tuple<std::string, std::string, std::string>
|
[[nodiscard]] std::tuple<std::string, std::string, std::string>
|
||||||
previewKey(const PokemonCard& card) const override;
|
previewKey(const PokemonCard& card) const override;
|
||||||
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
|
[[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
|
} // 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
|
||||||
@@ -41,10 +41,11 @@ constexpr const char kFilterInputHint[] = "Filter";
|
|||||||
|
|
||||||
std::string dirNameForGame(Game g) {
|
std::string dirNameForGame(Game g) {
|
||||||
switch (g) {
|
switch (g) {
|
||||||
case Game::Magic: return "magic";
|
case Game::Magic: return "magic";
|
||||||
case Game::Pokemon: return "pokemon";
|
case Game::Pokemon: return "pokemon";
|
||||||
case Game::YuGiOh: return "yugioh";
|
case Game::YuGiOh: return "yugioh";
|
||||||
case Game::DigiBattle99: return "digibattle99";
|
case Game::DigiBattle99: return "digibattle99";
|
||||||
|
case Game::JapanesePokemon: return "pokemon";
|
||||||
}
|
}
|
||||||
return "magic";
|
return "magic";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
||||||
|
|
||||||
#include "ccm/domain/Enums.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/app.h>
|
||||||
#include <wx/panel.h>
|
#include <wx/panel.h>
|
||||||
|
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace ccm::ui {
|
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,
|
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
|
||||||
ImageService& imageService,
|
ImageService& imageService,
|
||||||
@@ -14,15 +36,22 @@ PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
|
|||||||
CardPreviewService& cardPreview,
|
CardPreviewService& cardPreview,
|
||||||
EditMode mode,
|
EditMode mode,
|
||||||
PokemonCard initial,
|
PokemonCard initial,
|
||||||
const std::vector<Set>* preloadedSets)
|
const std::vector<Set>* westSets,
|
||||||
|
const std::vector<Set>* asiaSets)
|
||||||
: BaseCardEditDialog<PokemonCard>(
|
: BaseCardEditDialog<PokemonCard>(
|
||||||
parent,
|
parent,
|
||||||
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
|
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),
|
dialogMode_(mode),
|
||||||
cardPreview_(cardPreview),
|
cardPreview_(cardPreview),
|
||||||
|
westSets_(westSets),
|
||||||
|
asiaSets_(asiaSets),
|
||||||
variantFetchState_(std::make_shared<VariantFetchState>()) {
|
variantFetchState_(std::make_shared<VariantFetchState>()) {
|
||||||
|
const PokemonRegion region = initial.region;
|
||||||
|
mutableCard() = std::move(initial);
|
||||||
|
setPreloadedSetsPointer(region == PokemonRegion::Asia ? asiaSets_ : westSets_);
|
||||||
buildAndPopulate();
|
buildAndPopulate();
|
||||||
|
refreshSetNoRowMode();
|
||||||
if (dialogMode_ == EditMode::Edit) {
|
if (dialogMode_ == EditMode::Edit) {
|
||||||
scheduleDeferredVariantPrefetch();
|
scheduleDeferredVariantPrefetch();
|
||||||
}
|
}
|
||||||
@@ -32,12 +61,40 @@ PokemonCardEditDialog::~PokemonCardEditDialog() {
|
|||||||
if (variantFetchState_) {
|
if (variantFetchState_) {
|
||||||
variantFetchState_->alive.store(false);
|
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() {
|
void PokemonCardEditDialog::onCardLookupContextChanged() {
|
||||||
clearCachedPrintVariants();
|
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) {
|
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||||
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
|
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
|
||||||
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
|
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
|
||||||
@@ -50,6 +107,7 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
||||||
|
setNoLabel_ = new wxStaticText(this, wxID_ANY, "Set #");
|
||||||
auto* setNoPanel = new wxPanel(this, wxID_ANY);
|
auto* setNoPanel = new wxPanel(this, wxID_ANY);
|
||||||
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
|
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
|
||||||
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
|
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
|
||||||
@@ -63,20 +121,63 @@ void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
|||||||
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
|
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
|
||||||
setNoPanel->SetSizer(setNoRow);
|
setNoPanel->SetSizer(setNoRow);
|
||||||
|
|
||||||
appendRow(grid, "Set #", setNoPanel);
|
grid->Add(setNoLabel_, 0, wxALIGN_CENTER_VERTICAL);
|
||||||
|
grid->Add(setNoPanel, 1, wxEXPAND);
|
||||||
|
|
||||||
if (auto* setCombo = setComboControl()) {
|
if (auto* setCombo = setComboControl()) {
|
||||||
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
|
setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
|
void PokemonCardEditDialog::onRegionSwitch(wxCommandEvent&) {
|
||||||
std::string out(setNo);
|
const PokemonRegion next =
|
||||||
const auto slash = out.find('/');
|
(regionSwitch_ && regionSwitch_->GetValue()) ? PokemonRegion::Asia
|
||||||
if (slash != std::string::npos) {
|
: PokemonRegion::West;
|
||||||
out.resize(slash);
|
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) {
|
std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
|
||||||
@@ -84,20 +185,78 @@ std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctr
|
|||||||
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
|
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() {
|
void PokemonCardEditDialog::readExtraFromCard() {
|
||||||
clearCachedPrintVariants();
|
clearCachedPrintVariants();
|
||||||
|
selectedSetNo_ = normalizedStoredSetNo(constCard().setNo);
|
||||||
if (setNoCtrl_) {
|
if (setNoCtrl_) {
|
||||||
setNoCtrl_->ChangeValue(
|
setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str()));
|
||||||
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
|
|
||||||
}
|
}
|
||||||
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
|
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
|
||||||
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
||||||
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
||||||
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
|
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
|
||||||
|
if (regionSwitch_) {
|
||||||
|
regionSwitch_->SetValue(constCard().region == PokemonRegion::Asia, false);
|
||||||
|
}
|
||||||
|
refreshSetNoRowMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::writeExtraToCard() {
|
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 (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
|
||||||
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
||||||
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
||||||
@@ -106,9 +265,11 @@ void PokemonCardEditDialog::writeExtraToCard() {
|
|||||||
|
|
||||||
void PokemonCardEditDialog::clearCachedPrintVariants() {
|
void PokemonCardEditDialog::clearCachedPrintVariants() {
|
||||||
++variantFetchEpoch_;
|
++variantFetchEpoch_;
|
||||||
|
++previewFetchEpoch_;
|
||||||
cachedVariants_.clear();
|
cachedVariants_.clear();
|
||||||
uniqueSetNos_.clear();
|
uniqueSetNos_.clear();
|
||||||
setNoRingPos_ = 0;
|
setNoRingPos_ = 0;
|
||||||
|
closeUnnumberedPreview();
|
||||||
refreshVariantNextControls();
|
refreshVariantNextControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +303,10 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
|
|||||||
auto state = variantFetchState_;
|
auto state = variantFetchState_;
|
||||||
CardPreviewService* svc = &cardPreview_;
|
CardPreviewService* svc = &cardPreview_;
|
||||||
PokemonCardEditDialog* self = this;
|
PokemonCardEditDialog* self = this;
|
||||||
|
const Game game = backendGame();
|
||||||
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
|
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
|
||||||
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() {
|
setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog, game]() {
|
||||||
auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId);
|
auto detected = svc->detectPrintVariants(game, name, setId);
|
||||||
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
|
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
|
||||||
fillSetNoOnSuccess, showFailureDialog]() mutable {
|
fillSetNoOnSuccess, showFailureDialog]() mutable {
|
||||||
if (!state->alive.load()) return;
|
if (!state->alive.load()) return;
|
||||||
@@ -173,14 +335,20 @@ void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachedVariants_ = std::move(detected).value();
|
cachedVariants_ = std::move(detected).value();
|
||||||
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
|
if (fillSetNoOnSuccess && !cachedVariants_.empty()) {
|
||||||
setNoCtrl_->ChangeValue(
|
applySelectedSetNo(cachedVariants_.front().setNo);
|
||||||
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildVariantRingFromCache();
|
rebuildVariantRingFromCache();
|
||||||
syncRingPositionToControls();
|
syncRingPositionToControls();
|
||||||
refreshVariantNextControls();
|
refreshVariantNextControls();
|
||||||
|
if (isUnnumberedPromoSelected() && !uniqueSetNos_.empty()) {
|
||||||
|
ensureUnnumberedPreviewOpen();
|
||||||
|
refreshUnnumberedPreview();
|
||||||
|
if (unnumberedPreview_ != nullptr) {
|
||||||
|
unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
|
void PokemonCardEditDialog::rebuildVariantRingFromCache() {
|
||||||
@@ -197,8 +365,12 @@ void PokemonCardEditDialog::rebuildVariantRingFromCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::syncRingPositionToControls() {
|
void PokemonCardEditDialog::syncRingPositionToControls() {
|
||||||
if (!setNoCtrl_) return;
|
const std::string current = isUnnumberedPromoSelected()
|
||||||
const std::string current = storedSetNoFromControls(setNoCtrl_);
|
? normalizedStoredSetNo(selectedSetNo_)
|
||||||
|
: storedSetNoFromControls(setNoCtrl_);
|
||||||
|
if (!isUnnumberedPromoSelected() && setNoCtrl_) {
|
||||||
|
selectedSetNo_ = current;
|
||||||
|
}
|
||||||
setNoRingPos_ = 0;
|
setNoRingPos_ = 0;
|
||||||
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
|
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
|
||||||
if (uniqueSetNos_[i] == current) {
|
if (uniqueSetNos_[i] == current) {
|
||||||
@@ -206,26 +378,66 @@ void PokemonCardEditDialog::syncRingPositionToControls() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!uniqueSetNos_.empty() && selectedSetNo_.empty()) {
|
||||||
|
applySelectedSetNo(uniqueSetNos_[setNoRingPos_]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::refreshVariantNextControls() {
|
void PokemonCardEditDialog::refreshVariantNextControls() {
|
||||||
if (!nextSetNoBtn_) return;
|
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();
|
Layout();
|
||||||
if (GetSizer()) Fit();
|
if (GetSizer()) Fit();
|
||||||
|
if (unnumberedPreview_ != nullptr) {
|
||||||
|
unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
|
void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
|
||||||
autoDetectFromApi();
|
autoDetectFromApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
|
void PokemonCardEditDialog::stepVariantRing(int delta) {
|
||||||
if (uniqueSetNos_.size() <= 1) return;
|
if (uniqueSetNos_.size() <= 1 || delta == 0) return;
|
||||||
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
|
const auto n = static_cast<int>(uniqueSetNos_.size());
|
||||||
if (setNoCtrl_) {
|
auto pos = static_cast<int>(setNoRingPos_) + delta;
|
||||||
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
|
pos %= n;
|
||||||
}
|
if (pos < 0) pos += n;
|
||||||
|
setNoRingPos_ = static_cast<std::size_t>(pos);
|
||||||
|
applySelectedSetNo(uniqueSetNos_[setNoRingPos_]);
|
||||||
refreshVariantNextControls();
|
refreshVariantNextControls();
|
||||||
|
if (isUnnumberedPromoSelected()) {
|
||||||
|
ensureUnnumberedPreviewOpen();
|
||||||
|
refreshUnnumberedPreview();
|
||||||
|
if (unnumberedPreview_ != nullptr) {
|
||||||
|
unnumberedPreview_->setNavigationEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
|
||||||
|
stepVariantRing(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonCardEditDialog::autoDetectFromApi() {
|
void PokemonCardEditDialog::autoDetectFromApi() {
|
||||||
@@ -248,8 +460,115 @@ void PokemonCardEditDialog::autoDetectFromApi() {
|
|||||||
|
|
||||||
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
|
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
|
||||||
clearCachedPrintVariants();
|
clearCachedPrintVariants();
|
||||||
|
refreshSetNoRowMode();
|
||||||
scheduleDeferredVariantPrefetch();
|
scheduleDeferredVariantPrefetch();
|
||||||
ev.Skip();
|
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
|
} // namespace ccm::ui
|
||||||
|
|||||||
@@ -31,18 +31,22 @@ void PokemonGameView::ensureSetsLoaded() {
|
|||||||
if (attemptedInitialSetLoad_) return;
|
if (attemptedInitialSetLoad_) return;
|
||||||
attemptedInitialSetLoad_ = true;
|
attemptedInitialSetLoad_ = true;
|
||||||
|
|
||||||
auto cached = sets_.getSets(Game::Pokemon);
|
auto loadOrRefresh = [this](Game game, std::vector<Set>& cache) {
|
||||||
if (cached) {
|
auto cached = sets_.getSets(game);
|
||||||
setsCache_ = std::move(cached).value();
|
if (cached) {
|
||||||
if (!setsCache_.empty()) return;
|
cache = std::move(cached).value();
|
||||||
} else {
|
if (!cache.empty()) return;
|
||||||
setsCache_.clear();
|
} else {
|
||||||
}
|
cache.clear();
|
||||||
|
}
|
||||||
|
auto refreshed = sets_.updateSets(game);
|
||||||
|
if (refreshed) {
|
||||||
|
cache = std::move(refreshed).value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
auto refreshed = sets_.updateSets(Game::Pokemon);
|
loadOrRefresh(Game::Pokemon, setsCacheWest_);
|
||||||
if (refreshed) {
|
loadOrRefresh(Game::JapanesePokemon, setsCacheAsia_);
|
||||||
setsCache_ = std::move(refreshed).value();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
|
wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
|
||||||
@@ -81,13 +85,20 @@ void PokemonGameView::refreshCollection() {
|
|||||||
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
|
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::vector<Set>& PokemonGameView::setsForDialog() {
|
const std::vector<Set>& PokemonGameView::setsForDialog(PokemonRegion region) {
|
||||||
ensureSetsLoaded();
|
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);
|
auto loaded = sets_.getSets(Game::Pokemon);
|
||||||
if (loaded) setsCache_ = std::move(loaded).value();
|
if (loaded) setsCacheWest_ = std::move(loaded).value();
|
||||||
else setsCache_.clear();
|
else setsCacheWest_.clear();
|
||||||
return setsCache_;
|
return setsCacheWest_;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PokemonGameView::onAddCard(wxWindow* parentWindow) {
|
void PokemonGameView::onAddCard(wxWindow* parentWindow) {
|
||||||
@@ -98,11 +109,13 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
|
|||||||
}
|
}
|
||||||
PokemonCard fresh;
|
PokemonCard fresh;
|
||||||
fresh.amount = 1;
|
fresh.amount = 1;
|
||||||
|
fresh.region = PokemonRegion::West;
|
||||||
fresh.language = Language::English;
|
fresh.language = Language::English;
|
||||||
fresh.condition = Condition::NearMint;
|
fresh.condition = Condition::NearMint;
|
||||||
|
|
||||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
|
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
|
||||||
&setsForDialog());
|
&setsForDialog(PokemonRegion::West),
|
||||||
|
&setsForDialog(PokemonRegion::Asia));
|
||||||
themeModalDialog(&dlg, config_.current().theme);
|
themeModalDialog(&dlg, config_.current().theme);
|
||||||
CardEditModalGuard modalGuard;
|
CardEditModalGuard modalGuard;
|
||||||
if (dlg.ShowModal() != wxID_OK) return;
|
if (dlg.ShowModal() != wxID_OK) return;
|
||||||
@@ -147,7 +160,8 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
||||||
&setsForDialog());
|
&setsForDialog(PokemonRegion::West),
|
||||||
|
&setsForDialog(PokemonRegion::Asia));
|
||||||
themeModalDialog(&dlg, config_.current().theme);
|
themeModalDialog(&dlg, config_.current().theme);
|
||||||
CardEditModalGuard modalGuard;
|
CardEditModalGuard modalGuard;
|
||||||
if (dlg.ShowModal() != wxID_OK) return;
|
if (dlg.ShowModal() != wxID_OK) return;
|
||||||
@@ -181,15 +195,46 @@ void PokemonGameView::onDeleteCard(wxWindow* parentWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
|
std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
|
||||||
auto out = sets_.updateSets(Game::Pokemon);
|
auto westOut = sets_.updateSets(Game::Pokemon);
|
||||||
if (!out) {
|
auto asiaOut = sets_.updateSets(Game::JapanesePokemon);
|
||||||
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
|
|
||||||
"Error", wxOK | wxICON_ERROR);
|
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";
|
return "Update failed";
|
||||||
}
|
}
|
||||||
setsCache_ = out.value();
|
if (!westOut) {
|
||||||
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Pokemon sets.",
|
showThemedMessageDialog(
|
||||||
"Sets updated", wxOK | wxICON_INFORMATION);
|
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.";
|
return "Pokemon sets updated.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ enum PokemonDetailKey : int {
|
|||||||
kName = 0,
|
kName = 0,
|
||||||
kSet,
|
kSet,
|
||||||
kSetNo,
|
kSetNo,
|
||||||
|
kRegion,
|
||||||
kLanguage,
|
kLanguage,
|
||||||
kCondition,
|
kCondition,
|
||||||
kAmount,
|
kAmount,
|
||||||
@@ -34,6 +35,7 @@ PokemonSelectedCardPanel::declareDetailRows() const {
|
|||||||
{"Name", kName, "(no card selected)"},
|
{"Name", kName, "(no card selected)"},
|
||||||
{"Set", kSet, ""},
|
{"Set", kSet, ""},
|
||||||
{"Set #", kSetNo, ""},
|
{"Set #", kSetNo, ""},
|
||||||
|
{"Region", kRegion, ""},
|
||||||
{"Language", kLanguage, ""},
|
{"Language", kLanguage, ""},
|
||||||
{"Condition", kCondition, ""},
|
{"Condition", kCondition, ""},
|
||||||
{"Amount", kAmount, ""},
|
{"Amount", kAmount, ""},
|
||||||
@@ -56,6 +58,7 @@ std::string PokemonSelectedCardPanel::detailValueFor(const PokemonCard& card,
|
|||||||
case kName: return card.name;
|
case kName: return card.name;
|
||||||
case kSet: return card.set.name;
|
case kSet: return card.set.name;
|
||||||
case kSetNo: return card.setNo;
|
case kSetNo: return card.setNo;
|
||||||
|
case kRegion: return std::string(to_string(card.region));
|
||||||
case kLanguage: return std::string(to_string(card.language));
|
case kLanguage: return std::string(to_string(card.language));
|
||||||
case kCondition: return std::string(to_string(card.condition));
|
case kCondition: return std::string(to_string(card.condition));
|
||||||
case kAmount: return std::to_string(card.amount);
|
case kAmount: return std::to_string(card.amount);
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ namespace {
|
|||||||
|
|
||||||
wxString displayLabelForGame(Game g) {
|
wxString displayLabelForGame(Game g) {
|
||||||
switch (g) {
|
switch (g) {
|
||||||
case Game::Magic: return "Magic";
|
case Game::Magic: return "Magic";
|
||||||
case Game::Pokemon: return "Pokemon";
|
case Game::Pokemon: return "Pokemon";
|
||||||
case Game::YuGiOh: return "Yu-Gi-Oh!";
|
case Game::YuGiOh: return "Yu-Gi-Oh!";
|
||||||
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
|
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
|
||||||
|
case Game::JapanesePokemon: return "Pokemon"; // internal; not in allGames()
|
||||||
}
|
}
|
||||||
return wxString::FromUTF8(to_string(g).data());
|
return wxString::FromUTF8(to_string(g).data());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user