diff --git a/app/AGENTS.md b/app/AGENTS.md index c4175af..e596fac 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `DigiBattle99SetCatalogService`, `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`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. 3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). 4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). 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`). diff --git a/app/main.cpp b/app/main.cpp index 8af37d6..206fc7e 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -22,6 +22,7 @@ #include "ccm/services/CollectionService.hpp" #include "ccm/services/ConfigService.hpp" #include "ccm/services/DigiBattle99SetCatalogService.hpp" +#include "ccm/services/YuGiOhSetCatalogService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/services/SetService.hpp" #include "ccm/ui/AppContext.hpp" @@ -113,6 +114,8 @@ public: setRepo_ = std::make_unique(*fs_, *config_, &dirNameForGame); digiBattle99CatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); + ygoCatalogStore_ = + std::make_unique(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); imgSvc_ = std::make_unique(*imgStore_); @@ -163,7 +166,8 @@ public: pokeView_ = std::make_unique( *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_); ygoView_ = std::make_unique( - *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_); + *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_, + *ygoCatalogStore_); digiBattle99View_ = std::make_unique( *config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *digiBattle99Mod_, *digiBattle99CatalogStore_); @@ -207,6 +211,7 @@ private: std::unique_ptr> digiBattle99Repo_; std::unique_ptr setRepo_; std::unique_ptr digiBattle99CatalogStore_; + std::unique_ptr ygoCatalogStore_; std::unique_ptr imgStore_; std::unique_ptr imgSvc_; std::unique_ptr> magicCollSvc_; diff --git a/core/AGENTS.md b/core/AGENTS.md index 57d886d..c286ec5 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -4,11 +4,11 @@ ## Layer pointers -- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle 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), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle 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`, `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` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. -- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (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), `DigiBattle99SetCompletion` (pure Digi-Battle set-completion / checklist helpers), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.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. `DigiBattle99SetSource` also exposes `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect. +- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (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` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.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` and `DigiBattle99SetSource` also expose `parseCatalog` / `fetchAllWithCatalog` 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. - `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. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 216ec32..244467b 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(ccm_core STATIC src/domain/YuGiOhCard.cpp src/domain/DigiBattle99Card.cpp src/domain/DigiBattle99SetCatalog.cpp + src/domain/YuGiOhSetCatalog.cpp src/domain/JapanesePokemonCard.cpp src/domain/Configuration.cpp @@ -20,6 +21,8 @@ add_library(ccm_core STATIC src/services/CardFilter.cpp src/services/DigiBattle99SetCompletion.cpp src/services/DigiBattle99SetCatalogService.cpp + src/services/YuGiOhSetCompletion.cpp + src/services/YuGiOhSetCatalogService.cpp src/infra/CprHttpClient.cpp src/infra/StdFileSystem.cpp diff --git a/core/include/ccm/domain/YuGiOhSetCatalog.hpp b/core/include/ccm/domain/YuGiOhSetCatalog.hpp new file mode 100644 index 0000000..91fdfdf --- /dev/null +++ b/core/include/ccm/domain/YuGiOhSetCatalog.hpp @@ -0,0 +1,52 @@ +#pragma once + +// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set +// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and +// persisted at `/yugioh/set-catalog.json`. + +#include + +#include +#include +#include +#include + +namespace ccm { + +struct YuGiOhCatalogCard { + std::string setNo; + std::string name; + + friend bool operator==(const YuGiOhCatalogCard&, + const YuGiOhCatalogCard&) = default; +}; + +struct YuGiOhSetCatalogPack { + std::string setId; + std::string setName; + std::vector cards; + + friend bool operator==(const YuGiOhSetCatalogPack&, + const YuGiOhSetCatalogPack&) = default; +}; + +struct YuGiOhSetCatalog { + std::vector packs; + + [[nodiscard]] const YuGiOhSetCatalogPack* findPack( + std::string_view setId) const; + + [[nodiscard]] bool empty() const noexcept { return packs.empty(); } + + friend bool operator==(const YuGiOhSetCatalog&, + const YuGiOhSetCatalog&) = default; +}; + +void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c); +void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c); +void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p); +void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p); +void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c); +void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c); + +} // namespace ccm diff --git a/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp b/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp index 638e87e..3e563bc 100644 --- a/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp +++ b/core/include/ccm/games/yugioh/YuGiOhSetSource.hpp @@ -1,21 +1,45 @@ #pragma once // YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck. +// Sets come from cardsets.php; the set-completion catalog is built from the +// unfiltered cardinfo.php dump (card_sets[] per card). +#include "ccm/domain/Set.hpp" +#include "ccm/domain/YuGiOhSetCatalog.hpp" #include "ccm/games/IGameModule.hpp" #include "ccm/ports/IHttpClient.hpp" +#include +#include + namespace ccm { class YuGiOhSetSource final : public ISetSource { public: static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php"; + static constexpr const char* kCardInfoEndpoint = + "https://db.ygoprodeck.com/api/v7/cardinfo.php"; + + struct FetchWithCatalog { + std::vector sets; + YuGiOhSetCatalog catalog; + }; explicit YuGiOhSetSource(IHttpClient& http); Result> fetchAll() override; + + // Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for + // the pack checklist catalog. + Result fetchAllWithCatalog(); + static Result> parseResponse(const std::string& body); + // Build the offline checklist from a cardinfo.php body, resolving pack + // ids against the already-parsed sets list (by set_name → Set.id). + static Result parseCatalog(const std::string& body, + const std::vector& sets); + private: IHttpClient& http_; }; diff --git a/core/include/ccm/services/YuGiOhSetCatalogService.hpp b/core/include/ccm/services/YuGiOhSetCatalogService.hpp new file mode 100644 index 0000000..4e70623 --- /dev/null +++ b/core/include/ccm/services/YuGiOhSetCatalogService.hpp @@ -0,0 +1,36 @@ +#pragma once + +// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the +// configured dataStorage path. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/YuGiOhSetCatalog.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class YuGiOhSetCatalogService { +public: + using DirNameFn = std::function; + + YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName); + + Result load() const; + Result save(const YuGiOhSetCatalog& catalog); + + [[nodiscard]] bool exists() const; + +private: + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; + + [[nodiscard]] std::filesystem::path catalogPath() const; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/YuGiOhSetCompletion.hpp b/core/include/ccm/services/YuGiOhSetCompletion.hpp new file mode 100644 index 0000000..1f1747d --- /dev/null +++ b/core/include/ccm/services/YuGiOhSetCompletion.hpp @@ -0,0 +1,60 @@ +#pragma once + +// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists. +// Ownership counts only when collection card.set.id matches the pack and the +// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates / +// amount / rarity / firstEdition do not inflate the numerator. An optional +// languageFilter restricts ownership to cards of that language (packs with +// zero matches are omitted). + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/YuGiOhCard.hpp" +#include "ccm/domain/YuGiOhSetCatalog.hpp" + +#include +#include +#include +#include +#include + +namespace ccm { + +struct YuGiOhSetCompletionProgress { + std::string setId; + std::string setName; + std::size_t ownedUnique{0}; + std::size_t total{0}; + + [[nodiscard]] int percent() const noexcept { + if (total == 0) return 0; + return static_cast((ownedUnique * 100) / total); + } +}; + +struct YuGiOhChecklistEntry { + std::string setNo; + std::string name; + bool owned{false}; +}; + +// Distinct languages present in the collection, in allLanguages() order. +[[nodiscard]] std::vector +yuGiOhLanguagesInCollection(const std::vector& collection); + +// Packs where the collection owns ≥1 card with matching set.id, ordered by +// setName. Packs absent from the catalog are skipped. When languageFilter is +// set, only cards of that language count toward ownership. +[[nodiscard]] std::vector +computeYuGiOhSetCompletion(const std::vector& collection, + const YuGiOhSetCatalog& catalog, + std::optional languageFilter = std::nullopt); + +// Full catalog checklist for one pack; owned flags from the collection. +// When languageFilter is set, only cards of that language count as owned. +[[nodiscard]] std::vector +yuGiOhChecklistForSet(const std::vector& collection, + const YuGiOhSetCatalog& catalog, + std::string_view setId, + std::optional languageFilter = std::nullopt); + +} // namespace ccm diff --git a/core/src/domain/YuGiOhSetCatalog.cpp b/core/src/domain/YuGiOhSetCatalog.cpp new file mode 100644 index 0000000..728bd3f --- /dev/null +++ b/core/src/domain/YuGiOhSetCatalog.cpp @@ -0,0 +1,39 @@ +#include "ccm/domain/YuGiOhSetCatalog.hpp" + +namespace ccm { + +const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const { + for (const auto& pack : packs) { + if (pack.setId == setId) return &pack; + } + return nullptr; +} + +void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) { + j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}}; +} + +void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) { + j.at("setNo").get_to(c.setNo); + j.at("name").get_to(c.name); +} + +void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) { + j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}}; +} + +void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) { + j.at("id").get_to(p.setId); + j.at("name").get_to(p.setName); + j.at("cards").get_to(p.cards); +} + +void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) { + j = nlohmann::json{{"packs", c.packs}}; +} + +void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) { + j.at("packs").get_to(c.packs); +} + +} // namespace ccm diff --git a/core/src/games/yugioh/YuGiOhSetSource.cpp b/core/src/games/yugioh/YuGiOhSetSource.cpp index 0c5aa02..4f481fa 100644 --- a/core/src/games/yugioh/YuGiOhSetSource.cpp +++ b/core/src/games/yugioh/YuGiOhSetSource.cpp @@ -1,10 +1,15 @@ #include "ccm/games/yugioh/YuGiOhSetSource.hpp" +#include "ccm/util/YuGiOhPrintingSlot.hpp" + #include #include #include +#include #include +#include +#include namespace ccm { namespace { @@ -39,6 +44,47 @@ void appendMissingSetAliases(std::vector& sets) { } } +[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) { + const std::string abbrev = ygoAbbrevBeforeDash(setNo); + const std::string digits = ygoCollectorDigitsOnly(setNo); + if (abbrev.empty() || digits.empty()) return {}; + return abbrev + "|" + digits; +} + +[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) { + const std::string_view s = trimAsciiSpaces(setCode); + const auto dash = s.find('-'); + if (dash == std::string_view::npos || dash + 3 > s.size()) return false; + const std::string_view tail = s.substr(dash + 1); + if (tail.size() < 3) return false; + return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n') + && std::isdigit(static_cast(tail[2])) != 0; +} + +[[nodiscard]] std::string uppercaseAscii(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + return s; +} + +[[nodiscard]] std::string resolvePackId(const std::unordered_map& nameToId, + const std::string& setName, + const std::string& setCode) { + const auto it = nameToId.find(setName); + if (it != nameToId.end() && !it->second.empty()) return it->second; + const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode)); + return abbrev; +} + +struct PackBuild { + std::string setId; + std::string setName; + // slotKey → index into cards (for EN preference upgrades). + std::unordered_map slotIndex; + std::vector cards; +}; + } // namespace YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {} @@ -73,10 +119,116 @@ Result> YuGiOhSetSource::parseResponse(const std::string& body) } } +Result YuGiOhSetSource::parseCatalog(const std::string& body, + const std::vector& sets) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) { + return Result::err( + "YGOPRODeck cardinfo response missing data array."); + } + + std::unordered_map nameToId; + nameToId.reserve(sets.size()); + for (const auto& set : sets) { + if (set.name.empty() || set.id.empty()) continue; + // First wins — aliases and upstream rows rarely collide by name. + nameToId.emplace(set.name, set.id); + } + + // Keyed by pack setId. + std::unordered_map byId; + + for (const auto& cardJson : j.at("data")) { + const std::string cardName = cardJson.value("name", ""); + if (cardName.empty()) continue; + if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) { + continue; + } + for (const auto& printing : cardJson.at("card_sets")) { + const std::string setName = printing.value("set_name", ""); + const std::string setCode = printing.value("set_code", ""); + if (setName.empty() || setCode.empty()) continue; + if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue; + + const std::string slot = ygoSlotKey(setCode); + if (slot.empty()) continue; + + const std::string packId = resolvePackId(nameToId, setName, setCode); + if (packId.empty()) continue; + + auto& build = byId[packId]; + if (build.setId.empty()) { + build.setId = packId; + build.setName = setName; + } + + const auto existing = build.slotIndex.find(slot); + if (existing == build.slotIndex.end()) { + build.slotIndex.emplace(slot, build.cards.size()); + build.cards.push_back(YuGiOhCatalogCard{setCode, cardName}); + continue; + } + + // Prefer an EN-embedded code over a bare / other-region equivalent. + auto& prev = build.cards[existing->second]; + if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) { + prev.setNo = setCode; + if (!cardName.empty()) prev.name = cardName; + } + } + } + + YuGiOhSetCatalog catalog; + catalog.packs.reserve(byId.size()); + for (auto& [_, build] : byId) { + if (build.setId.empty() || build.cards.empty()) continue; + std::sort(build.cards.begin(), build.cards.end(), + [](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + YuGiOhSetCatalogPack pack; + pack.setId = std::move(build.setId); + pack.setName = std::move(build.setName); + pack.cards = std::move(build.cards); + catalog.packs.push_back(std::move(pack)); + } + + std::sort(catalog.packs.begin(), catalog.packs.end(), + [](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) { + return a.setName < b.setName; + }); + return Result::ok(std::move(catalog)); + } catch (const std::exception& e) { + return Result::err( + std::string("YGOPRODeck catalog parse error: ") + e.what()); + } +} + Result> YuGiOhSetSource::fetchAll() { auto resp = http_.get(kEndpoint); if (!resp) return Result>::err(resp.error()); return parseResponse(resp.value()); } +Result YuGiOhSetSource::fetchAllWithCatalog() { + auto setsResp = http_.get(kEndpoint); + if (!setsResp) return Result::err(setsResp.error()); + + auto sets = parseResponse(setsResp.value()); + if (!sets) return Result::err(sets.error()); + + auto infoResp = http_.get(kCardInfoEndpoint); + if (!infoResp) return Result::err(infoResp.error()); + + auto catalog = parseCatalog(infoResp.value(), sets.value()); + if (!catalog) return Result::err(catalog.error()); + + FetchWithCatalog out; + out.sets = std::move(sets).value(); + out.catalog = std::move(catalog).value(); + return Result::ok(std::move(out)); +} + } // namespace ccm diff --git a/core/src/services/YuGiOhSetCatalogService.cpp b/core/src/services/YuGiOhSetCatalogService.cpp new file mode 100644 index 0000000..f90b100 --- /dev/null +++ b/core/src/services/YuGiOhSetCatalogService.cpp @@ -0,0 +1,49 @@ +#include "ccm/services/YuGiOhSetCatalogService.hpp" + +#include + +#include + +namespace ccm { + +namespace fs = std::filesystem; + +YuGiOhSetCatalogService::YuGiOhSetCatalogService(IFileSystem& fs, + ConfigService& config, + DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + +fs::path YuGiOhSetCatalogService::catalogPath() const { + return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOh) / + "set-catalog.json"; +} + +bool YuGiOhSetCatalogService::exists() const { + return fs_.exists(catalogPath()); +} + +Result YuGiOhSetCatalogService::load() const { + const auto p = catalogPath(); + if (!fs_.exists(p)) { + return Result::err("Yu-Gi-Oh! set catalog not yet downloaded."); + } + auto text = fs_.readText(p); + if (!text) return Result::err(text.error()); + try { + const auto j = nlohmann::json::parse(text.value()); + return Result::ok(j.get()); + } catch (const std::exception& e) { + return Result::err( + std::string("set-catalog.json parse error: ") + e.what()); + } +} + +Result YuGiOhSetCatalogService::save(const YuGiOhSetCatalog& catalog) { + const auto p = catalogPath(); + auto dir = fs_.ensureDirectory(p.parent_path()); + if (!dir) return dir; + const nlohmann::json j = catalog; + return fs_.writeText(p, j.dump(2)); +} + +} // namespace ccm diff --git a/core/src/services/YuGiOhSetCompletion.cpp b/core/src/services/YuGiOhSetCompletion.cpp new file mode 100644 index 0000000..29af0c3 --- /dev/null +++ b/core/src/services/YuGiOhSetCompletion.cpp @@ -0,0 +1,132 @@ +#include "ccm/services/YuGiOhSetCompletion.hpp" + +#include "ccm/util/YuGiOhPrintingSlot.hpp" + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +using OwnedBySet = std::unordered_map>; + +[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) { + const std::string abbrev = ygoAbbrevBeforeDash(setNo); + const std::string digits = ygoCollectorDigitsOnly(setNo); + if (abbrev.empty() || digits.empty()) return {}; + return abbrev + "|" + digits; +} + +bool passesLanguageFilter(const YuGiOhCard& card, std::optional languageFilter) { + return !languageFilter.has_value() || card.language == *languageFilter; +} + +OwnedBySet ownedSlotsBySetId(const std::vector& collection, + std::optional languageFilter) { + OwnedBySet out; + for (const auto& card : collection) { + if (!passesLanguageFilter(card, languageFilter)) continue; + if (card.set.id.empty()) continue; + const std::string key = ygoSlotKey(card.setNo); + if (key.empty()) continue; + out[card.set.id].insert(key); + } + return out; +} + +} // namespace + +std::vector +yuGiOhLanguagesInCollection(const std::vector& collection) { + const auto& langs = allLanguages(); + std::array 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 out; + for (std::size_t i = 0; i < langs.size(); ++i) { + if (present[i]) out.push_back(langs[i]); + } + return out; +} + +std::vector +computeYuGiOhSetCompletion(const std::vector& collection, + const YuGiOhSetCatalog& catalog, + std::optional languageFilter) { + const OwnedBySet owned = ownedSlotsBySetId(collection, languageFilter); + + std::vector out; + out.reserve(owned.size()); + + for (const auto& [setId, ownedSlots] : owned) { + const auto* pack = catalog.findPack(setId); + if (pack == nullptr || pack->cards.empty()) continue; + + std::size_t matched = 0; + for (const auto& card : pack->cards) { + const std::string key = ygoSlotKey(card.setNo); + if (!key.empty() && ownedSlots.count(key) != 0) ++matched; + } + + YuGiOhSetCompletionProgress row; + row.setId = pack->setId; + row.setName = pack->setName; + row.ownedUnique = matched; + row.total = pack->cards.size(); + out.push_back(std::move(row)); + } + + std::sort(out.begin(), out.end(), + [](const YuGiOhSetCompletionProgress& a, + const YuGiOhSetCompletionProgress& b) { + return a.setName < b.setName; + }); + return out; +} + +std::vector +yuGiOhChecklistForSet(const std::vector& collection, + const YuGiOhSetCatalog& catalog, + std::string_view setId, + std::optional languageFilter) { + const auto* pack = catalog.findPack(setId); + if (pack == nullptr) return {}; + + std::unordered_set ownedSlots; + for (const auto& card : collection) { + if (!passesLanguageFilter(card, languageFilter)) continue; + if (card.set.id != setId) continue; + const std::string key = ygoSlotKey(card.setNo); + if (!key.empty()) ownedSlots.insert(key); + } + + std::vector out; + out.reserve(pack->cards.size()); + for (const auto& card : pack->cards) { + YuGiOhChecklistEntry entry; + entry.setNo = card.setNo; + entry.name = card.name; + const std::string key = ygoSlotKey(card.setNo); + entry.owned = !key.empty() && ownedSlots.count(key) != 0; + out.push_back(std::move(entry)); + } + + std::sort(out.begin(), out.end(), + [](const YuGiOhChecklistEntry& a, const YuGiOhChecklistEntry& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + return out; +} + +} // namespace ccm diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index 00f95be..7611b6d 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -82,6 +82,17 @@ Used in two situations: 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. Yugipedia’s MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold. +### Set-completion catalog (`cardinfo.php` all-cards dump) + +**Sets → Update Yu-Gi-Oh!** uses `YuGiOhSetSource::fetchAllWithCatalog()` so two HTTP responses write: + +1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases) +2. A pack checklist at `/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 card’s `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts. + +If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!. + ## Digimon Digi-Battle (1999) APIs (digimoncard.io) English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse). diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c4cad59..124def0 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -23,8 +23,9 @@ - `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`. - `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`. - `digibattle99_set_completion_tests.cpp` — `computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`. +- `yugioh_set_completion_tests.cpp` — `computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`. - `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`. -- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. +- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. Also `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist from `cardinfo.php`. - `yugioh_set_lookup_tests.cpp` — `lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous). - `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`. - `yugioh_card_preview_source_tests.cpp` — `YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005` → `LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cee6845..40388f2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(ccm_core_tests digibattle99_set_source_tests.cpp digibattle99_card_preview_source_tests.cpp digibattle99_set_completion_tests.cpp + yugioh_set_completion_tests.cpp japanese_pokemon_en_catalog_tests.cpp japanese_pokemon_set_source_tests.cpp japanese_pokemon_card_preview_source_tests.cpp diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 8273031..8d452c3 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -3,6 +3,7 @@ #include "ccm/domain/Configuration.hpp" #include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/domain/YuGiOhSetCatalog.hpp" #include "ccm/domain/Enums.hpp" #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" @@ -268,6 +269,28 @@ TEST_SUITE("DigiBattle99SetCatalog JSON") { } } +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"}); + 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"); + + const YuGiOhSetCatalog back = j.get(); + CHECK(back == catalog); + CHECK(back.findPack("LOB") != nullptr); + CHECK(back.findPack("missing") == nullptr); + } +} + TEST_SUITE("JapanesePokemonCard JSON") { TEST_CASE("uses 'setNo' and 'firstEdition' aliases") { JapanesePokemonCard c; diff --git a/tests/yugioh_set_completion_tests.cpp b/tests/yugioh_set_completion_tests.cpp new file mode 100644 index 0000000..d476bd8 --- /dev/null +++ b/tests/yugioh_set_completion_tests.cpp @@ -0,0 +1,250 @@ +#include + +#include "ccm/domain/YuGiOhCard.hpp" +#include "ccm/domain/YuGiOhSetCatalog.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/YuGiOhSetCatalogService.hpp" +#include "ccm/services/YuGiOhSetCompletion.hpp" +#include "fakes/InMemoryFileSystem.hpp" + +#include + +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; +} + +YuGiOhCard makeOwned(std::string setId, std::string setName, std::string setNo) { + YuGiOhCard c; + c.id = 1; + c.name = "Owned"; + c.set.id = std::move(setId); + c.set.name = std::move(setName); + c.setNo = std::move(setNo); + return c; +} + +YuGiOhSetCatalog sampleCatalog() { + YuGiOhSetCatalog catalog; + YuGiOhSetCatalogPack lob; + lob.setId = "LOB"; + lob.setName = "Legend of Blue Eyes White Dragon"; + lob.cards = { + {"LOB-001", "Blue-Eyes White Dragon"}, + {"LOB-EN005", "Dark Magician"}, + {"LOB-007", "Gaia The Fierce Knight"}, + }; + YuGiOhSetCatalogPack mrd; + mrd.setId = "MRD"; + mrd.setName = "Metal Raiders"; + mrd.cards = { + {"MRD-001", "Summoned Skull"}, + {"LOB-001", "Blue-Eyes White Dragon"}, + }; + catalog.packs.push_back(std::move(mrd)); + catalog.packs.push_back(std::move(lob)); + return catalog; +} + +} // namespace + +TEST_SUITE("computeYuGiOhSetCompletion") { + TEST_CASE("only packs with owned cards appear") { + const auto catalog = sampleCatalog(); + std::vector collection{ + makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"), + }; + const auto rows = computeYuGiOhSetCompletion(collection, catalog); + REQUIRE(rows.size() == 1); + CHECK(rows[0].setId == "LOB"); + CHECK(rows[0].ownedUnique == 1); + CHECK(rows[0].total == 3); + CHECK(rows[0].percent() == 33); + } + + TEST_CASE("printing slot match treats LOB-005 and LOB-EN005 as one slot") { + const auto catalog = sampleCatalog(); + YuGiOhCard a = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"); + a.amount = 4; + YuGiOhCard b = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-EN005"); + b.id = 2; + YuGiOhCard c = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"); + c.id = 3; + const auto rows = computeYuGiOhSetCompletion({a, b, c}, catalog); + REQUIRE(rows.size() == 1); + CHECK(rows[0].ownedUnique == 2); + CHECK(rows[0].total == 3); + CHECK(rows[0].percent() == 66); + } + + TEST_CASE("ownership on one pack does not complete another pack sharing setNo") { + const auto catalog = sampleCatalog(); + std::vector collection{ + makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"), + }; + const auto rows = computeYuGiOhSetCompletion(collection, catalog); + REQUIRE(rows.size() == 1); + CHECK(rows[0].setId == "LOB"); + } + + TEST_CASE("empty catalog yields no rows") { + YuGiOhSetCatalog empty; + std::vector collection{ + makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"), + }; + CHECK(computeYuGiOhSetCompletion(collection, empty).empty()); + } + + TEST_CASE("owned set missing from catalog is skipped") { + YuGiOhSetCatalog catalog; + YuGiOhSetCatalogPack onlyMrd; + onlyMrd.setId = "MRD"; + onlyMrd.setName = "Metal Raiders"; + onlyMrd.cards = {{"MRD-001", "Summoned Skull"}}; + catalog.packs.push_back(std::move(onlyMrd)); + + std::vector collection{ + makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"), + }; + CHECK(computeYuGiOhSetCompletion(collection, catalog).empty()); + } + + TEST_CASE("language filter hides packs with no cards in that language") { + const auto catalog = sampleCatalog(); + YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"); + en.language = Language::English; + + const auto allRows = computeYuGiOhSetCompletion({en}, catalog); + REQUIRE(allRows.size() == 1); + + const auto deRows = + computeYuGiOhSetCompletion({en}, catalog, Language::German); + CHECK(deRows.empty()); + + const auto enRows = + computeYuGiOhSetCompletion({en}, catalog, Language::English); + REQUIRE(enRows.size() == 1); + CHECK(enRows[0].ownedUnique == 1); + } + + TEST_CASE("same slot in two languages counts once aggregated; filter is exclusive") { + const auto catalog = sampleCatalog(); + YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"); + en.language = Language::English; + YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"); + de.id = 2; + de.language = Language::German; + + const auto allRows = computeYuGiOhSetCompletion({en, de}, catalog); + REQUIRE(allRows.size() == 1); + CHECK(allRows[0].ownedUnique == 1); + + const auto enRows = + computeYuGiOhSetCompletion({en, de}, catalog, Language::English); + REQUIRE(enRows.size() == 1); + CHECK(enRows[0].ownedUnique == 1); + + YuGiOhCard deOnly = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"); + deOnly.id = 3; + deOnly.language = Language::German; + const auto deRows = + computeYuGiOhSetCompletion({en, de, deOnly}, catalog, Language::German); + REQUIRE(deRows.size() == 1); + CHECK(deRows[0].ownedUnique == 2); + } +} + +TEST_SUITE("yuGiOhChecklistForSet") { + TEST_CASE("greys missing cards and marks owned ones") { + const auto catalog = sampleCatalog(); + std::vector collection{ + makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"), + }; + const auto list = yuGiOhChecklistForSet(collection, catalog, "LOB"); + REQUIRE(list.size() == 3); + CHECK(list[0].setNo == "LOB-001"); + CHECK(list[0].owned == false); + CHECK(list[1].setNo == "LOB-007"); + CHECK(list[1].owned == false); + CHECK(list[2].setNo == "LOB-EN005"); + CHECK(list[2].owned == true); + } + + TEST_CASE("unknown set returns empty") { + const auto catalog = sampleCatalog(); + CHECK(yuGiOhChecklistForSet({}, catalog, "missing").empty()); + } + + TEST_CASE("owned flags respect language filter") { + const auto catalog = sampleCatalog(); + YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"); + en.language = Language::English; + + const auto filtered = + yuGiOhChecklistForSet({en}, catalog, "LOB", Language::German); + REQUIRE(filtered.size() == 3); + CHECK(filtered[0].owned == false); + CHECK(filtered[1].owned == false); + CHECK(filtered[2].owned == false); + + const auto english = + yuGiOhChecklistForSet({en}, catalog, "LOB", Language::English); + REQUIRE(english.size() == 3); + CHECK(english[2].owned == true); + } +} + +TEST_SUITE("yuGiOhLanguagesInCollection") { + TEST_CASE("empty collection yields empty") { + CHECK(yuGiOhLanguagesInCollection({}).empty()); + } + + TEST_CASE("returns distinct languages in allLanguages order") { + YuGiOhCard jp = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"); + jp.language = Language::Japanese; + YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"); + en.id = 2; + en.language = Language::English; + YuGiOhCard enDup = makeOwned("MRD", "Metal Raiders", "MRD-001"); + enDup.id = 3; + enDup.language = Language::English; + YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-007"); + de.id = 4; + de.language = Language::German; + + const auto langs = yuGiOhLanguagesInCollection({jp, en, enDup, de}); + REQUIRE(langs.size() == 3); + CHECK(langs[0] == Language::English); + CHECK(langs[1] == Language::German); + CHECK(langs[2] == Language::Japanese); + } +} + +TEST_SUITE("YuGiOhSetCatalogService") { + TEST_CASE("save then load round-trips") { + InMemoryFileSystem fs; + auto config = makeConfig(fs, "/data"); + YuGiOhSetCatalogService store{fs, config, [](Game) { return "yugioh"; }}; + + CHECK_FALSE(store.exists()); + CHECK(store.load().isErr()); + + const auto catalog = sampleCatalog(); + REQUIRE(store.save(catalog).isOk()); + CHECK(store.exists()); + + const auto loaded = store.load(); + REQUIRE(loaded.isOk()); + CHECK(loaded.value() == catalog); + } +} diff --git a/tests/yugioh_set_source_tests.cpp b/tests/yugioh_set_source_tests.cpp index c8a5307..f3e955f 100644 --- a/tests/yugioh_set_source_tests.cpp +++ b/tests/yugioh_set_source_tests.cpp @@ -3,6 +3,8 @@ #include "ccm/games/yugioh/YuGiOhSetSource.hpp" #include "ccm/ports/IHttpClient.hpp" +#include + using namespace ccm; namespace { @@ -172,3 +174,114 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") { CHECK(out.error() == "offline"); } } + +TEST_SUITE("YuGiOhSetSource::parseCatalog") { + TEST_CASE("groups by set_name resolved to Set.id and dedupes printing slots") { + const std::vector sets{ + Set{"LOB", "Legend of Blue Eyes White Dragon", "2002/03/08"}, + Set{"MRD", "Metal Raiders", "2002/06/26"}, + }; + const std::string json = R"({ + "data": [ + { + "name": "Blue-Eyes White Dragon", + "card_sets": [ + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}, + {"set_name":"Metal Raiders","set_code":"MRD-010","set_rarity":"Ultra Rare"} + ] + }, + { + "name": "Dark Magician", + "card_sets": [ + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}, + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"}, + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-E003","set_rarity":"Ultra Rare"} + ] + } + ] + })"; + const auto out = YuGiOhSetSource::parseCatalog(json, sets); + REQUIRE(out.isOk()); + const auto* lob = out.value().findPack("LOB"); + REQUIRE(lob != nullptr); + REQUIRE(lob->cards.size() == 2); + bool sawBe = false; + bool sawDm = false; + for (const auto& c : lob->cards) { + if (c.name == "Blue-Eyes White Dragon" && c.setNo == "LOB-001") sawBe = true; + if (c.name == "Dark Magician" && c.setNo == "LOB-EN005") sawDm = true; + } + CHECK(sawBe); + CHECK(sawDm); + + const auto* mrd = out.value().findPack("MRD"); + REQUIRE(mrd != nullptr); + REQUIRE(mrd->cards.size() == 1); + CHECK(mrd->cards[0].setNo == "MRD-010"); + } + + TEST_CASE("missing data array returns error") { + CHECK(YuGiOhSetSource::parseCatalog(R"([])", {}).isErr()); + CHECK(YuGiOhSetSource::parseCatalog(R"({"data":{}})", {}).isErr()); + } +} + +namespace { + +class RoutingHttpClient final : public IHttpClient { +public: + std::string setsBody; + std::string infoBody; + bool setsOk = true; + bool infoOk = true; + std::vector urls; + + Result get(std::string_view url) override { + urls.emplace_back(url); + if (url == YuGiOhSetSource::kEndpoint) { + return setsOk ? Result::ok(setsBody) + : Result::err("sets offline"); + } + if (url == YuGiOhSetSource::kCardInfoEndpoint) { + return infoOk ? Result::ok(infoBody) + : Result::err("info offline"); + } + return Result::err("unexpected url"); + } +}; + +} // namespace + +TEST_SUITE("YuGiOhSetSource::fetchAllWithCatalog") { + TEST_CASE("fetches sets then cardinfo and returns both") { + RoutingHttpClient http; + http.setsBody = R"([{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}])"; + http.infoBody = R"({ + "data": [{ + "name": "Blue-Eyes White Dragon", + "card_sets": [ + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"} + ] + }] + })"; + YuGiOhSetSource src{http}; + const auto out = src.fetchAllWithCatalog(); + REQUIRE(out.isOk()); + REQUIRE(http.urls.size() == 2); + CHECK(http.urls[0] == YuGiOhSetSource::kEndpoint); + CHECK(http.urls[1] == YuGiOhSetSource::kCardInfoEndpoint); + CHECK(out.value().sets.front().id == "LOB"); + REQUIRE(out.value().catalog.findPack("LOB") != nullptr); + CHECK(out.value().catalog.findPack("LOB")->cards.size() == 1); + } + + TEST_CASE("cardinfo failure propagates after sets succeed") { + RoutingHttpClient http; + http.setsBody = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])"; + http.infoOk = false; + YuGiOhSetSource src{http}; + const auto out = src.fetchAllWithCatalog(); + REQUIRE(out.isErr()); + CHECK(out.error() == "info offline"); + } +} diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 3802346..81fb093 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -5,8 +5,8 @@ ## 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` 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 own a tabbed layout without changing other games’ 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 for Digimon via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic / Pokémon / Yu-Gi-Oh!) or a game’s `IGameView::contentPanel` (Digimon Digi-Battle notebook). 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/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 and Yu-Gi-Oh! own a tabbed layout without changing other games’ 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 / Pokémon) or a game’s `IGameView::contentPanel` (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` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. - `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `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 `/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `/` 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). @@ -15,6 +15,7 @@ - `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 lines of hook overrides on top of the matching base template. - `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`). - `include/ccm/ui/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/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`. @@ -28,7 +29,7 @@ 3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount. 4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`. 5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`). -6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs. +6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s and Yu-Gi-Oh!’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs. 7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link. 8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr` holding `std::atomic alive`, `std::atomic currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops. 9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG. diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index b0abb44..937e9f8 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(ccm_ui_wx STATIC src/YuGiOhSelectedCardPanel.cpp src/YuGiOhCardEditDialog.cpp src/YuGiOhGameView.cpp + src/YuGiOhSetCompletionPanel.cpp src/DigiBattle99CardListPanel.cpp src/DigiBattle99SelectedCardPanel.cpp src/DigiBattle99CardEditDialog.cpp diff --git a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp index 253e03b..dd40a59 100644 --- a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp @@ -7,31 +7,48 @@ #include "ccm/services/ConfigService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/services/SetService.hpp" +#include "ccm/services/YuGiOhSetCatalogService.hpp" #include "ccm/ui/IGameView.hpp" +#include #include #include #include +class wxBitmapButton; +class wxBoxSizer; +class wxPanel; +class wxSimplebook; +class wxSplitterWindow; +class wxStaticText; +class wxTextCtrl; + namespace ccm::ui { class YuGiOhCardListPanel; class YuGiOhSelectedCardPanel; +class YuGiOhSetCompletionPanel; class YuGiOhGameView final : public IGameView { public: - YuGiOhGameView(ConfigService& config, - CollectionService& collection, - SetService& sets, - ImageService& images, - CardPreviewService& cardPreview, - IGameModule& module); + YuGiOhGameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module, + YuGiOhSetCatalogService& catalogStore); [[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; } [[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; } 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() override; void onAddCard(wxWindow* parentWindow) override; @@ -45,6 +62,12 @@ public: private: void ensureSetsLoaded(); const std::vector& 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& collection_; @@ -52,11 +75,22 @@ private: ImageService& images_; CardPreviewService& cardPreview_; IGameModule& module_; + YuGiOhSetCatalogService& catalogStore_; - YuGiOhCardListPanel* listPanel_{nullptr}; - YuGiOhSelectedCardPanel* selectedPanel_{nullptr}; - std::vector setsCache_; - bool attemptedInitialSetLoad_{false}; + wxPanel* contentPanel_{nullptr}; + wxPanel* tabBar_{nullptr}; + wxSimplebook* book_{nullptr}; + wxSplitterWindow* singleSplitter_{nullptr}; + YuGiOhCardListPanel* listPanel_{nullptr}; + YuGiOhSelectedCardPanel* selectedPanel_{nullptr}; + YuGiOhSetCompletionPanel* setCompletionPanel_{nullptr}; + std::array tabPanels_{{nullptr, nullptr}}; + std::array tabLabels_{{nullptr, nullptr}}; + int activeTab_{0}; + std::array toolbarButtons_{{nullptr, nullptr, nullptr}}; + wxTextCtrl* filterInput_{nullptr}; + std::vector setsCache_; + bool attemptedInitialSetLoad_{false}; }; } // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhSetCompletionPanel.hpp b/ui_wx/include/ccm/ui/YuGiOhSetCompletionPanel.hpp new file mode 100644 index 0000000..23c78bc --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhSetCompletionPanel.hpp @@ -0,0 +1,71 @@ +#pragma once + +// YuGiOhSetCompletionPanel: 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/YuGiOhCard.hpp" +#include "ccm/domain/YuGiOhSetCatalog.hpp" +#include "ccm/services/YuGiOhSetCatalogService.hpp" +#include "ccm/ui/Theme.hpp" + +#include + +#include +#include +#include + +class wxBoxSizer; +class wxChoice; +class wxListCtrl; +class wxScrolledWindow; +class wxSimplebook; +class wxStaticText; + +namespace ccm::ui { + +class YuGiOhSetCompletionPanel : public wxPanel { +public: + YuGiOhSetCompletionPanel(wxWindow* parent, YuGiOhSetCatalogService& catalogStore); + + void setCollection(std::vector 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; + + YuGiOhSetCatalogService& catalogStore_; + YuGiOhSetCatalog catalog_; + bool catalogLoaded_{false}; + std::vector collection_; + ThemePalette palette_{}; + std::optional 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 diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp index 8d3490d..ba3e257 100644 --- a/ui_wx/src/YuGiOhGameView.cpp +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -1,32 +1,65 @@ #include "ccm/ui/YuGiOhGameView.hpp" +#include "ccm/games/yugioh/YuGiOhSetSource.hpp" #include "ccm/ui/CardEditModalGuard.hpp" +#include "ccm/ui/SvgIcons.hpp" +#include "ccm/ui/Theme.hpp" #include "ccm/ui/YuGiOhCardEditDialog.hpp" #include "ccm/ui/YuGiOhCardListPanel.hpp" #include "ccm/ui/YuGiOhSelectedCardPanel.hpp" -#include "ccm/ui/Theme.hpp" +#include "ccm/ui/YuGiOhSetCompletionPanel.hpp" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include #include #include namespace ccm::ui { -YuGiOhGameView::YuGiOhGameView(ConfigService& config, - CollectionService& collection, - SetService& sets, - ImageService& images, - CardPreviewService& cardPreview, - IGameModule& module) +namespace { +constexpr int kYgoToolbarIconPx = 18; +constexpr const char kYgoFilterHint[] = "Filter"; + +wxColour lighten(const wxColour& c, int amount) { + auto lift = [amount](unsigned char channel) -> unsigned char { + const int raised = static_cast(channel) + amount; + return static_cast(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(channel) - amount; + return static_cast(lowered < 0 ? 0 : lowered); + }; + return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue())); +} +} // namespace + +YuGiOhGameView::YuGiOhGameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module, + YuGiOhSetCatalogService& catalogStore) : config_(config), collection_(collection), sets_(sets), images_(images), cardPreview_(cardPreview), - module_(module) {} + module_(module), + catalogStore_(catalogStore) {} void YuGiOhGameView::ensureSetsLoaded() { if (attemptedInitialSetLoad_) return; @@ -50,6 +83,208 @@ void YuGiOhGameView::ensureSetsLoaded() { } } +void YuGiOhGameView::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 YuGiOhGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) { + auto* toolbar = new wxBoxSizer(wxHORIZONTAL); + auto makeToolBtn = [&](const char* svg, const wxString& tip) { + wxBitmap bmp = svgIconBitmap(svg, kYgoToolbarIconPx, "#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(kYgoFilterHint); + 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)); + }); +} + +void YuGiOhGameView::refreshToolbarIcons(const ThemePalette& palette) { + const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); + if (toolbarButtons_[0]) { + toolbarButtons_[0]->SetBitmap( + svgIconBitmap(kSvgToolbarAdd, kYgoToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[1]) { + toolbarButtons_[1]->SetBitmap( + svgIconBitmap(kSvgToolbarEdit, kYgoToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[2]) { + toolbarButtons_[2]->SetBitmap( + svgIconBitmap(kSvgToolbarDelete, kYgoToolbarIconPx, tbHex.c_str())); + } +} + +void YuGiOhGameView::selectTab(int index) { + if (index < 0 || index > 1 || book_ == nullptr) return; + activeTab_ = index; + book_->SetSelection(index); + refreshTabBarTheme(paletteForTheme(config_.current().theme)); +} + +void YuGiOhGameView::refreshTabBarTheme(const ThemePalette& palette) { + if (tabBar_ == nullptr) return; + + const wxColour barBg = palette.panelBg; + 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); + 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 YuGiOhGameView::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); + 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_); + 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* YuGiOhGameView::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 YuGiOhSetCompletionPanel(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)); + contentPanel_->CallAfter([this]() { + refreshTabBarTheme(paletteForTheme(config_.current().theme)); + }); + } + return contentPanel_; +} + wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) { if (listPanel_ == nullptr) { listPanel_ = new YuGiOhCardListPanel(parent); @@ -74,16 +309,23 @@ wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) { } void YuGiOhGameView::refreshCollection() { - if (listPanel_ == nullptr) return; + if (contentPanel_ == nullptr && listPanel_ == nullptr) return; + auto loaded = collection_.list(Game::YuGiOh); if (!loaded) { showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! collection: " + loaded.error(), "Error", wxOK | wxICON_ERROR); return; } - listPanel_->setCards(std::move(loaded).value()); - listPanel_->activateSelection(); - if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); + auto cards = std::move(loaded).value(); + if (listPanel_ != nullptr) { + listPanel_->setCards(cards); + listPanel_->activateSelection(); + if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); + } + if (setCompletionPanel_ != nullptr) { + setCompletionPanel_->setCollection(std::move(cards)); + } } const std::vector& YuGiOhGameView::setsForDialog() { @@ -94,8 +336,9 @@ const std::vector& YuGiOhGameView::setsForDialog() { setsCache_ = std::move(loaded).value(); std::sort(setsCache_.begin(), setsCache_.end(), [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + } else { + setsCache_.clear(); } - else setsCache_.clear(); return setsCache_; } @@ -135,13 +378,18 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) { persisted.images = std::move(normalized).value(); auto updated = collection_.update(Game::YuGiOh, persisted); if (!updated) { - showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(), - "Warning", wxOK | wxICON_WARNING); + 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); + showThemedMessageDialog( + parentWindow, + "Card added, but image rename to ID-prefixed format failed: " + normalized.error(), + "Warning", wxOK | wxICON_WARNING); } refreshCollection(); } @@ -150,7 +398,8 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; auto sel = listPanel_->selected(); if (!sel) { - showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); + showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", + wxOK | wxICON_INFORMATION); return; } if (cardEditModalIsActive()) { @@ -176,7 +425,8 @@ void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; auto sel = listPanel_->selected(); if (!sel) { - showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); + showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", + wxOK | wxICON_INFORMATION); return; } if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", @@ -193,27 +443,82 @@ void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) { } std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) { - auto out = sets_.updateSets(Game::YuGiOh); - if (!out) { - showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(), + auto* ygoSrc = dynamic_cast(&module_.setSource()); + if (ygoSrc == nullptr) { + showThemedMessageDialog(parentWindow, "Yu-Gi-Oh! set source unavailable.", "Error", wxOK | wxICON_ERROR); return "Update failed"; } - setsCache_ = out.value(); + + auto both = ygoSrc->fetchAllWithCatalog(); + if (!both) { + showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(), + "Error", wxOK | wxICON_ERROR); + return "Update failed"; + } + + auto savedSets = sets_.saveSets(Game::YuGiOh, both.value().sets); + if (!savedSets) { + showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(), + "Error", wxOK | wxICON_ERROR); + return "Update failed"; + } + + auto savedCatalog = catalogStore_.save(both.value().catalog); + if (!savedCatalog) { + showThemedMessageDialog(parentWindow, + "Sets saved, but set catalog failed: " + savedCatalog.error(), + "Warning", wxOK | wxICON_WARNING); + } + + setsCache_ = both.value().sets; std::sort(setsCache_.begin(), setsCache_.end(), [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); - showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Yu-Gi-Oh! sets.", - "Sets updated", wxOK | wxICON_INFORMATION); + if (setCompletionPanel_ != nullptr) { + setCompletionPanel_->reloadFromStore(); + if (auto loaded = collection_.list(Game::YuGiOh)) { + setCompletionPanel_->setCollection(std::move(loaded).value()); + } + } + + const std::size_t setCount = both.value().sets.size(); + const std::size_t packCount = both.value().catalog.packs.size(); + showThemedMessageDialog( + parentWindow, + "Updated " + std::to_string(setCount) + " Yu-Gi-Oh! sets and " + + std::to_string(packCount) + " set checklists.", + "Sets updated", wxOK | wxICON_INFORMATION); return "Yu-Gi-Oh! sets updated."; } void YuGiOhGameView::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(kYgoFilterHint); + filterInput_->Refresh(); + } + } + } if (listPanel_) listPanel_->setFilter(filter); } void YuGiOhGameView::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); + if (filterInput_ != nullptr) { + filterInput_->SetBackgroundColour(palette.inputBg); + filterInput_->SetForegroundColour(palette.inputText); + filterInput_->SetOwnBackgroundColour(palette.inputBg); + filterInput_->SetOwnForegroundColour(palette.inputText); + filterInput_->Refresh(); + } } } // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhSetCompletionPanel.cpp b/ui_wx/src/YuGiOhSetCompletionPanel.cpp new file mode 100644 index 0000000..963010c --- /dev/null +++ b/ui_wx/src/YuGiOhSetCompletionPanel.cpp @@ -0,0 +1,311 @@ +#include "ccm/ui/YuGiOhSetCompletionPanel.hpp" + +#include "ccm/services/YuGiOhSetCompletion.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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((static_cast(a) * 2 + static_cast(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 + +YuGiOhSetCompletionPanel::YuGiOhSetCompletionPanel(wxWindow* parent, + YuGiOhSetCatalogService& 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, &YuGiOhSetCompletionPanel::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 YuGiOhSetCompletionPanel::setCollection(std::vector cards) { + collection_ = std::move(cards); + refreshLanguageChoice(); + rebuildCurrentView(); +} + +void YuGiOhSetCompletionPanel::reloadFromStore() { + catalogLoaded_ = false; + catalog_ = {}; + if (catalogStore_.exists()) { + if (auto loaded = catalogStore_.load()) { + catalog_ = std::move(loaded).value(); + catalogLoaded_ = true; + } + } + showGridPage(); + rebuildGrid(); +} + +void YuGiOhSetCompletionPanel::applyTheme(const ThemePalette& palette) { + palette_ = palette; + applyThemeToWindowTree(this, palette, inferThemeFromWindow(this)); + rebuildCurrentView(); +} + +void YuGiOhSetCompletionPanel::showGridPage() { + detailSetId_.clear(); + detailSetName_.clear(); + book_->SetSelection(0); +} + +void YuGiOhSetCompletionPanel::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 YuGiOhSetCompletionPanel::displaySetName(const std::string& setName) const { + if (!languageFilter_.has_value()) return setName; + return setName + " (" + std::string(to_string(*languageFilter_)) + ")"; +} + +void YuGiOhSetCompletionPanel::refreshLanguageChoice() { + const auto previous = languageFilter_; + const auto present = yuGiOhLanguagesInCollection(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(i + 1); + languageFilter_ = previous; + break; + } + } + } + languageChoice_->SetSelection(selection); +} + +void YuGiOhSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) { + const int sel = languageChoice_->GetSelection(); + if (sel <= 0) { + languageFilter_ = std::nullopt; + } else { + const auto present = yuGiOhLanguagesInCollection(collection_); + const auto idx = static_cast(sel - 1); + if (idx < present.size()) { + languageFilter_ = present[idx]; + } else { + languageFilter_ = std::nullopt; + languageChoice_->SetSelection(0); + } + } + rebuildCurrentView(); +} + +void YuGiOhSetCompletionPanel::rebuildCurrentView() { + if (book_->GetSelection() == 1 && !detailSetId_.empty()) { + const auto rows = + computeYuGiOhSetCompletion(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 YuGiOhSetCompletionPanel::setEmptyMessage(const wxString& message) { + clearGridTiles(); + emptyLabel_->SetLabelText(message); + emptyLabel_->Wrap(480); + emptyLabel_->Show(); + scroll_->Hide(); + gridPage_->Layout(); +} + +void YuGiOhSetCompletionPanel::clearGridTiles() { + if (gridSizer_ == nullptr) return; + gridSizer_->Clear(true); +} + +void YuGiOhSetCompletionPanel::rebuildGrid() { + if (!catalogLoaded_) { + setEmptyMessage(wxString::FromUTF8( + "Set checklists are not downloaded yet.\n" + "Run Sets → Update Yu-Gi-Oh! to enable Set Completion.")); + return; + } + + const auto rows = computeYuGiOhSetCompletion(collection_, catalog_, languageFilter_); + if (rows.empty()) { + setEmptyMessage(wxString::FromUTF8( + "No Yu-Gi-Oh! sets in progress yet.\n" + "Add cards on the Single Cards tab to track set completion here.")); + 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 YuGiOhSetCompletionPanel::rebuildChecklist(const std::string& setId) { + checklist_->DeleteAllItems(); + const auto entries = + yuGiOhChecklistForSet(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. + const std::string line = + (entry.owned ? "✓ " : " ") + entry.setNo + " — " + entry.name; + 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