Compare commits

...

2 Commits

Author SHA1 Message Date
Sebastian Dine d3b4762b76 Minor: Additional functions (#25)
* several functions. fixes #1 and #2

* multi selection functionality
2026-08-03 08:35:38 +02:00
Sebastian Dine 2eb7c59f78 Minor: New Game Yu-Gi-Oh! Bandai (#22) 2026-07-30 14:51:53 +02:00
102 changed files with 5939 additions and 185 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ Run from the **workspace root**.
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme` in `Theme.cpp` (opt out of immersive dark mode + parent `WM_CTLCOLOREDIT` subclass — that message goes to the EDIT's parent, not `MainFrame`).
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
+8
View File
@@ -9,6 +9,7 @@ Currently, the application supports the following TCGs:
- Magic the Gathering
- Pokemon TCG
- Yu-Gi-Oh!
- Yu-Gi-Oh! (Bandai)
- Digimon (Digi-Battle)
## Screenshots
@@ -34,6 +35,13 @@ Currently, the application supports the following TCGs:
</details>
<details>
<summary>Yu-Gi-Oh! (Bandai)</summary>
![CCM3 Demo - Yu-Gi-Oh! (Bandai)](docs/assets/images/demo-ygo-bandai.png)
</details>
<details>
<summary>Digimon (Digi-Battle)</summary>
+1 -1
View File
@@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete
## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonCollectionRepository<YuGiOhBandaiCard>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `YuGiOhBandaiSetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `YuGiOhBandaiGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, `YuGiOhBandaiGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). 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`).
+28 -1
View File
@@ -5,6 +5,7 @@
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
@@ -12,6 +13,7 @@
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
#include "ccm/infra/JsonSetRepository.hpp"
@@ -23,6 +25,7 @@
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
@@ -31,6 +34,7 @@
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/MainFrame.hpp"
#include "ccm/ui/PokemonGameView.hpp"
#include "ccm/ui/YuGiOhBandaiGameView.hpp"
#include "ccm/ui/YuGiOhGameView.hpp"
#include <wx/app.h>
@@ -54,6 +58,7 @@ std::string dirNameForGame(ccm::Game g) {
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
case ccm::Game::YuGiOhBandai: return "yugiohbandai";
case ccm::Game::JapanesePokemon: return "pokemon";
}
return "magic";
@@ -91,6 +96,7 @@ public:
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
ygoBandaiMod_ = std::make_unique<ccm::YuGiOhBandaiGameModule>(*http_);
ccm::JapanesePokemonEnCatalog jpCatalog;
{
@@ -112,11 +118,17 @@ public:
digiBattle99Repo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
*fs_, *config_, &dirNameForGame);
ygoBandaiRepo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
digiBattle99CatalogStore_ =
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoCatalogStore_ =
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoMod_->setCatalogService(ygoCatalogStore_.get());
ygoBandaiCatalogStore_ =
std::make_unique<ccm::YuGiOhBandaiSetCatalogService>(*fs_, *config_, &dirNameForGame);
pokeCatalogStore_ =
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
@@ -131,11 +143,15 @@ public:
digiBattle99CollSvc_ =
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
*digiBattle99Repo_, *imgStore_);
ygoBandaiCollSvc_ =
std::make_unique<ccm::CollectionService<ccm::YuGiOhBandaiCard>>(
*ygoBandaiRepo_, *imgStore_);
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
setSvc_->registerModule(magicMod_.get());
setSvc_->registerModule(pokeMod_.get());
setSvc_->registerModule(ygoMod_.get());
setSvc_->registerModule(digiBattle99Mod_.get());
setSvc_->registerModule(ygoBandaiMod_.get());
setSvc_->registerModule(jpPokeMod_.get());
// Disk-backed preview cache lives next to the executable, in the same
@@ -161,6 +177,7 @@ public:
previewSvc_->registerModule(*pokeMod_);
previewSvc_->registerModule(*ygoMod_);
previewSvc_->registerModule(*digiBattle99Mod_);
previewSvc_->registerModule(*ygoBandaiMod_);
previewSvc_->registerModule(*jpPokeMod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
@@ -175,6 +192,9 @@ public:
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_, *digiBattle99CatalogStore_);
ygoBandaiView_ = std::make_unique<ccm::ui::YuGiOhBandaiGameView>(
*config_, *ygoBandaiCollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*ygoBandaiMod_, *ygoBandaiCatalogStore_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -185,8 +205,10 @@ public:
*pokeMod_,
*ygoMod_,
*digiBattle99Mod_,
*ygoBandaiMod_,
*jpPokeMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
{ magicView_.get(), pokeView_.get(), ygoView_.get(), ygoBandaiView_.get(),
digiBattle99View_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
@@ -208,14 +230,17 @@ private:
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
std::unique_ptr<ccm::YuGiOhBandaiGameModule> ygoBandaiMod_;
std::unique_ptr<ccm::JapanesePokemonGameModule> jpPokeMod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>> ygoBandaiRepo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
std::unique_ptr<ccm::YuGiOhBandaiSetCatalogService> ygoBandaiCatalogStore_;
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
@@ -223,6 +248,7 @@ private:
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhBandaiCard>> ygoBandaiCollSvc_;
std::unique_ptr<ccm::SetService> setSvc_;
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
@@ -230,6 +256,7 @@ private:
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
std::unique_ptr<ccm::ui::YuGiOhBandaiGameView> ygoBandaiView_;
std::unique_ptr<ccm::ui::AppContext> ctx_;
};
+3 -3
View File
@@ -4,11 +4,11 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `YuGiOhBandaiCard`, `YuGiOhBandaiSetCatalog` (Bandai pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `YuGiOhBandaiSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `yugiohbandai/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all expose a fully working set source + card preview source. `YuGiOhSetSource`, `YuGiOhBandaiSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations.
+7
View File
@@ -7,6 +7,8 @@ add_library(ccm_core STATIC
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/YuGiOhBandaiCard.cpp
src/domain/YuGiOhBandaiSetCatalog.cpp
src/domain/DigiBattle99Card.cpp
src/domain/DigiBattle99SetCatalog.cpp
src/domain/YuGiOhSetCatalog.cpp
@@ -24,6 +26,8 @@ add_library(ccm_core STATIC
src/services/DigiBattle99SetCatalogService.cpp
src/services/YuGiOhSetCompletion.cpp
src/services/YuGiOhSetCatalogService.cpp
src/services/YuGiOhBandaiSetCompletion.cpp
src/services/YuGiOhBandaiSetCatalogService.cpp
src/services/PokemonSetCompletion.cpp
src/services/PokemonSetCatalogService.cpp
@@ -47,6 +51,9 @@ add_library(ccm_core STATIC
src/games/digibattle99/DigiBattle99SetSource.cpp
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
src/games/digibattle99/DigiBattle99GameModule.cpp
src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp
src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp
src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
src/games/pokemonjp/JapanesePokemonSetSource.cpp
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
+2 -1
View File
@@ -21,6 +21,7 @@ enum class Game {
Pokemon,
YuGiOh,
DigiBattle99,
YuGiOhBandai,
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
};
@@ -70,7 +71,7 @@ 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, 5>& allGames() noexcept;
const std::array<Language, 10>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
@@ -0,0 +1,37 @@
#pragma once
// YuGiOhBandaiCard - Bandai Carddass (pre-Konami) card model.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct YuGiOhBandaiCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string rarity;
std::string note;
std::vector<std::string> images;
Language language{Language::Japanese};
Condition condition{Condition::NearMint};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const YuGiOhBandaiCard&, const YuGiOhBandaiCard&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhBandaiSetCatalog: offline pack → card checklist for Bandai set
// completion. Filled from Yugipedia set-gallery wikitext and persisted at
// `<dataStorage>/yugiohbandai/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhBandaiCatalogCard {
std::string setNo;
std::string name;
std::string rarity;
friend bool operator==(const YuGiOhBandaiCatalogCard&,
const YuGiOhBandaiCatalogCard&) = default;
};
struct YuGiOhBandaiSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhBandaiCatalogCard> cards;
friend bool operator==(const YuGiOhBandaiSetCatalogPack&,
const YuGiOhBandaiSetCatalogPack&) = default;
};
struct YuGiOhBandaiSetCatalog {
std::vector<YuGiOhBandaiSetCatalogPack> packs;
[[nodiscard]] const YuGiOhBandaiSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhBandaiSetCatalog&,
const YuGiOhBandaiSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c);
} // namespace ccm
@@ -16,6 +16,9 @@ namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
/// YGOPRODeck `set_rarity` for this printing when known (optional;
/// older `set-catalog.json` files omit it).
std::string rarity{};
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
@@ -35,6 +35,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setName) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setName,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setName,
std::string_view setNo) override;
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
// Does not invent zero-padding — CDN keys match API ids literally.
static std::string normalizeCardNumber(std::string_view setNo);
@@ -58,7 +64,8 @@ public:
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setName,
std::string_view wantedCardName);
std::string_view wantedCardName,
std::string_view wantedSetNo = {});
private:
IHttpClient& http_;
@@ -29,6 +29,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// Strip everything after the first '/' (e.g. "4/102" -> "4").
static std::string normalizeCollectorNumber(std::string_view setNo);
@@ -61,6 +67,9 @@ public:
std::string_view setId,
std::string_view wantedCardName);
// Parse TCGdex card-by-id JSON into print metadata (name + localId + rarity).
static Result<AutoDetectedPrint> parsePrintFromCardById(const std::string& body);
private:
IHttpClient& http_;
};
@@ -29,6 +29,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
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);
@@ -60,6 +66,15 @@ public:
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog);
// Reverse lookup: set + localId → name via catalog (no HTTP).
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCatalog(std::string_view setId,
std::string_view localId,
const JapanesePokemonEnCatalog& catalog);
// Parse TCGdex JA card-by-id JSON into print metadata.
static Result<AutoDetectedPrint> parsePrintFromCardResponse(const std::string& body);
private:
IHttpClient& http_;
const JapanesePokemonEnCatalog& catalog_;
@@ -1,8 +1,11 @@
#pragma once
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -25,6 +28,9 @@ namespace ccm {
// that endpoint returns a richer set listing (with rarities and release
// dates) than Yugipedia, and we don't need image data for it.
//
// Reverse lookup (set + setNo → name) uses the offline set-completion catalog
// written by Sets → Update Yu-Gi-Oh! (`YuGiOhSetCatalogService`).
//
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
// stored Language. Localized scans are intentionally not queried so the user
// sees a consistent, well-stocked gallery (EN scans are the most complete).
@@ -32,6 +38,12 @@ class YuGiOhCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhCardPreviewSource(IHttpClient& http);
// Optional offline catalog for set+setNo → name reverse lookup. When null
// or empty, detectVariantsBySetNo returns a clear "Update Sets" error.
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
catalogStore_ = catalogStore;
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
@@ -43,6 +55,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// ---- Yugipedia helpers (image preview path) ----------------------------
// Build the list of candidate Yugipedia file names to try, in priority
@@ -116,8 +134,29 @@ public:
std::string_view preferredSetName,
std::string_view wantedCardName);
// Offline reverse lookup against a set-completion catalog. `setId` is the
// pack's set code (e.g. "LOB"); `setNo` may be digits ("005") or a full
// collector code ("LOB-005" / "LOB-EN005").
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCatalog(const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::string_view setNo);
// YGOPRODeck cardset= dump filtered by collector digits (HTTP fallback when
// the offline catalog is missing or has no match).
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCardset(const std::string& body,
std::string_view preferredSetName,
std::string_view setNo);
static std::string buildCardsetOnlyUrl(std::string_view setName);
private:
IHttpClient& http_;
IHttpClient& http_;
YuGiOhSetCatalogService* catalogStore_{nullptr};
// Cached offline catalog so reverse auto-detect does not re-parse a
// multi-MB JSON file on every button click.
mutable std::optional<YuGiOhSetCatalog> catalogCache_;
};
} // namespace ccm
@@ -3,6 +3,7 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
namespace ccm {
@@ -17,6 +18,15 @@ public:
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
// Wire offline set catalog for set+setNo → name reverse auto-detect.
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
previewSource_.setCatalogService(catalogStore);
}
[[nodiscard]] YuGiOhCardPreviewSource& previewSource() noexcept {
return previewSource_;
}
private:
YuGiOhSetSource setSource_;
YuGiOhCardPreviewSource previewSource_;
@@ -0,0 +1,82 @@
#pragma once
// YuGiOhBandaiCardPreviewSource: Yugipedia pageimages + SMW ask for Bandai
// Carddass previews and auto-detect (by English name or Bandai number).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class YuGiOhBandaiCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhBandaiCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// Prefer "<Name> (Bandai)" / English / Sealdass page depending on setId.
static std::string preferredPageTitle(std::string_view name,
std::string_view setId,
std::string_view setNo);
static std::string buildPageImagesUrl(std::string_view pageTitle);
static std::string buildAskByNameUrl(std::string_view englishName);
static std::string buildAskByNumberUrl(std::string_view setNo);
// True for Jump/Toei promo codes (J1, TA2, …). Yugipedia's SMW
// `Bandai number` property is numeric-only, so these must use the
// promotional gallery instead of `action=ask`.
[[nodiscard]] static bool isAlphanumericPromoNumber(std::string_view setNo);
static Result<std::vector<AutoDetectedPrint>>
parsePromoGalleryResponse(const std::string& body,
std::string_view wantedSetNo);
static Result<std::string, PreviewLookupError>
parsePageImagesResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
parseAskResponse(const std::string& body,
std::string_view preferredSetId,
std::string_view wantedSetNo = {});
static AutoDetectedPrint enrichPrint(AutoDetectedPrint print,
std::string_view pageTitle);
private:
Result<std::string, PreviewLookupError> fetchPageImage(std::string_view pageTitle);
Result<std::vector<AutoDetectedPrint>> askByName(std::string_view name,
std::string_view setId);
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setId,
std::string_view setNo);
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,29 @@
#pragma once
// YuGiOhBandaiGameModule: Bandai Carddass via Yugipedia.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
namespace ccm {
class YuGiOhBandaiGameModule final : public IGameModule {
public:
explicit YuGiOhBandaiGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOhBandai; }
[[nodiscard]] std::string dirName() const override { return "yugiohbandai"; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
YuGiOhBandaiSetSource& bandaiSetSource() noexcept { return setSource_; }
private:
YuGiOhBandaiSetSource setSource_;
YuGiOhBandaiCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,67 @@
#pragma once
// YuGiOhBandaiSetSource: hardcoded Bandai set manifest + Yugipedia gallery
// wikitext catalogs for set completion.
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class YuGiOhBandaiSetSource final : public ISetSource {
public:
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhBandaiSetCatalog catalog;
};
struct SetManifestEntry {
const char* id;
const char* name;
const char* releaseDate; // YYYY/MM/DD
const char* galleryPage; // Yugipedia page title (may be shared)
// For the shared promo gallery: keep cards whose setNo starts with
// this prefix (empty = keep all from that page into this pack).
const char* setNoPrefix;
};
explicit YuGiOhBandaiSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
Result<FetchWithCatalog> fetchAllWithCatalog();
[[nodiscard]] static const std::vector<SetManifestEntry>& setManifest();
static Result<std::vector<Set>> parseResponse(const std::string& /*unused*/);
// Parse one gallery wikitext body into checklist cards.
static Result<std::vector<YuGiOhBandaiCatalogCard>>
parseGalleryWikitext(const std::string& wikitext);
// Map a Bandai number string to a set id (ban1/ban2/ban3/promos/sealdass).
static std::string setIdForNumber(std::string_view setNo);
static std::string setNameForId(std::string_view setId);
// Normalize printed numbers: strip leading zeros on pure-decimal values;
// uppercase letter prefixes (j1 → J1). Sealdass stays unpadded decimal.
static std::string normalizeCardNumber(std::string_view setNo);
static std::string expandRarityCode(std::string_view code);
static std::string buildGalleryParseUrl(std::string_view pageTitle);
static std::string englishNameFromGalleryTitle(std::string_view pageTitle);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -19,6 +19,12 @@ namespace ccm {
struct AutoDetectedPrint {
std::string setNo;
std::string rarity;
// Optional fields used by games that resolve set/name/language during
// auto-detect (e.g. Yu-Gi-Oh! Bandai). Existing games leave them empty.
std::string name;
std::string setId;
std::string setName;
std::string language; // Language enum spelling when known ("Japanese" / "English")
};
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
@@ -79,6 +85,23 @@ public:
return Result<std::vector<AutoDetectedPrint>>::err(
"Print variant listing not supported by this game.");
}
// Optional lookup by set + collector / Bandai number (fills name + rarity).
// `setId` uses the same meaning as detectPrintVariants for the game
// (set id for Pokémon/Bandai; set display name for Digi-Battle; set code
// id for Yu-Gi-Oh! catalog reverse lookup).
virtual Result<AutoDetectedPrint> detectBySetNo(std::string_view /*setId*/,
std::string_view /*setNo*/) {
return Result<AutoDetectedPrint>::err(
"Detect-by-number not supported by this game.");
}
virtual Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNo(std::string_view /*setId*/,
std::string_view /*setNo*/) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Detect-by-number variants not supported by this game.");
}
};
} // namespace ccm
+5
View File
@@ -23,6 +23,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <string_view>
@@ -47,6 +48,10 @@ namespace ccm {
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
std::string_view filter);
// Bandai: name, set.name, setNo, rarity, language, condition, amount, note.
[[nodiscard]] bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card,
std::string_view filter);
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter);
@@ -81,6 +81,15 @@ public:
std::string_view name,
std::string_view setId);
Result<AutoDetectedPrint> detectBySetNo(Game game,
std::string_view setId,
std::string_view setNo);
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
Game game,
std::string_view setId,
std::string_view setNo);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution. Cached by URL (same LRU bound).
Result<std::string> fetchImageBytesByUrl(std::string_view url);
+18
View File
@@ -20,6 +20,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <vector>
@@ -82,6 +83,20 @@ enum class DigiBattle99SortColumn {
Note,
};
enum class YuGiOhBandaiSortColumn {
Name,
SetReleaseDate,
SetNo,
Rarity,
Language,
Condition,
Amount,
Holo,
Signed,
Altered,
Note,
};
// Japanese Pokemon mirrors Pokemon columns.
enum class JapanesePokemonSortColumn {
Name,
@@ -107,6 +122,9 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending);
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
YuGiOhBandaiSortColumn column,
bool ascending);
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending);
@@ -0,0 +1,35 @@
#pragma once
// YuGiOhBandaiSetCatalogService: load/save yugiohbandai/set-catalog.json.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhBandaiSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhBandaiSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhBandaiSetCatalog> load() const;
Result<void> save(const YuGiOhBandaiSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,51 @@
#pragma once
// Pure helpers: Bandai set-completion progress and per-set checklists.
// Ownership keys on (set.id, normalized setNo). Never name-only.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhBandaiSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhBandaiChecklistEntry {
std::string setNo;
std::string name;
std::string rarity;
bool owned{false};
};
[[nodiscard]] std::vector<Language>
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection);
[[nodiscard]] std::vector<YuGiOhBandaiSetCompletionProgress>
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
[[nodiscard]] std::vector<YuGiOhBandaiChecklistEntry>
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,24 @@
#pragma once
// Shared rule for bidirectional Set # Auto detect (name ↔ set number):
// when both fields are filled, the field the user last edited is the lookup key.
namespace ccm {
enum class CardLookupEditField { None, Name, SetNo };
// Returns true when Auto detect should run setNo → name (reverse).
// `nameEmpty` / `setNoEmpty` are already trimmed/normalized by the caller.
// When both are empty the result is false (caller shows a validation message).
// When only one is filled, that direction wins. When both are filled, SetNo
// wins only if it was the last edited lookup field; otherwise Name wins
// (including `None`, matching the historical default).
[[nodiscard]] inline bool preferDetectBySetNo(bool nameEmpty,
bool setNoEmpty,
CardLookupEditField lastEdited) noexcept {
if (nameEmpty) return !setNoEmpty;
if (setNoEmpty) return false;
return lastEdited == CardLookupEditField::SetNo;
}
} // namespace ccm
+28 -1
View File
@@ -48,12 +48,39 @@ namespace ccm {
return out;
}
// Digits from a full set code (after '-') or from a digits-only Set # field.
[[nodiscard]] inline std::string ygoCollectorDigitsFromInput(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
if (s.find('-') != std::string_view::npos) return ygoCollectorDigitsOnly(s);
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
[[nodiscard]] inline std::string ygoDigitsStripLeadingZeros(std::string digits) {
std::size_t i = 0;
while (i + 1 < digits.size() && digits[i] == '0') ++i;
if (i > 0) digits.erase(0, i);
return digits;
}
// True when both designate the same collector number, ignoring leading zeros
// ("5" == "005") and accepting either a full set code or digits-only input.
[[nodiscard]] inline bool ygoCollectorDigitsEqual(std::string_view a,
std::string_view b) {
return ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(a)) ==
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(b));
}
// True when both strings designate the same printed slot: same abbreviation
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
// extracted from everything after that dash.
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b);
return ygoCollectorDigitsEqual(a, b);
}
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
+6 -3
View File
@@ -17,6 +17,7 @@ std::string_view to_string(Game g) noexcept {
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
case Game::YuGiOhBandai: return "YuGiOhBandai";
case Game::JapanesePokemon: return "JapanesePokemon";
}
CCM_UNREACHABLE();
@@ -72,6 +73,7 @@ std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
if (s == "YuGiOhBandai") return Game::YuGiOhBandai;
if (s == "JapanesePokemon") return Game::JapanesePokemon;
return std::nullopt;
}
@@ -115,9 +117,10 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 4>& allGames() noexcept {
static constexpr std::array<Game, 4> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
const std::array<Game, 5>& allGames() noexcept {
static constexpr std::array<Game, 5> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::YuGiOhBandai,
Game::DigiBattle99};
return v;
}
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhBandaiCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"setNo", c.setNo},
{"rarity", c.rarity},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("setNo").get_to(c.setNo);
j.at("rarity").get_to(c.rarity);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
@@ -0,0 +1,45 @@
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
namespace ccm {
const YuGiOhBandaiSetCatalogPack* YuGiOhBandaiSetCatalog::findPack(
std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}, {"rarity", c.rarity}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
if (j.contains("rarity")) {
j.at("rarity").get_to(c.rarity);
} else {
c.rarity.clear();
}
}
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+2
View File
@@ -11,11 +11,13 @@ const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) c
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
if (!c.rarity.empty()) j["rarity"] = c.rarity;
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
c.rarity = j.value("rarity", "");
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
@@ -35,6 +35,44 @@ bool cardInPack(const nlohmann::json& card, std::string_view packName) {
return false;
}
// Numeric collector suffix: "ST-01" → "01", "01" → "01", "BO-115" → "115".
std::string numericSuffix(std::string_view setNo) {
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
const auto dash = n.find('-');
const std::string_view tail =
dash == std::string::npos ? std::string_view{n} : std::string_view{n}.substr(dash + 1);
std::string out;
out.reserve(tail.size());
for (unsigned char c : tail) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
std::string stripLeadingZeros(std::string digits) {
std::size_t i = 0;
while (i + 1 < digits.size() && digits[i] == '0') ++i;
if (i > 0) digits.erase(0, i);
return digits;
}
bool hasAlphabeticPrefix(std::string_view setNo) {
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
return !n.empty() && std::isalpha(static_cast<unsigned char>(n.front())) != 0;
}
// Exact id match, or digits-only input matched to the numeric suffix with
// leading zeros ignored ("1" ↔ "ST-01", but not "ST-11").
bool cardNumbersMatch(std::string_view wanted, std::string_view actual) {
const std::string a = DigiBattle99CardPreviewSource::normalizeCardNumber(wanted);
const std::string b = DigiBattle99CardPreviewSource::normalizeCardNumber(actual);
if (a.empty() || b.empty()) return false;
if (a == b) return true;
// Full id typed (ST-01): require exact normalized equality only.
if (hasAlphabeticPrefix(a)) return false;
return stripLeadingZeros(numericSuffix(a)) == stripLeadingZeros(numericSuffix(b));
}
} // namespace
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
@@ -144,7 +182,8 @@ DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setName,
std::string_view wantedCardName) {
std::string_view wantedCardName,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
@@ -157,6 +196,7 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrint
const std::string wantedPack = trim(std::string(setName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
const std::string wantedNo = normalizeCardNumber(wantedSetNo);
std::vector<AutoDetectedPrint> collected;
for (const auto& card : j) {
@@ -166,13 +206,20 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrint
}
if (!cardInPack(card, wantedPack)) continue;
AutoDetectedPrint out;
out.name = trim(card.value("name", ""));
out.setNo = normalizeCardNumber(card.value("id", ""));
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
if (out.setNo.empty()) continue;
// digimoncard.io `card=` is fuzzy (card=1 can return ST-01 and ST-11).
// When the user typed a number, keep only exact / zero-padded matches.
if (!wantedNo.empty() && !cardNumbersMatch(wantedNo, out.setNo)) continue;
collected.push_back(std::move(out));
}
if (collected.empty()) {
if (!wantedNo.empty()) {
return R::err("Could not auto-detect Digi-Battle card name from set number.");
}
if (!wantedNameLower.empty() && !wantedPack.empty()) {
return R::err("Could not auto-detect Digi-Battle set print metadata.");
}
@@ -219,4 +266,40 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrin
return parsePrintVariants(fallback.value(), setName, name);
}
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectBySetNo(
std::string_view setName,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setName, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Digi-Battle card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectVariantsBySetNo(
std::string_view setName,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (trim(std::string(setName)).empty()) return R::err("Select a set first.");
const std::string num = normalizeCardNumber(setNo);
if (num.empty()) return R::err("Card number is empty.");
const std::string url = buildSearchUrl("", setName, num);
auto resp = http_.get(url);
if (resp) {
auto parsed = parsePrintVariants(resp.value(), setName, "", num);
if (parsed && !parsed.value().empty()) return parsed;
}
// Retry number-only; still filter by pack + exact/padded number.
const std::string fallbackUrl = buildSearchUrl("", "", num);
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
if (resp) return R::err("Could not auto-detect Digi-Battle card name from set number.");
return R::err(fallback.error());
}
return parsePrintVariants(fallback.value(), setName, "", num);
}
} // namespace ccm
@@ -27,6 +27,22 @@ std::string toLower(std::string s) {
return s;
}
std::string stripLeadingZeros(std::string_view s) {
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return std::string(s.substr(i));
}
// Exact localId match after slash-normalization, or leading-zero-insensitive
// equality ("4" ↔ "04", not "4" ↔ "14").
bool localIdsMatch(std::string_view a, std::string_view b) {
const std::string na = PokemonCardPreviewSource::normalizeCollectorNumber(a);
const std::string nb = PokemonCardPreviewSource::normalizeCollectorNumber(b);
if (na.empty() || nb.empty()) return false;
if (na == nb) return true;
return stripLeadingZeros(na) == stripLeadingZeros(nb);
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
@@ -299,4 +315,101 @@ Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVari
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::parsePrintFromCardById(
const std::string& body) {
using R = Result<AutoDetectedPrint>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err("TCGdex EN card response is not a JSON object.");
}
AutoDetectedPrint print;
print.name = trim(j.value("name", ""));
print.setNo = normalizeCollectorNumber(j.value("localId", ""));
print.rarity = trim(j.value("rarity", ""));
if (print.name.empty()) {
return R::err("TCGdex EN card has no name.");
}
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
const std::string id = j.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
}
}
return R::ok(std::move(print));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN card JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectBySetNo(std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err("Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
if (idCanon.empty()) return R::err("Select a set first.");
if (num.empty()) return R::err("Card number is empty.");
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) {
auto parsed = parsePrintFromCardById(byId.value());
if (parsed && localIdsMatch(parsed.value().setNo, num)) {
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(parsed).value());
return R::ok(std::move(out));
}
}
// Fallback: filtered search by set.id + localId.
const std::string url = buildSearchUrl("", idCanon, num);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
try {
const auto j = nlohmann::json::parse(resp.value());
if (!j.is_array() || j.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& card : j) {
AutoDetectedPrint print;
print.name = trim(card.value("name", ""));
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
print.rarity = trim(card.value("rarity", ""));
if (print.name.empty()) continue;
if (print.setNo.empty() && card.contains("id") && card.at("id").is_string()) {
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
}
}
// Defense-in-depth: TCGdex search can be fuzzy; never accept a
// different localId (e.g. "14" when the user asked for "4").
if (!localIdsMatch(print.setNo, num)) continue;
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
}
}
} // namespace ccm
@@ -439,4 +439,113 @@ JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
return parsed;
}
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::parsePrintFromCardResponse(
const std::string& body) {
using R = Result<AutoDetectedPrint>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err("TCGdex JA card response is not a JSON object.");
}
AutoDetectedPrint print;
print.name = trim(j.value("name", ""));
print.setNo = normalizeLocalId(j.value("localId", ""));
print.rarity = trim(j.value("rarity", ""));
if (print.name.empty()) {
return R::err("TCGdex JA card has no name.");
}
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
const std::string id = j.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeLocalId(id.substr(dash + 1));
}
}
return R::ok(std::move(print));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex JA card JSON parse error: ") + e.what());
}
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
std::string_view setId,
std::string_view localId,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string id = normalizeLocalId(localId);
if (setId.empty()) return R::err("Select a set first.");
if (id.empty()) return R::err("Card number is empty.");
// Prefer exact key, then leading-zero-insensitive scan ("1" ↔ "001").
auto found = catalog.findPrint(setId, id);
if (!found) {
for (const auto& print : catalog.printsForSet(setId)) {
if (localIdsMatch(print.localId, id)) {
found = print;
break;
}
}
}
if (!found) {
return R::err("Could not auto-detect card name from set number.");
}
AutoDetectedPrint print;
print.name = !found->nameEn.empty() ? found->nameEn : found->nameJa;
print.setNo = found->localId.empty() ? id : found->localId;
if (print.name.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(print));
return R::ok(std::move(out));
}
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::detectBySetNo(
std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (setId.empty()) return R::err("Select a set first.");
const std::string id = normalizeLocalId(setNo);
if (id.empty()) return R::err("Card number is empty.");
auto cardResp = http_.get(buildCardUrl(setId, id));
if (cardResp) {
auto parsed = parsePrintFromCardResponse(cardResp.value());
if (parsed && localIdsMatch(parsed.value().setNo, id)) {
// Prefer EN catalog name when available (exact or zero-insensitive).
if (auto cat = catalog_.findPrint(setId, id); cat && !cat->nameEn.empty()) {
parsed.value().name = cat->nameEn;
} else {
for (const auto& p : catalog_.printsForSet(setId)) {
if (localIdsMatch(p.localId, id) && !p.nameEn.empty()) {
parsed.value().name = p.nameEn;
break;
}
}
}
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(parsed).value());
return R::ok(std::move(out));
}
}
if (catalog_.hasPrintsForSet(setId)) {
return detectVariantsBySetNoFromCatalog(setId, id, catalog_);
}
return R::err("Could not auto-detect card name from set number.");
}
} // namespace ccm
@@ -555,4 +555,176 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string packId = std::string(trimAsciiSpaces(setId));
if (packId.empty()) return R::err("Select a set first.");
const std::string rawNo = std::string(trimAsciiSpaces(setNo));
if (rawNo.empty()) return R::err("Card number is empty.");
const std::string wantDigits =
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo));
if (wantDigits.empty()) return R::err("Card number is empty.");
const YuGiOhSetCatalogPack* pack = catalog.findPack(packId);
if (pack == nullptr) {
// Allow callers to pass the display set name (HTTP fallback path).
for (const auto& candidate : catalog.packs) {
if (candidate.setName == packId) {
pack = &candidate;
break;
}
}
}
if (pack == nullptr) {
return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first.");
}
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seenNames;
for (const auto& card : pack->cards) {
if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue;
if (card.name.empty()) continue;
if (!seenNames.insert(card.name).second) continue;
AutoDetectedPrint print;
print.name = card.name;
print.setNo = card.setNo;
print.rarity = card.rarity;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
}
std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) {
return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") +
rfc3986PercentEncode(setName);
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
const std::string& body,
std::string_view preferredSetName,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string wantDigits =
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo));
if (wantDigits.empty()) return R::err("Card number is empty.");
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err("YGOPRODeck response missing 'data' array.");
}
const std::string preferredLower = toLower(trim(std::string(preferredSetName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& card : j.at("data")) {
const std::string cardName = trim(card.value("name", ""));
if (cardName.empty()) continue;
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& printing : card.at("card_sets")) {
const std::string setName = trim(printing.value("set_name", ""));
const std::string setCode = trim(printing.value("set_code", ""));
if (setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
if (!preferredLower.empty() && toLower(setName) != preferredLower) continue;
if (!ygoCollectorDigitsEqual(setCode, setNo)) continue;
AutoDetectedPrint print;
print.name = cardName;
print.setNo = setCode;
print.rarity = trim(printing.value("set_rarity", ""));
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
out.push_back(std::move(print));
}
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string setKey = std::string(trimAsciiSpaces(setId));
if (setKey.empty()) return R::err("Select a set first.");
if (ygoCollectorDigitsFromInput(setNo).empty()) {
return R::err("Card number is empty.");
}
// 1) Offline catalog (preferred — fast once cached).
if (catalogStore_ != nullptr) {
if (!catalogCache_) {
auto loaded = catalogStore_->load();
if (loaded) catalogCache_ = std::move(loaded).value();
}
if (catalogCache_ && !catalogCache_->empty()) {
auto fromCatalog =
detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo);
// Prefer YGOPRODeck when reachable so rarity (and multi-rarity
// variants) come through — the offline catalog may predate the
// rarity field or only keep one rarity per printing slot.
const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey);
std::string setName = setKey;
if (pack != nullptr) {
setName = pack->setName;
} else {
for (const auto& candidate : catalogCache_->packs) {
if (candidate.setName == setKey) {
setName = candidate.setName;
break;
}
}
}
if (!setName.empty()) {
auto resp = http_.get(buildCardsetOnlyUrl(setName));
if (resp) {
auto fromHttp =
detectVariantsBySetNoFromCardset(resp.value(), setName, setNo);
if (fromHttp) return fromHttp;
}
}
if (fromCatalog) return fromCatalog;
// Prefer catalog miss text when HTTP also missed / was unreachable.
return fromCatalog;
}
}
// 2) No catalog: treat setKey as display set name and query YGOPRODeck.
auto resp = http_.get(buildCardsetOnlyUrl(setKey));
if (!resp) {
return R::err(
"Set catalog missing and YGOPRODeck lookup failed. "
"Run Sets → Update Yu-Gi-Oh! or check your network.");
}
return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo);
}
} // namespace ccm
+9 -1
View File
@@ -166,7 +166,9 @@ Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& b
const auto existing = build.slotIndex.find(slot);
if (existing == build.slotIndex.end()) {
build.slotIndex.emplace(slot, build.cards.size());
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
const std::string setRarity(
trimAsciiSpaces(printing.value("set_rarity", "")));
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity});
continue;
}
@@ -175,6 +177,12 @@ Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& b
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
prev.setNo = setCode;
if (!cardName.empty()) prev.name = cardName;
const std::string setRarity(
trimAsciiSpaces(printing.value("set_rarity", "")));
if (!setRarity.empty()) prev.rarity = setRarity;
} else if (prev.rarity.empty()) {
prev.rarity = std::string(
trimAsciiSpaces(printing.value("set_rarity", "")));
}
}
}
@@ -0,0 +1,453 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <sstream>
namespace ccm {
namespace {
using K = PreviewLookupError::Kind;
std::string trimCopy(std::string_view s) {
while (!s.empty() &&
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
s.front() == '\r')) {
s.remove_prefix(1);
}
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
s.back() == '\r')) {
s.remove_suffix(1);
}
return std::string(s);
}
std::string urlEncode(std::string_view s) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else if (c == ' ') {
out.push_back('+');
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
std::string wikiTitleEncode(std::string_view title) {
// MediaWiki titles use underscores for spaces in the titles= parameter.
std::string s;
s.reserve(title.size());
for (char c : title) {
s.push_back(c == ' ' ? '_' : c);
}
return urlEncode(s);
}
bool endsWith(std::string_view s, std::string_view suffix) {
return s.size() >= suffix.size() &&
s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
int askMatchRank(std::string_view pageTitle, std::string_view preferredSetId) {
// Lower is better.
if (preferredSetId == "bansealdass") {
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 0;
if (endsWith(pageTitle, " (Bandai)")) return 1;
return 5;
}
if (preferredSetId == "ban3") {
if (endsWith(pageTitle, " (Bandai)")) return 0;
if (endsWith(pageTitle, " (English Bandai)")) return 1;
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 4;
return 5;
}
if (endsWith(pageTitle, " (Bandai)")) return 0;
if (endsWith(pageTitle, " (English Bandai)")) return 1;
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 3;
return 5;
}
} // namespace
YuGiOhBandaiCardPreviewSource::YuGiOhBandaiCardPreviewSource(IHttpClient& http)
: http_(http) {}
std::string YuGiOhBandaiCardPreviewSource::preferredPageTitle(
std::string_view name,
std::string_view setId,
std::string_view setNo) {
const std::string n = trimCopy(name);
if (n.empty()) return {};
const std::string num = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
if (setId == "bansealdass") {
return n + " (Bandai Sealdass)";
}
// Promo pages on Yugipedia often omit the "(Bandai)" disambiguator
// (e.g. Blue-Eyes White Dragon's 3-Body Connection for TA2).
if (setId == "banpromo-j" || setId == "banpromo-ta" ||
isAlphanumericPromoNumber(num)) {
return n;
}
if (num == "118" || setId == "ban3") {
// Prefer JP Bandai page for most ban3 cards; English #118 uses the
// English Bandai title when setNo is 118.
if (num == "118") return n + " (English Bandai)";
}
return n + " (Bandai)";
}
std::string YuGiOhBandaiCardPreviewSource::buildPageImagesUrl(
std::string_view pageTitle) {
return std::string(
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=pageimages&piprop=original&titles=") +
wikiTitleEncode(pageTitle);
}
std::string YuGiOhBandaiCardPreviewSource::buildAskByNameUrl(
std::string_view englishName) {
// [[Category:Bandai cards]][[English name::<name>]]|?English name|?Bandai number|?Rarity|limit=20
std::ostringstream q;
q << "[[Category:Bandai cards]][[English name::" << englishName
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
urlEncode(q.str());
}
std::string YuGiOhBandaiCardPreviewSource::buildAskByNumberUrl(
std::string_view setNo) {
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
std::ostringstream q;
q << "[[Category:Bandai cards]][[Bandai number::" << n
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
urlEncode(q.str());
}
bool YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber(
std::string_view setNo) {
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
for (unsigned char c : n) {
if (std::isalpha(c)) return true;
}
return false;
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parsePromoGalleryResponse(
const std::string& body,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string want = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
if (want.empty()) return R::err("Card number is empty.");
std::string wikitext;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
return R::err("Yugipedia promo gallery response missing parse.wikitext");
}
wikitext = j.at("parse").at("wikitext").get<std::string>();
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia promo gallery JSON parse error: ") +
e.what());
}
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wikitext);
if (!cards) return R::err(cards.error());
std::vector<AutoDetectedPrint> out;
for (const auto& card : cards.value()) {
if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo) != want) continue;
AutoDetectedPrint print;
print.name = card.name;
print.setNo = card.setNo;
print.rarity = card.rarity;
print.setId = YuGiOhBandaiSetSource::setIdForNumber(card.setNo);
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
print.language = "Japanese";
out.push_back(std::move(print));
}
return R::ok(std::move(out));
}
AutoDetectedPrint YuGiOhBandaiCardPreviewSource::enrichPrint(
AutoDetectedPrint print,
std::string_view pageTitle) {
print.name = YuGiOhBandaiSetSource::englishNameFromGalleryTitle(pageTitle);
if (endsWith(pageTitle, " (Bandai Sealdass)")) {
print.setId = "bansealdass";
print.language = "Japanese";
} else if (endsWith(pageTitle, " (English Bandai)")) {
print.setId = "ban3";
print.language = "English";
} else {
if (print.setId.empty() && !print.setNo.empty()) {
print.setId = YuGiOhBandaiSetSource::setIdForNumber(print.setNo);
}
print.language = "Japanese";
}
if (!print.setId.empty()) {
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
}
return print;
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").contains("pages")) {
return R::err({K::Transient, "Yugipedia pageimages: missing query.pages"});
}
const auto& pages = j.at("query").at("pages");
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (page.contains("missing") || page.contains("invalid")) continue;
if (page.contains("original") && page.at("original").contains("source")) {
const auto url = page.at("original").at("source").get<std::string>();
if (!url.empty()) return R::ok(url);
}
if (page.contains("thumbnail") && page.at("thumbnail").contains("original")) {
const auto url = page.at("thumbnail").at("original").get<std::string>();
if (!url.empty()) return R::ok(url);
}
}
return R::err({K::NotFound, "Yugipedia pageimages: no image for page"});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia pageimages JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body,
std::string_view preferredSetId,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").contains("results")) {
return R::err("Yugipedia ask: missing query.results");
}
const auto& results = j.at("query").at("results");
if (!results.is_object() || results.empty()) {
return R::ok({});
}
const std::string wantNo = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
std::vector<std::pair<int, AutoDetectedPrint>> ranked;
for (auto it = results.begin(); it != results.end(); ++it) {
const std::string pageTitle = it.key();
const auto& printouts = it.value().value("printouts", nlohmann::json::object());
AutoDetectedPrint print;
if (printouts.contains("Bandai number") &&
printouts.at("Bandai number").is_array() &&
!printouts.at("Bandai number").empty()) {
const auto& num = printouts.at("Bandai number").at(0);
if (num.is_number_integer()) {
print.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(
std::to_string(num.get<int>()));
} else if (num.is_string()) {
print.setNo =
YuGiOhBandaiSetSource::normalizeCardNumber(num.get<std::string>());
}
}
// Defense-in-depth: SMW ask should be exact, but never accept a
// different Bandai number (e.g. #11 when the user asked for #1).
if (!wantNo.empty() &&
YuGiOhBandaiSetSource::normalizeCardNumber(print.setNo) != wantNo) {
continue;
}
if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() &&
!printouts.at("Rarity").empty()) {
const auto& rar = printouts.at("Rarity").at(0);
if (rar.is_object() && rar.contains("fulltext")) {
print.rarity = rar.at("fulltext").get<std::string>();
} else if (rar.is_string()) {
print.rarity = rar.get<std::string>();
}
}
if (printouts.contains("English name") &&
printouts.at("English name").is_array() &&
!printouts.at("English name").empty()) {
print.name = printouts.at("English name").at(0).get<std::string>();
}
print = enrichPrint(std::move(print), pageTitle);
if (print.name.empty()) continue;
ranked.emplace_back(askMatchRank(pageTitle, preferredSetId), std::move(print));
}
std::sort(ranked.begin(), ranked.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
std::vector<AutoDetectedPrint> out;
out.reserve(ranked.size());
for (auto& [rank, print] : ranked) {
(void)rank;
out.push_back(std::move(print));
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia ask JSON parse error: ") + e.what());
}
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::fetchPageImage(std::string_view pageTitle) {
using R = Result<std::string, PreviewLookupError>;
if (pageTitle.empty()) {
return R::err({K::NotFound, "Empty Bandai page title"});
}
const std::string url = buildPageImagesUrl(pageTitle);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parsePageImagesResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByName(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = trimCopy(name);
if (n.empty()) return R::err("Card name is empty.");
const std::string url = buildAskByNameUrl(n);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parseAskResponse(resp.value(), setId);
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByNumber(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
if (n.empty()) return R::err("Card number is empty.");
// Promo codes (J1, TA2, …) are not valid values for SMW's numeric
// `Bandai number` property — ask returns a type error. Resolve them from
// the promotional set gallery instead.
R list = [&]() -> R {
if (isAlphanumericPromoNumber(n)) {
static constexpr const char* kPromoGallery =
"Set Card Galleries:Promotional Cards (Bandai)";
const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parsePromoGalleryResponse(resp.value(), n);
}
const std::string url = buildAskByNumberUrl(n);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parseAskResponse(resp.value(), setId, n);
}();
if (!list) return list;
const std::string wantSet = trimCopy(setId);
if (wantSet.empty()) return list;
std::vector<AutoDetectedPrint> filtered;
filtered.reserve(list.value().size());
for (auto& print : list.value()) {
if (print.setId == wantSet) filtered.push_back(std::move(print));
}
if (filtered.empty()) {
return R::err("No Bandai card matched that number in the selected set.");
}
return R::ok(std::move(filtered));
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Bandai card from number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
std::string_view setNo) {
return askByNumber(setId, setNo);
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
const std::string title = preferredPageTitle(name, setId, setNo);
auto direct = fetchPageImage(title);
if (direct) return direct;
// Try English Bandai if JP page missed for #118.
if (YuGiOhBandaiSetSource::normalizeCardNumber(setNo) == "118") {
auto en = fetchPageImage(trimCopy(name) + " (English Bandai)");
if (en) return en;
}
// Fall back to SMW ask by name, then pageimages on the best hit.
auto variants = askByName(name, setId);
if (!variants) {
// Prefer the original NotFound if ask also failed transiently only
// after a clean miss; otherwise surface ask error as Transient.
if (direct.error().kind == K::NotFound) {
return R::err({K::Transient, variants.error()});
}
return direct;
}
if (variants.value().empty()) {
return R::err({K::NotFound, "No Bandai card matched the name"});
}
const auto& best = variants.value().front();
std::string askTitle = preferredPageTitle(best.name, best.setId, best.setNo);
if (best.language == "English") {
askTitle = best.name + " (English Bandai)";
} else if (best.setId == "bansealdass") {
askTitle = best.name + " (Bandai Sealdass)";
}
return fetchPageImage(askTitle);
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectFirstPrint(
std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err("Could not auto-detect Bandai print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name,
std::string_view setId) {
return askByName(name, setId);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
namespace ccm {
YuGiOhBandaiGameModule::YuGiOhBandaiGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,267 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <regex>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
std::string trimCopy(std::string_view s) {
while (!s.empty() &&
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
s.front() == '\r')) {
s.remove_prefix(1);
}
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
s.back() == '\r')) {
s.remove_suffix(1);
}
return std::string(s);
}
std::string urlEncode(std::string_view s) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else if (c == ' ') {
out.push_back('+');
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
} // namespace
YuGiOhBandaiSetSource::YuGiOhBandaiSetSource(IHttpClient& http) : http_(http) {}
const std::vector<YuGiOhBandaiSetSource::SetManifestEntry>&
YuGiOhBandaiSetSource::setManifest() {
static const std::vector<SetManifestEntry> kManifest{
{"ban1", "1st Generation", "1998/09/01",
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation", ""},
{"ban2", "2nd Generation", "1998/11/01",
"Set Card Galleries:2nd Generation (Bandai)", ""},
{"ban3", "3rd Generation", "1999/03/06",
"Set Card Galleries:3rd Generation (Bandai)", ""},
{"banpromo-j", "Jump Promos", "1998/01/01",
"Set Card Galleries:Promotional Cards (Bandai)", "J"},
{"banpromo-ta", "Toei Promos", "1999/03/06",
"Set Card Galleries:Promotional Cards (Bandai)", "TA"},
{"bansealdass", "Sealdass", "1999/06/01",
"Set Card Galleries:Yu-Gi-Oh! Bandai Sealdass", ""},
};
return kManifest;
}
Result<std::vector<Set>> YuGiOhBandaiSetSource::parseResponse(
const std::string& /*unused*/) {
std::vector<Set> out;
for (const auto& e : setManifest()) {
out.push_back(Set{e.id, e.name, e.releaseDate});
}
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<std::vector<Set>> YuGiOhBandaiSetSource::fetchAll() {
return parseResponse({});
}
std::string YuGiOhBandaiSetSource::buildGalleryParseUrl(std::string_view pageTitle) {
return std::string(
"https://yugipedia.com/api.php?action=parse&format=json&formatversion=2"
"&prop=wikitext&page=") +
urlEncode(pageTitle);
}
std::string YuGiOhBandaiSetSource::normalizeCardNumber(std::string_view setNo) {
std::string s = trimCopy(setNo);
if (s.empty()) return {};
// Strip a leading '#' if present.
if (s.front() == '#') s.erase(s.begin());
// Uppercase letter prefix forms: j1 / ta2.
bool hasAlpha = false;
for (char& c : s) {
if (std::isalpha(static_cast<unsigned char>(c))) {
hasAlpha = true;
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
}
if (hasAlpha) return s;
// Pure decimal: strip leading zeros but keep a single zero.
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return s.substr(i);
}
std::string YuGiOhBandaiSetSource::expandRarityCode(std::string_view code) {
const std::string c = trimCopy(code);
if (c == "C") return "Common";
if (c == "R") return "Rare";
if (c == "SR") return "Super Rare";
if (c == "UR") return "Ultra Rare";
if (c == "HFR" || c == "Holo Seal" || c == "HS") return "Holo Seal";
if (c.empty()) return {};
return c;
}
std::string YuGiOhBandaiSetSource::englishNameFromGalleryTitle(
std::string_view pageTitle) {
std::string name = trimCopy(pageTitle);
const auto stripSuffix = [&](std::string_view suffix) {
if (name.size() > suffix.size() &&
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
name.resize(name.size() - suffix.size());
name = trimCopy(name);
}
};
stripSuffix(" (Bandai Sealdass)");
stripSuffix(" (English Bandai)");
stripSuffix(" (Bandai)");
return name;
}
std::string YuGiOhBandaiSetSource::setIdForNumber(std::string_view setNo) {
const std::string n = normalizeCardNumber(setNo);
if (n.empty()) return {};
if (!n.empty() && (n[0] == 'J' || n[0] == 'j')) return "banpromo-j";
if (n.size() >= 2 && (n[0] == 'T' || n[0] == 't') &&
(n[1] == 'A' || n[1] == 'a')) {
return "banpromo-ta";
}
// Pure decimal → generation by range. Callers that need Sealdass must
// pass set context; number alone cannot disambiguate 142 vs Sealdass.
bool pureDecimal = true;
for (char c : n) {
if (!std::isdigit(static_cast<unsigned char>(c))) {
pureDecimal = false;
break;
}
}
if (!pureDecimal) return {};
const int v = std::stoi(n);
if (v >= 1 && v <= 42) return "ban1";
if (v >= 43 && v <= 88) return "ban2";
if (v >= 89 && v <= 118) return "ban3";
return {};
}
std::string YuGiOhBandaiSetSource::setNameForId(std::string_view setId) {
for (const auto& e : setManifest()) {
if (e.id == setId) return e.name;
}
return {};
}
Result<std::vector<YuGiOhBandaiCatalogCard>>
YuGiOhBandaiSetSource::parseGalleryWikitext(const std::string& wikitext) {
using R = Result<std::vector<YuGiOhBandaiCatalogCard>>;
std::vector<YuGiOhBandaiCatalogCard> out;
// Generation galleries (raw):
// … | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|ja}}
// Promo galleries (often expanded with <br />):
// … | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}
static const std::regex kLine(
R"((?:\{\{pound\}\}|\[\[)([A-Za-z0-9]+)(?:\]\])?(?:\s*\(\[\[([A-Za-z0-9]+)\]\]\))?[^\n]*?\{\{Gallery card names\|([^}|]+))",
std::regex::ECMAScript);
std::unordered_set<std::string> seen;
for (std::sregex_iterator it(wikitext.begin(), wikitext.end(), kLine), end;
it != end; ++it) {
const std::smatch& m = *it;
YuGiOhBandaiCatalogCard card;
card.setNo = normalizeCardNumber(m[1].str());
if (card.setNo.empty()) continue;
if (m[2].matched) {
card.rarity = expandRarityCode(m[2].str());
}
card.name = englishNameFromGalleryTitle(m[3].str());
if (card.name.empty()) continue;
if (!seen.insert(card.setNo).second) continue;
out.push_back(std::move(card));
}
return R::ok(std::move(out));
}
Result<YuGiOhBandaiSetSource::FetchWithCatalog>
YuGiOhBandaiSetSource::fetchAllWithCatalog() {
using R = Result<FetchWithCatalog>;
auto sets = parseResponse({});
if (!sets) return R::err(sets.error());
YuGiOhBandaiSetCatalog catalog;
std::unordered_map<std::string, std::string> pageCache;
for (const auto& entry : setManifest()) {
const std::string page = entry.galleryPage;
std::string body;
auto cached = pageCache.find(page);
if (cached != pageCache.end()) {
body = cached->second;
} else {
const std::string url = buildGalleryParseUrl(page);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
body = std::move(resp).value();
pageCache.emplace(page, body);
}
std::string wikitext;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
return R::err("Yugipedia gallery response missing parse.wikitext");
}
wikitext = j.at("parse").at("wikitext").get<std::string>();
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia gallery JSON parse error: ") +
e.what());
}
auto cards = parseGalleryWikitext(wikitext);
if (!cards) return R::err(cards.error());
YuGiOhBandaiSetCatalogPack pack;
pack.setId = entry.id;
pack.setName = entry.name;
const std::string prefix = entry.setNoPrefix;
for (const auto& card : cards.value()) {
if (!prefix.empty()) {
if (card.setNo.size() < prefix.size() ||
card.setNo.compare(0, prefix.size(), prefix) != 0) {
continue;
}
}
pack.cards.push_back(card);
}
catalog.packs.push_back(std::move(pack));
}
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog);
return R::ok(std::move(out));
}
} // namespace ccm
+16
View File
@@ -83,6 +83,22 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi
return false;
}
bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card, std::string_view filter) {
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter) {
if (filter.empty()) return true;
+29
View File
@@ -262,6 +262,35 @@ Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
return it->second->detectPrintVariants(name, setId);
}
Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
}
return it->second->detectBySetNo(setId, setNo);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
Game game,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::vector<AutoDetectedPrint>>::err(
"No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Auto-detect not enabled for this game.");
}
return it->second->detectVariantsBySetNo(setId, setNo);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
// The by-URL path is used for fixed per-game card-back fallback images.
// A failure there is always transient (the URL itself is constant), so
+73
View File
@@ -293,6 +293,79 @@ void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
}
}
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
YuGiOhBandaiSortColumn column,
bool ascending) {
switch (column) {
case YuGiOhBandaiSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case YuGiOhBandaiSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
}, ascending));
break;
case YuGiOhBandaiSortColumn::SetNo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.setNo) < asciiLower(b.setNo);
}, ascending));
break;
case YuGiOhBandaiSortColumn::Rarity:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.rarity) < asciiLower(b.rarity);
}, ascending));
break;
case YuGiOhBandaiSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
}, ascending));
break;
case YuGiOhBandaiSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
}, ascending));
break;
case YuGiOhBandaiSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.holo < b.holo;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending) {
@@ -0,0 +1,50 @@
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhBandaiSetCatalogService::YuGiOhBandaiSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhBandaiSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOhBandai) /
"set-catalog.json";
}
bool YuGiOhBandaiSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhBandaiSetCatalog> YuGiOhBandaiSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhBandaiSetCatalog>::err(
"Yu-Gi-Oh! (Bandai) set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhBandaiSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhBandaiSetCatalog>::ok(j.get<YuGiOhBandaiSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhBandaiSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhBandaiSetCatalogService::save(const YuGiOhBandaiSetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
@@ -0,0 +1,128 @@
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
bool passesLanguageFilter(const YuGiOhBandaiCard& card,
std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSetNosBySetId(const std::vector<YuGiOhBandaiCard>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<YuGiOhBandaiSetCompletionProgress>
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
std::vector<YuGiOhBandaiSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo =
YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
YuGiOhBandaiSetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhBandaiSetCompletionProgress& a,
const YuGiOhBandaiSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhBandaiChecklistEntry>
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<YuGiOhBandaiChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhBandaiChecklistEntry entry;
entry.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
entry.name = card.name;
entry.rarity = card.rarity;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhBandaiChecklistEntry& a,
const YuGiOhBandaiChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+3 -1
View File
@@ -18,7 +18,7 @@ struct Replacement {
std::string_view to;
};
constexpr std::array<Replacement, 14> kReplacements{{
constexpr std::array<Replacement, 16> kReplacements{{
{"'", ""},
{"`", ""},
{",", ""},
@@ -34,6 +34,8 @@ constexpr std::array<Replacement, 14> kReplacements{{
{"\xC3\xBB", "u"}, // u-circumflex
// Remaining accented vowels appear in modern Scryfall data but were not
// listed in the Rust source. Keeping behavior 1:1 deliberately.
{"\xE2\x99\x82", "male"}, // ♂ male sign
{"\xE2\x99\x80", "female"}, // ♀ female sign
}};
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
+3 -3
View File
@@ -11,13 +11,13 @@ Long-form contributor documentation that lives outside the source tree.
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, 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).
- `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!, Yu-Gi-Oh! (Bandai), and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching).
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
## Subdirectories
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-ygo-bandai.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
## Conventions
@@ -28,7 +28,7 @@ Long-form contributor documentation that lives outside the source tree.
## Required follow-ups
- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`.
- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `YuGiOhBandaiSetSource`, `YuGiOhBandaiCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples.
- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete.
- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`).
+2 -1
View File
@@ -141,7 +141,7 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
- `static Result<std::string> parseResponse(const std::string& body);`
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Keep both optional per game — default behavior should remain an explicit unsupported error.
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. For bidirectional identify (set + name → setNo, or set + setNo → name), also override `detectBySetNo(...)` / `detectVariantsBySetNo(setId, setNo)` and wire them through `CardPreviewService`. In the edit dialog, bind Set # `wxEVT_TEXT` to `markSetNoLookupEdited()` and branch Auto detect with `shouldDetectBySetNo(...)` so the last typed field wins when both are filled (see existing Yu-Gi-Oh! / Pokémon / Digi-Battle / Bandai dialogs). `AutoDetectedPrint` carries `setNo` + `rarity` for every game; optional `name` / `setId` / `setName` / `language` fields stay empty when unused. Keep all of these optional per game — default behavior should remain an explicit unsupported error.
Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
@@ -325,6 +325,7 @@ Derive from `BaseCardEditDialog<<Name>Card>`. Override:
- `readExtraFromCard()` — copy fields from `constCard()` into your widgets.
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
- `validateExtraFields()` — optional; called from OK after name/set checks. Return `false` to block save (show your own themed dialog). Yu-Gi-Oh! (Bandai) requires a non-empty set number here.
Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row):
+44 -2
View File
@@ -33,7 +33,11 @@ Used by `PokemonCardPreviewSource` in two ways:
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId``AutoDetectedPrint::setNo` and `rarity``AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped. The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId``AutoDetectedPrint::setNo` and `rarity``AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped.
3. **Reverse auto-detect (`detectVariantsBySetNo`, same Set # Auto detect button).** Requires a selected set. When **Name** is blank and **Set #** is filled, uses `GET /v2/en/cards/{setId}-{localId}` (then filtered search) to fill the card **name**. Returned `localId`s are post-filtered so a fuzzy hit cannot win on a shared digit prefix (`4` must not accept `14`). When Name is filled, behavior stays name → setNo as above. Set is always required for either direction.
The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component.
@@ -96,6 +100,8 @@ Used in two situations:
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted.
3. **Reverse auto-detect (`detectVariantsBySetNo`).** When **Name** is blank and **Set #** is filled, the Set # Auto detect button looks up the offline `yugioh/set-catalog.json` checklist (same file as Set Completion) by `Set.id` + collector digits / full code, and fills the card **name** (and **rarity** when present on the catalog row or when YGOPRODeck `cardset=` enrichment succeeds). Digit matching strips leading zeros but is not a prefix match (`5``LOB-005`, `1` does not match `LOB-011`). Name→Set # Auto detect also applies the matched prints rarity. When **both** Name and Set # are filled, the field last typed by the user is the lookup key (so editing Set # after a name detect and clicking Auto detect again resolves by set number, not by re-running the name path). Requires a prior **Sets → Update Yu-Gi-Oh!** so the catalog exists (re-run Update to refresh rarities on older catalogs). Set is always required for both directions.
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
### Set-completion catalog (`cardinfo.php` all-cards dump)
@@ -105,10 +111,41 @@ YGOPRODeck publishes rate limits and asks clients to cache responses and avoid a
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
2. A pack checklist at `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name }` drawn from each cards `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name, rarity? }` drawn from each cards `card_sets[]` (`set_rarity` when present). European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
## Yu-Gi-Oh! (Bandai) APIs (Yugipedia)
Bandai Carddass (pre-Konami) is wired as `Game::YuGiOhBandai` (`dirName` `yugiohbandai`, UI label **Yu-Gi-Oh! (Bandai)**). There is no dedicated Bandai REST API; everything goes through Yugipedia MediaWiki + Semantic MediaWiki.
### Info API (sets + catalog)
`YuGiOhBandaiSetSource` keeps an **app-owned set manifest** (stable ids, no fragile category scrape):
| id | Name | Numbers |
|---|---|---|
| `ban1` | 1st Generation | 142 |
| `ban2` | 2nd Generation | 4388 |
| `ban3` | 3rd Generation | 89118 |
| `banpromo-j` | Jump Promos | J1J3 |
| `banpromo-ta` | Toei Promos | TA1TA2 |
| `bansealdass` | Sealdass | 142 |
`fetchAll()` returns that manifest (offline — no HTTP). `fetchAllWithCatalog()` additionally `GET`s each sets Yugipedia gallery page via `action=parse&prop=wikitext` and parses lines like `… | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|…}}` into checklist entries `{setNo, name, rarity}` (rarity codes `C`/`R`/`SR` → Common/Rare/Super Rare). The shared promo gallery is split by `setNo` prefix (`J*` vs `TA*`). Persisted at `yugiohbandai/set-catalog.json`.
**Set Completion** ownership keys on `(set.id, normalized setNo)`. Because `fetchAll()` is offline, Add/Edit can work before any catalog download; the catalog is filled on the first visit to the Set Completion tab (or via **Sets → Update Yu-Gi-Oh! (Bandai)**). Cards without a set number do not count toward progress.
English Blue-Eyes is **not** a separate set — it is `ban3` card `#118` with language English.
### Asset API (preview + auto-detect)
1. **Preview:** `pageimages` on preferred titles `Name (Bandai)` / `Name (English Bandai)` / `Name (Bandai Sealdass)`, falling back to SMW `ask` by English name then `pageimages` on the best hit.
2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`. Requires a selected set.
3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` (or promo gallery parse for `J*`/`TA*` codes) → same fields, then **filtered to the selected set**. Ask results are also dropped when the returned Bandai number does not match the requested one after normalization (`1` must not accept `11`). The Set # Auto detect button is bidirectional: blank name + number fills name; name filled fills number/rarity. Set is always required.
Card-back fallback URL: `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`.
## Digimon Digi-Battle (1999) APIs (digimoncard.io)
English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse).
@@ -157,6 +194,8 @@ where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also s
**Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity).
**Reverse auto-detect** (`detectVariantsBySetNo`): when Name is blank and Set # is filled, search with `card=` + `pack=` fills `AutoDetectedPrint::name` (and normalizes `setNo`). Hits are post-filtered so digits-only input matches the numeric suffix with leading zeros ignored (`1``ST-01`, not `ST-11`). Set (pack display name) is always required for either direction.
Empty search array / `{"error":"..."}``NotFound`; bad JSON / HTTP → `Transient`.
## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend
@@ -231,6 +270,8 @@ It does **not** substitute another printing of the same Pokémon when both TCGde
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).
Reverse auto-detect (`detectVariantsBySetNo`) uses `GET /v2/ja/cards/{setId}-{localId}` (preferring catalog `nameEn` when present) or a catalog scan with leading-zero-insensitive `localId` matching (`1``001`, not `011`) for catalog-only sets, filling Name when Set # is known and Name is blank. Set is always required.
Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ``Erika's Oddish`, `わるいリザードン``Dark Charizard`, `R団のサンダー``Rocket's Zapdos`, neo garbled `輝くセレビ``Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`).
That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1PMCG6, neo1neo4, VS1, web1, E1E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
@@ -315,6 +356,7 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C
- 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! (Bandai): `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`.
- Digimon (Digi-Battle): no stable public back URL; load `<exeDir>/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time).
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

+4
View File
@@ -26,6 +26,9 @@ add_executable(ccm_core_tests
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp
yugiohbandai_set_source_tests.cpp
yugiohbandai_card_preview_source_tests.cpp
yugiohbandai_set_completion_tests.cpp
yugioh_set_completion_tests.cpp
pokemon_set_completion_tests.cpp
set_no_natural_tests.cpp
@@ -40,6 +43,7 @@ add_executable(ccm_core_tests
card_sorter_tests.cpp
card_filter_tests.cpp
ascii_utils_tests.cpp
card_lookup_detect_tests.cpp
http_get_mapping_tests.cpp
cpr_http_client_tests.cpp
+34
View File
@@ -10,6 +10,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/services/CardFilter.hpp"
@@ -287,6 +288,39 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
}
}
TEST_SUITE("CardFilter::matchesYuGiOhBandaiFilter") {
TEST_CASE("matches name set setNo rarity language") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
c.set.name = "1st Generation";
c.setNo = "14";
c.rarity = "Rare";
c.language = Language::Japanese;
CHECK(matchesYuGiOhBandaiFilter(c, "magician"));
CHECK(matchesYuGiOhBandaiFilter(c, "1st"));
CHECK(matchesYuGiOhBandaiFilter(c, "14"));
CHECK(matchesYuGiOhBandaiFilter(c, "rare"));
CHECK(matchesYuGiOhBandaiFilter(c, "japanese"));
CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "blue-eyes"));
}
TEST_CASE("empty filter matches everything") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
CHECK(matchesYuGiOhBandaiFilter(c, ""));
}
TEST_CASE("boolean flag columns are not matched") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
c.holo = true;
c.signed_ = true;
c.altered = true;
CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "true"));
CHECK(matchesYuGiOhBandaiFilter(c, "dark"));
}
}
TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") {
TEST_CASE("matches by name and set.name") {
JapanesePokemonCard c;
+33
View File
@@ -0,0 +1,33 @@
#include <doctest/doctest.h>
#include "ccm/util/CardLookupDetect.hpp"
using namespace ccm;
TEST_SUITE("preferDetectBySetNo") {
TEST_CASE("only set number filled uses reverse lookup") {
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::None));
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::Name));
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::SetNo));
}
TEST_CASE("only name filled uses name lookup") {
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::None));
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::Name));
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::SetNo));
}
TEST_CASE("both empty does not prefer reverse") {
CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::None));
CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::SetNo));
}
TEST_CASE("both filled: last edited SetNo prefers reverse") {
CHECK(preferDetectBySetNo(false, false, CardLookupEditField::SetNo));
}
TEST_CASE("both filled: Name or None keeps name lookup") {
CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::Name));
CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::None));
}
}
+56
View File
@@ -34,6 +34,7 @@ public:
std::string lastSetNo;
std::string detectLastName;
std::string detectLastSetId;
std::string detectLastSetNo;
AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
bool allowAutoDetect{true};
@@ -67,6 +68,23 @@ public:
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override {
detectLastSetId = std::string(setId);
detectLastSetNo = std::string(setNo);
return Result<AutoDetectedPrint>::ok(detectedPrint);
}
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override {
detectLastSetId = std::string(setId);
detectLastSetNo = std::string(setNo);
std::vector<AutoDetectedPrint> v;
v.push_back(detectedPrint);
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return allowAutoDetect;
}
@@ -992,3 +1010,41 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") {
std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectVariantsBySetNo") {
TEST_CASE("routes set-scoped reverse lookup to registered source") {
FakeSource source;
source.detectedPrint = AutoDetectedPrint{"4", "Rare", "Pikachu"};
FakeGameModule module;
module.gameId = Game::Pokemon;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectVariantsBySetNo(Game::Pokemon, "base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Pikachu");
CHECK(out.value()[0].setNo == "4");
CHECK(source.detectLastSetId == "base1");
CHECK(source.detectLastSetNo == "4");
}
TEST_CASE("returns error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectVariantsBySetNo(Game::Magic, "lea", "1");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
+46
View File
@@ -10,6 +10,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/CardSorter.hpp"
@@ -548,6 +549,51 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") {
}
}
TEST_SUITE("CardSorter - YuGiOhBandai columns") {
TEST_CASE("Holo sorts false before true; Rarity and SetNo sort") {
YuGiOhBandaiCard a;
a.id = 1;
a.name = "a";
a.set = Set{"ban1", "1st Generation", "1998/09/01"};
a.setNo = "14";
a.rarity = "Rare";
a.holo = true;
YuGiOhBandaiCard b;
b.id = 2;
b.name = "b";
b.set = Set{"ban1", "1st Generation", "1998/09/01"};
b.setNo = "9";
b.rarity = "Common";
b.holo = false;
std::vector<YuGiOhBandaiCard> v = {a, b};
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Holo, /*ascending=*/true);
CHECK(v[0].id == 2);
CHECK(v[1].id == 1);
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetNo, /*ascending=*/true);
CHECK(v[0].setNo == "14");
CHECK(v[1].setNo == "9");
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Rarity, /*ascending=*/true);
CHECK(v[0].rarity == "Common");
}
TEST_CASE("Set column sorts by release date") {
YuGiOhBandaiCard a;
a.id = 1;
a.set = Set{"ban3", "3rd Generation", "1999/03/06"};
YuGiOhBandaiCard b;
b.id = 2;
b.set = Set{"ban1", "1st Generation", "1998/09/01"};
std::vector<YuGiOhBandaiCard> v = {a, b};
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(v[0].id == 2);
CHECK(v[1].id == 1);
}
}
TEST_SUITE("CardSorter - JapanesePokemon columns") {
TEST_CASE("Holo and FirstEdition sort false before true") {
std::vector<JapanesePokemonCard> v = {
@@ -177,3 +177,56 @@ TEST_SUITE("DigiBattle99CardPreviewSource::detectPrintVariants") {
CHECK(out.value()[1].setNo == "ST-126");
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::detectVariantsBySetNo") {
TEST_CASE("card id search fills name within pack") {
FixedHttpClient http;
http.body = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "st-01");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Agumon");
CHECK(out.value()[0].setNo == "ST-01");
CHECK(http.lastUrl.find("card=ST-01") != std::string::npos);
}
TEST_CASE("digits-only 1 matches ST-01 not ST-11 from fuzzy API hits") {
FixedHttpClient http;
http.body = R"([
{"name":"Patamon","id":"ST-11","set_name":["Series 1 Starter Set"]},
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Other","id":"BO-1","set_name":["Booster 1"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "1");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Agumon");
CHECK(out.value()[0].setNo == "ST-01");
}
TEST_CASE("digits-only 11 matches ST-11 not ST-01") {
FixedHttpClient http;
http.body = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Patamon","id":"ST-11","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "11");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Patamon");
CHECK(out.value()[0].setNo == "ST-11");
}
TEST_CASE("empty pack is rejected") {
FixedHttpClient http;
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("", "ST-01");
REQUIRE(out.isErr());
CHECK(out.error().find("set") != std::string::npos);
}
}
+101 -3
View File
@@ -9,6 +9,8 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
@@ -31,6 +33,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json jDigi = "DigiBattle99";
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
nlohmann::json jBandai = "YuGiOhBandai";
CHECK(jBandai.get<Game>() == Game::YuGiOhBandai);
nlohmann::json jJp = "JapanesePokemon";
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
@@ -77,7 +82,7 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
for (const auto game : allGames()) {
CHECK(game != Game::JapanesePokemon);
}
CHECK(allGames().size() == 4);
CHECK(allGames().size() == 5);
CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon);
CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon);
CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon);
@@ -312,26 +317,119 @@ TEST_SUITE("DigiBattle99SetCatalog JSON") {
}
}
TEST_SUITE("YuGiOhBandaiCard JSON") {
TEST_CASE("round-trips with setNo, rarity, holo and signed alias") {
YuGiOhBandaiCard c;
c.id = 14;
c.amount = 2;
c.name = "Dark Magician";
c.set = Set{"ban1", "1st Generation", "1998/09/01"};
c.setNo = "14";
c.rarity = "Rare";
c.note = "classic";
c.images = {"a.png"};
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.holo = true;
c.signed_ = true;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "14");
CHECK(j.at("rarity") == "Rare");
CHECK(j.at("holo") == true);
CHECK(j.at("signed") == true);
CHECK_FALSE(j.contains("firstEdition"));
const YuGiOhBandaiCard back = j.get<YuGiOhBandaiCard>();
CHECK(back == c);
}
TEST_CASE("missing each required key throws") {
const nlohmann::json full = {
{"id", 14},
{"amount", 1},
{"name", "Dark Magician"},
{"set", nlohmann::json{
{"id", "ban1"},
{"name", "1st Generation"},
{"releaseDate", "1998/09/01"},
}},
{"setNo", "14"},
{"rarity", "Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"holo", false},
{"signed", false},
{"altered", false},
};
for (const char* key : {
"id", "amount", "name", "set", "setNo", "rarity", "note", "images",
"language", "condition", "holo", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<YuGiOhBandaiCard>());
}
}
}
TEST_SUITE("YuGiOhBandaiSetCatalog JSON") {
TEST_CASE("round-trips packs with rarity") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back(YuGiOhBandaiCatalogCard{"14", "Dark Magician", "Rare"});
pack.cards.push_back(YuGiOhBandaiCatalogCard{"9", "Blue-Eyes White Dragon", "Super Rare"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").at(0).at("id") == "ban1");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "14");
CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Rare");
const YuGiOhBandaiSetCatalog back = j.get<YuGiOhBandaiSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("ban1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("YuGiOhSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon", "Ultra Rare"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician", "Ultra Rare"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "LOB");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "LOB-001");
CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Ultra Rare");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("LOB") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
TEST_CASE("legacy catalog JSON without rarity still loads") {
const auto j = nlohmann::json::parse(R"({
"packs":[{"id":"LOB","name":"Legend of Blue Eyes White Dragon",
"cards":[{"setNo":"LOB-001","name":"Blue-Eyes White Dragon"}]}]
})");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
REQUIRE(back.packs.size() == 1);
REQUIRE(back.packs[0].cards.size() == 1);
CHECK(back.packs[0].cards[0].rarity.empty());
}
}
TEST_SUITE("PokemonSetCatalog JSON") {
+7
View File
@@ -28,6 +28,13 @@ TEST_SUITE("FsNames::formatTextForFs") {
TEST_CASE("idempotent on already-clean strings") {
CHECK(formatTextForFs("AlreadyClean") == "AlreadyClean");
}
TEST_CASE("male and female signs become male/female") {
CHECK(formatTextForFs("\xE2\x99\x82") == "male");
CHECK(formatTextForFs("\xE2\x99\x80") == "female");
CHECK(formatTextForFs("Nidoran \xE2\x99\x82") == "Nidoranmale");
CHECK(formatTextForFs("Nidoran \xE2\x99\x80") == "Nidoranfemale");
}
}
TEST_SUITE("FsNames::parseIndexFromFilename") {
+12
View File
@@ -5,6 +5,7 @@
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
@@ -65,6 +66,17 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOhBandai module reports canonical metadata") {
NoopHttpClient http;
YuGiOhBandaiGameModule module(http);
CHECK(module.id() == Game::YuGiOhBandai);
CHECK(module.dirName() == "yugiohbandai");
CHECK(module.displayName() == "Yu-Gi-Oh! (Bandai)");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("JapanesePokemon module reports canonical metadata") {
NoopHttpClient http;
JapanesePokemonGameModule module(http);
@@ -591,3 +591,58 @@ TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only")
CHECK(shining.value()[0].setNo == "013");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog") {
TEST_CASE("resolves English name from setId + localId") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}
]
})");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "001", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Bulbasaur");
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("leading-zero-insensitive localId still resolves") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"},
{"set_id":"PMCG1","local_id":"011","name_en":"Weedle","name_ja":"ビードル"}
]
})");
REQUIRE(catalog.isOk());
const auto byOne = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "1", catalog.value());
REQUIRE(byOne.isOk());
REQUIRE(byOne.value().size() == 1);
CHECK(byOne.value()[0].name == "Bulbasaur");
CHECK(byOne.value()[0].setNo == "001");
const auto byEleven =
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "11", catalog.value());
REQUIRE(byEleven.isOk());
REQUIRE(byEleven.value().size() == 1);
CHECK(byEleven.value()[0].name == "Weedle");
}
TEST_CASE("unknown localId is an error") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}
]
})");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "999", catalog.value());
REQUIRE(out.isErr());
}
}
@@ -323,3 +323,78 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
CHECK(http.calls == 2);
}
}
TEST_SUITE("PokemonCardPreviewSource::detectVariantsBySetNo") {
TEST_CASE("card-by-id fills name from TCGdex response") {
FixedHttpClient http;
http.body = R"({
"id":"base1-4",
"localId":"4",
"name":"Charmander",
"rarity":"Common",
"image":"https://assets.tcgdex.net/en/base/base1/4"
})";
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Charmander");
CHECK(out.value()[0].setNo == "4");
CHECK(out.value()[0].rarity == "Common");
CHECK(http.lastUrl.find("/v2/en/cards/base1-4") != std::string::npos);
}
TEST_CASE("search fallback rejects localIds that only share a digit prefix") {
struct ScriptedHttp : IHttpClient {
int n = 0;
Result<std::string> get(std::string_view) override {
++n;
if (n == 1) {
return Result<std::string>::err("not found");
}
// Fuzzy search returns both "14" and "4"; only "4" may be kept.
return Result<std::string>::ok(R"([
{"id":"base1-14","localId":"14","name":"Wrong","rarity":"Common"},
{"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"}
])");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Charmander");
CHECK(out.value()[0].setNo == "4");
}
TEST_CASE("card-by-id response with mismatched localId falls through to search") {
struct ScriptedHttp : IHttpClient {
int n = 0;
Result<std::string> get(std::string_view) override {
++n;
if (n == 1) {
return Result<std::string>::ok(R"({
"id":"base1-14","localId":"14","name":"Wrong","rarity":"Rare"
})");
}
return Result<std::string>::ok(R"([
{"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"}
])");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Charmander");
CHECK(out.value()[0].setNo == "4");
}
TEST_CASE("empty set id is rejected") {
FixedHttpClient http;
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("", "4");
REQUIRE(out.isErr());
CHECK(out.error().find("set") != std::string::npos);
}
}
+38
View File
@@ -31,6 +31,7 @@ public:
if (gameId == Game::Pokemon) return "pokemon";
if (gameId == Game::YuGiOh) return "yugioh";
if (gameId == Game::DigiBattle99) return "digibattle99";
if (gameId == Game::YuGiOhBandai) return "yugiohbandai";
if (gameId == Game::JapanesePokemon) return "pokemon";
return "yugioh";
}
@@ -182,6 +183,43 @@ TEST_SUITE("SetService") {
CHECK(digi.source.calls == 1);
}
TEST_CASE("YuGiOhBandai 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 bandai{Game::YuGiOhBandai};
bandai.source.result = Result<std::vector<Set>>::ok(
{{"ban1", "1st Generation", "1998/09/01"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
svc.registerModule(&digi);
svc.registerModule(&bandai);
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::YuGiOhBandai);
REQUIRE(out.isOk());
CHECK(out.value().front().id == "ban1");
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
CHECK(digi.source.calls == 1);
CHECK(bandai.source.calls == 1);
}
TEST_CASE("JapanesePokemon module routes independently when all games are registered") {
InMemSetRepo repo;
SetService svc{repo};
+113
View File
@@ -1,5 +1,6 @@
#include <doctest/doctest.h>
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
@@ -86,6 +87,15 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001"));
}
TEST_CASE("matches collector numbers ignoring leading zeros") {
CHECK(ygoPrintingSlotsMatch("LOB-5", "LOB-005"));
CHECK(ygoPrintingSlotsMatch("LOB-EN005", "LOB-5"));
CHECK(ygoCollectorDigitsEqual("005", "5"));
CHECK(ygoCollectorDigitsEqual("LOB-EN005", "5"));
CHECK(ygoCollectorDigitsFromInput("005") == "005");
CHECK(ygoCollectorDigitsFromInput("LOB-EN005") == "005");
}
TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") {
CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005"));
@@ -935,3 +945,106 @@ TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
CHECK(out.error() == "offline");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog") {
TEST_CASE("matches digits within pack to card name") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician", "Ultra Rare"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN001", "Blue-Eyes White Dragon", "Ultra Rare"});
catalog.packs.push_back(std::move(pack));
const auto byDigits =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "005");
REQUIRE(byDigits.isOk());
REQUIRE(byDigits.value().size() == 1);
CHECK(byDigits.value()[0].name == "Dark Magician");
CHECK(byDigits.value()[0].setNo == "LOB-005");
CHECK(byDigits.value()[0].rarity == "Ultra Rare");
const auto byUnpadded =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "5");
REQUIRE(byUnpadded.isOk());
REQUIRE(byUnpadded.value().size() == 1);
CHECK(byUnpadded.value()[0].name == "Dark Magician");
const auto byRegionCode =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "LOB-001");
REQUIRE(byRegionCode.isOk());
REQUIRE(byRegionCode.value().size() == 1);
CHECK(byRegionCode.value()[0].name == "Blue-Eyes White Dragon");
}
TEST_CASE("digits-only 1 does not match collector 011") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-011", "Hitotsu-Me Giant"});
catalog.packs.push_back(std::move(pack));
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "1")
.isErr());
const auto byEleven =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "11");
REQUIRE(byEleven.isOk());
REQUIRE(byEleven.value().size() == 1);
CHECK(byEleven.value()[0].name == "Hitotsu-Me Giant");
}
TEST_CASE("unknown pack or number returns error") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
catalog.packs.push_back(std::move(pack));
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "SDK", "001")
.isErr());
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "999")
.isErr());
}
TEST_CASE("detectVariantsBySetNo falls back to cardset HTTP without catalog") {
FixedHttpClient http;
http.body = R"({
"data":[
{"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"}
]}
]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Legend of Blue Eyes White Dragon", "5");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Dark Magician");
CHECK(out.value()[0].setNo == "LOB-EN005");
CHECK(out.value()[0].rarity == "Ultra Rare");
CHECK(http.lastUrl.find("cardset=") != std::string::npos);
CHECK(http.lastUrl.find("fname=") == std::string::npos);
}
TEST_CASE("cardset reverse keeps distinct rarities for the same set code") {
const std::string body = R"({
"data":[
{"name":"Elemental HERO Bubbleman",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
body, "Soul of the Duelist", "15");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].name == "Elemental HERO Bubbleman");
CHECK(out.value()[0].rarity == "Ultra Rare");
CHECK(out.value()[1].rarity == "Ultimate Rare");
}
}
+5
View File
@@ -213,11 +213,16 @@ TEST_SUITE("YuGiOhSetSource::parseCatalog") {
}
CHECK(sawBe);
CHECK(sawDm);
for (const auto& c : lob->cards) {
if (c.name == "Blue-Eyes White Dragon") CHECK(c.rarity == "Ultra Rare");
if (c.name == "Dark Magician") CHECK(c.rarity == "Ultra Rare");
}
const auto* mrd = out.value().findPack("MRD");
REQUIRE(mrd != nullptr);
REQUIRE(mrd->cards.size() == 1);
CHECK(mrd->cards[0].setNo == "MRD-010");
CHECK(mrd->cards[0].rarity == "Ultra Rare");
}
TEST_CASE("missing data array returns error") {
@@ -0,0 +1,269 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include <doctest/doctest.h>
#include <string>
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string body;
std::string lastUrl;
bool fail{false};
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (fail) return Result<std::string>::err("http fail");
return Result<std::string>::ok(body);
}
};
} // namespace
TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") {
TEST_CASE("preferredPageTitle picks Bandai / English / Sealdass") {
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "ban1", "14") ==
"Dark Magician (Bandai)");
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Blue-Eyes White Dragon", "ban3",
"118") ==
"Blue-Eyes White Dragon (English Bandai)");
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "bansealdass",
"2") ==
"Dark Magician (Bandai Sealdass)");
}
TEST_CASE("buildPageImagesUrl encodes spaces as underscores then percent") {
const auto url =
YuGiOhBandaiCardPreviewSource::buildPageImagesUrl("Dark Magician (Bandai)");
CHECK(url.find("titles=Dark_Magician_%28Bandai%29") != std::string::npos);
}
TEST_CASE("buildAskByNameUrl includes English name constraint") {
const auto url = YuGiOhBandaiCardPreviewSource::buildAskByNameUrl("Dark Magician");
CHECK(url.find("action=ask") != std::string::npos);
CHECK(url.find("query=") != std::string::npos);
}
TEST_CASE("parsePageImagesResponse returns original source") {
const std::string body = R"JSON({
"query": {
"pages": {
"1": {
"title": "Dark Magician (Bandai)",
"original": {"source": "https://ms.yugipedia.com/d/d0/DarkMagician.png"}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body);
REQUIRE(out);
CHECK(out.value() == "https://ms.yugipedia.com/d/d0/DarkMagician.png");
}
TEST_CASE("parsePageImagesResponse missing page is NotFound") {
const std::string body = R"JSON({
"query": { "pages": { "-1": { "missing": true, "title": "Nope" } } }
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body);
REQUIRE_FALSE(out);
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("parseAskResponse fills setNo rarity name and setId") {
const std::string body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1");
REQUIRE(out);
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Dark Magician");
CHECK(out.value()[0].setNo == "14");
CHECK(out.value()[0].rarity == "Rare");
CHECK(out.value()[0].setId == "ban1");
CHECK(out.value()[0].language == "Japanese");
}
TEST_CASE("parseAskResponse prefers Bandai over Sealdass when set is ban1") {
const std::string body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai Sealdass)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [2],
"Rarity": [{"fulltext": "Common"}]
}
},
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1");
REQUIRE(out);
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "14");
CHECK(out.value()[0].setId == "ban1");
}
TEST_CASE("parseAskResponse drops results whose Bandai number does not match") {
const std::string body = R"JSON({
"query": {
"results": {
"Card Eleven (Bandai)": {
"printouts": {
"English name": ["Card Eleven"],
"Bandai number": [11],
"Rarity": [{"fulltext": "Common"}]
}
},
"Card One (Bandai)": {
"printouts": {
"English name": ["Card One"],
"Bandai number": [1],
"Rarity": [{"fulltext": "Common"}]
}
}
}
}
})JSON";
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1", "1");
REQUIRE(out);
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Card One");
CHECK(out.value()[0].setNo == "1");
}
TEST_CASE("fetchImageUrl uses pageimages URL") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"pages": {
"1": {
"title": "Dark Magician (Bandai)",
"original": {"source": "https://ms.yugipedia.com/x.png"}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.fetchImageUrl("Dark Magician", "ban1", "14");
REQUIRE(out);
CHECK(out.value() == "https://ms.yugipedia.com/x.png");
CHECK(http.lastUrl.find("pageimages") != std::string::npos);
}
TEST_CASE("detectFirstPrint uses ask response") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectFirstPrint("Dark Magician", "ban1");
REQUIRE(out);
CHECK(out.value().setNo == "14");
CHECK(out.value().rarity == "Rare");
CHECK(out.value().setId == "ban1");
}
TEST_CASE("detectBySetNo uses ask-by-number URL") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectBySetNo("ban1", "014");
REQUIRE(out);
CHECK(out.value().name == "Dark Magician");
CHECK(http.lastUrl.find("action=ask") != std::string::npos);
}
TEST_CASE("detectBySetNo resolves promo TA2 from gallery parse") {
FixedHttpClient http;
http.body = R"JSON({
"parse": {
"wikitext": "WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) {{Gallery card names|Wicked Chain|ja}}\nBlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectBySetNo("banpromo-ta", "ta2");
REQUIRE(out);
CHECK(out.value().name == "Blue-Eyes White Dragon's 3-Body Connection");
CHECK(out.value().setNo == "TA2");
CHECK(out.value().setId == "banpromo-ta");
CHECK(out.value().rarity == "Super Rare");
CHECK(http.lastUrl.find("action=parse") != std::string::npos);
CHECK(http.lastUrl.find("Promotional") != std::string::npos);
}
TEST_CASE("detectBySetNo filters out prints from other sets") {
FixedHttpClient http;
http.body = R"JSON({
"query": {
"results": {
"Dark Magician (Bandai)": {
"printouts": {
"English name": ["Dark Magician"],
"Bandai number": [14],
"Rarity": [{"fulltext": "Rare"}]
}
}
}
}
})JSON";
YuGiOhBandaiCardPreviewSource src(http);
auto out = src.detectBySetNo("bansealdass", "14");
CHECK_FALSE(out);
CHECK(out.error().find("selected set") != std::string::npos);
}
TEST_CASE("isAlphanumericPromoNumber detects Jump and Toei codes") {
CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("TA2"));
CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("j1"));
CHECK_FALSE(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("14"));
}
TEST_CASE("preferredPageTitle omits Bandai suffix for promo sets") {
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle(
"Blue-Eyes White Dragon's 3-Body Connection", "banpromo-ta", "TA2") ==
"Blue-Eyes White Dragon's 3-Body Connection");
}
}
+153
View File
@@ -0,0 +1,153 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
#include <doctest/doctest.h>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
YuGiOhBandaiCard makeOwned(std::string setId, std::string setName, std::string setNo) {
YuGiOhBandaiCard c;
c.set = Set{std::move(setId), std::move(setName), "1998/09/01"};
c.setNo = std::move(setNo);
c.name = "Card";
c.language = Language::Japanese;
return c;
}
} // namespace
TEST_SUITE("YuGiOhBandaiSetCompletion") {
TEST_CASE("unique setNo within a pack; amount does not inflate") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"});
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
std::vector<YuGiOhBandaiCard> coll;
auto a = makeOwned("ban1", "1st Generation", "14");
a.amount = 5;
coll.push_back(a);
coll.push_back(makeOwned("ban1", "1st Generation", "014"));
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
REQUIRE(progress.size() == 1);
CHECK(progress[0].ownedUnique == 1);
CHECK(progress[0].total == 2);
CHECK(progress[0].percent() == 50);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack ban1;
ban1.setId = "ban1";
ban1.setName = "1st Generation";
ban1.cards.push_back({"14", "Dark Magician", "Rare"});
YuGiOhBandaiSetCatalogPack seal;
seal.setId = "bansealdass";
seal.setName = "Sealdass";
seal.cards.push_back({"14", "Other", "Common"});
catalog.packs.push_back(ban1);
catalog.packs.push_back(seal);
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban1", "1st Generation", "14")};
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
REQUIRE(progress.size() == 1);
CHECK(progress[0].setId == "ban1");
}
TEST_CASE("checklist marks owned rows") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"});
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban1", "1st Generation", "14")};
auto list = yuGiOhBandaiChecklistForSet(coll, catalog, "ban1");
REQUIRE(list.size() == 2);
CHECK(list[0].setNo == "14");
CHECK(list[0].owned);
CHECK(list[1].setNo == "9");
CHECK_FALSE(list[1].owned);
}
TEST_CASE("empty setNo produces no progress rows") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
YuGiOhBandaiCard missingNo = makeOwned("ban1", "1st Generation", "");
YuGiOhBandaiCard whitespaceNo = makeOwned("ban1", "1st Generation", " ");
std::vector<YuGiOhBandaiCard> coll{missingNo, whitespaceNo};
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
CHECK(progress.empty());
}
TEST_CASE("set.id plus setNo against catalog pack yields a tile") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban2";
pack.setName = "2nd Generation";
pack.cards.push_back({"47", "Time Wizard", "Super Rare"});
pack.cards.push_back({"48", "Polymerization", "Super Rare"});
catalog.packs.push_back(pack);
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban2", "2nd Generation", "047")};
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
REQUIRE(progress.size() == 1);
CHECK(progress[0].setId == "ban2");
CHECK(progress[0].setName == "2nd Generation");
CHECK(progress[0].ownedUnique == 1);
CHECK(progress[0].total == 2);
CHECK(progress[0].percent() == 50);
}
}
TEST_SUITE("YuGiOhBandaiSetCatalogService") {
TEST_CASE("catalog service round-trips against InMemoryFileSystem") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
YuGiOhBandaiSetCatalogService svc(fs, config, [](Game) { return "yugiohbandai"; });
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back({"14", "Dark Magician", "Rare"});
catalog.packs.push_back(pack);
REQUIRE(svc.save(catalog));
CHECK(svc.exists());
auto loaded = svc.load();
REQUIRE(loaded);
CHECK(loaded.value() == catalog);
}
}
+139
View File
@@ -0,0 +1,139 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <doctest/doctest.h>
#include <string>
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string body;
std::string lastUrl;
bool fail{false};
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
if (fail) return Result<std::string>::err("http fail");
return Result<std::string>::ok(body);
}
};
} // namespace
TEST_SUITE("YuGiOhBandaiSetSource") {
TEST_CASE("parseResponse returns stable manifest ordered by release date") {
auto sets = YuGiOhBandaiSetSource::parseResponse({});
REQUIRE(sets);
REQUIRE(sets.value().size() == 6);
CHECK(sets.value()[0].id == "ban1");
CHECK(sets.value()[0].name == "1st Generation");
CHECK(sets.value()[0].releaseDate == "1998/09/01");
CHECK(sets.value()[5].id == "bansealdass");
}
TEST_CASE("normalizeCardNumber strips leading zeros and uppercases prefixes") {
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("014") == "14");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("#9") == "9");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("j1") == "J1");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("ta2") == "TA2");
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber(" ") == "");
}
TEST_CASE("expandRarityCode maps gallery abbreviations") {
CHECK(YuGiOhBandaiSetSource::expandRarityCode("C") == "Common");
CHECK(YuGiOhBandaiSetSource::expandRarityCode("R") == "Rare");
CHECK(YuGiOhBandaiSetSource::expandRarityCode("SR") == "Super Rare");
CHECK(YuGiOhBandaiSetSource::expandRarityCode("HFR") == "Holo Seal");
}
TEST_CASE("setIdForNumber maps ranges and promo prefixes") {
CHECK(YuGiOhBandaiSetSource::setIdForNumber("14") == "ban1");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("50") == "ban2");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("118") == "ban3");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("J1") == "banpromo-j");
CHECK(YuGiOhBandaiSetSource::setIdForNumber("TA2") == "banpromo-ta");
}
TEST_CASE("parseGalleryWikitext extracts number rarity and English name") {
const std::string wiki =
"DarkMagician-BAN1-JP-R.png | {{pound}}014 ([[R]]) "
"{{Gallery card names|Dark Magician (Bandai)|ja}}\n"
"BlueEyesWhiteDragon-BAN1-JP-SR.png | {{pound}}009 ([[SR]]) "
"{{Gallery card names|Blue-Eyes White Dragon (Bandai)|ja}}\n";
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
REQUIRE(cards);
REQUIRE(cards.value().size() == 2);
CHECK(cards.value()[0].setNo == "14");
CHECK(cards.value()[0].name == "Dark Magician");
CHECK(cards.value()[0].rarity == "Rare");
CHECK(cards.value()[1].setNo == "9");
CHECK(cards.value()[1].rarity == "Super Rare");
}
TEST_CASE("parseGalleryWikitext accepts promo [[TA2]] number format") {
const std::string wiki =
"WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) "
"{{Gallery card names|Wicked Chain|ja}}\n"
"BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]]) "
"{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
"MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]]) "
"{{Gallery card names|Mirror Force (Bandai)|ja}}\n";
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
REQUIRE(cards);
REQUIRE(cards.value().size() == 3);
CHECK(cards.value()[0].setNo == "TA1");
CHECK(cards.value()[0].name == "Wicked Chain");
CHECK(cards.value()[0].rarity == "Super Rare");
CHECK(cards.value()[1].setNo == "TA2");
CHECK(cards.value()[1].name == "Blue-Eyes White Dragon's 3-Body Connection");
CHECK(cards.value()[2].setNo == "J1");
CHECK(cards.value()[2].name == "Mirror Force");
}
TEST_CASE("parseGalleryWikitext tolerates <br /> between rarity and name template") {
// Live Yugipedia promo gallery captions insert <br /> after expansion.
const std::string wiki =
"<gallery mode=\"packed\">\n"
"BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])<br />"
"{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
"MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]])<br />"
"{{Gallery card names|Mirror Force (Bandai)|ja}}\n"
"</gallery>\n";
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
REQUIRE(cards);
REQUIRE(cards.value().size() == 2);
CHECK(cards.value()[0].setNo == "TA2");
CHECK(cards.value()[0].name == "Blue-Eyes White Dragon's 3-Body Connection");
CHECK(cards.value()[0].rarity == "Super Rare");
CHECK(cards.value()[1].setNo == "J1");
}
TEST_CASE("parseGalleryWikitext empty body yields empty ok") {
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext("");
REQUIRE(cards);
CHECK(cards.value().empty());
}
TEST_CASE("fetchAll returns manifest without HTTP") {
FixedHttpClient http;
YuGiOhBandaiSetSource src(http);
auto sets = src.fetchAll();
REQUIRE(sets);
CHECK(sets.value().size() == 6);
CHECK(http.lastUrl.empty());
}
TEST_CASE("buildGalleryParseUrl percent-encodes page title") {
const auto url = YuGiOhBandaiSetSource::buildGalleryParseUrl(
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation");
CHECK(url.find("action=parse") != std::string::npos);
CHECK(url.find("prop=wikitext") != std::string::npos);
CHECK(url.find("page=") != std::string::npos);
}
}
+8 -6
View File
@@ -5,9 +5,9 @@
## Layer pointers
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magics splitter mounting.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a games `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). 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/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magics splitter mounting. `attachSharedToolbarEdit` lets Magic wire MainFrames Edit button for multi-select hide/show; hostsOwnLayout games manage their own Edit button via `setToolbarEditVisible`.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a games `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). 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()`. Edit is hidden when more than one list row is selected. `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. **Multi-select** is enabled (no `wxLC_SINGLE_SEL`): Ctrl toggles rows, Shift selects a range; `selected()` / `selectedCount()` / `selectedCards()` expose the selection; rebuild/sort/filter preserve all selected ids; Ctrl+C copies every selected row as TSV (one line per card). Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)``Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
@@ -16,11 +16,12 @@
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`, `PokemonSetCompletionPanel`. Same Add/Edit shape as Magic for the card form; the game view hosts **Single Cards | Set Completion** via `contentPanel` / `hostsOwnLayout` (like Digimon/Yu-Gi-Oh!). Catalog from `PokemonSetCatalogService` (`set-catalog-west.json` / `set-catalog-asia.json`), filled on Update Pokemon. The Add/Edit/Delete + filter toolbar lives inside the Single Cards tab; MainFrame hides its shared toolbar while Pokemon is active.
- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`).
- `include/ccm/ui/YuGiOh*.hpp` + `src/YuGiOh*.cpp` — Yu-Gi-Oh!: list/selected/edit plus `YuGiOhGameView` notebook (**Single Cards** | **Set Completion**) via the same `hostsOwnLayout` / `contentPanel` pattern as Digimon, and `YuGiOhSetCompletionPanel`. Catalog from `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), filled on Update Sets from YGOPRODeck `cardinfo.php`.
- `include/ccm/ui/YuGiOhBandai*.hpp` + `src/YuGiOhBandai*.cpp` — Yu-Gi-Oh! (Bandai): same notebook layout as Digimon/YGO; dual auto-detect (name or Bandai number) via Yugipedia SMW ask; catalog from `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`).
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
- `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next.
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`).
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`, `setToolbarEditVisible`, `deleteCardsConfirmMessage`) for consistent dark/light dialogs and multi-select toolbar/delete UX. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`).
## Conventions
@@ -61,7 +62,7 @@
- Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window.
- Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background.
- Do not call `applyNativeClassTheme(..., "DarkMode_Explorer", "Explorer")` for `wxTextCtrl`; on some Windows builds this causes black typed text in dark mode. Keep text inputs palette-driven (`SetThemeEnabled(false)` in dark/high-contrast as needed).
- If a specific text field still renders wrong while typing (notably `MainFrame`'s filter box), enforce text/background in `MainFrame::MSWWindowProc` via `WM_CTLCOLOREDIT` for that control handle.
- Text inputs are hardened in `Theme.cpp` via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme`: opt the EDIT HWND out of immersive dark mode, clear its visual style, and subclass the **parent** to answer `WM_CTLCOLOREDIT` (that message goes to the parent, not the frame — an earlier frame-level handler never ran for the toolbar filter).
- Keep toolbar button behavior stable under dark/high-contrast: avoid changes that break click/tooltip affordances while experimenting with hover contrast fixes.
- For dark/high-contrast button readability, do not trust native hover/pressed rendering on Windows; custom state painting in `Theme.cpp` is allowed when native visuals ignore configured colors.
- Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode.
@@ -75,6 +76,7 @@
- Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game.
- Keep shared templates game-agnostic: put buttons and detection behavior in `<Name>CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged.
- For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input.
- Bidirectional identify (Yu-Gi-Oh!, Bandai, Pokémon West/Asia, Digi-Battle): Set is always required. Set # **Auto detect** fills set number from Name, or fills Name from Set #. When **both** fields are filled, the field the user last typed is the lookup key (`CardLookupEditField` / `preferDetectBySetNo` in `ccm/util/CardLookupDetect.hpp`, tracked by `BaseCardEditDialog::markNameLookupEdited` / `markSetNoLookupEdited`). Programmatic `ChangeValue` from a detect result does not flip the key.
## Required follow-ups
@@ -90,7 +92,7 @@
1. Implement three derived classes under `include/ccm/ui/` mirroring the Magic / Pokemon trio:
- `<Name>CardListPanel : public BaseCardListPanel<<Name>Card, <Name>SortColumn>` — override `declareTextColumns()`, `declareIconColumns()`, `renderTextCell()`, `isIconColumnSet()`, `sortBy()`, `matchesFilter()`.
- `<Name>SelectedCardPanel : public BaseSelectedCardPanel<<Name>Card>` — override `declareDetailRows()`, `declareFlagIcons()`, `detailValueFor()`, `isFlagSet()`, `previewKey()`, `gameId()`. Define a local `enum` of `DetailKey` constants for clarity.
- `<Name>CardEditDialog : public BaseCardEditDialog<<Name>Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`.
- `<Name>CardEditDialog : public BaseCardEditDialog<<Name>Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`, and optionally `validateExtraFields()` (Bandai requires set number).
2. Add a `<Name>GameView : public IGameView` that owns those panels and the typed `CollectionService<<Name>Card>&`. Bind `EVT_CARD_SELECTED` on the list panel inside `listPanel(parent)` to push the typed selection into the selected panel. The `MagicGameView` / `PokemonGameView` pair is the canonical reference.
3. Re-add the new view to `AppContext::gameViews` in the composition root (`app/main.cpp`). The `Game` and `Sets` menus pick it up automatically.
4. Add SVG glyphs for any new flag columns to `SvgIcons.{hpp,cpp}` (with the `@FILL@` placeholder).
+8 -1
View File
@@ -26,6 +26,11 @@ add_library(ccm_ui_wx STATIC
src/DigiBattle99CardEditDialog.cpp
src/DigiBattle99GameView.cpp
src/DigiBattle99SetCompletionPanel.cpp
src/YuGiOhBandaiCardListPanel.cpp
src/YuGiOhBandaiSelectedCardPanel.cpp
src/YuGiOhBandaiCardEditDialog.cpp
src/YuGiOhBandaiGameView.cpp
src/YuGiOhBandaiSetCompletionPanel.cpp
src/SettingsDialog.cpp
src/SwitchCtrl.cpp
@@ -61,8 +66,10 @@ target_link_libraries(ccm_ui_wx
# the per-row flag glyphs with proper transparency. Without msimg32 linked
# explicitly the linker fails on `AlphaBlend@44` even though gdi32 is pulled
# in transitively by wxWidgets.
# Theme.cpp subclasses EDIT parents via SetWindowSubclass / DefSubclassProc /
# RemoveWindowSubclass (comctl32); those symbols are not pulled in by wx alone.
if (WIN32)
target_link_libraries(ccm_ui_wx PRIVATE msimg32)
target_link_libraries(ccm_ui_wx PRIVATE msimg32 comctl32)
endif()
target_compile_features(ccm_ui_wx PUBLIC cxx_std_20)
+1
View File
@@ -27,6 +27,7 @@ struct AppContext {
IGameModule& pokemonModule;
IGameModule& yuGiOhModule;
IGameModule& digiBattle99Module;
IGameModule& yuGiOhBandaiModule;
// 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
@@ -22,6 +22,7 @@
#include "ccm/services/SetService.hpp"
#include "ccm/ui/ImageViewerDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/util/CardLookupDetect.hpp"
#include <wx/arrstr.h>
#include <wx/button.h>
@@ -136,6 +137,30 @@ protected:
// controls cannot outlive the lookup identity.
virtual void onCardLookupContextChanged() {}
// Bidirectional Set # Auto detect: track which of Name / Set # the user
// last typed so a second detect uses that field as the lookup key.
void markNameLookupEdited() { lastLookupEditField_ = CardLookupEditField::Name; }
// Subclasses bind Set # `wxEVT_TEXT` to this (or call it from their handler).
// Also clears print-variant caches via `onCardLookupContextChanged`.
void markSetNoLookupEdited() {
lastLookupEditField_ = CardLookupEditField::SetNo;
onCardLookupContextChanged();
}
[[nodiscard]] CardLookupEditField lastLookupEditField() const noexcept {
return lastLookupEditField_;
}
// `nameEmpty` / `setNoEmpty` must already be trimmed/normalized by the caller.
[[nodiscard]] bool shouldDetectBySetNo(bool nameEmpty, bool setNoEmpty) const noexcept {
return preferDetectBySetNo(nameEmpty, setNoEmpty, lastLookupEditField_);
}
// Extra validation after name/set checks and writeFromControls(). Return
// false to block OK (subclass should show its own themed dialog).
[[nodiscard]] virtual bool validateExtraFields() { return true; }
// Common helpers ----------------------------------------------------------
void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) {
@@ -148,6 +173,8 @@ protected:
[[nodiscard]] const TCard& constCard() const noexcept { return card_; }
void syncCardFromControls() { writeFromControls(); }
[[nodiscard]] wxComboBox* setComboControl() const noexcept { return setCombo_; }
[[nodiscard]] wxTextCtrl* nameControl() const noexcept { return nameCtrl_; }
[[nodiscard]] wxChoice* languageChoiceControl() const noexcept { return languageChoice_; }
[[nodiscard]] const Set* selectedSetFromControls() const {
const auto& available = availableSets();
@@ -204,6 +231,7 @@ private:
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) {
markNameLookupEdited();
onCardLookupContextChanged();
ev.Skip();
});
@@ -471,6 +499,7 @@ private:
"Add card", wxOK | wxICON_INFORMATION);
return;
}
if (!validateExtraFields()) return;
if (mode_ == EditMode::Edit && !(card_ == openingSnapshot_)) {
if (showThemedConfirmDialog(
this,
@@ -619,6 +648,7 @@ private:
const std::vector<Set>* preloadedSets_{nullptr};
wxTextCtrl* nameCtrl_{nullptr};
CardLookupEditField lastLookupEditField_{CardLookupEditField::None};
wxComboBox* setCombo_{nullptr};
wxSpinCtrl* amountCtrl_{nullptr};
wxChoice* languageChoice_{nullptr};
+164 -37
View File
@@ -40,8 +40,10 @@
#include "ccm/ui/Theme.hpp"
#include <wx/bitmap.h>
#include <wx/clipbrd.h>
#include <wx/colour.h>
#include <wx/cursor.h>
#include <wx/dataobj.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listctrl.h>
@@ -59,6 +61,7 @@
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -74,6 +77,10 @@ wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
// `IGameView` implementations bind this to open Edit for `selected()`.
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
// Raised when the list wants a short status-bar note (e.g. clipboard copy).
// `event.GetString()` is the message; MainFrame shows it in the bottom strip.
wxDECLARE_EVENT(EVT_UI_STATUS, wxCommandEvent);
template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
@@ -81,14 +88,18 @@ public:
using sort_column_type = TSortColumn;
// Replace the displayed rows. When preferSelectId is set, selects that
// card if present (used after Add). Otherwise preserves the previously
// selected card by id when still present; the first-row CallAfter path in
// rebuildRows() runs only when there was no prior selection (startup).
// card exclusively if present (used after Add). Otherwise preserves the
// previously selected card ids when still present; the first-row CallAfter
// path in rebuildRows() runs only when there was no prior selection
// (startup).
void setCards(std::vector<TCard> cards,
std::optional<std::uint32_t> preferSelectId = std::nullopt) {
std::optional<std::uint32_t> keepId = preferSelectId;
if (!keepId) {
if (auto sel = selected()) keepId = sel->id;
std::optional<std::vector<std::uint32_t>> keepIds;
if (preferSelectId) {
keepIds = std::vector<std::uint32_t>{*preferSelectId};
} else {
auto ids = selectedIds();
if (!ids.empty()) keepIds = std::move(ids);
}
cards_ = std::move(cards);
// Drop sort state when the underlying data is replaced - the indicator
@@ -96,7 +107,7 @@ public:
// wxListCtrl keeps the indicator across DeleteAllItems().
nextDirByCol_.clear();
list_->RemoveSortIndicator();
rebuildRows(keepId);
rebuildRows(keepIds);
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
@@ -104,16 +115,17 @@ public:
}
// Update the filter string and rebuild the visible rows in place. The
// panel preserves the previously-selected card across the rebuild when
// it still matches the new filter; otherwise the first remaining row is
// panel preserves previously-selected cards across the rebuild when they
// still match the new filter; otherwise the first remaining row is
// selected, or none if the filter excluded everything. A single
// EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
void setFilter(std::string_view filter) {
if (filter_ == filter) return;
filter_.assign(filter);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
rebuildRows(keepIds);
}
void applyTheme(const ThemePalette& palette) {
@@ -123,22 +135,52 @@ public:
SetForegroundColour(palette.text);
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
refreshHeaderTheme(palette);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
rebuildRows(keepIds);
Refresh();
}
[[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
[[nodiscard]] const std::string& filter() const noexcept { return filter_; }
// First selected card (detail panel / single-edit primary).
[[nodiscard]] std::optional<TCard> selected() const {
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (const TCard* c = cardForRow(sel)) return *c;
return std::nullopt;
}
[[nodiscard]] std::size_t selectedCount() const {
if (list_ == nullptr) return 0;
std::size_t n = 0;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
++n;
}
return n;
}
[[nodiscard]] std::vector<TCard> selectedCards() const {
std::vector<TCard> out;
if (list_ == nullptr) return out;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
if (const TCard* c = cardForRow(row)) out.push_back(*c);
}
return out;
}
[[nodiscard]] std::vector<std::uint32_t> selectedIds() const {
std::vector<std::uint32_t> out;
if (list_ == nullptr) return out;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
if (const TCard* c = cardForRow(row)) out.push_back(c->id);
}
return out;
}
// Ensure the selected row is actively focused so Windows uses the active
// highlight color (blue in light mode), keeping selected-row icons legible.
// Ensure the first selected row is actively focused so Windows uses the
// active highlight color (blue in light mode), keeping selected-row icons
// legible. Does not clear a multi-selection.
void activateSelection() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
@@ -151,8 +193,9 @@ public:
}
// Move the selection by `delta` rows (+1 / -1). Used when Up/Down are
// pressed while focus is on the filter box. Clamps to the visible range;
// leaves list HWND focus alone so the caret can stay in the filter.
// pressed while focus is on the filter box. Collapses any multi-selection
// to a single row. Clamps to the visible range; leaves list HWND focus
// alone so the caret can stay in the filter.
void nudgeSelection(int delta) {
if (list_ == nullptr || list_->GetItemCount() <= 0 || delta == 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
@@ -161,12 +204,12 @@ public:
long next = row + delta;
if (next < 0) next = 0;
if (next >= count) next = count - 1;
if (next == row) {
list_->EnsureVisible(next);
return;
}
suppressListFocus_ = true;
list_->SetItemState(row, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
// Clear every selected row so filter nudge is always single-select.
long sel = -1;
while ((sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
list_->SetItemState(sel, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
}
list_->SetItemState(next,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
@@ -215,8 +258,10 @@ protected:
// Subclass calls this once from its constructor body (after virtual hooks
// are reachable) to wire up columns + the header row + custom-draw hooks.
void buildLayout() {
// Multi-select: native Ctrl (toggle) and Shift (range) without
// wxLC_SINGLE_SEL. Set-completion tables keep single-select separately.
list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
wxLC_REPORT | wxLC_NO_HEADER);
textCols_ = declareTextColumns();
iconCols_ = declareIconColumns();
@@ -274,6 +319,7 @@ protected:
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
list_->Bind(wxEVT_KEY_DOWN, &BaseCardListPanel::onListKeyDown, this);
}
// Forwarded helpers ------------------------------------------------------
@@ -523,11 +569,12 @@ private:
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
nextDirByCol_[*sortCol] = !ascending;
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
sortBy(*sortCol, ascending);
rebuildRows(keepId);
rebuildRows(keepIds);
if (list_ != nullptr) list_->SetFocus();
}
@@ -590,7 +637,9 @@ private:
// ----- row rendering -----------------------------------------------------
void rebuildRows(std::optional<std::uint32_t> keepId = std::nullopt) {
// nullopt keepIds → no prior selection (startup / empty): defer first-row
// select. Otherwise restore every id that is still visible after filter.
void rebuildRows(std::optional<std::vector<std::uint32_t>> keepIds = std::nullopt) {
// Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
// SELECTED (from the SetItemState below) events while we churn through
// the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
@@ -605,10 +654,16 @@ private:
}
}
std::unordered_set<std::uint32_t> keepSet;
if (keepIds) {
keepSet.insert(keepIds->begin(), keepIds->end());
}
long row = 0;
long rowToSelect = -1;
long firstRestored = -1;
const int firstText = firstTextColIdx();
const int noteCol = noteColIdx();
std::vector<long> rowsToSelect;
for (std::size_t srcIdx : filteredIndices_) {
const auto& c = cards_[srcIdx];
// Insert via the hidden column-0 spacer. We never set sub-item
@@ -632,16 +687,22 @@ private:
const std::string note = renderTextCell(c, textCols_.size() - 1);
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
if (keepId && c.id == *keepId) rowToSelect = idx;
if (!keepSet.empty() && keepSet.count(c.id) != 0) {
rowsToSelect.push_back(idx);
if (firstRestored < 0) firstRestored = idx;
}
++row;
}
bool deferredInitialSelect = false;
if (!filteredIndices_.empty() && rowToSelect >= 0) {
list_->SetItemState(rowToSelect,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(rowToSelect);
} else if (!filteredIndices_.empty() && !keepId.has_value()) {
if (!filteredIndices_.empty() && !rowsToSelect.empty()) {
for (long r : rowsToSelect) {
const long flags = (r == firstRestored)
? (wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED)
: wxLIST_STATE_SELECTED;
list_->SetItemState(r, flags, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
}
list_->EnsureVisible(firstRestored);
} else if (!filteredIndices_.empty() && !keepIds.has_value()) {
// Defer the initial selection to the next event turn so first
// paint stays responsive.
deferredInitialSelect = true;
@@ -705,6 +766,72 @@ private:
});
}
void onListKeyDown(wxKeyEvent& event) {
const int key = event.GetKeyCode();
const bool copyChord =
(event.ControlDown() || event.CmdDown()) && (key == 'C' || key == 'c');
if (!copyChord) {
event.Skip();
return;
}
copySelectedRowsToClipboard();
}
void copySelectedRowsToClipboard() {
const auto cards = selectedCards();
if (cards.empty() || textCols_.empty()) return;
auto formatRow = [&](const TCard& card) {
std::string line;
auto appendCell = [&](std::string_view cell) {
if (!line.empty()) line.push_back('\t');
line.append(cell);
};
// Leading text columns (everything except trailing Note).
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
appendCell(renderTextCell(card, i));
}
// Icon/flag columns — no list text; export as true/false.
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
appendCell(isIconColumnSet(card, i) ? "true" : "false");
}
// Trailing Note.
appendCell(renderTextCell(card, textCols_.size() - 1));
return line;
};
std::string payload = formatRow(cards.front());
for (std::size_t i = 1; i < cards.size(); ++i) {
payload.push_back('\n');
payload.append(formatRow(cards[i]));
}
wxClipboardLocker lock;
if (!lock) return;
if (!wxTheClipboard->SetData(
new wxTextDataObject(wxString::FromUTF8(payload.c_str())))) {
return;
}
if (cards.size() == 1) {
emitUiStatus("Saved entry to clipboard");
} else {
emitUiStatus(wxString::Format("Saved %zu entries to clipboard", cards.size()));
}
}
void emitUiStatus(const wxString& message) {
wxCommandEvent ev(EVT_UI_STATUS, GetId());
ev.SetEventObject(this);
ev.SetString(message);
// Same parent-hop as BaseSelectedCardPanel::emitPreviewStatus so the
// command event can propagate up to MainFrame's status strip.
if (auto* parent = GetParent()) {
parent->GetEventHandler()->ProcessEvent(ev);
} else {
ProcessWindowEvent(ev);
}
}
// ----- members ----------------------------------------------------------
static constexpr int kFlagIconSize = 14;
@@ -307,6 +307,8 @@ private:
case Game::DigiBattle99:
// No stable public Digi-Battle back URL; UI uses bundled PNG.
return {};
case Game::YuGiOhBandai:
return "https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png";
}
return {};
}
@@ -49,9 +49,14 @@ private:
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void requestBySetNoAsync(unsigned capturedEpoch,
std::string setName,
std::string setNo,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
@@ -63,6 +63,7 @@ public:
}
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
+5
View File
@@ -19,6 +19,7 @@
#include <string_view>
#include <vector>
class wxBitmapButton;
class wxPanel;
class wxWindow;
@@ -67,6 +68,10 @@ public:
virtual void onEditCard(wxWindow* parentWindow) = 0;
virtual void onDeleteCard(wxWindow* parentWindow) = 0;
// Magic uses MainFrame's shared Edit button; hostsOwnLayout games ignore
// this and manage their own toolbar. Default no-op.
virtual void attachSharedToolbarEdit(wxBitmapButton* edit) { (void)edit; }
// Sets menu action ("Update Magic" / "Update Pokemon"). Returns the
// user-visible status string for the parent's status bar.
virtual std::string onUpdateSets(wxWindow* parentWindow) = 0;
+3
View File
@@ -42,6 +42,7 @@ public:
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
void attachSharedToolbarEdit(wxBitmapButton* edit) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void nudgeSelection(int delta) override;
@@ -51,6 +52,7 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void syncEditToolbarVisibility();
ConfigService& config_;
CollectionService<MagicCard>& collection_;
@@ -61,6 +63,7 @@ private:
MagicCardListPanel* listPanel_{nullptr};
MagicSelectedCardPanel* selectedPanel_{nullptr};
wxBitmapButton* sharedEditButton_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
-4
View File
@@ -51,10 +51,6 @@ private:
[[nodiscard]] IGameView* activeView();
#ifdef __WXMSW__
WXLRESULT MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) override;
#endif
AppContext& ctx_;
Game activeGame_{Game::Magic};
@@ -62,9 +62,14 @@ private:
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void requestBySetNoAsync(unsigned capturedEpoch,
std::string setId,
std::string setNo,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
+1
View File
@@ -66,6 +66,7 @@ public:
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; }
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog(PokemonRegion region);
void ensureSingleCardsMounted(wxWindow* splitterParent);
+15
View File
@@ -4,9 +4,15 @@
#include <wx/colour.h>
#include <cstddef>
#include <string>
#include <string_view>
class wxBitmapButton;
class wxDialog;
class wxWindow;
class wxString;
class wxTextCtrl;
namespace ccm::ui {
@@ -23,8 +29,17 @@ struct ThemePalette {
ThemePalette paletteForTheme(Theme theme);
Theme inferThemeFromWindow(const wxWindow* window);
void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme);
// Force palette colors onto a text input (incl. MSW dark-mode typed-text fix).
void applyPaletteToTextCtrl(wxTextCtrl* text, const ThemePalette& palette, Theme theme);
void themeModalDialog(wxDialog* dlg, Theme theme);
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style);
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption);
// Show/hide the shared or per-game Edit toolbar button and reflow its sizer
// so Add/Delete close the gap when Edit is hidden for multi-select.
void setToolbarEditVisible(wxBitmapButton* edit, bool visible);
// Confirm copy for Delete: one card by name, or "Delete N selected entries?".
wxString deleteCardsConfirmMessage(std::size_t count, std::string_view singleCardName);
} // namespace ccm::ui
@@ -0,0 +1,87 @@
#pragma once
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <wx/checkbox.h>
#include <wx/choice.h>
#include <atomic>
#include <memory>
#include <span>
#include <string>
#include <vector>
namespace ccm::ui {
class YuGiOhBandaiCardEditDialog final : public BaseCardEditDialog<YuGiOhBandaiCard> {
public:
YuGiOhBandaiCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
YuGiOhBandaiCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~YuGiOhBandaiCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void appendExtraRows(wxFlexGridSizer* grid) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::span<const Language> languagesForChoice() const override;
[[nodiscard]] std::string updateMenuName() const override {
return "Update Yu-Gi-Oh! (Bandai)";
}
void onCardLookupContextChanged() override;
[[nodiscard]] bool validateExtraFields() override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectBySetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onAutoDetectByName(wxCommandEvent&);
void onRarityChoiceChanged(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
void requestByNameAsync(unsigned capturedEpoch, std::string name, std::string setId,
bool showFailureDialog);
void requestByNoAsync(unsigned capturedEpoch, std::string setId, std::string setNo,
bool showFailureDialog);
void applyDetectedList(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool showFailureDialog, bool applyFirst);
void clearCachedPrintVariants();
void applyDetectedPrint(const AutoDetectedPrint& print);
void applyRarityStringToChoice(const std::string& rarity);
void maybeAutoCheckHoloForRarity(const std::string& rarity);
void refreshVariantNextControls();
[[nodiscard]] std::size_t findAvailableSetIndex(const std::string& setId) const;
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxChoice* rarityChoice_{nullptr};
wxButton* autoRarityBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::size_t variantRingPos_{0};
};
} // namespace ccm::ui
@@ -0,0 +1,26 @@
#pragma once
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
class YuGiOhBandaiCardListPanel final
: public BaseCardListPanel<YuGiOhBandaiCard, YuGiOhBandaiSortColumn> {
public:
explicit YuGiOhBandaiCardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const YuGiOhBandaiCard& card,
std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const YuGiOhBandaiCard& card,
std::size_t idx) const override;
void sortBy(YuGiOhBandaiSortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const YuGiOhBandaiCard& card,
std::string_view filter) const override;
};
} // namespace ccm::ui
@@ -0,0 +1,110 @@
#pragma once
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <array>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
class wxBitmapButton;
class wxBoxSizer;
class wxPanel;
class wxSimplebook;
class wxSplitterWindow;
class wxStaticText;
class wxTextCtrl;
namespace ccm::ui {
class YuGiOhBandaiCardListPanel;
class YuGiOhBandaiSelectedCardPanel;
class YuGiOhBandaiSetCompletionPanel;
class YuGiOhBandaiGameView final : public IGameView {
public:
YuGiOhBandaiGameView(ConfigService& config,
CollectionService<YuGiOhBandaiCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module,
YuGiOhBandaiSetCatalogService& catalogStore);
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOhBandai; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
wxPanel* contentPanel(wxWindow* parent) override;
[[nodiscard]] wxPanel* contentPanelIfCreated() const noexcept override {
return contentPanel_;
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) override;
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void nudgeSelection(int delta) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override {
return "Update Yu-Gi-Oh! (Bandai)";
}
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
// Fetches sets + checklist catalog from Yugipedia and persists both.
// Returns false on failure (error dialogs already shown).
[[nodiscard]] bool downloadSetsAndCatalog(wxWindow* parentWindow,
std::size_t* setCountOut = nullptr,
std::size_t* packCountOut = nullptr);
// Fetches sets + checklist catalog when set-catalog.json is missing.
// Returns true if the catalog exists afterward. Shows error dialogs on failure.
[[nodiscard]] bool ensureCatalogLoaded(wxWindow* parentWindow);
void refreshSetCompletionFromStore();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer);
void buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer);
void selectTab(int index);
void refreshToolbarIcons(const ThemePalette& palette);
void refreshTabBarTheme(const ThemePalette& palette);
ConfigService& config_;
CollectionService<YuGiOhBandaiCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
YuGiOhBandaiSetCatalogService& catalogStore_;
wxPanel* contentPanel_{nullptr};
wxPanel* tabBar_{nullptr};
wxSimplebook* book_{nullptr};
wxSplitterWindow* singleSplitter_{nullptr};
YuGiOhBandaiCardListPanel* listPanel_{nullptr};
YuGiOhBandaiSelectedCardPanel* selectedPanel_{nullptr};
YuGiOhBandaiSetCompletionPanel* setCompletionPanel_{nullptr};
std::array<wxPanel*, 2> tabPanels_{{nullptr, nullptr}};
std::array<wxStaticText*, 2> tabLabels_{{nullptr, nullptr}};
int activeTab_{0};
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
wxTextCtrl* filterInput_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,25 @@
#pragma once
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class YuGiOhBandaiSelectedCardPanel final : public BaseSelectedCardPanel<YuGiOhBandaiCard> {
public:
YuGiOhBandaiSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview);
protected:
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
[[nodiscard]] std::vector<FlagIconSpec> declareFlagIcons() const override;
[[nodiscard]] std::string detailValueFor(const YuGiOhBandaiCard& card,
DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const YuGiOhBandaiCard& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const YuGiOhBandaiCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOhBandai; }
};
} // namespace ccm::ui
@@ -0,0 +1,71 @@
#pragma once
// YuGiOhBandaiSetCompletionPanel: Set Completion tab — pack tiles with
// progress bars for sets the user owns >=1 card of, plus an in-tab checklist
// drill-down (unowned rows greyed). Catalog is offline (set-catalog.json).
// Optional language filter restricts ownership to one language and labels
// set titles as "{setName} ({language})".
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/panel.h>
#include <optional>
#include <string>
#include <vector>
class wxBoxSizer;
class wxChoice;
class wxListCtrl;
class wxScrolledWindow;
class wxSimplebook;
class wxStaticText;
namespace ccm::ui {
class YuGiOhBandaiSetCompletionPanel : public wxPanel {
public:
YuGiOhBandaiSetCompletionPanel(wxWindow* parent, YuGiOhBandaiSetCatalogService& catalogStore);
void setCollection(std::vector<YuGiOhBandaiCard> cards);
void reloadFromStore();
void applyTheme(const ThemePalette& palette);
private:
void showGridPage();
void showChecklistPage(const std::string& setId, const std::string& setName);
void rebuildGrid();
void rebuildChecklist(const std::string& setId);
void setEmptyMessage(const wxString& message);
void clearGridTiles();
void refreshLanguageChoice();
void onLanguageChoice(wxCommandEvent& event);
void rebuildCurrentView();
[[nodiscard]] std::string displaySetName(const std::string& setName) const;
YuGiOhBandaiSetCatalogService& catalogStore_;
YuGiOhBandaiSetCatalog catalog_;
bool catalogLoaded_{false};
std::vector<YuGiOhBandaiCard> collection_;
ThemePalette palette_{};
std::optional<Language> languageFilter_;
wxChoice* languageChoice_{nullptr};
wxSimplebook* book_{nullptr};
wxPanel* gridPage_{nullptr};
wxScrolledWindow* scroll_{nullptr};
wxBoxSizer* gridSizer_{nullptr};
wxStaticText* emptyLabel_{nullptr};
wxPanel* detailPage_{nullptr};
wxStaticText* detailTitle_{nullptr};
wxListCtrl* checklist_{nullptr};
std::string detailSetId_;
std::string detailSetName_;
};
} // namespace ccm::ui
@@ -8,6 +8,11 @@
#include <wx/button.h>
#include <wx/stattext.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
class YuGiOhCardEditDialog final : public BaseCardEditDialog<YuGiOhCard> {
@@ -19,6 +24,7 @@ public:
EditMode mode,
YuGiOhCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~YuGiOhCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -31,6 +37,10 @@ protected:
void onSetSelectionApplied() override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onAutoDetectRarity(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
@@ -42,6 +52,10 @@ private:
void onSetCodeAutoDetect(wxCommandEvent&);
void syncSetModeHint();
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
void requestBySetNoAsync(unsigned capturedEpoch, std::string setId, std::string setName,
std::string setNo);
void applyReverseDetectedList(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected);
void refreshSetNoFullPreview();
void clearCachedPrintVariants();
bool fetchAndCachePrintVariants();
@@ -59,6 +73,7 @@ private:
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxStaticText* setNoFullPreview_{nullptr};
wxChoice* rarityChoice_{nullptr};
+1
View File
@@ -61,6 +61,7 @@ public:
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; }
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
+1
View File
@@ -12,6 +12,7 @@ namespace ccm::ui {
wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
wxDEFINE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
wxDEFINE_EVENT(EVT_UI_STATUS, wxCommandEvent);
wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent);
} // namespace ccm::ui
+64 -11
View File
@@ -4,6 +4,7 @@
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <cctype>
#include <thread>
#include <unordered_set>
@@ -60,6 +61,7 @@ void DigiBattle99CardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); });
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
@@ -148,7 +150,31 @@ void DigiBattle99CardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
fillSetNoOnSuccess, /*fillNameOnSuccess=*/false,
showFailureDialog);
});
}).detach();
}
void DigiBattle99CardEditDialog::requestBySetNoAsync(unsigned capturedEpoch,
std::string setName,
std::string setNo,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable();
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
DigiBattle99CardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, setName = std::move(setName),
setNo = std::move(setNo), showFailureDialog]() {
auto detected = svc->detectVariantsBySetNo(Game::DigiBattle99, setName, setNo);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
/*fillSetNoOnSuccess=*/true,
/*fillNameOnSuccess=*/true, showFailureDialog);
});
}).detach();
}
@@ -157,10 +183,11 @@ void DigiBattle99CardEditDialog::applyDetectedVariants(
unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
if ((fillSetNoOnSuccess || fillNameOnSuccess) && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
@@ -173,9 +200,16 @@ void DigiBattle99CardEditDialog::applyDetectedVariants(
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
if (!cachedVariants_.empty()) {
const auto& first = cachedVariants_.front();
if (fillNameOnSuccess && !first.name.empty()) {
if (auto* name = nameControl()) {
name->ChangeValue(wxString::FromUTF8(first.name.c_str()));
}
}
if (fillSetNoOnSuccess && setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(first.setNo.c_str()));
}
}
rebuildVariantRingFromCache();
@@ -231,19 +265,38 @@ void DigiBattle99CardEditDialog::onNextSetNo(wxCommandEvent&) {
void DigiBattle99CardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.name.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
std::string name = card.name;
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string setNo =
setNoCtrl_ ? storedSetNoFromControls(setNoCtrl_) : std::string();
const bool nameEmpty = name.empty();
const bool setNoEmpty = setNo.empty();
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.name, true, true);
if (nameEmpty && setNoEmpty) {
showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) {
requestBySetNoAsync(epoch, card.set.name, setNo, true);
return;
}
requestVariantsAsync(epoch, name, card.set.name, true, true);
}
void DigiBattle99CardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
+24 -15
View File
@@ -300,6 +300,7 @@ wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
@@ -400,6 +401,11 @@ void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) {
void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit",
@@ -427,21 +433,25 @@ void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete",
wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::DigiBattle99, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
for (const auto& card : cards) {
auto removed = collection_.remove(Game::DigiBattle99, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
@@ -511,6 +521,11 @@ void DigiBattle99GameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void DigiBattle99GameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(toolbarButtons_[1], showEdit);
}
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
@@ -518,13 +533,7 @@ void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
refreshToolbarIcons(palette);
refreshTabBarTheme(palette);
if (filterInput_ != nullptr) {
filterInput_->SetBackgroundColour(palette.inputBg);
filterInput_->SetForegroundColour(palette.inputText);
filterInput_->SetOwnBackgroundColour(palette.inputBg);
filterInput_->SetOwnForegroundColour(palette.inputText);
filterInput_->Refresh();
}
applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme);
}
} // namespace ccm::ui
+29 -8
View File
@@ -55,6 +55,7 @@ wxPanel* MagicGameView::listPanel(wxWindow* parent) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
@@ -71,6 +72,16 @@ wxPanel* MagicGameView::selectedPanel(wxWindow* parent) {
return selectedPanel_;
}
void MagicGameView::attachSharedToolbarEdit(wxBitmapButton* edit) {
sharedEditButton_ = edit;
syncEditToolbarVisibility();
}
void MagicGameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(sharedEditButton_, showEdit);
}
void MagicGameView::refreshCollection(std::optional<std::uint32_t> selectId) {
if (listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::Magic);
@@ -139,6 +150,11 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
void MagicGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
@@ -165,20 +181,24 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) {
void MagicGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::Magic, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
for (const auto& card : cards) {
auto removed = collection_.remove(Game::Magic, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
@@ -207,6 +227,7 @@ void MagicGameView::nudgeSelection(int delta) {
void MagicGameView::applyTheme(const ThemePalette& palette) {
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
syncEditToolbarVisibility();
}
} // namespace ccm::ui
+14 -34
View File
@@ -3,6 +3,9 @@
// BaseSelectedCardPanel.hpp is included for the shared EVT_PREVIEW_STATUS
// declaration so MainFrame can subscribe to preview-status updates from any
// active selected panel without depending on a specific game's view.
// BaseCardListPanel.hpp provides EVT_UI_STATUS for list-driven status notes
// (e.g. clipboard copy).
#include "ccm/ui/BaseCardListPanel.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
#include "ccm/ui/IGameView.hpp"
#include "ccm/ui/AppVersion.hpp"
@@ -29,10 +32,6 @@
#include <string>
#include <utility>
#ifdef __WXMSW__
#include <windows.h>
#endif
namespace ccm::ui {
namespace {
@@ -44,6 +43,7 @@ std::string dirNameForGame(Game g) {
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::YuGiOhBandai: return "yugiohbandai";
case Game::DigiBattle99: return "digibattle99";
case Game::JapanesePokemon: return "pokemon";
}
@@ -150,6 +150,9 @@ void MainFrame::buildLayout() {
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
for (auto* view : ctx_.gameViews) {
if (view != nullptr) view->attachSharedToolbarEdit(toolbarButtons_[1]);
}
toolbar->AddStretchSpacer(1);
filterInput_ = new wxTextCtrl(toolbarPanel_, wxID_ANY, "", wxDefaultPosition,
wxSize(260, -1));
@@ -198,12 +201,14 @@ void MainFrame::buildLayout() {
// Selection changes are handled per-view (each IGameView binds
// EVT_CARD_SELECTED on its own typed list panel and pushes the typed
// selection into its selected panel). MainFrame only reacts to preview
// status updates from any active selected panel.
Bind(EVT_PREVIEW_STATUS, [this](wxCommandEvent& ev) {
// selection into its selected panel). MainFrame reacts to status-bar
// updates from preview fetches and list actions (e.g. clipboard copy).
auto onStatusMessage = [this](wxCommandEvent& ev) {
const wxString msg = ev.GetString();
setStatusTextUi(msg.IsEmpty() ? wxString("Ready") : msg);
});
};
Bind(EVT_PREVIEW_STATUS, onStatusMessage);
Bind(EVT_UI_STATUS, onStatusMessage);
}
IGameView* MainFrame::activeView() {
@@ -314,11 +319,7 @@ void MainFrame::applyTheme() {
SetBackgroundColour(palette.windowBg);
SetForegroundColour(palette.text);
if (filterInput_ != nullptr) {
filterInput_->SetBackgroundColour(palette.inputBg);
filterInput_->SetForegroundColour(palette.inputText);
filterInput_->SetOwnBackgroundColour(palette.inputBg);
filterInput_->SetOwnForegroundColour(palette.inputText);
filterInput_->Refresh();
applyPaletteToTextCtrl(filterInput_, palette, currentTheme);
}
for (auto* view : ctx_.gameViews) {
if (view != nullptr) view->applyTheme(palette);
@@ -481,25 +482,4 @@ void MainFrame::onDelete(wxCommandEvent&) {
if (auto* view = activeView()) view->onDeleteCard(this);
}
#ifdef __WXMSW__
WXLRESULT MainFrame::MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) {
if (message == WM_CTLCOLOREDIT && filterInput_ != nullptr) {
const HWND target = reinterpret_cast<HWND>(lParam);
const HWND filterHwnd = reinterpret_cast<HWND>(filterInput_->GetHandle());
if (target != nullptr && filterHwnd != nullptr && target == filterHwnd) {
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
HDC hdc = reinterpret_cast<HDC>(wParam);
::SetTextColor(hdc, RGB(palette.inputText.Red(), palette.inputText.Green(),
palette.inputText.Blue()));
::SetBkColor(hdc, RGB(palette.inputBg.Red(), palette.inputBg.Green(),
palette.inputBg.Blue()));
::SetDCBrushColor(hdc, RGB(palette.inputBg.Red(), palette.inputBg.Green(),
palette.inputBg.Blue()));
return reinterpret_cast<WXLRESULT>(::GetStockObject(DC_BRUSH));
}
}
return wxFrame::MSWWindowProc(message, wParam, lParam);
}
#endif
} // namespace ccm::ui
+67 -10
View File
@@ -8,6 +8,7 @@
#include <wx/app.h>
#include <wx/panel.h>
#include <cctype>
#include <thread>
#include <unordered_set>
@@ -115,6 +116,7 @@ void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); });
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
@@ -311,7 +313,32 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
fillSetNoOnSuccess, /*fillNameOnSuccess=*/false,
showFailureDialog);
});
}).detach();
}
void PokemonCardEditDialog::requestBySetNoAsync(unsigned capturedEpoch,
std::string setId,
std::string setNo,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable();
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
const Game game = backendGame();
std::thread([state, svc, self, capturedEpoch, setId = std::move(setId),
setNo = std::move(setNo), showFailureDialog, game]() {
auto detected = svc->detectVariantsBySetNo(game, setId, setNo);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
/*fillSetNoOnSuccess=*/true,
/*fillNameOnSuccess=*/true, showFailureDialog);
});
}).detach();
}
@@ -319,10 +346,11 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
if ((fillSetNoOnSuccess || fillNameOnSuccess) && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
@@ -335,8 +363,16 @@ void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && !cachedVariants_.empty()) {
applySelectedSetNo(cachedVariants_.front().setNo);
if (!cachedVariants_.empty()) {
const auto& first = cachedVariants_.front();
if (fillNameOnSuccess && !first.name.empty()) {
if (auto* name = nameControl()) {
name->ChangeValue(wxString::FromUTF8(first.name.c_str()));
}
}
if (fillSetNoOnSuccess) {
applySelectedSetNo(first.setNo);
}
}
rebuildVariantRingFromCache();
@@ -443,19 +479,40 @@ void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
void PokemonCardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.id.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
std::string name = card.name;
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string setNo = isUnnumberedPromoSelected()
? normalizedStoredSetNo(selectedSetNo_)
: (setNoCtrl_ ? storedSetNoFromControls(setNoCtrl_)
: normalizedStoredSetNo(selectedSetNo_));
const bool nameEmpty = name.empty();
const bool setNoEmpty = setNo.empty();
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
if (nameEmpty && setNoEmpty) {
showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) {
requestBySetNoAsync(epoch, card.set.id, setNo, true);
return;
}
requestVariantsAsync(epoch, name, card.set.id, true, true);
}
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
+24 -14
View File
@@ -302,6 +302,7 @@ wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
@@ -402,6 +403,11 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
void PokemonGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
@@ -429,20 +435,24 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
void PokemonGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::Pokemon, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
for (const auto& card : cards) {
auto removed = collection_.remove(Game::Pokemon, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
@@ -594,6 +604,11 @@ void PokemonGameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void PokemonGameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(toolbarButtons_[1], showEdit);
}
void PokemonGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
@@ -601,12 +616,7 @@ void PokemonGameView::applyTheme(const ThemePalette& palette) {
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
refreshToolbarIcons(palette);
refreshTabBarTheme(palette);
if (filterInput_ != nullptr) {
filterInput_->SetBackgroundColour(palette.inputBg);
filterInput_->SetForegroundColour(palette.inputText);
filterInput_->SetOwnBackgroundColour(palette.inputBg);
filterInput_->SetOwnForegroundColour(palette.inputText);
}
applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme);
}
} // namespace ccm::ui
+1
View File
@@ -18,6 +18,7 @@ wxString displayLabelForGame(Game g) {
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "Yu-Gi-Oh!";
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
case Game::YuGiOhBandai: return "Yu-Gi-Oh! (Bandai)";
case Game::JapanesePokemon: return "Pokemon"; // internal; not in allGames()
}
return wxString::FromUTF8(to_string(g).data());
+114 -3
View File
@@ -20,7 +20,10 @@
#include <wx/toplevel.h>
#include <wx/window.h>
#include <unordered_map>
#include <unordered_set>
#include <string>
#include <string_view>
#ifdef __WXMSW__
#include <windows.h>
@@ -216,6 +219,79 @@ void applyNativeClassTheme(wxWindow* window, Theme theme, const wchar_t* darkCla
setWindowTheme(hwnd, dark ? darkClass : lightClass, nullptr);
}
// WM_CTLCOLOREDIT is sent to the EDIT's parent, not the top-level frame. Immersive
// dark mode can still paint black typed text even when wx colours are set, so we
// subclass each parent once and force text/background from the wxTextCtrl palette.
constexpr UINT_PTR kEditColorSubclassId = 0x43434d45; // 'CCME'
std::unordered_set<HWND> gEditColorSubclassedParents;
std::unordered_map<HWND, wxTextCtrl*> gPaletteTextCtrls;
std::unordered_set<wxTextCtrl*> gPaletteTextCtrlDestroyBound;
LRESULT CALLBACK editColorParentSubclass(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam,
UINT_PTR /*subclassId*/, DWORD_PTR /*refData*/) {
if (msg == WM_CTLCOLOREDIT) {
const HWND editHwnd = reinterpret_cast<HWND>(lParam);
const auto it = gPaletteTextCtrls.find(editHwnd);
if (it != gPaletteTextCtrls.end() && it->second != nullptr) {
wxTextCtrl* text = it->second;
const wxColour fg = text->GetForegroundColour();
const wxColour bg = text->GetBackgroundColour();
if (fg.IsOk() && bg.IsOk()) {
HDC hdc = reinterpret_cast<HDC>(wParam);
::SetTextColor(hdc, RGB(fg.Red(), fg.Green(), fg.Blue()));
::SetBkColor(hdc, RGB(bg.Red(), bg.Green(), bg.Blue()));
::SetDCBrushColor(hdc, RGB(bg.Red(), bg.Green(), bg.Blue()));
return reinterpret_cast<LRESULT>(::GetStockObject(DC_BRUSH));
}
}
} else if (msg == WM_NCDESTROY) {
gEditColorSubclassedParents.erase(hwnd);
::RemoveWindowSubclass(hwnd, editColorParentSubclass, kEditColorSubclassId);
}
return ::DefSubclassProc(hwnd, msg, wParam, lParam);
}
void ensureEditColorParentSubclass(wxTextCtrl* text) {
if (text == nullptr) return;
const HWND editHwnd = reinterpret_cast<HWND>(text->GetHandle());
if (editHwnd == nullptr) return;
gPaletteTextCtrls[editHwnd] = text;
if (gPaletteTextCtrlDestroyBound.insert(text).second) {
text->Bind(wxEVT_DESTROY, [text, editHwnd](wxWindowDestroyEvent& event) {
gPaletteTextCtrls.erase(editHwnd);
gPaletteTextCtrlDestroyBound.erase(text);
event.Skip();
});
}
const HWND parent = ::GetParent(editHwnd);
if (parent == nullptr) return;
if (gEditColorSubclassedParents.count(parent) != 0) return;
if (::SetWindowSubclass(parent, editColorParentSubclass, kEditColorSubclassId, 0) != FALSE) {
gEditColorSubclassedParents.insert(parent);
}
}
void hardenTextCtrlNativeTheme(wxTextCtrl* text, Theme theme) {
if (text == nullptr) return;
const bool darkLike = isDarkLikeTheme(theme);
text->SetThemeEnabled(!darkLike);
const HWND hwnd = reinterpret_cast<HWND>(text->GetHandle());
if (hwnd == nullptr) return;
// Opt this EDIT out of immersive dark mode so typed text uses our palette.
if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) {
allowDarkModeForWindow(hwnd, FALSE);
}
if (darkLike) {
if (auto setWindowTheme = resolveSetWindowTheme()) {
// Empty theme class disables visual-style painting of the EDIT contents.
setWindowTheme(hwnd, L"", L"");
}
}
ensureEditColorParentSubclass(text);
}
COLORREF toColorRef(const wxColour& c) {
return RGB(c.Red(), c.Green(), c.Blue());
}
@@ -412,9 +488,13 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
dynamic_cast<wxSpinCtrl*>(root) != nullptr) {
if (auto* text = dynamic_cast<wxTextCtrl*>(root)) {
// On Windows, themed EDIT controls can ignore wx foreground color
// while typing in dark mode; disable native theming there so the
// control consistently uses palette-driven text/background colors.
// while typing in dark mode; disable native theming and force
// WM_CTLCOLOREDIT colours via the parent subclass helper.
#ifdef __WXMSW__
hardenTextCtrlNativeTheme(text, theme);
#else
text->SetThemeEnabled(!isDarkLikeTheme(theme));
#endif
}
root->SetBackgroundColour(palette.inputBg);
root->SetForegroundColour(palette.inputText);
@@ -428,7 +508,7 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
} else if (dynamic_cast<wxTextCtrl*>(root) != nullptr) {
// Do not apply Explorer class theming to edit controls: on some
// Windows builds it forces black typed text in dark mode.
// Keep text fields palette-driven via wx colors instead.
// Keep text fields palette-driven via wx colours + CTLCOLOR fix.
} else {
applyNativeClassTheme(root, theme, L"DarkMode_Explorer", L"Explorer");
}
@@ -637,6 +717,20 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t
}
}
void applyPaletteToTextCtrl(wxTextCtrl* text, const ThemePalette& palette, Theme theme) {
if (text == nullptr) return;
#ifdef __WXMSW__
hardenTextCtrlNativeTheme(text, theme);
#else
text->SetThemeEnabled(!isDarkLikeTheme(theme));
#endif
text->SetBackgroundColour(palette.inputBg);
text->SetForegroundColour(palette.inputText);
text->SetOwnBackgroundColour(palette.inputBg);
text->SetOwnForegroundColour(palette.inputText);
text->Refresh();
}
void themeModalDialog(wxDialog* dlg, Theme theme) {
if (dlg == nullptr) return;
const ThemePalette palette = paletteForTheme(theme);
@@ -684,6 +778,23 @@ int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxS
return dlg.ShowModal();
}
void setToolbarEditVisible(wxBitmapButton* edit, bool visible) {
if (edit == nullptr) return;
edit->Show(visible);
if (auto* parent = edit->GetParent()) {
parent->Layout();
}
}
wxString deleteCardsConfirmMessage(std::size_t count, std::string_view singleCardName) {
if (count == 1) {
return wxString::Format(
"Delete \"%s\"?",
wxString::FromUTF8(singleCardName.data(), singleCardName.size()));
}
return wxString::Format("Delete %zu selected entries?", count);
}
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption) {
wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize,
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
+390
View File
@@ -0,0 +1,390 @@
#include "ccm/ui/YuGiOhBandaiCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <algorithm>
#include <cctype>
#include <thread>
#include <utility>
namespace ccm::ui {
namespace {
const char* const kRarityOptions[] = {
"Common",
"Rare",
"Super Rare",
"Ultra Rare",
"Holo Seal",
};
} // namespace
YuGiOhBandaiCardEditDialog::YuGiOhBandaiCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
YuGiOhBandaiCard initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<YuGiOhBandaiCard>(
parent,
mode == EditMode::Create ? "Add Yu-Gi-Oh! (Bandai) Card"
: "Edit Yu-Gi-Oh! (Bandai) Card",
imageService, setService, mode, std::move(initial), Game::YuGiOhBandai,
preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
YuGiOhBandaiCardEditDialog::~YuGiOhBandaiCardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
std::span<const Language> YuGiOhBandaiCardEditDialog::languagesForChoice() const {
static constexpr Language kLangs[] = {Language::Japanese, Language::English};
return kLangs;
}
void YuGiOhBandaiCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
bool YuGiOhBandaiCardEditDialog::validateExtraFields() {
const std::string setNo =
YuGiOhBandaiSetSource::normalizeCardNumber(constCard().setNo);
if (setNo.empty()) {
showThemedMessageDialog(
this,
"Set number (No.) is required for set completion tracking.\n"
"Enter a Bandai number or use Auto detect.",
"Add card", wxOK | wxICON_INFORMATION);
return false;
}
// Persist the normalized form so ownership keys stay stable.
mutableCard().setNo = setNo;
if (setNoCtrl_) setNoCtrl_->ChangeValue(wxString::FromUTF8(setNo.c_str()));
return true;
}
void YuGiOhBandaiCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
flagsBox->Add(holoCheck_, 0, wxRIGHT, 12);
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
}
void YuGiOhBandaiCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onAutoDetectBySetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); });
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
setNoPanel->SetSizer(setNoRow);
auto* rarityPanel = new wxPanel(this, wxID_ANY);
rarityChoice_ = new wxChoice(rarityPanel, wxID_ANY);
wxArrayString rarityItems;
rarityItems.Alloc(static_cast<int>(sizeof(kRarityOptions) / sizeof(kRarityOptions[0])));
for (const char* rarity : kRarityOptions) {
rarityItems.Add(wxString::FromUTF8(rarity));
}
rarityChoice_->Append(rarityItems);
rarityChoice_->Bind(wxEVT_CHOICE, &YuGiOhBandaiCardEditDialog::onRarityChoiceChanged, this);
autoRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Auto detect");
autoRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onAutoDetectByName, this);
auto* rarityRow = new wxBoxSizer(wxHORIZONTAL);
rarityRow->Add(rarityChoice_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
rarityRow->Add(autoRarityBtn_, 0, wxALIGN_CENTER_VERTICAL);
rarityPanel->SetSizer(rarityRow);
appendRow(grid, "No.", setNoPanel);
appendRow(grid, "Rarity", rarityPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &YuGiOhBandaiCardEditDialog::onSetSelectionChanged, this);
}
}
void YuGiOhBandaiCardEditDialog::readExtraFromCard() {
clearCachedPrintVariants();
if (setNoCtrl_) setNoCtrl_->ChangeValue(wxString::FromUTF8(constCard().setNo.c_str()));
applyRarityStringToChoice(constCard().rarity);
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
}
void YuGiOhBandaiCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString(wxConvUTF8);
if (rarityChoice_) mutableCard().rarity = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void YuGiOhBandaiCardEditDialog::applyRarityStringToChoice(const std::string& rarity) {
if (!rarityChoice_) return;
if (rarity.empty()) {
rarityChoice_->SetSelection(0);
return;
}
const wxString wxRare = wxString::FromUTF8(rarity.c_str());
int idx = rarityChoice_->FindString(wxRare);
if (idx == wxNOT_FOUND) {
rarityChoice_->Append(wxRare);
idx = rarityChoice_->GetCount() - 1;
}
if (idx != wxNOT_FOUND) rarityChoice_->SetSelection(idx);
}
void YuGiOhBandaiCardEditDialog::maybeAutoCheckHoloForRarity(const std::string& rarity) {
if (rarity == "Holo Seal" && holoCheck_ != nullptr) {
holoCheck_->SetValue(true);
}
}
void YuGiOhBandaiCardEditDialog::onRarityChoiceChanged(wxCommandEvent&) {
if (!rarityChoice_) return;
maybeAutoCheckHoloForRarity(rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8));
}
std::size_t YuGiOhBandaiCardEditDialog::findAvailableSetIndex(const std::string& setId) const {
const auto& available = availableSets();
auto lowerAscii = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return s;
};
const std::string target = lowerAscii(setId);
for (std::size_t i = 0; i < available.size(); ++i) {
if (lowerAscii(available[i].id) == target) return i;
}
return available.size();
}
void YuGiOhBandaiCardEditDialog::applyDetectedPrint(const AutoDetectedPrint& print) {
if (!print.name.empty()) {
if (auto* name = nameControl()) {
name->ChangeValue(wxString::FromUTF8(print.name.c_str()));
}
}
if (!print.setNo.empty() && setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(print.setNo.c_str()));
}
if (!print.rarity.empty()) {
applyRarityStringToChoice(print.rarity);
maybeAutoCheckHoloForRarity(print.rarity);
}
if (!print.language.empty()) {
if (auto lang = languageFromString(print.language)) {
for (const Language l : languagesForChoice()) {
if (l != *lang) continue;
if (auto* choice = languageChoiceControl()) {
const wxString wanted =
wxString::FromUTF8(std::string(to_string(*lang)).c_str());
const int idx = choice->FindString(wanted);
if (idx != wxNOT_FOUND) choice->SetSelection(idx);
}
break;
}
}
}
if (!print.setId.empty()) {
const std::size_t idx = findAvailableSetIndex(print.setId);
if (idx < availableSets().size()) {
applySetSelectionByIndex(idx);
}
}
}
void YuGiOhBandaiCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
variantRingPos_ = 0;
refreshVariantNextControls();
}
void YuGiOhBandaiCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(cachedVariants_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void YuGiOhBandaiCardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void YuGiOhBandaiCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
if (card.name.empty()) return;
requestByNameAsync(capturedEpoch, card.name, card.set.id, false);
}
void YuGiOhBandaiCardEditDialog::requestByNameAsync(unsigned capturedEpoch, std::string name,
std::string setId, bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (showFailureDialog && autoRarityBtn_) autoRarityBtn_->Disable();
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
YuGiOhBandaiCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setId = std::move(setId), showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::YuGiOhBandai, name, setId);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedList(capturedEpoch, std::move(detected), showFailureDialog,
/*applyFirst=*/true);
});
}).detach();
}
void YuGiOhBandaiCardEditDialog::requestByNoAsync(unsigned capturedEpoch, std::string setId,
std::string setNo, bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable();
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
YuGiOhBandaiCardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, setId = std::move(setId),
setNo = std::move(setNo), showFailureDialog]() {
auto detected = svc->detectVariantsBySetNo(Game::YuGiOhBandai, setId, setNo);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedList(capturedEpoch, std::move(detected), showFailureDialog,
/*applyFirst=*/true);
});
}).detach();
}
void YuGiOhBandaiCardEditDialog::applyDetectedList(
unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool showFailureDialog,
bool applyFirst) {
if (capturedEpoch != variantFetchEpoch_) return;
if (autoSetNoBtn_) autoSetNoBtn_->Enable();
if (autoRarityBtn_) autoRarityBtn_->Enable();
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(),
"Auto detect", wxOK | wxICON_WARNING);
}
return;
}
if (detected.value().empty()) {
if (showFailureDialog) {
showThemedMessageDialog(this, "No matching Yu-Gi-Oh! (Bandai) card found.",
"Auto detect", wxOK | wxICON_INFORMATION);
}
return;
}
cachedVariants_ = std::move(detected).value();
variantRingPos_ = 0;
if (applyFirst) applyDetectedPrint(cachedVariants_.front());
refreshVariantNextControls();
}
void YuGiOhBandaiCardEditDialog::onAutoDetectBySetNo(wxCommandEvent&) {
syncCardFromControls();
const auto& card = constCard();
std::string setId;
if (const Set* set = selectedSetFromControls()) setId = set->id;
if (setId.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
std::string name = card.name;
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string setNo =
setNoCtrl_ ? setNoCtrl_->GetValue().ToStdString(wxConvUTF8) : std::string();
const bool nameEmpty = name.empty();
const bool setNoEmpty =
YuGiOhBandaiSetSource::normalizeCardNumber(setNo).empty();
if (nameEmpty && setNoEmpty) {
showThemedMessageDialog(this, "Enter a card name or Bandai number.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) {
requestByNoAsync(epoch, setId, setNo, true);
return;
}
requestByNameAsync(epoch, name, setId, true);
}
void YuGiOhBandaiCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (cachedVariants_.size() <= 1) return;
variantRingPos_ = (variantRingPos_ + 1) % cachedVariants_.size();
applyDetectedPrint(cachedVariants_[variantRingPos_]);
}
void YuGiOhBandaiCardEditDialog::onAutoDetectByName(wxCommandEvent&) {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
std::string setId;
if (const Set* set = selectedSetFromControls()) setId = set->id;
if (setId.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestByNameAsync(epoch, card.name, setId, true);
}
void YuGiOhBandaiCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+73
View File
@@ -0,0 +1,73 @@
#include "ccm/ui/YuGiOhBandaiCardListPanel.hpp"
#include "ccm/services/CardFilter.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
YuGiOhBandaiCardListPanel::YuGiOhBandaiCardListPanel(wxWindow* parent)
: BaseCardListPanel<YuGiOhBandaiCard, YuGiOhBandaiSortColumn>(parent) {
buildLayout();
}
std::vector<YuGiOhBandaiCardListPanel::TextColumnSpec>
YuGiOhBandaiCardListPanel::declareTextColumns() const {
return {
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Name},
{"Set", 150, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::SetReleaseDate},
{"No.", 70, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::SetNo},
{"Rarity", 100, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Rarity},
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhBandaiSortColumn::Amount},
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Condition},
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Language},
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Note},
};
}
std::vector<YuGiOhBandaiCardListPanel::IconColumnSpec>
YuGiOhBandaiCardListPanel::declareIconColumns() const {
constexpr int kFlagColWidth = 36;
return {
{kSvgHolo, kFlagColWidth, YuGiOhBandaiSortColumn::Holo},
{kSvgSigned, kFlagColWidth, YuGiOhBandaiSortColumn::Signed},
{kSvgAltered, kFlagColWidth, YuGiOhBandaiSortColumn::Altered},
};
}
std::string YuGiOhBandaiCardListPanel::renderTextCell(const YuGiOhBandaiCard& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.name;
case 1: return card.set.name;
case 2: return card.setNo;
case 3: return card.rarity;
case 4: return std::to_string(card.amount);
case 5: return std::string(to_string(card.condition));
case 6: return std::string(to_string(card.language));
case 7: return card.note;
}
return {};
}
bool YuGiOhBandaiCardListPanel::isIconColumnSet(const YuGiOhBandaiCard& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.holo;
case 1: return card.signed_;
case 2: return card.altered;
}
return false;
}
void YuGiOhBandaiCardListPanel::sortBy(YuGiOhBandaiSortColumn column, bool ascending) {
sortYuGiOhBandaiCards(mutableCards(), column, ascending);
}
bool YuGiOhBandaiCardListPanel::matchesFilter(const YuGiOhBandaiCard& card,
std::string_view filter) const {
return matchesYuGiOhBandaiFilter(card, filter);
}
} // namespace ccm::ui
+567
View File
@@ -0,0 +1,567 @@
#include "ccm/ui/YuGiOhBandaiGameView.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/YuGiOhBandaiCardEditDialog.hpp"
#include "ccm/ui/YuGiOhBandaiCardListPanel.hpp"
#include "ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp"
#include "ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/bmpbuttn.h>
#include <wx/dcclient.h>
#include <wx/panel.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/splitter.h>
#include <wx/stattext.h>
#include <wx/textctrl.h>
#include <wx/window.h>
#include <string>
namespace ccm::ui {
namespace {
constexpr int kBandaiToolbarIconPx = 18;
constexpr const char kBandaiFilterHint[] = "Filter";
wxColour lighten(const wxColour& c, int amount) {
auto lift = [amount](unsigned char channel) -> unsigned char {
const int raised = static_cast<int>(channel) + amount;
return static_cast<unsigned char>(raised > 255 ? 255 : raised);
};
return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue()));
}
wxColour darken(const wxColour& c, int amount) {
auto drop = [amount](unsigned char channel) -> unsigned char {
const int lowered = static_cast<int>(channel) - amount;
return static_cast<unsigned char>(lowered < 0 ? 0 : lowered);
};
return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue()));
}
} // namespace
YuGiOhBandaiGameView::YuGiOhBandaiGameView(ConfigService& config,
CollectionService<YuGiOhBandaiCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module,
YuGiOhBandaiSetCatalogService& catalogStore)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module),
catalogStore_(catalogStore) {}
void YuGiOhBandaiGameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
attemptedInitialSetLoad_ = true;
auto cached = sets_.getSets(Game::YuGiOhBandai);
if (cached) {
setsCache_ = std::move(cached).value();
if (!setsCache_.empty()) return;
} else {
setsCache_.clear();
}
auto refreshed = sets_.updateSets(Game::YuGiOhBandai);
if (refreshed) {
setsCache_ = std::move(refreshed).value();
}
}
void YuGiOhBandaiGameView::refreshSetCompletionFromStore() {
if (setCompletionPanel_ == nullptr) return;
setCompletionPanel_->reloadFromStore();
if (auto loaded = collection_.list(Game::YuGiOhBandai)) {
setCompletionPanel_->setCollection(std::move(loaded).value());
}
}
bool YuGiOhBandaiGameView::downloadSetsAndCatalog(wxWindow* parentWindow,
std::size_t* setCountOut,
std::size_t* packCountOut) {
auto* bandaiSrc = dynamic_cast<YuGiOhBandaiSetSource*>(&module_.setSource());
if (bandaiSrc == nullptr) {
showThemedMessageDialog(parentWindow, "Yu-Gi-Oh! (Bandai) set source unavailable.",
"Error", wxOK | wxICON_ERROR);
return false;
}
auto both = bandaiSrc->fetchAllWithCatalog();
if (!both) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(),
"Error", wxOK | wxICON_ERROR);
return false;
}
auto savedSets = sets_.saveSets(Game::YuGiOhBandai, both.value().sets);
if (!savedSets) {
showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(),
"Error", wxOK | wxICON_ERROR);
return false;
}
auto savedCatalog = catalogStore_.save(both.value().catalog);
if (!savedCatalog) {
showThemedMessageDialog(parentWindow,
"Sets saved, but set catalog failed: " + savedCatalog.error(),
"Warning", wxOK | wxICON_WARNING);
return false;
}
setsCache_ = both.value().sets;
if (setCountOut != nullptr) *setCountOut = both.value().sets.size();
if (packCountOut != nullptr) *packCountOut = both.value().catalog.packs.size();
refreshSetCompletionFromStore();
return true;
}
bool YuGiOhBandaiGameView::ensureCatalogLoaded(wxWindow* parentWindow) {
if (catalogStore_.exists()) return true;
return downloadSetsAndCatalog(parentWindow, nullptr, nullptr);
}
void YuGiOhBandaiGameView::ensureSingleCardsMounted(wxWindow* splitterParent) {
if (singleSplitter_ == nullptr) {
singleSplitter_ = new wxSplitterWindow(splitterParent, wxID_ANY, wxDefaultPosition,
wxDefaultSize, wxSP_LIVE_UPDATE);
singleSplitter_->SetMinimumPaneSize(280);
}
auto* list = listPanel(singleSplitter_);
auto* selected = selectedPanel(singleSplitter_);
if (!singleSplitter_->IsSplit()) {
singleSplitter_->SplitVertically(selected, list, 360);
}
}
void YuGiOhBandaiGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) {
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
auto makeToolBtn = [&](const char* svg, const wxString& tip) {
wxBitmap bmp = svgIconBitmap(svg, kBandaiToolbarIconPx, "#000000");
auto* b = new wxBitmapButton(parent, wxID_ANY, bmp, wxDefaultPosition, wxDefaultSize,
wxBU_EXACTFIT);
b->SetToolTip(tip);
return b;
};
toolbarButtons_[0] = makeToolBtn(kSvgToolbarAdd, "Add Card");
toolbarButtons_[1] = makeToolBtn(kSvgToolbarEdit, "Edit");
toolbarButtons_[2] = makeToolBtn(kSvgToolbarDelete, "Delete");
toolbar->AddSpacer(4);
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->AddStretchSpacer(1);
filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1));
filterInput_->SetHint(kBandaiFilterHint);
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
pageSizer->Add(toolbar, 0, wxEXPAND);
toolbarButtons_[0]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onAddCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[1]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onEditCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[2]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onDeleteCard(owner != nullptr ? owner : contentPanel_);
});
filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
filterInput_->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& ev) {
const int code = ev.GetKeyCode();
if (code == WXK_UP || code == WXK_DOWN) {
nudgeSelection(code == WXK_UP ? -1 : 1);
return;
}
ev.Skip();
});
}
void YuGiOhBandaiGameView::refreshToolbarIcons(const ThemePalette& palette) {
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
if (toolbarButtons_[0]) {
toolbarButtons_[0]->SetBitmap(
svgIconBitmap(kSvgToolbarAdd, kBandaiToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[1]) {
toolbarButtons_[1]->SetBitmap(
svgIconBitmap(kSvgToolbarEdit, kBandaiToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[2]) {
toolbarButtons_[2]->SetBitmap(
svgIconBitmap(kSvgToolbarDelete, kBandaiToolbarIconPx, tbHex.c_str()));
}
}
void YuGiOhBandaiGameView::selectTab(int index) {
if (index < 0 || index > 1 || book_ == nullptr) return;
activeTab_ = index;
book_->SetSelection(index);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
// Bandai sets.json can load offline from the hardcoded manifest; the
// set-completion catalog needs a Yugipedia fetch. Pull it on first visit.
if (index == 1 && !catalogStore_.exists()) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
// Errors are shown inside ensureCatalogLoaded; empty-state UI remains if it fails.
(void)ensureCatalogLoaded(owner != nullptr ? owner : contentPanel_);
}
}
void YuGiOhBandaiGameView::refreshTabBarTheme(const ThemePalette& palette) {
if (tabBar_ == nullptr) return;
const wxColour barBg = palette.panelBg;
// Match toolbar button plate (Add/Edit/Delete), not a darker inset fill.
const wxColour tabBg = palette.buttonBg;
tabBar_->SetBackgroundColour(barBg);
tabBar_->SetOwnBackgroundColour(barBg);
for (int i = 0; i < 2; ++i) {
auto* tab = tabPanels_[i];
auto* label = tabLabels_[i];
if (tab == nullptr || label == nullptr) continue;
const bool selected = (i == activeTab_);
tab->SetBackgroundColour(tabBg);
tab->SetOwnBackgroundColour(tabBg);
// Keep the label plate identical to the tab fill so a late theme pass
// cannot leave a darker box around the caption.
label->SetBackgroundColour(tabBg);
label->SetOwnBackgroundColour(tabBg);
label->SetForegroundColour(palette.text);
label->SetOwnForegroundColour(palette.text);
wxFont font = label->GetFont();
font.SetWeight(selected ? wxFONTWEIGHT_BOLD : wxFONTWEIGHT_NORMAL);
label->SetFont(font);
tab->Refresh();
label->Refresh();
}
tabBar_->Layout();
tabBar_->Refresh();
}
void YuGiOhBandaiGameView::buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer) {
tabBar_ = new wxPanel(parent, wxID_ANY);
tabBar_->SetBackgroundStyle(wxBG_STYLE_PAINT);
auto* tabSizer = new wxBoxSizer(wxHORIZONTAL);
tabSizer->AddSpacer(4);
const char* labels[2] = {"Single Cards", "Set Completion"};
for (int i = 0; i < 2; ++i) {
auto* tab = new wxPanel(tabBar_, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
tab->SetCursor(wxCursor(wxCURSOR_HAND));
tab->SetBackgroundStyle(wxBG_STYLE_PAINT);
auto* label = new wxStaticText(tab, wxID_ANY, wxString::FromUTF8(labels[i]));
auto* inner = new wxBoxSizer(wxVERTICAL);
// Compact padding so the strip stays short; frame is drawn in paint.
inner->Add(label, 0, wxALIGN_CENTER | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 5);
tab->SetSizer(inner);
auto onClick = [this, i](wxMouseEvent&) { selectTab(i); };
tab->Bind(wxEVT_LEFT_DOWN, onClick);
label->Bind(wxEVT_LEFT_DOWN, onClick);
tab->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
tab->Bind(wxEVT_PAINT, [this, tab, i](wxPaintEvent&) {
wxPaintDC dc(tab);
const ThemePalette palette = paletteForTheme(config_.current().theme);
const bool dark = config_.current().theme == Theme::Dark;
const bool selected = (i == activeTab_);
// Same plate as toolbar bitmap buttons.
const wxColour bg = palette.buttonBg;
const wxColour frame =
dark ? lighten(palette.panelBg, 55) : darken(palette.panelBg, 45);
const wxColour frameSel = dark ? lighten(palette.panelBg, 85) : darken(palette.panelBg, 70);
const wxRect r = tab->GetClientRect();
dc.SetPen(wxPen(selected ? frameSel : frame, 1));
dc.SetBrush(wxBrush(bg));
dc.DrawRectangle(r.x, r.y, r.width, r.height);
if (selected) {
dc.SetPen(wxPen(palette.text, 2));
dc.DrawLine(r.GetLeft() + 4, r.GetBottom() - 1, r.GetRight() - 4,
r.GetBottom() - 1);
}
});
tabPanels_[i] = tab;
tabLabels_[i] = label;
if (i > 0) tabSizer->AddSpacer(4);
tabSizer->Add(tab, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 3);
}
tabSizer->AddStretchSpacer(1);
tabBar_->Bind(wxEVT_PAINT, [this](wxPaintEvent&) {
wxPaintDC dc(tabBar_);
const ThemePalette palette = paletteForTheme(config_.current().theme);
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(palette.panelBg));
dc.DrawRectangle(tabBar_->GetClientRect());
dc.SetPen(wxPen(darken(palette.text, 120), 1));
const wxRect r = tabBar_->GetClientRect();
dc.DrawLine(r.GetLeft(), r.GetBottom(), r.GetRight(), r.GetBottom());
});
tabBar_->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
tabBar_->SetSizer(tabSizer);
rootSizer->Add(tabBar_, 0, wxEXPAND);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
wxPanel* YuGiOhBandaiGameView::contentPanel(wxWindow* parent) {
if (contentPanel_ == nullptr) {
contentPanel_ = new wxPanel(parent);
auto* root = new wxBoxSizer(wxVERTICAL);
buildTabBar(contentPanel_, root);
book_ = new wxSimplebook(contentPanel_, wxID_ANY);
auto* singlePage = new wxPanel(book_);
auto* singleSizer = new wxBoxSizer(wxVERTICAL);
buildSingleCardsToolbar(singlePage, singleSizer);
ensureSingleCardsMounted(singlePage);
singleSizer->Add(singleSplitter_, 1, wxEXPAND);
singlePage->SetSizer(singleSizer);
book_->AddPage(singlePage, "Single Cards");
setCompletionPanel_ = new YuGiOhBandaiSetCompletionPanel(book_, catalogStore_);
setCompletionPanel_->reloadFromStore();
book_->AddPage(setCompletionPanel_, "Set Completion");
root->Add(book_, 1, wxEXPAND | wxTOP, 5);
contentPanel_->SetSizer(root);
selectTab(0);
refreshToolbarIcons(paletteForTheme(config_.current().theme));
// First mount: re-assert tab plate colors after the initial layout paint.
contentPanel_->CallAfter([this]() {
refreshTabBarTheme(paletteForTheme(config_.current().theme));
});
}
return contentPanel_;
}
wxPanel* YuGiOhBandaiGameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new YuGiOhBandaiCardListPanel(parent);
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
});
}
return listPanel_;
}
wxPanel* YuGiOhBandaiGameView::selectedPanel(wxWindow* parent) {
if (selectedPanel_ == nullptr) {
selectedPanel_ = new YuGiOhBandaiSelectedCardPanel(parent, images_, cardPreview_);
}
return selectedPanel_;
}
void YuGiOhBandaiGameView::refreshCollection(std::optional<std::uint32_t> selectId) {
// Ensure the Bandai host (and list panel) exist even when MainFrame mounts
// via contentPanel before an explicit listPanel call.
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::YuGiOhBandai);
if (!loaded) {
showThemedMessageDialog(
nullptr,
"Failed to load Yu-Gi-Oh! (Bandai) collection: " + loaded.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
auto cards = std::move(loaded).value();
if (listPanel_ != nullptr) {
listPanel_->setCards(cards, selectId);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->setCollection(std::move(cards));
}
}
const std::vector<Set>& YuGiOhBandaiGameView::setsForDialog() {
ensureSetsLoaded();
if (!setsCache_.empty()) return setsCache_;
auto loaded = sets_.getSets(Game::YuGiOhBandai);
if (loaded) setsCache_ = std::move(loaded).value();
else setsCache_.clear();
return setsCache_;
}
void YuGiOhBandaiGameView::onAddCard(wxWindow* parentWindow) {
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
return;
}
YuGiOhBandaiCard fresh;
fresh.amount = 1;
fresh.condition = Condition::NearMint;
// language defaults to Language::Japanese via the domain type.
YuGiOhBandaiCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create,
fresh, &setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::YuGiOhBandai, dlg.card());
if (!added) {
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
YuGiOhBandaiCard persisted = dlg.card();
persisted.id = added.value();
auto normalized = images_.normalizeNamesForPersistedCard(
Game::YuGiOhBandai, persisted.id, persisted.set.name, persisted.name, persisted.images);
if (normalized) {
if (normalized.value() != persisted.images) {
persisted.images = std::move(normalized).value();
auto updated = collection_.update(Game::YuGiOhBandai, persisted);
if (!updated) {
showThemedMessageDialog(
parentWindow,
"Card added, but image name normalization failed to persist: " +
updated.error(),
"Warning", wxOK | wxICON_WARNING);
}
}
} else {
showThemedMessageDialog(
parentWindow,
"Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection(added.value());
}
void YuGiOhBandaiGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION);
return;
}
YuGiOhBandaiCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit,
*sel, &setsForDialog());
themeModalDialog(&dlg, config_.current().theme);
CardEditModalGuard modalGuard;
if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::YuGiOhBandai, dlg.card());
if (!updated) {
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
void YuGiOhBandaiGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete",
wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
for (const auto& card : cards) {
auto removed = collection_.remove(Game::YuGiOhBandai, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
std::string YuGiOhBandaiGameView::onUpdateSets(wxWindow* parentWindow) {
std::size_t setCount = 0;
std::size_t packCount = 0;
if (!downloadSetsAndCatalog(parentWindow, &setCount, &packCount)) {
return "Update failed";
}
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(setCount) + " Yu-Gi-Oh! (Bandai) sets and " +
std::to_string(packCount) + " set checklists.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Yu-Gi-Oh! (Bandai) sets updated.";
}
void YuGiOhBandaiGameView::setFilter(std::string_view filter) {
if (filterInput_ != nullptr) {
const wxString wanted = wxString::FromUTF8(std::string(filter).c_str());
if (filterInput_->GetValue() != wanted) {
filterInput_->ChangeValue(wanted);
if (filter.empty()) {
filterInput_->SetHint(kBandaiFilterHint);
filterInput_->Refresh();
}
}
}
if (listPanel_) listPanel_->setFilter(filter);
}
void YuGiOhBandaiGameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void YuGiOhBandaiGameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(toolbarButtons_[1], showEdit);
}
void YuGiOhBandaiGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
refreshToolbarIcons(palette);
refreshTabBarTheme(palette);
applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme);
}
} // namespace ccm::ui
@@ -0,0 +1,83 @@
#include "ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
namespace {
enum YuGiOhBandaiDetailKey : int {
kName = 0,
kSet,
kSetNo,
kRarity,
kAmount,
kCondition,
kLanguage,
kHolo,
kSigned,
kAltered,
};
} // namespace
YuGiOhBandaiSelectedCardPanel::YuGiOhBandaiSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview)
: BaseSelectedCardPanel<YuGiOhBandaiCard>(parent, imageService, cardPreview) {
buildLayout();
}
std::vector<YuGiOhBandaiSelectedCardPanel::DetailRowSpec>
YuGiOhBandaiSelectedCardPanel::declareDetailRows() const {
return {
{"Name", kName, "(no card selected)"},
{"Set", kSet, ""},
{"No.", kSetNo, ""},
{"Rarity", kRarity, ""},
{"Amount", kAmount, ""},
{"Condition", kCondition, ""},
{"Language", kLanguage, ""},
};
}
std::vector<YuGiOhBandaiSelectedCardPanel::FlagIconSpec>
YuGiOhBandaiSelectedCardPanel::declareFlagIcons() const {
return {
{kSvgHolo, "Holo", kHolo},
{kSvgSigned, "Signed", kSigned},
{kSvgAltered, "Altered", kAltered},
};
}
std::string YuGiOhBandaiSelectedCardPanel::detailValueFor(const YuGiOhBandaiCard& card,
DetailKey key) const {
switch (key) {
case kName: return card.name;
case kSet: return card.set.name;
case kSetNo: return card.setNo;
case kRarity: return card.rarity;
case kAmount: return std::to_string(card.amount);
case kCondition: return std::string(to_string(card.condition));
case kLanguage: return std::string(to_string(card.language));
case kNoteKey: return card.note;
}
return {};
}
bool YuGiOhBandaiSelectedCardPanel::isFlagSet(const YuGiOhBandaiCard& card,
DetailKey key) const {
switch (key) {
case kHolo: return card.holo;
case kSigned: return card.signed_;
case kAltered: return card.altered;
}
return false;
}
std::tuple<std::string, std::string, std::string>
YuGiOhBandaiSelectedCardPanel::previewKey(const YuGiOhBandaiCard& card) const {
return {card.name, card.set.id, card.setNo};
}
} // namespace ccm::ui
@@ -0,0 +1,336 @@
#include "ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
#include <wx/button.h>
#include <wx/choice.h>
#include <wx/cursor.h>
#include <wx/gauge.h>
#include <wx/listctrl.h>
#include <wx/scrolwin.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <string>
#include <utility>
namespace ccm::ui {
namespace {
wxColour mutedTextColour(const ThemePalette& palette) {
// Blend text toward panel background so missing checklist rows read as greyed.
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
return static_cast<unsigned char>((static_cast<int>(a) * 2 + static_cast<int>(b)) / 3);
};
return wxColour(blend(palette.text.Red(), palette.panelBg.Red()),
blend(palette.text.Green(), palette.panelBg.Green()),
blend(palette.text.Blue(), palette.panelBg.Blue()));
}
} // namespace
YuGiOhBandaiSetCompletionPanel::YuGiOhBandaiSetCompletionPanel(
wxWindow* parent, YuGiOhBandaiSetCatalogService& catalogStore)
: wxPanel(parent), catalogStore_(catalogStore) {
palette_ = paletteForTheme(inferThemeFromWindow(this));
auto* langRow = new wxBoxSizer(wxHORIZONTAL);
auto* langLabel = new wxStaticText(this, wxID_ANY, "Language");
languageChoice_ = new wxChoice(this, wxID_ANY);
languageChoice_->Append("All languages");
languageChoice_->SetSelection(0);
languageChoice_->Bind(wxEVT_CHOICE, &YuGiOhBandaiSetCompletionPanel::onLanguageChoice,
this);
langRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
langRow->Add(languageChoice_, 0, wxALIGN_CENTER_VERTICAL);
book_ = new wxSimplebook(this, wxID_ANY);
gridPage_ = new wxPanel(book_);
auto* gridRoot = new wxBoxSizer(wxVERTICAL);
emptyLabel_ = new wxStaticText(gridPage_, wxID_ANY, "");
emptyLabel_->Wrap(480);
gridRoot->Add(emptyLabel_, 0, wxALL | wxEXPAND, 12);
scroll_ = new wxScrolledWindow(gridPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxVSCROLL | wxTAB_TRAVERSAL);
scroll_->SetScrollRate(0, 16);
gridSizer_ = new wxBoxSizer(wxVERTICAL);
scroll_->SetSizer(gridSizer_);
gridRoot->Add(scroll_, 1, wxEXPAND);
gridPage_->SetSizer(gridRoot);
book_->AddPage(gridPage_, "Grid");
detailPage_ = new wxPanel(book_);
auto* detailRoot = new wxBoxSizer(wxVERTICAL);
auto* topRow = new wxBoxSizer(wxHORIZONTAL);
auto* backBtn = new wxButton(detailPage_, wxID_ANY, "Back");
backBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { showGridPage(); });
detailTitle_ = new wxStaticText(detailPage_, wxID_ANY, "");
auto titleFont = detailTitle_->GetFont();
titleFont.MakeBold().MakeLarger();
detailTitle_->SetFont(titleFont);
topRow->Add(backBtn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
topRow->Add(detailTitle_, 1, wxALIGN_CENTER_VERTICAL);
detailRoot->Add(topRow, 0, wxEXPAND | wxALL, 8);
checklist_ = new wxListCtrl(detailPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
checklist_->AppendColumn("Card", wxLIST_FORMAT_LEFT, 520);
detailRoot->Add(checklist_, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);
detailPage_->SetSizer(detailRoot);
book_->AddPage(detailPage_, "Detail");
auto* root = new wxBoxSizer(wxVERTICAL);
root->Add(langRow, 0, wxEXPAND | wxALL, 8);
root->Add(book_, 1, wxEXPAND);
SetSizer(root);
showGridPage();
}
void YuGiOhBandaiSetCompletionPanel::setCollection(std::vector<YuGiOhBandaiCard> cards) {
collection_ = std::move(cards);
refreshLanguageChoice();
rebuildCurrentView();
}
void YuGiOhBandaiSetCompletionPanel::reloadFromStore() {
catalogLoaded_ = false;
catalog_ = {};
if (catalogStore_.exists()) {
if (auto loaded = catalogStore_.load()) {
catalog_ = std::move(loaded).value();
catalogLoaded_ = true;
}
}
showGridPage();
rebuildGrid();
}
void YuGiOhBandaiSetCompletionPanel::applyTheme(const ThemePalette& palette) {
palette_ = palette;
applyThemeToWindowTree(this, palette, inferThemeFromWindow(this));
rebuildCurrentView();
}
void YuGiOhBandaiSetCompletionPanel::showGridPage() {
detailSetId_.clear();
detailSetName_.clear();
book_->SetSelection(0);
}
void YuGiOhBandaiSetCompletionPanel::showChecklistPage(const std::string& setId,
const std::string& setName) {
detailSetId_ = setId;
detailSetName_ = setName;
detailTitle_->SetLabelText(wxString::FromUTF8(displaySetName(setName).c_str()));
rebuildChecklist(setId);
book_->SetSelection(1);
}
std::string YuGiOhBandaiSetCompletionPanel::displaySetName(const std::string& setName) const {
if (!languageFilter_.has_value()) return setName;
return setName + " (" + std::string(to_string(*languageFilter_)) + ")";
}
void YuGiOhBandaiSetCompletionPanel::refreshLanguageChoice() {
const auto previous = languageFilter_;
const auto present = yuGiOhBandaiLanguagesInCollection(collection_);
languageChoice_->Clear();
languageChoice_->Append("All languages");
for (const Language lang : present) {
languageChoice_->Append(wxString::FromUTF8(std::string(to_string(lang)).c_str()));
}
int selection = 0;
languageFilter_ = std::nullopt;
if (previous.has_value()) {
for (std::size_t i = 0; i < present.size(); ++i) {
if (present[i] == *previous) {
selection = static_cast<int>(i + 1);
languageFilter_ = previous;
break;
}
}
}
languageChoice_->SetSelection(selection);
}
void YuGiOhBandaiSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) {
const int sel = languageChoice_->GetSelection();
if (sel <= 0) {
languageFilter_ = std::nullopt;
} else {
const auto present = yuGiOhBandaiLanguagesInCollection(collection_);
const auto idx = static_cast<std::size_t>(sel - 1);
if (idx < present.size()) {
languageFilter_ = present[idx];
} else {
languageFilter_ = std::nullopt;
languageChoice_->SetSelection(0);
}
}
rebuildCurrentView();
}
void YuGiOhBandaiSetCompletionPanel::rebuildCurrentView() {
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
const auto rows =
computeYuGiOhBandaiSetCompletion(collection_, catalog_, languageFilter_);
bool stillVisible = false;
for (const auto& row : rows) {
if (row.setId == detailSetId_) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
showGridPage();
rebuildGrid();
return;
}
detailTitle_->SetLabelText(
wxString::FromUTF8(displaySetName(detailSetName_).c_str()));
rebuildChecklist(detailSetId_);
} else {
rebuildGrid();
}
}
void YuGiOhBandaiSetCompletionPanel::setEmptyMessage(const wxString& message) {
clearGridTiles();
emptyLabel_->SetLabelText(message);
emptyLabel_->Wrap(480);
emptyLabel_->Show();
scroll_->Hide();
gridPage_->Layout();
}
void YuGiOhBandaiSetCompletionPanel::clearGridTiles() {
if (gridSizer_ == nullptr) return;
gridSizer_->Clear(true);
}
void YuGiOhBandaiSetCompletionPanel::rebuildGrid() {
if (!catalogLoaded_) {
setEmptyMessage(wxString::FromUTF8(
"Set checklists are not downloaded yet.\n"
"Run Sets \xE2\x86\x92 Update Yu-Gi-Oh! (Bandai) to enable Set Completion."));
return;
}
const auto rows = computeYuGiOhBandaiSetCompletion(collection_, catalog_, languageFilter_);
if (rows.empty()) {
if (collection_.empty()) {
setEmptyMessage(wxString::FromUTF8(
"No Yu-Gi-Oh! (Bandai) sets in progress yet.\n"
"Add cards on the Single Cards tab to track set completion here."));
} else {
bool anyCountable = false;
for (const auto& card : collection_) {
if (card.set.id.empty()) continue;
if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo).empty()) continue;
anyCountable = true;
break;
}
if (!anyCountable) {
setEmptyMessage(wxString::FromUTF8(
"Your cards need a set number (No.) to track set completion.\n"
"Edit each card and enter its Bandai number, or use Auto detect."));
} else {
setEmptyMessage(wxString::FromUTF8(
"None of your cards match a downloaded set checklist.\n"
"Run Sets \xE2\x86\x92 Update Yu-Gi-Oh! (Bandai), and confirm "
"each card's set and No."));
}
}
return;
}
emptyLabel_->Hide();
scroll_->Show();
clearGridTiles();
for (const auto& row : rows) {
auto* tile = new wxPanel(scroll_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxBORDER_SIMPLE);
tile->SetBackgroundColour(palette_.panelBg);
auto* tileSizer = new wxBoxSizer(wxVERTICAL);
const std::string title = displaySetName(row.setName);
auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(title.c_str()));
auto nameFont = nameLbl->GetFont();
nameFont.MakeBold();
nameLbl->SetFont(nameFont);
nameLbl->SetForegroundColour(palette_.text);
const std::string counts =
std::to_string(row.ownedUnique) + " / " + std::to_string(row.total) + " (" +
std::to_string(row.percent()) + "%)";
auto* countLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(counts.c_str()));
countLbl->SetForegroundColour(palette_.text);
auto* gauge = new wxGauge(tile, wxID_ANY, 100, wxDefaultPosition, wxSize(-1, 14),
wxGA_HORIZONTAL | wxGA_SMOOTH);
gauge->SetValue(row.percent());
tileSizer->Add(nameLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 10);
tileSizer->Add(countLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 6);
tileSizer->Add(gauge, 0, wxEXPAND | wxALL, 10);
tile->SetSizer(tileSizer);
const std::string setId = row.setId;
const std::string setName = row.setName;
auto openDetail = [this, setId, setName](wxMouseEvent&) {
showChecklistPage(setId, setName);
};
tile->Bind(wxEVT_LEFT_UP, openDetail);
nameLbl->Bind(wxEVT_LEFT_UP, openDetail);
countLbl->Bind(wxEVT_LEFT_UP, openDetail);
gauge->Bind(wxEVT_LEFT_UP, openDetail);
tile->SetCursor(wxCursor(wxCURSOR_HAND));
nameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
countLbl->SetCursor(wxCursor(wxCURSOR_HAND));
gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
}
gridSizer_->AddStretchSpacer(1);
scroll_->FitInside();
gridPage_->Layout();
Layout();
}
void YuGiOhBandaiSetCompletionPanel::rebuildChecklist(const std::string& setId) {
checklist_->DeleteAllItems();
const auto entries =
yuGiOhBandaiChecklistForSet(collection_, catalog_, setId, languageFilter_);
const wxColour muted = mutedTextColour(palette_);
// Fixed green so owned checkmarks stay readable in both light and dark themes.
const wxColour ownedGreen(46, 160, 67);
long idx = 0;
for (const auto& entry : entries) {
// Align names: checkmark + two spaces vs four spaces for missing cards.
std::string line =
(entry.owned ? "\xE2\x9C\x93 " : " ") + entry.setNo + " \xE2\x80\x94 " + entry.name;
if (!entry.rarity.empty()) {
line += " (" + entry.rarity + ")";
}
const long row = checklist_->InsertItem(idx++, wxString::FromUTF8(line.c_str()));
if (row < 0) continue;
if (entry.owned) {
checklist_->SetItemTextColour(row, ownedGreen);
} else {
checklist_->SetItemTextColour(row, muted);
}
}
checklist_->SetColumnWidth(0, wxLIST_AUTOSIZE);
detailPage_->Layout();
}
} // namespace ccm::ui

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