diff --git a/app/main.cpp b/app/main.cpp index 3b6fe77..f8d6f21 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -126,6 +126,7 @@ public: std::make_unique(*fs_, *config_, &dirNameForGame); ygoCatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); + ygoMod_->setCatalogService(ygoCatalogStore_.get()); ygoBandaiCatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); pokeCatalogStore_ = diff --git a/core/include/ccm/domain/YuGiOhSetCatalog.hpp b/core/include/ccm/domain/YuGiOhSetCatalog.hpp index 91fdfdf..2ff51ae 100644 --- a/core/include/ccm/domain/YuGiOhSetCatalog.hpp +++ b/core/include/ccm/domain/YuGiOhSetCatalog.hpp @@ -16,6 +16,9 @@ namespace ccm { struct YuGiOhCatalogCard { std::string setNo; std::string name; + /// YGOPRODeck `set_rarity` for this printing when known (optional; + /// older `set-catalog.json` files omit it). + std::string rarity{}; friend bool operator==(const YuGiOhCatalogCard&, const YuGiOhCatalogCard&) = default; diff --git a/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp b/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp index 3f6c57b..330c5fc 100644 --- a/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp +++ b/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp @@ -35,6 +35,12 @@ public: Result> detectPrintVariants(std::string_view name, std::string_view setName) override; + Result detectBySetNo(std::string_view setName, + std::string_view setNo) override; + Result> detectVariantsBySetNo( + std::string_view setName, + std::string_view setNo) override; + // Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88). // Does not invent zero-padding — CDN keys match API ids literally. static std::string normalizeCardNumber(std::string_view setNo); @@ -58,7 +64,8 @@ public: static Result> parsePrintVariants(const std::string& body, std::string_view setName, - std::string_view wantedCardName); + std::string_view wantedCardName, + std::string_view wantedSetNo = {}); private: IHttpClient& http_; diff --git a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp index 5962d8e..b51a67e 100644 --- a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp @@ -29,6 +29,12 @@ public: Result> detectPrintVariants(std::string_view name, std::string_view setId) override; + Result detectBySetNo(std::string_view setId, + std::string_view setNo) override; + Result> detectVariantsBySetNo( + std::string_view setId, + std::string_view setNo) override; + // Strip everything after the first '/' (e.g. "4/102" -> "4"). static std::string normalizeCollectorNumber(std::string_view setNo); @@ -61,6 +67,9 @@ public: std::string_view setId, std::string_view wantedCardName); + // Parse TCGdex card-by-id JSON into print metadata (name + localId + rarity). + static Result parsePrintFromCardById(const std::string& body); + private: IHttpClient& http_; }; diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp index bf0aea4..86f0012 100644 --- a/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp @@ -29,6 +29,12 @@ public: Result> detectPrintVariants(std::string_view name, std::string_view setId) override; + Result detectBySetNo(std::string_view setId, + std::string_view setNo) override; + Result> detectVariantsBySetNo( + std::string_view setId, + std::string_view setNo) override; + static std::string normalizeLocalId(std::string_view setNo); static std::string buildSetDetailUrl(std::string_view setId); static std::string buildCardUrl(std::string_view setId, std::string_view localId); @@ -60,6 +66,15 @@ public: std::string_view wantedCardName, const JapanesePokemonEnCatalog& catalog); + // Reverse lookup: set + localId → name via catalog (no HTTP). + static Result> + detectVariantsBySetNoFromCatalog(std::string_view setId, + std::string_view localId, + const JapanesePokemonEnCatalog& catalog); + + // Parse TCGdex JA card-by-id JSON into print metadata. + static Result parsePrintFromCardResponse(const std::string& body); + private: IHttpClient& http_; const JapanesePokemonEnCatalog& catalog_; diff --git a/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp b/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp index a401c36..d9dc639 100644 --- a/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp +++ b/core/include/ccm/games/yugioh/YuGiOhCardPreviewSource.hpp @@ -1,8 +1,11 @@ #pragma once +#include "ccm/domain/YuGiOhSetCatalog.hpp" #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" +#include "ccm/services/YuGiOhSetCatalogService.hpp" +#include #include #include #include @@ -25,6 +28,9 @@ namespace ccm { // that endpoint returns a richer set listing (with rarities and release // dates) than Yugipedia, and we don't need image data for it. // +// Reverse lookup (set + setNo → name) uses the offline set-completion catalog +// written by Sets → Update Yu-Gi-Oh! (`YuGiOhSetCatalogService`). +// // Region policy: always English (EN/NA/EU/AU) regardless of the card's // stored Language. Localized scans are intentionally not queried so the user // sees a consistent, well-stocked gallery (EN scans are the most complete). @@ -32,6 +38,12 @@ class YuGiOhCardPreviewSource final : public ICardPreviewSource { public: explicit YuGiOhCardPreviewSource(IHttpClient& http); + // Optional offline catalog for set+setNo → name reverse lookup. When null + // or empty, detectVariantsBySetNo returns a clear "Update Sets" error. + void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept { + catalogStore_ = catalogStore; + } + [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; } Result @@ -43,6 +55,12 @@ public: Result> detectPrintVariants(std::string_view name, std::string_view setId) override; + Result detectBySetNo(std::string_view setId, + std::string_view setNo) override; + Result> detectVariantsBySetNo( + std::string_view setId, + std::string_view setNo) override; + // ---- Yugipedia helpers (image preview path) ---------------------------- // Build the list of candidate Yugipedia file names to try, in priority @@ -116,8 +134,29 @@ public: std::string_view preferredSetName, std::string_view wantedCardName); + // Offline reverse lookup against a set-completion catalog. `setId` is the + // pack's set code (e.g. "LOB"); `setNo` may be digits ("005") or a full + // collector code ("LOB-005" / "LOB-EN005"). + static Result> + detectVariantsBySetNoFromCatalog(const YuGiOhSetCatalog& catalog, + std::string_view setId, + std::string_view setNo); + + // YGOPRODeck cardset= dump filtered by collector digits (HTTP fallback when + // the offline catalog is missing or has no match). + static Result> + detectVariantsBySetNoFromCardset(const std::string& body, + std::string_view preferredSetName, + std::string_view setNo); + + static std::string buildCardsetOnlyUrl(std::string_view setName); + private: - IHttpClient& http_; + IHttpClient& http_; + YuGiOhSetCatalogService* catalogStore_{nullptr}; + // Cached offline catalog so reverse auto-detect does not re-parse a + // multi-MB JSON file on every button click. + mutable std::optional catalogCache_; }; } // namespace ccm diff --git a/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp b/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp index 2e45ea8..49583a8 100644 --- a/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp +++ b/core/include/ccm/games/yugioh/YuGiOhGameModule.hpp @@ -3,6 +3,7 @@ #include "ccm/games/IGameModule.hpp" #include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" #include "ccm/games/yugioh/YuGiOhSetSource.hpp" +#include "ccm/services/YuGiOhSetCatalogService.hpp" namespace ccm { @@ -17,6 +18,15 @@ public: ISetSource& setSource() override { return setSource_; } ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + // Wire offline set catalog for set+setNo → name reverse auto-detect. + void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept { + previewSource_.setCatalogService(catalogStore); + } + + [[nodiscard]] YuGiOhCardPreviewSource& previewSource() noexcept { + return previewSource_; + } + private: YuGiOhSetSource setSource_; YuGiOhCardPreviewSource previewSource_; diff --git a/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp index 2fc93bc..f5060cf 100644 --- a/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp +++ b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp @@ -29,9 +29,11 @@ public: Result> detectPrintVariants(std::string_view name, std::string_view setId) override; - Result detectBySetNo(std::string_view setNo) override; + Result detectBySetNo(std::string_view setId, + std::string_view setNo) override; Result> detectVariantsBySetNo( + std::string_view setId, std::string_view setNo) override; // Prefer " (Bandai)" / English / Sealdass page depending on setId. @@ -58,7 +60,9 @@ public: parsePageImagesResponse(const std::string& body); static Result> - parseAskResponse(const std::string& body, std::string_view preferredSetId); + parseAskResponse(const std::string& body, + std::string_view preferredSetId, + std::string_view wantedSetNo = {}); static AutoDetectedPrint enrichPrint(AutoDetectedPrint print, std::string_view pageTitle); @@ -69,7 +73,8 @@ private: Result> askByName(std::string_view name, std::string_view setId); - Result> askByNumber(std::string_view setNo); + Result> askByNumber(std::string_view setId, + std::string_view setNo); IHttpClient& http_; }; diff --git a/core/include/ccm/ports/ICardPreviewSource.hpp b/core/include/ccm/ports/ICardPreviewSource.hpp index f86614f..40b1d56 100644 --- a/core/include/ccm/ports/ICardPreviewSource.hpp +++ b/core/include/ccm/ports/ICardPreviewSource.hpp @@ -86,14 +86,19 @@ public: "Print variant listing not supported by this game."); } - // Optional lookup by collector / Bandai number (fills name + set + rarity). - virtual Result detectBySetNo(std::string_view /*setNo*/) { + // Optional lookup by set + collector / Bandai number (fills name + rarity). + // `setId` uses the same meaning as detectPrintVariants for the game + // (set id for Pokémon/Bandai; set display name for Digi-Battle; set code + // id for Yu-Gi-Oh! catalog reverse lookup). + virtual Result detectBySetNo(std::string_view /*setId*/, + std::string_view /*setNo*/) { return Result::err( "Detect-by-number not supported by this game."); } virtual Result> - detectVariantsBySetNo(std::string_view /*setNo*/) { + detectVariantsBySetNo(std::string_view /*setId*/, + std::string_view /*setNo*/) { return Result>::err( "Detect-by-number variants not supported by this game."); } diff --git a/core/include/ccm/services/CardPreviewService.hpp b/core/include/ccm/services/CardPreviewService.hpp index 2804209..8c49b65 100644 --- a/core/include/ccm/services/CardPreviewService.hpp +++ b/core/include/ccm/services/CardPreviewService.hpp @@ -81,10 +81,14 @@ public: std::string_view name, std::string_view setId); - Result detectBySetNo(Game game, std::string_view setNo); + Result detectBySetNo(Game game, + std::string_view setId, + std::string_view setNo); - Result> detectVariantsBySetNo(Game game, - std::string_view setNo); + Result> detectVariantsBySetNo( + Game game, + std::string_view setId, + std::string_view setNo); // Download image bytes from a fully-qualified URL without going through // per-game preview-source resolution. Cached by URL (same LRU bound). diff --git a/core/include/ccm/util/CardLookupDetect.hpp b/core/include/ccm/util/CardLookupDetect.hpp new file mode 100644 index 0000000..2cd7555 --- /dev/null +++ b/core/include/ccm/util/CardLookupDetect.hpp @@ -0,0 +1,24 @@ +#pragma once + +// Shared rule for bidirectional Set # Auto detect (name ↔ set number): +// when both fields are filled, the field the user last edited is the lookup key. + +namespace ccm { + +enum class CardLookupEditField { None, Name, SetNo }; + +// Returns true when Auto detect should run setNo → name (reverse). +// `nameEmpty` / `setNoEmpty` are already trimmed/normalized by the caller. +// When both are empty the result is false (caller shows a validation message). +// When only one is filled, that direction wins. When both are filled, SetNo +// wins only if it was the last edited lookup field; otherwise Name wins +// (including `None`, matching the historical default). +[[nodiscard]] inline bool preferDetectBySetNo(bool nameEmpty, + bool setNoEmpty, + CardLookupEditField lastEdited) noexcept { + if (nameEmpty) return !setNoEmpty; + if (setNoEmpty) return false; + return lastEdited == CardLookupEditField::SetNo; +} + +} // namespace ccm diff --git a/core/include/ccm/util/YuGiOhPrintingSlot.hpp b/core/include/ccm/util/YuGiOhPrintingSlot.hpp index c23d22b..40b9529 100644 --- a/core/include/ccm/util/YuGiOhPrintingSlot.hpp +++ b/core/include/ccm/util/YuGiOhPrintingSlot.hpp @@ -48,12 +48,39 @@ namespace ccm { return out; } +// Digits from a full set code (after '-') or from a digits-only Set # field. +[[nodiscard]] inline std::string ygoCollectorDigitsFromInput(std::string_view raw) { + const std::string_view s = trimAsciiSpaces(raw); + if (s.find('-') != std::string_view::npos) return ygoCollectorDigitsOnly(s); + std::string out; + out.reserve(s.size()); + for (unsigned char c : s) { + if (std::isdigit(c) != 0) out.push_back(static_cast(c)); + } + return out; +} + +[[nodiscard]] inline std::string ygoDigitsStripLeadingZeros(std::string digits) { + std::size_t i = 0; + while (i + 1 < digits.size() && digits[i] == '0') ++i; + if (i > 0) digits.erase(0, i); + return digits; +} + +// True when both designate the same collector number, ignoring leading zeros +// ("5" == "005") and accepting either a full set code or digits-only input. +[[nodiscard]] inline bool ygoCollectorDigitsEqual(std::string_view a, + std::string_view b) { + return ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(a)) == + ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(b)); +} + // True when both strings designate the same printed slot: same abbreviation // before the first '-' (ASCII case-insensitive) and the same ordered digit run // extracted from everything after that dash. [[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) { if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false; - return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b); + return ygoCollectorDigitsEqual(a, b); } // YGOPRODeck sometimes lists European alternate numbering alongside NA prints under diff --git a/core/src/domain/YuGiOhSetCatalog.cpp b/core/src/domain/YuGiOhSetCatalog.cpp index 728bd3f..1030bff 100644 --- a/core/src/domain/YuGiOhSetCatalog.cpp +++ b/core/src/domain/YuGiOhSetCatalog.cpp @@ -11,11 +11,13 @@ const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) c void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) { j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}}; + if (!c.rarity.empty()) j["rarity"] = c.rarity; } void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) { j.at("setNo").get_to(c.setNo); j.at("name").get_to(c.name); + c.rarity = j.value("rarity", ""); } void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) { diff --git a/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp b/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp index 2f2ae37..1dd3d47 100644 --- a/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp +++ b/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp @@ -35,6 +35,44 @@ bool cardInPack(const nlohmann::json& card, std::string_view packName) { return false; } +// Numeric collector suffix: "ST-01" → "01", "01" → "01", "BO-115" → "115". +std::string numericSuffix(std::string_view setNo) { + const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo); + const auto dash = n.find('-'); + const std::string_view tail = + dash == std::string::npos ? std::string_view{n} : std::string_view{n}.substr(dash + 1); + std::string out; + out.reserve(tail.size()); + for (unsigned char c : tail) { + if (std::isdigit(c) != 0) out.push_back(static_cast(c)); + } + return out; +} + +std::string stripLeadingZeros(std::string digits) { + std::size_t i = 0; + while (i + 1 < digits.size() && digits[i] == '0') ++i; + if (i > 0) digits.erase(0, i); + return digits; +} + +bool hasAlphabeticPrefix(std::string_view setNo) { + const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo); + return !n.empty() && std::isalpha(static_cast(n.front())) != 0; +} + +// Exact id match, or digits-only input matched to the numeric suffix with +// leading zeros ignored ("1" ↔ "ST-01", but not "ST-11"). +bool cardNumbersMatch(std::string_view wanted, std::string_view actual) { + const std::string a = DigiBattle99CardPreviewSource::normalizeCardNumber(wanted); + const std::string b = DigiBattle99CardPreviewSource::normalizeCardNumber(actual); + if (a.empty() || b.empty()) return false; + if (a == b) return true; + // Full id typed (ST-01): require exact normalized equality only. + if (hasAlphabeticPrefix(a)) return false; + return stripLeadingZeros(numericSuffix(a)) == stripLeadingZeros(numericSuffix(b)); +} + } // namespace DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http) @@ -144,7 +182,8 @@ DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name, Result> DigiBattle99CardPreviewSource::parsePrintVariants( const std::string& body, std::string_view setName, - std::string_view wantedCardName) { + std::string_view wantedCardName, + std::string_view wantedSetNo) { using R = Result>; try { const auto j = nlohmann::json::parse(body); @@ -157,6 +196,7 @@ Result> DigiBattle99CardPreviewSource::parsePrint const std::string wantedPack = trim(std::string(setName)); const std::string wantedNameLower = toLower(trim(std::string(wantedCardName))); + const std::string wantedNo = normalizeCardNumber(wantedSetNo); std::vector collected; for (const auto& card : j) { @@ -166,13 +206,20 @@ Result> DigiBattle99CardPreviewSource::parsePrint } if (!cardInPack(card, wantedPack)) continue; AutoDetectedPrint out; + out.name = trim(card.value("name", "")); out.setNo = normalizeCardNumber(card.value("id", "")); out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted. if (out.setNo.empty()) continue; + // digimoncard.io `card=` is fuzzy (card=1 can return ST-01 and ST-11). + // When the user typed a number, keep only exact / zero-padded matches. + if (!wantedNo.empty() && !cardNumbersMatch(wantedNo, out.setNo)) continue; collected.push_back(std::move(out)); } if (collected.empty()) { + if (!wantedNo.empty()) { + return R::err("Could not auto-detect Digi-Battle card name from set number."); + } if (!wantedNameLower.empty() && !wantedPack.empty()) { return R::err("Could not auto-detect Digi-Battle set print metadata."); } @@ -219,4 +266,40 @@ Result> DigiBattle99CardPreviewSource::detectPrin return parsePrintVariants(fallback.value(), setName, name); } +Result DigiBattle99CardPreviewSource::detectBySetNo( + std::string_view setName, + std::string_view setNo) { + auto list = detectVariantsBySetNo(setName, setNo); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err( + "Could not auto-detect Digi-Battle card name from set number."); + } + return Result::ok(list.value().front()); +} + +Result> DigiBattle99CardPreviewSource::detectVariantsBySetNo( + std::string_view setName, + std::string_view setNo) { + using R = Result>; + if (trim(std::string(setName)).empty()) return R::err("Select a set first."); + const std::string num = normalizeCardNumber(setNo); + if (num.empty()) return R::err("Card number is empty."); + + const std::string url = buildSearchUrl("", setName, num); + auto resp = http_.get(url); + if (resp) { + auto parsed = parsePrintVariants(resp.value(), setName, "", num); + if (parsed && !parsed.value().empty()) return parsed; + } + // Retry number-only; still filter by pack + exact/padded number. + const std::string fallbackUrl = buildSearchUrl("", "", num); + auto fallback = http_.get(fallbackUrl); + if (!fallback) { + if (resp) return R::err("Could not auto-detect Digi-Battle card name from set number."); + return R::err(fallback.error()); + } + return parsePrintVariants(fallback.value(), setName, "", num); +} + } // namespace ccm diff --git a/core/src/games/pokemon/PokemonCardPreviewSource.cpp b/core/src/games/pokemon/PokemonCardPreviewSource.cpp index 5812a23..d470191 100644 --- a/core/src/games/pokemon/PokemonCardPreviewSource.cpp +++ b/core/src/games/pokemon/PokemonCardPreviewSource.cpp @@ -27,6 +27,22 @@ std::string toLower(std::string s) { return s; } +std::string stripLeadingZeros(std::string_view s) { + std::size_t i = 0; + while (i + 1 < s.size() && s[i] == '0') ++i; + return std::string(s.substr(i)); +} + +// Exact localId match after slash-normalization, or leading-zero-insensitive +// equality ("4" ↔ "04", not "4" ↔ "14"). +bool localIdsMatch(std::string_view a, std::string_view b) { + const std::string na = PokemonCardPreviewSource::normalizeCollectorNumber(a); + const std::string nb = PokemonCardPreviewSource::normalizeCollectorNumber(b); + if (na.empty() || nb.empty()) return false; + if (na == nb) return true; + return stripLeadingZeros(na) == stripLeadingZeros(nb); +} + } // namespace PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {} @@ -299,4 +315,101 @@ Result> PokemonCardPreviewSource::detectPrintVari } } +Result PokemonCardPreviewSource::parsePrintFromCardById( + const std::string& body) { + using R = Result; + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_object()) { + return R::err("TCGdex EN card response is not a JSON object."); + } + AutoDetectedPrint print; + print.name = trim(j.value("name", "")); + print.setNo = normalizeCollectorNumber(j.value("localId", "")); + print.rarity = trim(j.value("rarity", "")); + if (print.name.empty()) { + return R::err("TCGdex EN card has no name."); + } + if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) { + const std::string id = j.at("id").get(); + const auto dash = id.rfind('-'); + if (dash != std::string::npos) { + print.setNo = normalizeCollectorNumber(id.substr(dash + 1)); + } + } + return R::ok(std::move(print)); + } catch (const std::exception& e) { + return R::err(std::string("TCGdex EN card JSON parse error: ") + e.what()); + } +} + +Result PokemonCardPreviewSource::detectBySetNo(std::string_view setId, + std::string_view setNo) { + auto list = detectVariantsBySetNo(setId, setNo); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err("Could not auto-detect card name from set number."); + } + return Result::ok(list.value().front()); +} + +Result> PokemonCardPreviewSource::detectVariantsBySetNo( + std::string_view setId, + std::string_view setNo) { + using R = Result>; + const std::string idCanon = canonicalizeWestSetId(setId); + const std::string num = normalizeCollectorNumber(setNo); + if (idCanon.empty()) return R::err("Select a set first."); + if (num.empty()) return R::err("Card number is empty."); + + auto byId = http_.get(buildCardByIdUrl(idCanon, num)); + if (byId) { + auto parsed = parsePrintFromCardById(byId.value()); + if (parsed && localIdsMatch(parsed.value().setNo, num)) { + std::vector out; + out.push_back(std::move(parsed).value()); + return R::ok(std::move(out)); + } + } + + // Fallback: filtered search by set.id + localId. + const std::string url = buildSearchUrl("", idCanon, num); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + try { + const auto j = nlohmann::json::parse(resp.value()); + if (!j.is_array() || j.empty()) { + return R::err("Could not auto-detect card name from set number."); + } + std::vector out; + std::unordered_set seen; + for (const auto& card : j) { + AutoDetectedPrint print; + print.name = trim(card.value("name", "")); + print.setNo = normalizeCollectorNumber(card.value("localId", "")); + print.rarity = trim(card.value("rarity", "")); + if (print.name.empty()) continue; + if (print.setNo.empty() && card.contains("id") && card.at("id").is_string()) { + const std::string id = card.at("id").get(); + const auto dash = id.rfind('-'); + if (dash != std::string::npos) { + print.setNo = normalizeCollectorNumber(id.substr(dash + 1)); + } + } + // Defense-in-depth: TCGdex search can be fuzzy; never accept a + // different localId (e.g. "14" when the user asked for "4"). + if (!localIdsMatch(print.setNo, num)) continue; + const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity; + if (!seen.insert(key).second) continue; + out.push_back(std::move(print)); + } + if (out.empty()) { + return R::err("Could not auto-detect card name from set number."); + } + return R::ok(std::move(out)); + } catch (const std::exception& e) { + return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what()); + } +} + } // namespace ccm diff --git a/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp b/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp index 33b3e84..ac8ce87 100644 --- a/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp +++ b/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp @@ -439,4 +439,113 @@ JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name, return parsed; } +Result JapanesePokemonCardPreviewSource::parsePrintFromCardResponse( + const std::string& body) { + using R = Result; + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_object()) { + return R::err("TCGdex JA card response is not a JSON object."); + } + AutoDetectedPrint print; + print.name = trim(j.value("name", "")); + print.setNo = normalizeLocalId(j.value("localId", "")); + print.rarity = trim(j.value("rarity", "")); + if (print.name.empty()) { + return R::err("TCGdex JA card has no name."); + } + if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) { + const std::string id = j.at("id").get(); + const auto dash = id.rfind('-'); + if (dash != std::string::npos) { + print.setNo = normalizeLocalId(id.substr(dash + 1)); + } + } + return R::ok(std::move(print)); + } catch (const std::exception& e) { + return R::err(std::string("TCGdex JA card JSON parse error: ") + e.what()); + } +} + +Result> +JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog( + std::string_view setId, + std::string_view localId, + const JapanesePokemonEnCatalog& catalog) { + using R = Result>; + const std::string id = normalizeLocalId(localId); + if (setId.empty()) return R::err("Select a set first."); + if (id.empty()) return R::err("Card number is empty."); + + // Prefer exact key, then leading-zero-insensitive scan ("1" ↔ "001"). + auto found = catalog.findPrint(setId, id); + if (!found) { + for (const auto& print : catalog.printsForSet(setId)) { + if (localIdsMatch(print.localId, id)) { + found = print; + break; + } + } + } + if (!found) { + return R::err("Could not auto-detect card name from set number."); + } + AutoDetectedPrint print; + print.name = !found->nameEn.empty() ? found->nameEn : found->nameJa; + print.setNo = found->localId.empty() ? id : found->localId; + if (print.name.empty()) { + return R::err("Could not auto-detect card name from set number."); + } + std::vector out; + out.push_back(std::move(print)); + return R::ok(std::move(out)); +} + +Result JapanesePokemonCardPreviewSource::detectBySetNo( + std::string_view setId, + std::string_view setNo) { + auto list = detectVariantsBySetNo(setId, setNo); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err( + "Could not auto-detect card name from set number."); + } + return Result::ok(list.value().front()); +} + +Result> +JapanesePokemonCardPreviewSource::detectVariantsBySetNo(std::string_view setId, + std::string_view setNo) { + using R = Result>; + if (setId.empty()) return R::err("Select a set first."); + const std::string id = normalizeLocalId(setNo); + if (id.empty()) return R::err("Card number is empty."); + + auto cardResp = http_.get(buildCardUrl(setId, id)); + if (cardResp) { + auto parsed = parsePrintFromCardResponse(cardResp.value()); + if (parsed && localIdsMatch(parsed.value().setNo, id)) { + // Prefer EN catalog name when available (exact or zero-insensitive). + if (auto cat = catalog_.findPrint(setId, id); cat && !cat->nameEn.empty()) { + parsed.value().name = cat->nameEn; + } else { + for (const auto& p : catalog_.printsForSet(setId)) { + if (localIdsMatch(p.localId, id) && !p.nameEn.empty()) { + parsed.value().name = p.nameEn; + break; + } + } + } + std::vector out; + out.push_back(std::move(parsed).value()); + return R::ok(std::move(out)); + } + } + + if (catalog_.hasPrintsForSet(setId)) { + return detectVariantsBySetNoFromCatalog(setId, id, catalog_); + } + return R::err("Could not auto-detect card name from set number."); +} + } // namespace ccm diff --git a/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp b/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp index b2efc9b..46f6e23 100644 --- a/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp +++ b/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp @@ -555,4 +555,176 @@ Result> YuGiOhCardPreviewSource::detectPrintVaria return parsePrintVariants(fallback.value(), canonicalSetName, name); } +Result> +YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog( + const YuGiOhSetCatalog& catalog, + std::string_view setId, + std::string_view setNo) { + using R = Result>; + const std::string packId = std::string(trimAsciiSpaces(setId)); + if (packId.empty()) return R::err("Select a set first."); + + const std::string rawNo = std::string(trimAsciiSpaces(setNo)); + if (rawNo.empty()) return R::err("Card number is empty."); + + const std::string wantDigits = + ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo)); + if (wantDigits.empty()) return R::err("Card number is empty."); + + const YuGiOhSetCatalogPack* pack = catalog.findPack(packId); + if (pack == nullptr) { + // Allow callers to pass the display set name (HTTP fallback path). + for (const auto& candidate : catalog.packs) { + if (candidate.setName == packId) { + pack = &candidate; + break; + } + } + } + if (pack == nullptr) { + return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first."); + } + + std::vector out; + std::unordered_set seenNames; + for (const auto& card : pack->cards) { + if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue; + if (card.name.empty()) continue; + if (!seenNames.insert(card.name).second) continue; + AutoDetectedPrint print; + print.name = card.name; + print.setNo = card.setNo; + print.rarity = card.rarity; + out.push_back(std::move(print)); + } + if (out.empty()) { + return R::err("Could not auto-detect card name from set number."); + } + return R::ok(std::move(out)); +} + +std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) { + return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") + + rfc3986PercentEncode(setName); +} + +Result> +YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset( + const std::string& body, + std::string_view preferredSetName, + std::string_view setNo) { + using R = Result>; + const std::string wantDigits = + ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo)); + if (wantDigits.empty()) return R::err("Card number is empty."); + + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return R::err("YGOPRODeck response missing 'data' array."); + } + const std::string preferredLower = toLower(trim(std::string(preferredSetName))); + + std::vector out; + std::unordered_set seen; + for (const auto& card : j.at("data")) { + const std::string cardName = trim(card.value("name", "")); + if (cardName.empty()) continue; + if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue; + for (const auto& printing : card.at("card_sets")) { + const std::string setName = trim(printing.value("set_name", "")); + const std::string setCode = trim(printing.value("set_code", "")); + if (setCode.empty()) continue; + if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue; + if (!preferredLower.empty() && toLower(setName) != preferredLower) continue; + if (!ygoCollectorDigitsEqual(setCode, setNo)) continue; + AutoDetectedPrint print; + print.name = cardName; + print.setNo = setCode; + print.rarity = trim(printing.value("set_rarity", "")); + const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity; + if (!seen.insert(key).second) continue; + out.push_back(std::move(print)); + } + } + if (out.empty()) { + return R::err("Could not auto-detect card name from set number."); + } + return R::ok(std::move(out)); + } catch (const std::exception& e) { + return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what()); + } +} + +Result YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId, + std::string_view setNo) { + auto list = detectVariantsBySetNo(setId, setNo); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err( + "Could not auto-detect card name from set number."); + } + return Result::ok(list.value().front()); +} + +Result> YuGiOhCardPreviewSource::detectVariantsBySetNo( + std::string_view setId, + std::string_view setNo) { + using R = Result>; + const std::string setKey = std::string(trimAsciiSpaces(setId)); + if (setKey.empty()) return R::err("Select a set first."); + if (ygoCollectorDigitsFromInput(setNo).empty()) { + return R::err("Card number is empty."); + } + + // 1) Offline catalog (preferred — fast once cached). + if (catalogStore_ != nullptr) { + if (!catalogCache_) { + auto loaded = catalogStore_->load(); + if (loaded) catalogCache_ = std::move(loaded).value(); + } + if (catalogCache_ && !catalogCache_->empty()) { + auto fromCatalog = + detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo); + + // Prefer YGOPRODeck when reachable so rarity (and multi-rarity + // variants) come through — the offline catalog may predate the + // rarity field or only keep one rarity per printing slot. + const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey); + std::string setName = setKey; + if (pack != nullptr) { + setName = pack->setName; + } else { + for (const auto& candidate : catalogCache_->packs) { + if (candidate.setName == setKey) { + setName = candidate.setName; + break; + } + } + } + if (!setName.empty()) { + auto resp = http_.get(buildCardsetOnlyUrl(setName)); + if (resp) { + auto fromHttp = + detectVariantsBySetNoFromCardset(resp.value(), setName, setNo); + if (fromHttp) return fromHttp; + } + } + + if (fromCatalog) return fromCatalog; + // Prefer catalog miss text when HTTP also missed / was unreachable. + return fromCatalog; + } + } + + // 2) No catalog: treat setKey as display set name and query YGOPRODeck. + auto resp = http_.get(buildCardsetOnlyUrl(setKey)); + if (!resp) { + return R::err( + "Set catalog missing and YGOPRODeck lookup failed. " + "Run Sets → Update Yu-Gi-Oh! or check your network."); + } + return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo); +} + } // namespace ccm diff --git a/core/src/games/yugioh/YuGiOhSetSource.cpp b/core/src/games/yugioh/YuGiOhSetSource.cpp index 4f481fa..6b0a6c7 100644 --- a/core/src/games/yugioh/YuGiOhSetSource.cpp +++ b/core/src/games/yugioh/YuGiOhSetSource.cpp @@ -166,7 +166,9 @@ Result YuGiOhSetSource::parseCatalog(const std::string& b const auto existing = build.slotIndex.find(slot); if (existing == build.slotIndex.end()) { build.slotIndex.emplace(slot, build.cards.size()); - build.cards.push_back(YuGiOhCatalogCard{setCode, cardName}); + const std::string setRarity( + trimAsciiSpaces(printing.value("set_rarity", ""))); + build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity}); continue; } @@ -175,6 +177,12 @@ Result YuGiOhSetSource::parseCatalog(const std::string& b if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) { prev.setNo = setCode; if (!cardName.empty()) prev.name = cardName; + const std::string setRarity( + trimAsciiSpaces(printing.value("set_rarity", ""))); + if (!setRarity.empty()) prev.rarity = setRarity; + } else if (prev.rarity.empty()) { + prev.rarity = std::string( + trimAsciiSpaces(printing.value("set_rarity", ""))); } } } diff --git a/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp b/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp index 85a2fee..3210f2f 100644 --- a/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp +++ b/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp @@ -238,7 +238,8 @@ YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) Result> YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body, - std::string_view preferredSetId) { + std::string_view preferredSetId, + std::string_view wantedSetNo) { using R = Result>; try { const auto j = nlohmann::json::parse(body); @@ -250,6 +251,8 @@ YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body, return R::ok({}); } + const std::string wantNo = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo); + std::vector> ranked; for (auto it = results.begin(); it != results.end(); ++it) { const std::string pageTitle = it.key(); @@ -268,6 +271,12 @@ YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body, YuGiOhBandaiSetSource::normalizeCardNumber(num.get()); } } + // Defense-in-depth: SMW ask should be exact, but never accept a + // different Bandai number (e.g. #11 when the user asked for #1). + if (!wantNo.empty() && + YuGiOhBandaiSetSource::normalizeCardNumber(print.setNo) != wantNo) { + continue; + } if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() && !printouts.at("Rarity").empty()) { const auto& rar = printouts.at("Rarity").at(0); @@ -328,6 +337,7 @@ Result> YuGiOhBandaiCardPreviewSource::askByName( } Result> YuGiOhBandaiCardPreviewSource::askByNumber( + std::string_view setId, std::string_view setNo) { using R = Result>; const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo); @@ -336,19 +346,52 @@ Result> YuGiOhBandaiCardPreviewSource::askByNumbe // Promo codes (J1, TA2, …) are not valid values for SMW's numeric // `Bandai number` property — ask returns a type error. Resolve them from // the promotional set gallery instead. - if (isAlphanumericPromoNumber(n)) { - static constexpr const char* kPromoGallery = - "Set Card Galleries:Promotional Cards (Bandai)"; - const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery); + R list = [&]() -> R { + if (isAlphanumericPromoNumber(n)) { + static constexpr const char* kPromoGallery = + "Set Card Galleries:Promotional Cards (Bandai)"; + const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + return parsePromoGalleryResponse(resp.value(), n); + } + const std::string url = buildAskByNumberUrl(n); auto resp = http_.get(url); if (!resp) return R::err(resp.error()); - return parsePromoGalleryResponse(resp.value(), n); - } + return parseAskResponse(resp.value(), setId, n); + }(); + if (!list) return list; - const std::string url = buildAskByNumberUrl(n); - auto resp = http_.get(url); - if (!resp) return R::err(resp.error()); - return parseAskResponse(resp.value(), {}); + const std::string wantSet = trimCopy(setId); + if (wantSet.empty()) return list; + + std::vector filtered; + filtered.reserve(list.value().size()); + for (auto& print : list.value()) { + if (print.setId == wantSet) filtered.push_back(std::move(print)); + } + if (filtered.empty()) { + return R::err("No Bandai card matched that number in the selected set."); + } + return R::ok(std::move(filtered)); +} + +Result YuGiOhBandaiCardPreviewSource::detectBySetNo( + std::string_view setId, + std::string_view setNo) { + auto list = detectVariantsBySetNo(setId, setNo); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err( + "Could not auto-detect Bandai card from number."); + } + return Result::ok(list.value().front()); +} + +Result> +YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setId, + std::string_view setNo) { + return askByNumber(setId, setNo); } Result @@ -407,20 +450,4 @@ YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name, return askByName(name, setId); } -Result YuGiOhBandaiCardPreviewSource::detectBySetNo( - std::string_view setNo) { - auto list = detectVariantsBySetNo(setNo); - if (!list) return Result::err(list.error()); - if (list.value().empty()) { - return Result::err( - "Could not auto-detect Bandai card from number."); - } - return Result::ok(list.value().front()); -} - -Result> -YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setNo) { - return askByNumber(setNo); -} - } // namespace ccm diff --git a/core/src/services/CardPreviewService.cpp b/core/src/services/CardPreviewService.cpp index 8761cb6..4881312 100644 --- a/core/src/services/CardPreviewService.cpp +++ b/core/src/services/CardPreviewService.cpp @@ -263,6 +263,7 @@ Result> CardPreviewService::detectPrintVariants( } Result CardPreviewService::detectBySetNo(Game game, + std::string_view setId, std::string_view setNo) { auto it = sources_.find(game); if (it == sources_.end() || it->second == nullptr) { @@ -271,11 +272,12 @@ Result CardPreviewService::detectBySetNo(Game game, if (!it->second->supportsAutoDetectPrint()) { return Result::err("Auto-detect not enabled for this game."); } - return it->second->detectBySetNo(setNo); + return it->second->detectBySetNo(setId, setNo); } Result> CardPreviewService::detectVariantsBySetNo( Game game, + std::string_view setId, std::string_view setNo) { auto it = sources_.find(game); if (it == sources_.end() || it->second == nullptr) { @@ -286,7 +288,7 @@ Result> CardPreviewService::detectVariantsBySetNo return Result>::err( "Auto-detect not enabled for this game."); } - return it->second->detectVariantsBySetNo(setNo); + return it->second->detectVariantsBySetNo(setId, setNo); } Result CardPreviewService::fetchImageBytesByUrl(std::string_view url) { diff --git a/core/src/util/FsNames.cpp b/core/src/util/FsNames.cpp index 5c7a1cc..a158452 100644 --- a/core/src/util/FsNames.cpp +++ b/core/src/util/FsNames.cpp @@ -18,7 +18,7 @@ struct Replacement { std::string_view to; }; -constexpr std::array kReplacements{{ +constexpr std::array kReplacements{{ {"'", ""}, {"`", ""}, {",", ""}, @@ -34,6 +34,8 @@ constexpr std::array kReplacements{{ {"\xC3\xBB", "u"}, // u-circumflex // Remaining accented vowels appear in modern Scryfall data but were not // listed in the Rust source. Keeping behavior 1:1 deliberately. + {"\xE2\x99\x82", "male"}, // ♂ male sign + {"\xE2\x99\x80", "female"}, // ♀ female sign }}; void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) { diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md index 038a7ac..3ae3aca 100644 --- a/docs/adding-a-new-game.md +++ b/docs/adding-a-new-game.md @@ -141,7 +141,7 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header - `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);` - `static Result parseResponse(const std::string& body);` -If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Games that can resolve metadata from a collector / Bandai number alone (Yu-Gi-Oh! Bandai) should also override `detectBySetNo(...)` / `detectVariantsBySetNo(...)` and wire them through `CardPreviewService`. `AutoDetectedPrint` carries `setNo` + `rarity` for every game; optional `name` / `setId` / `setName` / `language` fields stay empty when unused. Keep all of these optional per game — default behavior should remain an explicit unsupported error. +If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. For bidirectional identify (set + name → setNo, or set + setNo → name), also override `detectBySetNo(...)` / `detectVariantsBySetNo(setId, setNo)` and wire them through `CardPreviewService`. In the edit dialog, bind Set # `wxEVT_TEXT` to `markSetNoLookupEdited()` and branch Auto detect with `shouldDetectBySetNo(...)` so the last typed field wins when both are filled (see existing Yu-Gi-Oh! / Pokémon / Digi-Battle / Bandai dialogs). `AutoDetectedPrint` carries `setNo` + `rarity` for every game; optional `name` / `setId` / `setName` / `language` fields stay empty when unused. Keep all of these optional per game — default behavior should remain an explicit unsupported error. Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover: diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index f9ab71d..c20f039 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -33,7 +33,11 @@ Used by `PokemonCardPreviewSource` in two ways: 1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build. -2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId` → `AutoDetectedPrint::setNo` and `rarity` → `AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped. The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save. +2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId` → `AutoDetectedPrint::setNo` and `rarity` → `AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped. + +3. **Reverse auto-detect (`detectVariantsBySetNo`, same Set # Auto detect button).** Requires a selected set. When **Name** is blank and **Set #** is filled, uses `GET /v2/en/cards/{setId}-{localId}` (then filtered search) to fill the card **name**. Returned `localId`s are post-filtered so a fuzzy hit cannot win on a shared digit prefix (`4` must not accept `14`). When Name is filled, behavior stays name → setNo as above. Set is always required for either direction. + +The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save. The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component. @@ -96,6 +100,8 @@ Used in two situations: 2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted. +3. **Reverse auto-detect (`detectVariantsBySetNo`).** When **Name** is blank and **Set #** is filled, the Set # Auto detect button looks up the offline `yugioh/set-catalog.json` checklist (same file as Set Completion) by `Set.id` + collector digits / full code, and fills the card **name** (and **rarity** when present on the catalog row or when YGOPRODeck `cardset=` enrichment succeeds). Digit matching strips leading zeros but is not a prefix match (`5` ↔ `LOB-005`, `1` does not match `LOB-011`). Name→Set # Auto detect also applies the matched print’s rarity. When **both** Name and Set # are filled, the field last typed by the user is the lookup key (so editing Set # after a name detect and clicking Auto detect again resolves by set number, not by re-running the name path). Requires a prior **Sets → Update Yu-Gi-Oh!** so the catalog exists (re-run Update to refresh rarities on older catalogs). Set is always required for both directions. + YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. 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) @@ -105,7 +111,7 @@ YGOPRODeck publishes rate limits and asks clients to cache responses and avoid a 1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases) 2. A pack checklist at `/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. +Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name, rarity? }` drawn from each card’s `card_sets[]` (`set_rarity` when present). European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts. If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!. @@ -135,8 +141,8 @@ English Blue-Eyes is **not** a separate set — it is `ban3` card `#118` with la ### Asset API (preview + auto-detect) 1. **Preview:** `pageimages` on preferred titles `Name (Bandai)` / `Name (English Bandai)` / `Name (Bandai Sealdass)`, falling back to SMW `ask` by English name then `pageimages` on the best hit. -2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`. -3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` → same fields. +2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`. Requires a selected set. +3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` (or promo gallery parse for `J*`/`TA*` codes) → same fields, then **filtered to the selected set**. Ask results are also dropped when the returned Bandai number does not match the requested one after normalization (`1` must not accept `11`). The Set # Auto detect button is bidirectional: blank name + number fills name; name filled fills number/rarity. Set is always required. Card-back fallback URL: `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`. @@ -188,6 +194,8 @@ where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also s **Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity). +**Reverse auto-detect** (`detectVariantsBySetNo`): when Name is blank and Set # is filled, search with `card=` + `pack=` fills `AutoDetectedPrint::name` (and normalizes `setNo`). Hits are post-filtered so digits-only input matches the numeric suffix with leading zeros ignored (`1` ↔ `ST-01`, not `ST-11`). Set (pack display name) is always required for either direction. + Empty search array / `{"error":"..."}` → `NotFound`; bad JSON / HTTP → `Transient`. ## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend @@ -262,6 +270,8 @@ It does **not** substitute another printing of the same Pokémon when both TCGde Auto-detect / Next uses the same set-detail `cards[]`, matching the typed name against catalog English names or TCGdex Japanese names. Catalog EN aliases are applied only when the catalog `name_ja` agrees with the TCGdex row (stale seed mappings like Charmander→`001` are ignored). +Reverse auto-detect (`detectVariantsBySetNo`) uses `GET /v2/ja/cards/{setId}-{localId}` (preferring catalog `nameEn` when present) or a catalog scan with leading-zero-insensitive `localId` matching (`1` ↔ `001`, not `011`) for catalog-only sets, filling Name when Set # is known and Name is blank. Set is always required. + Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ` → `Erika's Oddish`, `わるいリザードン` → `Dark Charizard`, `R団のサンダー` → `Rocket's Zapdos`, neo garbled `輝くセレビ` → `Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`). That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1–PMCG6, neo1–neo4, VS1, web1, E1–E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d2b7404..615686b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(ccm_core_tests card_sorter_tests.cpp card_filter_tests.cpp ascii_utils_tests.cpp + card_lookup_detect_tests.cpp http_get_mapping_tests.cpp cpr_http_client_tests.cpp diff --git a/tests/card_lookup_detect_tests.cpp b/tests/card_lookup_detect_tests.cpp new file mode 100644 index 0000000..48b6e7b --- /dev/null +++ b/tests/card_lookup_detect_tests.cpp @@ -0,0 +1,33 @@ +#include + +#include "ccm/util/CardLookupDetect.hpp" + +using namespace ccm; + +TEST_SUITE("preferDetectBySetNo") { + TEST_CASE("only set number filled uses reverse lookup") { + CHECK(preferDetectBySetNo(true, false, CardLookupEditField::None)); + CHECK(preferDetectBySetNo(true, false, CardLookupEditField::Name)); + CHECK(preferDetectBySetNo(true, false, CardLookupEditField::SetNo)); + } + + TEST_CASE("only name filled uses name lookup") { + CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::None)); + CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::Name)); + CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::SetNo)); + } + + TEST_CASE("both empty does not prefer reverse") { + CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::None)); + CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::SetNo)); + } + + TEST_CASE("both filled: last edited SetNo prefers reverse") { + CHECK(preferDetectBySetNo(false, false, CardLookupEditField::SetNo)); + } + + TEST_CASE("both filled: Name or None keeps name lookup") { + CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::Name)); + CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::None)); + } +} diff --git a/tests/card_preview_service_tests.cpp b/tests/card_preview_service_tests.cpp index 53ad47f..d9f654e 100644 --- a/tests/card_preview_service_tests.cpp +++ b/tests/card_preview_service_tests.cpp @@ -34,6 +34,7 @@ public: std::string lastSetNo; std::string detectLastName; std::string detectLastSetId; + std::string detectLastSetNo; AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"}; bool allowAutoDetect{true}; @@ -67,6 +68,23 @@ public: return Result>::ok(std::move(v)); } + Result detectBySetNo(std::string_view setId, + std::string_view setNo) override { + detectLastSetId = std::string(setId); + detectLastSetNo = std::string(setNo); + return Result::ok(detectedPrint); + } + + Result> detectVariantsBySetNo( + std::string_view setId, + std::string_view setNo) override { + detectLastSetId = std::string(setId); + detectLastSetNo = std::string(setNo); + std::vector v; + v.push_back(detectedPrint); + return Result>::ok(std::move(v)); + } + [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return allowAutoDetect; } @@ -992,3 +1010,41 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") { std::string::npos); } } + +TEST_SUITE("CardPreviewService::detectVariantsBySetNo") { + TEST_CASE("routes set-scoped reverse lookup to registered source") { + FakeSource source; + source.detectedPrint = AutoDetectedPrint{"4", "Rare", "Pikachu"}; + FakeGameModule module; + module.gameId = Game::Pokemon; + module.preview = &source; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.detectVariantsBySetNo(Game::Pokemon, "base1", "4"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Pikachu"); + CHECK(out.value()[0].setNo == "4"); + CHECK(source.detectLastSetId == "base1"); + CHECK(source.detectLastSetNo == "4"); + } + + TEST_CASE("returns error when game does not enable auto-detect") { + FakeSource source; + source.allowAutoDetect = false; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.detectVariantsBySetNo(Game::Magic, "lea", "1"); + CHECK(out.isErr()); + CHECK(out.error().find("not enabled") != std::string::npos); + } +} diff --git a/tests/digibattle99_card_preview_source_tests.cpp b/tests/digibattle99_card_preview_source_tests.cpp index 2e88a92..c46c872 100644 --- a/tests/digibattle99_card_preview_source_tests.cpp +++ b/tests/digibattle99_card_preview_source_tests.cpp @@ -177,3 +177,56 @@ TEST_SUITE("DigiBattle99CardPreviewSource::detectPrintVariants") { CHECK(out.value()[1].setNo == "ST-126"); } } + +TEST_SUITE("DigiBattle99CardPreviewSource::detectVariantsBySetNo") { + TEST_CASE("card id search fills name within pack") { + FixedHttpClient http; + http.body = R"([ + {"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]} + ])"; + DigiBattle99CardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "st-01"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Agumon"); + CHECK(out.value()[0].setNo == "ST-01"); + CHECK(http.lastUrl.find("card=ST-01") != std::string::npos); + } + + TEST_CASE("digits-only 1 matches ST-01 not ST-11 from fuzzy API hits") { + FixedHttpClient http; + http.body = R"([ + {"name":"Patamon","id":"ST-11","set_name":["Series 1 Starter Set"]}, + {"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}, + {"name":"Other","id":"BO-1","set_name":["Booster 1"]} + ])"; + DigiBattle99CardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "1"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Agumon"); + CHECK(out.value()[0].setNo == "ST-01"); + } + + TEST_CASE("digits-only 11 matches ST-11 not ST-01") { + FixedHttpClient http; + http.body = R"([ + {"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}, + {"name":"Patamon","id":"ST-11","set_name":["Series 1 Starter Set"]} + ])"; + DigiBattle99CardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "11"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Patamon"); + CHECK(out.value()[0].setNo == "ST-11"); + } + + TEST_CASE("empty pack is rejected") { + FixedHttpClient http; + DigiBattle99CardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("", "ST-01"); + REQUIRE(out.isErr()); + CHECK(out.error().find("set") != std::string::npos); + } +} diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 6d03435..8623799 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -404,20 +404,32 @@ TEST_SUITE("YuGiOhSetCatalog JSON") { YuGiOhSetCatalogPack pack; pack.setId = "LOB"; pack.setName = "Legend of Blue Eyes White Dragon"; - pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon"}); - pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician"}); + pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon", "Ultra Rare"}); + pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician", "Ultra Rare"}); catalog.packs.push_back(std::move(pack)); nlohmann::json j = catalog; CHECK(j.at("packs").is_array()); CHECK(j.at("packs").at(0).at("id") == "LOB"); CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "LOB-001"); + CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Ultra Rare"); const YuGiOhSetCatalog back = j.get(); CHECK(back == catalog); CHECK(back.findPack("LOB") != nullptr); CHECK(back.findPack("missing") == nullptr); } + + TEST_CASE("legacy catalog JSON without rarity still loads") { + const auto j = nlohmann::json::parse(R"({ + "packs":[{"id":"LOB","name":"Legend of Blue Eyes White Dragon", + "cards":[{"setNo":"LOB-001","name":"Blue-Eyes White Dragon"}]}] + })"); + const YuGiOhSetCatalog back = j.get(); + REQUIRE(back.packs.size() == 1); + REQUIRE(back.packs[0].cards.size() == 1); + CHECK(back.packs[0].cards[0].rarity.empty()); + } } TEST_SUITE("PokemonSetCatalog JSON") { diff --git a/tests/fs_names_tests.cpp b/tests/fs_names_tests.cpp index 921944b..153b3ff 100644 --- a/tests/fs_names_tests.cpp +++ b/tests/fs_names_tests.cpp @@ -28,6 +28,13 @@ TEST_SUITE("FsNames::formatTextForFs") { TEST_CASE("idempotent on already-clean strings") { CHECK(formatTextForFs("AlreadyClean") == "AlreadyClean"); } + + TEST_CASE("male and female signs become male/female") { + CHECK(formatTextForFs("\xE2\x99\x82") == "male"); + CHECK(formatTextForFs("\xE2\x99\x80") == "female"); + CHECK(formatTextForFs("Nidoran \xE2\x99\x82") == "Nidoranmale"); + CHECK(formatTextForFs("Nidoran \xE2\x99\x80") == "Nidoranfemale"); + } } TEST_SUITE("FsNames::parseIndexFromFilename") { diff --git a/tests/japanese_pokemon_card_preview_source_tests.cpp b/tests/japanese_pokemon_card_preview_source_tests.cpp index 413b0de..4b23b69 100644 --- a/tests/japanese_pokemon_card_preview_source_tests.cpp +++ b/tests/japanese_pokemon_card_preview_source_tests.cpp @@ -591,3 +591,58 @@ TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only") CHECK(shining.value()[0].setNo == "013"); } } + +TEST_SUITE("JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog") { + TEST_CASE("resolves English name from setId + localId") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"} + ] + })"); + REQUIRE(catalog.isOk()); + const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog( + "PMCG1", "001", catalog.value()); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Bulbasaur"); + CHECK(out.value()[0].setNo == "001"); + } + + TEST_CASE("leading-zero-insensitive localId still resolves") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}, + {"set_id":"PMCG1","local_id":"011","name_en":"Weedle","name_ja":"ビードル"} + ] + })"); + REQUIRE(catalog.isOk()); + const auto byOne = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog( + "PMCG1", "1", catalog.value()); + REQUIRE(byOne.isOk()); + REQUIRE(byOne.value().size() == 1); + CHECK(byOne.value()[0].name == "Bulbasaur"); + CHECK(byOne.value()[0].setNo == "001"); + + const auto byEleven = + JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog( + "PMCG1", "11", catalog.value()); + REQUIRE(byEleven.isOk()); + REQUIRE(byEleven.value().size() == 1); + CHECK(byEleven.value()[0].name == "Weedle"); + } + + TEST_CASE("unknown localId is an error") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"} + ] + })"); + REQUIRE(catalog.isOk()); + const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog( + "PMCG1", "999", catalog.value()); + REQUIRE(out.isErr()); + } +} diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp index ec369eb..5f561ae 100644 --- a/tests/pokemon_card_preview_source_tests.cpp +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -323,3 +323,78 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { CHECK(http.calls == 2); } } + +TEST_SUITE("PokemonCardPreviewSource::detectVariantsBySetNo") { + TEST_CASE("card-by-id fills name from TCGdex response") { + FixedHttpClient http; + http.body = R"({ + "id":"base1-4", + "localId":"4", + "name":"Charmander", + "rarity":"Common", + "image":"https://assets.tcgdex.net/en/base/base1/4" + })"; + PokemonCardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("base1", "4"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Charmander"); + CHECK(out.value()[0].setNo == "4"); + CHECK(out.value()[0].rarity == "Common"); + CHECK(http.lastUrl.find("/v2/en/cards/base1-4") != std::string::npos); + } + + TEST_CASE("search fallback rejects localIds that only share a digit prefix") { + struct ScriptedHttp : IHttpClient { + int n = 0; + Result get(std::string_view) override { + ++n; + if (n == 1) { + return Result::err("not found"); + } + // Fuzzy search returns both "14" and "4"; only "4" may be kept. + return Result::ok(R"([ + {"id":"base1-14","localId":"14","name":"Wrong","rarity":"Common"}, + {"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"} + ])"); + } + } http; + PokemonCardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("base1", "4"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Charmander"); + CHECK(out.value()[0].setNo == "4"); + } + + TEST_CASE("card-by-id response with mismatched localId falls through to search") { + struct ScriptedHttp : IHttpClient { + int n = 0; + Result get(std::string_view) override { + ++n; + if (n == 1) { + return Result::ok(R"({ + "id":"base1-14","localId":"14","name":"Wrong","rarity":"Rare" + })"); + } + return Result::ok(R"([ + {"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"} + ])"); + } + } http; + PokemonCardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("base1", "4"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Charmander"); + CHECK(out.value()[0].setNo == "4"); + } + + TEST_CASE("empty set id is rejected") { + FixedHttpClient http; + PokemonCardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("", "4"); + REQUIRE(out.isErr()); + CHECK(out.error().find("set") != std::string::npos); + } +} diff --git a/tests/yugioh_card_preview_source_tests.cpp b/tests/yugioh_card_preview_source_tests.cpp index c0cf5dd..72874ed 100644 --- a/tests/yugioh_card_preview_source_tests.cpp +++ b/tests/yugioh_card_preview_source_tests.cpp @@ -1,5 +1,6 @@ #include +#include "ccm/domain/YuGiOhSetCatalog.hpp" #include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" #include "ccm/util/YuGiOhPrintingSlot.hpp" @@ -86,6 +87,15 @@ TEST_SUITE("ygoPrintingSlotsMatch") { CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001")); } + TEST_CASE("matches collector numbers ignoring leading zeros") { + CHECK(ygoPrintingSlotsMatch("LOB-5", "LOB-005")); + CHECK(ygoPrintingSlotsMatch("LOB-EN005", "LOB-5")); + CHECK(ygoCollectorDigitsEqual("005", "5")); + CHECK(ygoCollectorDigitsEqual("LOB-EN005", "5")); + CHECK(ygoCollectorDigitsFromInput("005") == "005"); + CHECK(ygoCollectorDigitsFromInput("LOB-EN005") == "005"); + } + TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") { CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005")); @@ -935,3 +945,106 @@ TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") { CHECK(out.error() == "offline"); } } + +TEST_SUITE("YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog") { + TEST_CASE("matches digits within pack to card name") { + YuGiOhSetCatalog catalog; + YuGiOhSetCatalogPack pack; + pack.setId = "LOB"; + pack.setName = "Legend of Blue Eyes White Dragon"; + pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician", "Ultra Rare"}); + pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN001", "Blue-Eyes White Dragon", "Ultra Rare"}); + catalog.packs.push_back(std::move(pack)); + + const auto byDigits = + YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "005"); + REQUIRE(byDigits.isOk()); + REQUIRE(byDigits.value().size() == 1); + CHECK(byDigits.value()[0].name == "Dark Magician"); + CHECK(byDigits.value()[0].setNo == "LOB-005"); + CHECK(byDigits.value()[0].rarity == "Ultra Rare"); + + const auto byUnpadded = + YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "5"); + REQUIRE(byUnpadded.isOk()); + REQUIRE(byUnpadded.value().size() == 1); + CHECK(byUnpadded.value()[0].name == "Dark Magician"); + + const auto byRegionCode = + YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "LOB-001"); + REQUIRE(byRegionCode.isOk()); + REQUIRE(byRegionCode.value().size() == 1); + CHECK(byRegionCode.value()[0].name == "Blue-Eyes White Dragon"); + } + + TEST_CASE("digits-only 1 does not match collector 011") { + YuGiOhSetCatalog catalog; + YuGiOhSetCatalogPack pack; + pack.setId = "LOB"; + pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"}); + pack.cards.push_back(YuGiOhCatalogCard{"LOB-011", "Hitotsu-Me Giant"}); + catalog.packs.push_back(std::move(pack)); + + CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "1") + .isErr()); + + const auto byEleven = + YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "11"); + REQUIRE(byEleven.isOk()); + REQUIRE(byEleven.value().size() == 1); + CHECK(byEleven.value()[0].name == "Hitotsu-Me Giant"); + } + + TEST_CASE("unknown pack or number returns error") { + YuGiOhSetCatalog catalog; + YuGiOhSetCatalogPack pack; + pack.setId = "LOB"; + pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"}); + catalog.packs.push_back(std::move(pack)); + + CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "SDK", "001") + .isErr()); + CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "999") + .isErr()); + } + + TEST_CASE("detectVariantsBySetNo falls back to cardset HTTP without catalog") { + FixedHttpClient http; + http.body = R"({ + "data":[ + {"name":"Dark Magician", + "card_sets":[ + {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"} + ]} + ] + })"; + YuGiOhCardPreviewSource src{http}; + const auto out = src.detectVariantsBySetNo("Legend of Blue Eyes White Dragon", "5"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Dark Magician"); + CHECK(out.value()[0].setNo == "LOB-EN005"); + CHECK(out.value()[0].rarity == "Ultra Rare"); + CHECK(http.lastUrl.find("cardset=") != std::string::npos); + CHECK(http.lastUrl.find("fname=") == std::string::npos); + } + + TEST_CASE("cardset reverse keeps distinct rarities for the same set code") { + const std::string body = R"({ + "data":[ + {"name":"Elemental HERO Bubbleman", + "card_sets":[ + {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}, + {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"} + ]} + ] + })"; + const auto out = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset( + body, "Soul of the Duelist", "15"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].name == "Elemental HERO Bubbleman"); + CHECK(out.value()[0].rarity == "Ultra Rare"); + CHECK(out.value()[1].rarity == "Ultimate Rare"); + } +} diff --git a/tests/yugioh_set_source_tests.cpp b/tests/yugioh_set_source_tests.cpp index f3e955f..905512c 100644 --- a/tests/yugioh_set_source_tests.cpp +++ b/tests/yugioh_set_source_tests.cpp @@ -213,11 +213,16 @@ TEST_SUITE("YuGiOhSetSource::parseCatalog") { } CHECK(sawBe); CHECK(sawDm); + for (const auto& c : lob->cards) { + if (c.name == "Blue-Eyes White Dragon") CHECK(c.rarity == "Ultra Rare"); + if (c.name == "Dark Magician") CHECK(c.rarity == "Ultra Rare"); + } const auto* mrd = out.value().findPack("MRD"); REQUIRE(mrd != nullptr); REQUIRE(mrd->cards.size() == 1); CHECK(mrd->cards[0].setNo == "MRD-010"); + CHECK(mrd->cards[0].rarity == "Ultra Rare"); } TEST_CASE("missing data array returns error") { diff --git a/tests/yugiohbandai_card_preview_source_tests.cpp b/tests/yugiohbandai_card_preview_source_tests.cpp index 7a18284..4d8252f 100644 --- a/tests/yugiohbandai_card_preview_source_tests.cpp +++ b/tests/yugiohbandai_card_preview_source_tests.cpp @@ -124,6 +124,34 @@ TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") { CHECK(out.value()[0].setId == "ban1"); } + TEST_CASE("parseAskResponse drops results whose Bandai number does not match") { + const std::string body = R"JSON({ + "query": { + "results": { + "Card Eleven (Bandai)": { + "printouts": { + "English name": ["Card Eleven"], + "Bandai number": [11], + "Rarity": [{"fulltext": "Common"}] + } + }, + "Card One (Bandai)": { + "printouts": { + "English name": ["Card One"], + "Bandai number": [1], + "Rarity": [{"fulltext": "Common"}] + } + } + } + } + })JSON"; + auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1", "1"); + REQUIRE(out); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Card One"); + CHECK(out.value()[0].setNo == "1"); + } + TEST_CASE("fetchImageUrl uses pageimages URL") { FixedHttpClient http; http.body = R"JSON({ @@ -182,7 +210,7 @@ TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") { } })JSON"; YuGiOhBandaiCardPreviewSource src(http); - auto out = src.detectBySetNo("014"); + auto out = src.detectBySetNo("ban1", "014"); REQUIRE(out); CHECK(out.value().name == "Dark Magician"); CHECK(http.lastUrl.find("action=ask") != std::string::npos); @@ -196,7 +224,7 @@ TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") { } })JSON"; YuGiOhBandaiCardPreviewSource src(http); - auto out = src.detectBySetNo("ta2"); + auto out = src.detectBySetNo("banpromo-ta", "ta2"); REQUIRE(out); CHECK(out.value().name == "Blue-Eyes White Dragon's 3-Body Connection"); CHECK(out.value().setNo == "TA2"); @@ -206,6 +234,27 @@ TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") { CHECK(http.lastUrl.find("Promotional") != std::string::npos); } + TEST_CASE("detectBySetNo filters out prints from other sets") { + FixedHttpClient http; + http.body = R"JSON({ + "query": { + "results": { + "Dark Magician (Bandai)": { + "printouts": { + "English name": ["Dark Magician"], + "Bandai number": [14], + "Rarity": [{"fulltext": "Rare"}] + } + } + } + } + })JSON"; + YuGiOhBandaiCardPreviewSource src(http); + auto out = src.detectBySetNo("bansealdass", "14"); + CHECK_FALSE(out); + CHECK(out.error().find("selected set") != std::string::npos); + } + TEST_CASE("isAlphanumericPromoNumber detects Jump and Toei codes") { CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("TA2")); CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("j1")); diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index b259d2c..32c69f6 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -5,9 +5,9 @@ ## Layer pointers - `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly). -- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magic’s splitter mounting. -- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. -- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). +- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magic’s splitter mounting. `attachSharedToolbarEdit` lets Magic wire MainFrame’s Edit button for multi-select hide/show; hostsOwnLayout games manage their own Edit button via `setToolbarEditVisible`. +- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. Edit is hidden when more than one list row is selected. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. +- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. **Multi-select** is enabled (no `wxLC_SINGLE_SEL`): Ctrl toggles rows, Shift selects a range; `selected()` / `selectedCount()` / `selectedCards()` expose the selection; rebuild/sort/filter preserve all selected ids; Ctrl+C copies every selected row as TSV (one line per card). Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. - `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` 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). - `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. @@ -21,7 +21,7 @@ - `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types. - `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`. - `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next. -- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`). +- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`, `setToolbarEditVisible`, `deleteCardsConfirmMessage`) for consistent dark/light dialogs and multi-select toolbar/delete UX. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`). ## Conventions @@ -76,6 +76,7 @@ - Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game. - Keep shared templates game-agnostic: put buttons and detection behavior in `CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged. - For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input. + - Bidirectional identify (Yu-Gi-Oh!, Bandai, Pokémon West/Asia, Digi-Battle): Set is always required. Set # **Auto detect** fills set number from Name, or fills Name from Set #. When **both** fields are filled, the field the user last typed is the lookup key (`CardLookupEditField` / `preferDetectBySetNo` in `ccm/util/CardLookupDetect.hpp`, tracked by `BaseCardEditDialog::markNameLookupEdited` / `markSetNoLookupEdited`). Programmatic `ChangeValue` from a detect result does not flip the key. ## Required follow-ups diff --git a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp index 6dd48af..481979f 100644 --- a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp @@ -22,6 +22,7 @@ #include "ccm/services/SetService.hpp" #include "ccm/ui/ImageViewerDialog.hpp" #include "ccm/ui/Theme.hpp" +#include "ccm/util/CardLookupDetect.hpp" #include #include @@ -136,6 +137,26 @@ protected: // controls cannot outlive the lookup identity. virtual void onCardLookupContextChanged() {} + // Bidirectional Set # Auto detect: track which of Name / Set # the user + // last typed so a second detect uses that field as the lookup key. + void markNameLookupEdited() { lastLookupEditField_ = CardLookupEditField::Name; } + + // Subclasses bind Set # `wxEVT_TEXT` to this (or call it from their handler). + // Also clears print-variant caches via `onCardLookupContextChanged`. + void markSetNoLookupEdited() { + lastLookupEditField_ = CardLookupEditField::SetNo; + onCardLookupContextChanged(); + } + + [[nodiscard]] CardLookupEditField lastLookupEditField() const noexcept { + return lastLookupEditField_; + } + + // `nameEmpty` / `setNoEmpty` must already be trimmed/normalized by the caller. + [[nodiscard]] bool shouldDetectBySetNo(bool nameEmpty, bool setNoEmpty) const noexcept { + return preferDetectBySetNo(nameEmpty, setNoEmpty, lastLookupEditField_); + } + // Extra validation after name/set checks and writeFromControls(). Return // false to block OK (subclass should show its own themed dialog). [[nodiscard]] virtual bool validateExtraFields() { return true; } @@ -210,6 +231,7 @@ private: nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str())); nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) { + markNameLookupEdited(); onCardLookupContextChanged(); ev.Skip(); }); @@ -626,6 +648,7 @@ private: const std::vector* preloadedSets_{nullptr}; wxTextCtrl* nameCtrl_{nullptr}; + CardLookupEditField lastLookupEditField_{CardLookupEditField::None}; wxComboBox* setCombo_{nullptr}; wxSpinCtrl* amountCtrl_{nullptr}; wxChoice* languageChoice_{nullptr}; diff --git a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp index 6cbaac5..4cddd53 100644 --- a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp @@ -40,8 +40,10 @@ #include "ccm/ui/Theme.hpp" #include +#include #include #include +#include #include #include #include @@ -59,6 +61,7 @@ #include #include #include +#include #include #include @@ -74,6 +77,10 @@ wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent); // `IGameView` implementations bind this to open Edit for `selected()`. wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent); +// Raised when the list wants a short status-bar note (e.g. clipboard copy). +// `event.GetString()` is the message; MainFrame shows it in the bottom strip. +wxDECLARE_EVENT(EVT_UI_STATUS, wxCommandEvent); + template class BaseCardListPanel : public wxPanel { public: @@ -81,14 +88,18 @@ public: using sort_column_type = TSortColumn; // Replace the displayed rows. When preferSelectId is set, selects that - // card if present (used after Add). Otherwise preserves the previously - // selected card by id when still present; the first-row CallAfter path in - // rebuildRows() runs only when there was no prior selection (startup). + // card exclusively if present (used after Add). Otherwise preserves the + // previously selected card ids when still present; the first-row CallAfter + // path in rebuildRows() runs only when there was no prior selection + // (startup). void setCards(std::vector cards, std::optional preferSelectId = std::nullopt) { - std::optional keepId = preferSelectId; - if (!keepId) { - if (auto sel = selected()) keepId = sel->id; + std::optional> keepIds; + if (preferSelectId) { + keepIds = std::vector{*preferSelectId}; + } else { + auto ids = selectedIds(); + if (!ids.empty()) keepIds = std::move(ids); } cards_ = std::move(cards); // Drop sort state when the underlying data is replaced - the indicator @@ -96,7 +107,7 @@ public: // wxListCtrl keeps the indicator across DeleteAllItems(). nextDirByCol_.clear(); list_->RemoveSortIndicator(); - rebuildRows(keepId); + rebuildRows(keepIds); if (!autoSizedOnce_ && !cards_.empty()) { autoSizeAllColumns(); autoSizedOnce_ = true; @@ -104,16 +115,17 @@ public: } // Update the filter string and rebuild the visible rows in place. The - // panel preserves the previously-selected card across the rebuild when - // it still matches the new filter; otherwise the first remaining row is + // panel preserves previously-selected cards across the rebuild when they + // still match the new filter; otherwise the first remaining row is // selected, or none if the filter excluded everything. A single // EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs. void setFilter(std::string_view filter) { if (filter_ == filter) return; filter_.assign(filter); - std::optional keepId; - if (auto sel = selected()) keepId = sel->id; - rebuildRows(keepId); + auto ids = selectedIds(); + std::optional> keepIds; + if (!ids.empty()) keepIds = std::move(ids); + rebuildRows(keepIds); } void applyTheme(const ThemePalette& palette) { @@ -123,22 +135,52 @@ public: SetForegroundColour(palette.text); rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255)); refreshHeaderTheme(palette); - std::optional keepId; - if (auto sel = selected()) keepId = sel->id; - rebuildRows(keepId); + auto ids = selectedIds(); + std::optional> keepIds; + if (!ids.empty()) keepIds = std::move(ids); + rebuildRows(keepIds); Refresh(); } [[nodiscard]] const std::vector& cards() const noexcept { return cards_; } [[nodiscard]] const std::string& filter() const noexcept { return filter_; } + // First selected card (detail panel / single-edit primary). [[nodiscard]] std::optional selected() const { const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED); if (const TCard* c = cardForRow(sel)) return *c; return std::nullopt; } + [[nodiscard]] std::size_t selectedCount() const { + if (list_ == nullptr) return 0; + std::size_t n = 0; + long row = -1; + while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) { + ++n; + } + return n; + } + [[nodiscard]] std::vector selectedCards() const { + std::vector out; + if (list_ == nullptr) return out; + long row = -1; + while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) { + if (const TCard* c = cardForRow(row)) out.push_back(*c); + } + return out; + } + [[nodiscard]] std::vector selectedIds() const { + std::vector out; + if (list_ == nullptr) return out; + long row = -1; + while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) { + if (const TCard* c = cardForRow(row)) out.push_back(c->id); + } + return out; + } - // Ensure the selected row is actively focused so Windows uses the active - // highlight color (blue in light mode), keeping selected-row icons legible. + // Ensure the first selected row is actively focused so Windows uses the + // active highlight color (blue in light mode), keeping selected-row icons + // legible. Does not clear a multi-selection. void activateSelection() { if (list_ == nullptr || list_->GetItemCount() <= 0) return; long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED); @@ -151,8 +193,9 @@ public: } // Move the selection by `delta` rows (+1 / -1). Used when Up/Down are - // pressed while focus is on the filter box. Clamps to the visible range; - // leaves list HWND focus alone so the caret can stay in the filter. + // pressed while focus is on the filter box. Collapses any multi-selection + // to a single row. Clamps to the visible range; leaves list HWND focus + // alone so the caret can stay in the filter. void nudgeSelection(int delta) { if (list_ == nullptr || list_->GetItemCount() <= 0 || delta == 0) return; long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED); @@ -161,12 +204,12 @@ public: long next = row + delta; if (next < 0) next = 0; if (next >= count) next = count - 1; - if (next == row) { - list_->EnsureVisible(next); - return; - } suppressListFocus_ = true; - list_->SetItemState(row, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED); + // Clear every selected row so filter nudge is always single-select. + long sel = -1; + while ((sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) { + list_->SetItemState(sel, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED); + } list_->SetItemState(next, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED); @@ -215,8 +258,10 @@ protected: // Subclass calls this once from its constructor body (after virtual hooks // are reachable) to wire up columns + the header row + custom-draw hooks. void buildLayout() { + // Multi-select: native Ctrl (toggle) and Shift (range) without + // wxLC_SINGLE_SEL. Set-completion tables keep single-select separately. list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER); + wxLC_REPORT | wxLC_NO_HEADER); textCols_ = declareTextColumns(); iconCols_ = declareIconColumns(); @@ -274,6 +319,7 @@ protected: list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this); list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this); list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this); + list_->Bind(wxEVT_KEY_DOWN, &BaseCardListPanel::onListKeyDown, this); } // Forwarded helpers ------------------------------------------------------ @@ -523,11 +569,12 @@ private: const bool ascending = (it == nextDirByCol_.end()) ? true : it->second; nextDirByCol_[*sortCol] = !ascending; - std::optional keepId; - if (auto sel = selected()) keepId = sel->id; + auto ids = selectedIds(); + std::optional> keepIds; + if (!ids.empty()) keepIds = std::move(ids); sortBy(*sortCol, ascending); - rebuildRows(keepId); + rebuildRows(keepIds); if (list_ != nullptr) list_->SetFocus(); } @@ -590,7 +637,9 @@ private: // ----- row rendering ----------------------------------------------------- - void rebuildRows(std::optional keepId = std::nullopt) { + // nullopt keepIds → no prior selection (startup / empty): defer first-row + // select. Otherwise restore every id that is still visible after filter. + void rebuildRows(std::optional> keepIds = std::nullopt) { // Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and // SELECTED (from the SetItemState below) events while we churn through // the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale. @@ -605,10 +654,16 @@ private: } } + std::unordered_set keepSet; + if (keepIds) { + keepSet.insert(keepIds->begin(), keepIds->end()); + } + long row = 0; - long rowToSelect = -1; + long firstRestored = -1; const int firstText = firstTextColIdx(); const int noteCol = noteColIdx(); + std::vector rowsToSelect; for (std::size_t srcIdx : filteredIndices_) { const auto& c = cards_[srcIdx]; // Insert via the hidden column-0 spacer. We never set sub-item @@ -632,16 +687,22 @@ private: const std::string note = renderTextCell(c, textCols_.size() - 1); list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str())); - if (keepId && c.id == *keepId) rowToSelect = idx; + if (!keepSet.empty() && keepSet.count(c.id) != 0) { + rowsToSelect.push_back(idx); + if (firstRestored < 0) firstRestored = idx; + } ++row; } bool deferredInitialSelect = false; - if (!filteredIndices_.empty() && rowToSelect >= 0) { - list_->SetItemState(rowToSelect, - wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED, - wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED); - list_->EnsureVisible(rowToSelect); - } else if (!filteredIndices_.empty() && !keepId.has_value()) { + if (!filteredIndices_.empty() && !rowsToSelect.empty()) { + for (long r : rowsToSelect) { + const long flags = (r == firstRestored) + ? (wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED) + : wxLIST_STATE_SELECTED; + list_->SetItemState(r, flags, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED); + } + list_->EnsureVisible(firstRestored); + } else if (!filteredIndices_.empty() && !keepIds.has_value()) { // Defer the initial selection to the next event turn so first // paint stays responsive. deferredInitialSelect = true; @@ -705,6 +766,72 @@ private: }); } + void onListKeyDown(wxKeyEvent& event) { + const int key = event.GetKeyCode(); + const bool copyChord = + (event.ControlDown() || event.CmdDown()) && (key == 'C' || key == 'c'); + if (!copyChord) { + event.Skip(); + return; + } + copySelectedRowsToClipboard(); + } + + void copySelectedRowsToClipboard() { + const auto cards = selectedCards(); + if (cards.empty() || textCols_.empty()) return; + + auto formatRow = [&](const TCard& card) { + std::string line; + auto appendCell = [&](std::string_view cell) { + if (!line.empty()) line.push_back('\t'); + line.append(cell); + }; + // Leading text columns (everything except trailing Note). + for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) { + appendCell(renderTextCell(card, i)); + } + // Icon/flag columns — no list text; export as true/false. + for (std::size_t i = 0; i < iconCols_.size(); ++i) { + appendCell(isIconColumnSet(card, i) ? "true" : "false"); + } + // Trailing Note. + appendCell(renderTextCell(card, textCols_.size() - 1)); + return line; + }; + + std::string payload = formatRow(cards.front()); + for (std::size_t i = 1; i < cards.size(); ++i) { + payload.push_back('\n'); + payload.append(formatRow(cards[i])); + } + + wxClipboardLocker lock; + if (!lock) return; + if (!wxTheClipboard->SetData( + new wxTextDataObject(wxString::FromUTF8(payload.c_str())))) { + return; + } + if (cards.size() == 1) { + emitUiStatus("Saved entry to clipboard"); + } else { + emitUiStatus(wxString::Format("Saved %zu entries to clipboard", cards.size())); + } + } + + void emitUiStatus(const wxString& message) { + wxCommandEvent ev(EVT_UI_STATUS, GetId()); + ev.SetEventObject(this); + ev.SetString(message); + // Same parent-hop as BaseSelectedCardPanel::emitPreviewStatus so the + // command event can propagate up to MainFrame's status strip. + if (auto* parent = GetParent()) { + parent->GetEventHandler()->ProcessEvent(ev); + } else { + ProcessWindowEvent(ev); + } + } + // ----- members ---------------------------------------------------------- static constexpr int kFlagIconSize = 14; diff --git a/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp b/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp index 82f0d8f..cfd3eb3 100644 --- a/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp @@ -49,9 +49,14 @@ private: std::string setName, bool fillSetNoOnSuccess, bool showFailureDialog); + void requestBySetNoAsync(unsigned capturedEpoch, + std::string setName, + std::string setNo, + bool showFailureDialog); void applyDetectedVariants(unsigned capturedEpoch, Result> detected, bool fillSetNoOnSuccess, + bool fillNameOnSuccess, bool showFailureDialog); void rebuildVariantRingFromCache(); void syncRingPositionToControls(); diff --git a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp index 2640d36..ac50bcf 100644 --- a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp +++ b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp @@ -63,6 +63,7 @@ public: } private: + void syncEditToolbarVisibility(); void ensureSetsLoaded(); const std::vector& setsForDialog(); void ensureSingleCardsMounted(wxWindow* splitterParent); diff --git a/ui_wx/include/ccm/ui/IGameView.hpp b/ui_wx/include/ccm/ui/IGameView.hpp index 94671a0..a6b80a6 100644 --- a/ui_wx/include/ccm/ui/IGameView.hpp +++ b/ui_wx/include/ccm/ui/IGameView.hpp @@ -19,6 +19,7 @@ #include #include +class wxBitmapButton; class wxPanel; class wxWindow; @@ -67,6 +68,10 @@ public: virtual void onEditCard(wxWindow* parentWindow) = 0; virtual void onDeleteCard(wxWindow* parentWindow) = 0; + // Magic uses MainFrame's shared Edit button; hostsOwnLayout games ignore + // this and manage their own toolbar. Default no-op. + virtual void attachSharedToolbarEdit(wxBitmapButton* edit) { (void)edit; } + // Sets menu action ("Update Magic" / "Update Pokemon"). Returns the // user-visible status string for the parent's status bar. virtual std::string onUpdateSets(wxWindow* parentWindow) = 0; diff --git a/ui_wx/include/ccm/ui/MagicGameView.hpp b/ui_wx/include/ccm/ui/MagicGameView.hpp index 39fc821..1a3a849 100644 --- a/ui_wx/include/ccm/ui/MagicGameView.hpp +++ b/ui_wx/include/ccm/ui/MagicGameView.hpp @@ -42,6 +42,7 @@ public: void onAddCard(wxWindow* parentWindow) override; void onEditCard(wxWindow* parentWindow) override; void onDeleteCard(wxWindow* parentWindow) override; + void attachSharedToolbarEdit(wxBitmapButton* edit) override; std::string onUpdateSets(wxWindow* parentWindow) override; void setFilter(std::string_view filter) override; void nudgeSelection(int delta) override; @@ -51,6 +52,7 @@ public: private: void ensureSetsLoaded(); const std::vector& setsForDialog(); + void syncEditToolbarVisibility(); ConfigService& config_; CollectionService& collection_; @@ -61,6 +63,7 @@ private: MagicCardListPanel* listPanel_{nullptr}; MagicSelectedCardPanel* selectedPanel_{nullptr}; + wxBitmapButton* sharedEditButton_{nullptr}; std::vector setsCache_; bool attemptedInitialSetLoad_{false}; }; diff --git a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp index 05a197e..5b6db28 100644 --- a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp @@ -62,9 +62,14 @@ private: std::string setId, bool fillSetNoOnSuccess, bool showFailureDialog); + void requestBySetNoAsync(unsigned capturedEpoch, + std::string setId, + std::string setNo, + bool showFailureDialog); void applyDetectedVariants(unsigned capturedEpoch, Result> detected, bool fillSetNoOnSuccess, + bool fillNameOnSuccess, bool showFailureDialog); void rebuildVariantRingFromCache(); void syncRingPositionToControls(); diff --git a/ui_wx/include/ccm/ui/PokemonGameView.hpp b/ui_wx/include/ccm/ui/PokemonGameView.hpp index 6fb5295..9986e22 100644 --- a/ui_wx/include/ccm/ui/PokemonGameView.hpp +++ b/ui_wx/include/ccm/ui/PokemonGameView.hpp @@ -66,6 +66,7 @@ public: [[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; } private: + void syncEditToolbarVisibility(); void ensureSetsLoaded(); const std::vector& setsForDialog(PokemonRegion region); void ensureSingleCardsMounted(wxWindow* splitterParent); diff --git a/ui_wx/include/ccm/ui/Theme.hpp b/ui_wx/include/ccm/ui/Theme.hpp index bc779e0..2616db1 100644 --- a/ui_wx/include/ccm/ui/Theme.hpp +++ b/ui_wx/include/ccm/ui/Theme.hpp @@ -4,6 +4,11 @@ #include +#include +#include +#include + +class wxBitmapButton; class wxDialog; class wxWindow; class wxString; @@ -30,4 +35,11 @@ void themeModalDialog(wxDialog* dlg, Theme theme); int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style); int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption); +// Show/hide the shared or per-game Edit toolbar button and reflow its sizer +// so Add/Delete close the gap when Edit is hidden for multi-select. +void setToolbarEditVisible(wxBitmapButton* edit, bool visible); + +// Confirm copy for Delete: one card by name, or "Delete N selected entries?". +wxString deleteCardsConfirmMessage(std::size_t count, std::string_view singleCardName); + } // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp index 092f98c..cf36be6 100644 --- a/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp @@ -54,7 +54,8 @@ private: void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch); void requestByNameAsync(unsigned capturedEpoch, std::string name, std::string setId, bool showFailureDialog); - void requestByNoAsync(unsigned capturedEpoch, std::string setNo, bool showFailureDialog); + void requestByNoAsync(unsigned capturedEpoch, std::string setId, std::string setNo, + bool showFailureDialog); void applyDetectedList(unsigned capturedEpoch, Result> detected, bool showFailureDialog, bool applyFirst); diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp index 74fbe51..6231c72 100644 --- a/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp @@ -64,6 +64,7 @@ public: } private: + void syncEditToolbarVisibility(); void ensureSetsLoaded(); // Fetches sets + checklist catalog from Yugipedia and persists both. // Returns false on failure (error dialogs already shown). diff --git a/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp b/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp index 7894f59..3d8de5d 100644 --- a/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp @@ -8,6 +8,11 @@ #include #include +#include +#include +#include +#include + namespace ccm::ui { class YuGiOhCardEditDialog final : public BaseCardEditDialog { @@ -19,6 +24,7 @@ public: EditMode mode, YuGiOhCard initial, const std::vector* preloadedSets = nullptr); + ~YuGiOhCardEditDialog() override; protected: void buildFlagsRow(wxBoxSizer* flagsBox) override; @@ -31,6 +37,10 @@ protected: void onSetSelectionApplied() override; private: + struct VariantFetchState { + std::atomic alive{true}; + }; + void onAutoDetectSetNo(wxCommandEvent&); void onAutoDetectRarity(wxCommandEvent&); void onNextSetNo(wxCommandEvent&); @@ -42,6 +52,10 @@ private: void onSetCodeAutoDetect(wxCommandEvent&); void syncSetModeHint(); void autoDetectFromApi(bool fillSetNo, bool fillRarity); + void requestBySetNoAsync(unsigned capturedEpoch, std::string setId, std::string setName, + std::string setNo); + void applyReverseDetectedList(unsigned capturedEpoch, + Result> detected); void refreshSetNoFullPreview(); void clearCachedPrintVariants(); bool fetchAndCachePrintVariants(); @@ -59,6 +73,7 @@ private: EditMode dialogMode_; unsigned variantFetchEpoch_{0}; CardPreviewService& cardPreview_; + std::shared_ptr variantFetchState_; wxTextCtrl* setNoCtrl_{nullptr}; wxStaticText* setNoFullPreview_{nullptr}; wxChoice* rarityChoice_{nullptr}; diff --git a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp index f8940c4..91d65b4 100644 --- a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp @@ -61,6 +61,7 @@ public: [[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; } private: + void syncEditToolbarVisibility(); void ensureSetsLoaded(); const std::vector& setsForDialog(); void ensureSingleCardsMounted(wxWindow* splitterParent); diff --git a/ui_wx/src/BaseEvents.cpp b/ui_wx/src/BaseEvents.cpp index a24ab90..297892c 100644 --- a/ui_wx/src/BaseEvents.cpp +++ b/ui_wx/src/BaseEvents.cpp @@ -12,6 +12,7 @@ namespace ccm::ui { wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent); wxDEFINE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent); +wxDEFINE_EVENT(EVT_UI_STATUS, wxCommandEvent); wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent); } // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99CardEditDialog.cpp b/ui_wx/src/DigiBattle99CardEditDialog.cpp index 17d7683..4159aaa 100644 --- a/ui_wx/src/DigiBattle99CardEditDialog.cpp +++ b/ui_wx/src/DigiBattle99CardEditDialog.cpp @@ -4,6 +4,7 @@ #include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" #include #include +#include #include #include @@ -60,6 +61,7 @@ void DigiBattle99CardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next"); nextSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onNextSetNo, this); nextSetNoBtn_->Show(false); + setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); }); auto* setNoRow = new wxBoxSizer(wxHORIZONTAL); setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); @@ -148,7 +150,31 @@ void DigiBattle99CardEditDialog::requestVariantsAsync(unsigned capturedEpoch, fillSetNoOnSuccess, showFailureDialog]() mutable { if (!state->alive.load()) return; self->applyDetectedVariants(capturedEpoch, std::move(detected), - fillSetNoOnSuccess, showFailureDialog); + fillSetNoOnSuccess, /*fillNameOnSuccess=*/false, + showFailureDialog); + }); + }).detach(); +} + +void DigiBattle99CardEditDialog::requestBySetNoAsync(unsigned capturedEpoch, + std::string setName, + std::string setNo, + bool showFailureDialog) { + if (capturedEpoch != variantFetchEpoch_) return; + if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable(); + + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + DigiBattle99CardEditDialog* self = this; + std::thread([state, svc, self, capturedEpoch, setName = std::move(setName), + setNo = std::move(setNo), showFailureDialog]() { + auto detected = svc->detectVariantsBySetNo(Game::DigiBattle99, setName, setNo); + wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), + showFailureDialog]() mutable { + if (!state->alive.load()) return; + self->applyDetectedVariants(capturedEpoch, std::move(detected), + /*fillSetNoOnSuccess=*/true, + /*fillNameOnSuccess=*/true, showFailureDialog); }); }).detach(); } @@ -157,10 +183,11 @@ void DigiBattle99CardEditDialog::applyDetectedVariants( unsigned capturedEpoch, Result> detected, bool fillSetNoOnSuccess, + bool fillNameOnSuccess, bool showFailureDialog) { if (capturedEpoch != variantFetchEpoch_) return; - if (fillSetNoOnSuccess && autoSetNoBtn_) { + if ((fillSetNoOnSuccess || fillNameOnSuccess) && autoSetNoBtn_) { autoSetNoBtn_->Enable(); } @@ -173,9 +200,16 @@ void DigiBattle99CardEditDialog::applyDetectedVariants( } cachedVariants_ = std::move(detected).value(); - if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) { - setNoCtrl_->ChangeValue( - wxString::FromUTF8(cachedVariants_.front().setNo.c_str())); + if (!cachedVariants_.empty()) { + const auto& first = cachedVariants_.front(); + if (fillNameOnSuccess && !first.name.empty()) { + if (auto* name = nameControl()) { + name->ChangeValue(wxString::FromUTF8(first.name.c_str())); + } + } + if (fillSetNoOnSuccess && setNoCtrl_) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(first.setNo.c_str())); + } } rebuildVariantRingFromCache(); @@ -231,19 +265,38 @@ void DigiBattle99CardEditDialog::onNextSetNo(wxCommandEvent&) { void DigiBattle99CardEditDialog::autoDetectFromApi() { syncCardFromControls(); const auto& card = constCard(); - if (card.name.empty()) { - showThemedMessageDialog(this, "Enter a card name first.", "Auto detect", - wxOK | wxICON_INFORMATION); - return; - } if (card.set.name.empty()) { showThemedMessageDialog(this, "Select a set first.", "Auto detect", wxOK | wxICON_INFORMATION); return; } + std::string name = card.name; + while (!name.empty() && std::isspace(static_cast(name.front()))) { + name.erase(name.begin()); + } + while (!name.empty() && std::isspace(static_cast(name.back()))) { + name.pop_back(); + } + + const std::string setNo = + setNoCtrl_ ? storedSetNoFromControls(setNoCtrl_) : std::string(); + const bool nameEmpty = name.empty(); + const bool setNoEmpty = setNo.empty(); const unsigned epoch = variantFetchEpoch_; - requestVariantsAsync(epoch, card.name, card.set.name, true, true); + + if (nameEmpty && setNoEmpty) { + showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + + if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) { + requestBySetNoAsync(epoch, card.set.name, setNo, true); + return; + } + + requestVariantsAsync(epoch, name, card.set.name, true, true); } void DigiBattle99CardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp index e952e97..a8537b5 100644 --- a/ui_wx/src/DigiBattle99GameView.cpp +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -300,6 +300,7 @@ wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) { if (selectedPanel_ != nullptr && listPanel_ != nullptr) { selectedPanel_->setCard(listPanel_->selected()); } + syncEditToolbarVisibility(); }); listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { wxWindow* owner = wxGetTopLevelParent(listPanel_); @@ -400,6 +401,11 @@ void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) { void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; + if (listPanel_->selectedCount() != 1) { + showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } auto sel = listPanel_->selected(); if (!sel) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", @@ -427,21 +433,25 @@ void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) { void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; - auto sel = listPanel_->selected(); - if (!sel) { + const auto cards = listPanel_->selectedCards(); + if (cards.empty()) { showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); return; } - if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + if (showThemedConfirmDialog(parentWindow, + deleteCardsConfirmMessage(cards.size(), cards.front().name), "Confirm") != wxID_YES) { return; } - auto removed = collection_.remove(Game::DigiBattle99, sel->id); - if (!removed) { - showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), - "Error", wxOK | wxICON_ERROR); - return; + for (const auto& card : cards) { + auto removed = collection_.remove(Game::DigiBattle99, card.id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + refreshCollection(); + return; + } } refreshCollection(); } @@ -511,6 +521,11 @@ void DigiBattle99GameView::nudgeSelection(int delta) { if (listPanel_) listPanel_->nudgeSelection(delta); } +void DigiBattle99GameView::syncEditToolbarVisibility() { + const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1; + setToolbarEditVisible(toolbarButtons_[1], showEdit); +} + void DigiBattle99GameView::applyTheme(const ThemePalette& palette) { if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme); if (listPanel_) listPanel_->applyTheme(palette); diff --git a/ui_wx/src/MagicGameView.cpp b/ui_wx/src/MagicGameView.cpp index 5c1bb94..2ad9b88 100644 --- a/ui_wx/src/MagicGameView.cpp +++ b/ui_wx/src/MagicGameView.cpp @@ -55,6 +55,7 @@ wxPanel* MagicGameView::listPanel(wxWindow* parent) { if (selectedPanel_ != nullptr && listPanel_ != nullptr) { selectedPanel_->setCard(listPanel_->selected()); } + syncEditToolbarVisibility(); }); listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { wxWindow* owner = wxGetTopLevelParent(listPanel_); @@ -71,6 +72,16 @@ wxPanel* MagicGameView::selectedPanel(wxWindow* parent) { return selectedPanel_; } +void MagicGameView::attachSharedToolbarEdit(wxBitmapButton* edit) { + sharedEditButton_ = edit; + syncEditToolbarVisibility(); +} + +void MagicGameView::syncEditToolbarVisibility() { + const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1; + setToolbarEditVisible(sharedEditButton_, showEdit); +} + void MagicGameView::refreshCollection(std::optional selectId) { if (listPanel_ == nullptr) return; auto loaded = collection_.list(Game::Magic); @@ -139,6 +150,11 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) { void MagicGameView::onEditCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; + if (listPanel_->selectedCount() != 1) { + showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } auto sel = listPanel_->selected(); if (!sel) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); @@ -165,20 +181,24 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) { void MagicGameView::onDeleteCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; - auto sel = listPanel_->selected(); - if (!sel) { + const auto cards = listPanel_->selectedCards(); + if (cards.empty()) { showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); return; } - if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + if (showThemedConfirmDialog(parentWindow, + deleteCardsConfirmMessage(cards.size(), cards.front().name), "Confirm") != wxID_YES) { return; } - auto removed = collection_.remove(Game::Magic, sel->id); - if (!removed) { - showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), - "Error", wxOK | wxICON_ERROR); - return; + for (const auto& card : cards) { + auto removed = collection_.remove(Game::Magic, card.id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + refreshCollection(); + return; + } } refreshCollection(); } @@ -207,6 +227,7 @@ void MagicGameView::nudgeSelection(int delta) { void MagicGameView::applyTheme(const ThemePalette& palette) { if (listPanel_) listPanel_->applyTheme(palette); if (selectedPanel_) selectedPanel_->applyTheme(palette); + syncEditToolbarVisibility(); } } // namespace ccm::ui diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index 4ef060e..dd9b233 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -3,6 +3,9 @@ // BaseSelectedCardPanel.hpp is included for the shared EVT_PREVIEW_STATUS // declaration so MainFrame can subscribe to preview-status updates from any // active selected panel without depending on a specific game's view. +// BaseCardListPanel.hpp provides EVT_UI_STATUS for list-driven status notes +// (e.g. clipboard copy). +#include "ccm/ui/BaseCardListPanel.hpp" #include "ccm/ui/BaseSelectedCardPanel.hpp" #include "ccm/ui/IGameView.hpp" #include "ccm/ui/AppVersion.hpp" @@ -147,6 +150,9 @@ void MainFrame::buildLayout() { toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); + for (auto* view : ctx_.gameViews) { + if (view != nullptr) view->attachSharedToolbarEdit(toolbarButtons_[1]); + } toolbar->AddStretchSpacer(1); filterInput_ = new wxTextCtrl(toolbarPanel_, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); @@ -195,12 +201,14 @@ void MainFrame::buildLayout() { // Selection changes are handled per-view (each IGameView binds // EVT_CARD_SELECTED on its own typed list panel and pushes the typed - // selection into its selected panel). MainFrame only reacts to preview - // status updates from any active selected panel. - Bind(EVT_PREVIEW_STATUS, [this](wxCommandEvent& ev) { + // selection into its selected panel). MainFrame reacts to status-bar + // updates from preview fetches and list actions (e.g. clipboard copy). + auto onStatusMessage = [this](wxCommandEvent& ev) { const wxString msg = ev.GetString(); setStatusTextUi(msg.IsEmpty() ? wxString("Ready") : msg); - }); + }; + Bind(EVT_PREVIEW_STATUS, onStatusMessage); + Bind(EVT_UI_STATUS, onStatusMessage); } IGameView* MainFrame::activeView() { diff --git a/ui_wx/src/PokemonCardEditDialog.cpp b/ui_wx/src/PokemonCardEditDialog.cpp index f7ccf2b..fed1172 100644 --- a/ui_wx/src/PokemonCardEditDialog.cpp +++ b/ui_wx/src/PokemonCardEditDialog.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -115,6 +116,7 @@ void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next"); nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this); nextSetNoBtn_->Show(false); + setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); }); auto* setNoRow = new wxBoxSizer(wxHORIZONTAL); setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); @@ -311,7 +313,32 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch, fillSetNoOnSuccess, showFailureDialog]() mutable { if (!state->alive.load()) return; self->applyDetectedVariants(capturedEpoch, std::move(detected), - fillSetNoOnSuccess, showFailureDialog); + fillSetNoOnSuccess, /*fillNameOnSuccess=*/false, + showFailureDialog); + }); + }).detach(); +} + +void PokemonCardEditDialog::requestBySetNoAsync(unsigned capturedEpoch, + std::string setId, + std::string setNo, + bool showFailureDialog) { + if (capturedEpoch != variantFetchEpoch_) return; + if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable(); + + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + PokemonCardEditDialog* self = this; + const Game game = backendGame(); + std::thread([state, svc, self, capturedEpoch, setId = std::move(setId), + setNo = std::move(setNo), showFailureDialog, game]() { + auto detected = svc->detectVariantsBySetNo(game, setId, setNo); + wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), + showFailureDialog]() mutable { + if (!state->alive.load()) return; + self->applyDetectedVariants(capturedEpoch, std::move(detected), + /*fillSetNoOnSuccess=*/true, + /*fillNameOnSuccess=*/true, showFailureDialog); }); }).detach(); } @@ -319,10 +346,11 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch, void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch, Result> detected, bool fillSetNoOnSuccess, + bool fillNameOnSuccess, bool showFailureDialog) { if (capturedEpoch != variantFetchEpoch_) return; - if (fillSetNoOnSuccess && autoSetNoBtn_) { + if ((fillSetNoOnSuccess || fillNameOnSuccess) && autoSetNoBtn_) { autoSetNoBtn_->Enable(); } @@ -335,8 +363,16 @@ void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch, } cachedVariants_ = std::move(detected).value(); - if (fillSetNoOnSuccess && !cachedVariants_.empty()) { - applySelectedSetNo(cachedVariants_.front().setNo); + if (!cachedVariants_.empty()) { + const auto& first = cachedVariants_.front(); + if (fillNameOnSuccess && !first.name.empty()) { + if (auto* name = nameControl()) { + name->ChangeValue(wxString::FromUTF8(first.name.c_str())); + } + } + if (fillSetNoOnSuccess) { + applySelectedSetNo(first.setNo); + } } rebuildVariantRingFromCache(); @@ -443,19 +479,40 @@ void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) { void PokemonCardEditDialog::autoDetectFromApi() { syncCardFromControls(); const auto& card = constCard(); - if (card.name.empty()) { - showThemedMessageDialog(this, "Enter a card name first.", "Auto detect", - wxOK | wxICON_INFORMATION); - return; - } if (card.set.id.empty()) { showThemedMessageDialog(this, "Select a set first.", "Auto detect", wxOK | wxICON_INFORMATION); return; } + std::string name = card.name; + while (!name.empty() && std::isspace(static_cast(name.front()))) { + name.erase(name.begin()); + } + while (!name.empty() && std::isspace(static_cast(name.back()))) { + name.pop_back(); + } + + const std::string setNo = isUnnumberedPromoSelected() + ? normalizedStoredSetNo(selectedSetNo_) + : (setNoCtrl_ ? storedSetNoFromControls(setNoCtrl_) + : normalizedStoredSetNo(selectedSetNo_)); + const bool nameEmpty = name.empty(); + const bool setNoEmpty = setNo.empty(); const unsigned epoch = variantFetchEpoch_; - requestVariantsAsync(epoch, card.name, card.set.id, true, true); + + if (nameEmpty && setNoEmpty) { + showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + + if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) { + requestBySetNoAsync(epoch, card.set.id, setNo, true); + return; + } + + requestVariantsAsync(epoch, name, card.set.id, true, true); } void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index addecc6..7b57717 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -302,6 +302,7 @@ wxPanel* PokemonGameView::listPanel(wxWindow* parent) { if (selectedPanel_ != nullptr && listPanel_ != nullptr) { selectedPanel_->setCard(listPanel_->selected()); } + syncEditToolbarVisibility(); }); listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { wxWindow* owner = wxGetTopLevelParent(listPanel_); @@ -402,6 +403,11 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) { void PokemonGameView::onEditCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; + if (listPanel_->selectedCount() != 1) { + showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } auto sel = listPanel_->selected(); if (!sel) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); @@ -429,20 +435,24 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) { void PokemonGameView::onDeleteCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; - auto sel = listPanel_->selected(); - if (!sel) { + const auto cards = listPanel_->selectedCards(); + if (cards.empty()) { showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); return; } - if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + if (showThemedConfirmDialog(parentWindow, + deleteCardsConfirmMessage(cards.size(), cards.front().name), "Confirm") != wxID_YES) { return; } - auto removed = collection_.remove(Game::Pokemon, sel->id); - if (!removed) { - showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), - "Error", wxOK | wxICON_ERROR); - return; + for (const auto& card : cards) { + auto removed = collection_.remove(Game::Pokemon, card.id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + refreshCollection(); + return; + } } refreshCollection(); } @@ -594,6 +604,11 @@ void PokemonGameView::nudgeSelection(int delta) { if (listPanel_) listPanel_->nudgeSelection(delta); } +void PokemonGameView::syncEditToolbarVisibility() { + const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1; + setToolbarEditVisible(toolbarButtons_[1], showEdit); +} + void PokemonGameView::applyTheme(const ThemePalette& palette) { if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme); if (listPanel_) listPanel_->applyTheme(palette); diff --git a/ui_wx/src/Theme.cpp b/ui_wx/src/Theme.cpp index 05f345b..b0cf70f 100644 --- a/ui_wx/src/Theme.cpp +++ b/ui_wx/src/Theme.cpp @@ -22,6 +22,8 @@ #include #include +#include +#include #ifdef __WXMSW__ #include @@ -776,6 +778,23 @@ int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxS return dlg.ShowModal(); } +void setToolbarEditVisible(wxBitmapButton* edit, bool visible) { + if (edit == nullptr) return; + edit->Show(visible); + if (auto* parent = edit->GetParent()) { + parent->Layout(); + } +} + +wxString deleteCardsConfirmMessage(std::size_t count, std::string_view singleCardName) { + if (count == 1) { + return wxString::Format( + "Delete \"%s\"?", + wxString::FromUTF8(singleCardName.data(), singleCardName.size())); + } + return wxString::Format("Delete %zu selected entries?", count); +} + int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption) { wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER); diff --git a/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp b/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp index 73e547d..43ecd28 100644 --- a/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp +++ b/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp @@ -94,6 +94,7 @@ void YuGiOhBandaiCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next"); nextSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onNextSetNo, this); nextSetNoBtn_->Show(false); + setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); }); auto* setNoRow = new wxBoxSizer(wxHORIZONTAL); setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); @@ -265,17 +266,17 @@ void YuGiOhBandaiCardEditDialog::requestByNameAsync(unsigned capturedEpoch, std: }).detach(); } -void YuGiOhBandaiCardEditDialog::requestByNoAsync(unsigned capturedEpoch, std::string setNo, - bool showFailureDialog) { +void YuGiOhBandaiCardEditDialog::requestByNoAsync(unsigned capturedEpoch, std::string setId, + std::string setNo, bool showFailureDialog) { if (capturedEpoch != variantFetchEpoch_) return; if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable(); auto state = variantFetchState_; CardPreviewService* svc = &cardPreview_; YuGiOhBandaiCardEditDialog* self = this; - std::thread([state, svc, self, capturedEpoch, setNo = std::move(setNo), - showFailureDialog]() { - auto detected = svc->detectVariantsBySetNo(Game::YuGiOhBandai, setNo); + std::thread([state, svc, self, capturedEpoch, setId = std::move(setId), + setNo = std::move(setNo), showFailureDialog]() { + auto detected = svc->detectVariantsBySetNo(Game::YuGiOhBandai, setId, setNo); wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), showFailureDialog]() mutable { if (!state->alive.load()) return; @@ -318,15 +319,40 @@ void YuGiOhBandaiCardEditDialog::applyDetectedList( void YuGiOhBandaiCardEditDialog::onAutoDetectBySetNo(wxCommandEvent&) { syncCardFromControls(); - const std::string setNo = - setNoCtrl_ ? setNoCtrl_->GetValue().ToStdString(wxConvUTF8) : std::string(); - if (setNo.empty()) { - showThemedMessageDialog(this, "Enter a Bandai number first.", "Auto detect", + const auto& card = constCard(); + std::string setId; + if (const Set* set = selectedSetFromControls()) setId = set->id; + if (setId.empty()) { + showThemedMessageDialog(this, "Select a set first.", "Auto detect", wxOK | wxICON_INFORMATION); return; } + + std::string name = card.name; + while (!name.empty() && std::isspace(static_cast(name.front()))) { + name.erase(name.begin()); + } + while (!name.empty() && std::isspace(static_cast(name.back()))) { + name.pop_back(); + } + const std::string setNo = + setNoCtrl_ ? setNoCtrl_->GetValue().ToStdString(wxConvUTF8) : std::string(); + const bool nameEmpty = name.empty(); + const bool setNoEmpty = + YuGiOhBandaiSetSource::normalizeCardNumber(setNo).empty(); + + if (nameEmpty && setNoEmpty) { + showThemedMessageDialog(this, "Enter a card name or Bandai number.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + const unsigned epoch = variantFetchEpoch_; - requestByNoAsync(epoch, setNo, true); + if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) { + requestByNoAsync(epoch, setId, setNo, true); + return; + } + requestByNameAsync(epoch, name, setId, true); } void YuGiOhBandaiCardEditDialog::onNextSetNo(wxCommandEvent&) { @@ -345,6 +371,11 @@ void YuGiOhBandaiCardEditDialog::onAutoDetectByName(wxCommandEvent&) { } std::string setId; if (const Set* set = selectedSetFromControls()) setId = set->id; + if (setId.empty()) { + showThemedMessageDialog(this, "Select a set first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } const unsigned epoch = variantFetchEpoch_; requestByNameAsync(epoch, card.name, setId, true); diff --git a/ui_wx/src/YuGiOhBandaiGameView.cpp b/ui_wx/src/YuGiOhBandaiGameView.cpp index 3925e6e..e806120 100644 --- a/ui_wx/src/YuGiOhBandaiGameView.cpp +++ b/ui_wx/src/YuGiOhBandaiGameView.cpp @@ -360,6 +360,7 @@ wxPanel* YuGiOhBandaiGameView::listPanel(wxWindow* parent) { if (selectedPanel_ != nullptr && listPanel_ != nullptr) { selectedPanel_->setCard(listPanel_->selected()); } + syncEditToolbarVisibility(); }); listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { wxWindow* owner = wxGetTopLevelParent(listPanel_); @@ -460,6 +461,11 @@ void YuGiOhBandaiGameView::onAddCard(wxWindow* parentWindow) { void YuGiOhBandaiGameView::onEditCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; + if (listPanel_->selectedCount() != 1) { + showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } auto sel = listPanel_->selected(); if (!sel) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", @@ -487,21 +493,25 @@ void YuGiOhBandaiGameView::onEditCard(wxWindow* parentWindow) { void YuGiOhBandaiGameView::onDeleteCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; - auto sel = listPanel_->selected(); - if (!sel) { + const auto cards = listPanel_->selectedCards(); + if (cards.empty()) { showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); return; } - if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + if (showThemedConfirmDialog(parentWindow, + deleteCardsConfirmMessage(cards.size(), cards.front().name), "Confirm") != wxID_YES) { return; } - auto removed = collection_.remove(Game::YuGiOhBandai, sel->id); - if (!removed) { - showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), - "Error", wxOK | wxICON_ERROR); - return; + for (const auto& card : cards) { + auto removed = collection_.remove(Game::YuGiOhBandai, card.id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + refreshCollection(); + return; + } } refreshCollection(); } @@ -539,6 +549,11 @@ void YuGiOhBandaiGameView::nudgeSelection(int delta) { if (listPanel_) listPanel_->nudgeSelection(delta); } +void YuGiOhBandaiGameView::syncEditToolbarVisibility() { + const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1; + setToolbarEditVisible(toolbarButtons_[1], showEdit); +} + void YuGiOhBandaiGameView::applyTheme(const ThemePalette& palette) { if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme); if (listPanel_) listPanel_->applyTheme(palette); diff --git a/ui_wx/src/YuGiOhCardEditDialog.cpp b/ui_wx/src/YuGiOhCardEditDialog.cpp index 12d2d04..eb80ed8 100644 --- a/ui_wx/src/YuGiOhCardEditDialog.cpp +++ b/ui_wx/src/YuGiOhCardEditDialog.cpp @@ -1,12 +1,16 @@ #include "ccm/ui/YuGiOhCardEditDialog.hpp" #include "ccm/ui/SwitchCtrl.hpp" +#include "ccm/ui/Theme.hpp" #include "ccm/domain/Enums.hpp" +#include "ccm/util/CardLookupDetect.hpp" #include "ccm/util/YuGiOhPrintingSlot.hpp" #include "ccm/util/YuGiOhSetLookup.hpp" #include #include #include #include +#include +#include #include namespace ccm::ui { @@ -39,13 +43,18 @@ YuGiOhCardEditDialog::YuGiOhCardEditDialog(wxWindow* parent, mode == EditMode::Create ? "Add Yu-Gi-Oh! Card" : "Edit Yu-Gi-Oh! Card", imageService, setService, mode, std::move(initial), Game::YuGiOh, preloadedSets), dialogMode_(mode), - cardPreview_(cardPreview) { + cardPreview_(cardPreview), + variantFetchState_(std::make_shared()) { buildAndPopulate(); if (dialogMode_ == EditMode::Edit) { scheduleDeferredVariantPrefetch(); } } +YuGiOhCardEditDialog::~YuGiOhCardEditDialog() { + if (variantFetchState_) variantFetchState_->alive = false; +} + void YuGiOhCardEditDialog::onCardLookupContextChanged() { clearCachedPrintVariants(); } @@ -298,7 +307,97 @@ void YuGiOhCardEditDialog::refreshVariantNextControls() { } void YuGiOhCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) { - autoDetectFromApi(true, false); + syncCardFromControls(); + const auto& card = constCard(); + if (card.set.id.empty()) { + showThemedMessageDialog(this, "Select a set first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + + // Trim so a blank-looking Name field does not take the name→setNo path. + std::string name = card.name; + while (!name.empty() && std::isspace(static_cast(name.front()))) { + name.erase(name.begin()); + } + while (!name.empty() && std::isspace(static_cast(name.back()))) { + name.pop_back(); + } + + const std::string setNoDigits = + setNoCtrl_ ? setNoCtrl_->GetValue().ToStdString(wxConvUTF8) : std::string(); + const bool nameEmpty = name.empty(); + const bool setNoEmpty = ygoCollectorDigitsFromInput(setNoDigits).empty(); + + if (nameEmpty && setNoEmpty) { + showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + + if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) { + const unsigned epoch = variantFetchEpoch_; + requestBySetNoAsync(epoch, card.set.id, card.set.name, setNoDigits); + return; + } + + autoDetectFromApi(true, true); +} + +void YuGiOhCardEditDialog::requestBySetNoAsync(unsigned capturedEpoch, std::string setId, + std::string setName, std::string setNo) { + if (capturedEpoch != variantFetchEpoch_) return; + if (autoSetNoBtn_) autoSetNoBtn_->Disable(); + + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + YuGiOhCardEditDialog* self = this; + std::thread([state, svc, self, capturedEpoch, setId = std::move(setId), + setName = std::move(setName), setNo = std::move(setNo)]() { + // Prefer set id (offline catalog pack key); fall back to display name + // for YGOPRODeck cardset= when the catalog is missing. + auto detected = svc->detectVariantsBySetNo(Game::YuGiOh, setId, setNo); + if (!detected && !setName.empty() && setName != setId) { + detected = svc->detectVariantsBySetNo(Game::YuGiOh, setName, setNo); + } + wxTheApp->CallAfter([state, self, capturedEpoch, + detected = std::move(detected)]() mutable { + if (!state->alive.load()) return; + self->applyReverseDetectedList(capturedEpoch, std::move(detected)); + }); + }).detach(); +} + +void YuGiOhCardEditDialog::applyReverseDetectedList( + unsigned capturedEpoch, + Result> detected) { + if (capturedEpoch != variantFetchEpoch_) return; + if (autoSetNoBtn_) autoSetNoBtn_->Enable(); + + if (!detected) { + showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), + "Auto detect", wxOK | wxICON_WARNING); + return; + } + if (detected.value().empty()) { + showThemedMessageDialog(this, "No matching Yu-Gi-Oh! card found.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + + cachedVariants_ = std::move(detected).value(); + const auto& p = cachedVariants_.front(); + if (auto* name = nameControl(); name && !p.name.empty()) { + name->ChangeValue(wxString::FromUTF8(p.name.c_str())); + } + if (setNoCtrl_ && !p.setNo.empty()) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(p.setNo).c_str())); + } + if (!p.rarity.empty()) applyRarityStringToChoice(p.rarity); + rebuildVariantRingsFromCache(); + syncRingPositionsToControls(); + refreshSetNoFullPreview(); + refreshVariantNextControls(); } void YuGiOhCardEditDialog::onAutoDetectRarity(wxCommandEvent&) { @@ -360,6 +459,7 @@ void YuGiOhCardEditDialog::autoDetectFromApi(bool fillSetNo, bool fillRarity) { } void YuGiOhCardEditDialog::onSetNoTextChanged(wxCommandEvent&) { + markSetNoLookupEdited(); refreshSetNoFullPreview(); if (!cachedVariants_.empty()) { rebuildVariantRingsFromCache(); diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp index 78d3706..39a092f 100644 --- a/ui_wx/src/YuGiOhGameView.cpp +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -300,6 +300,7 @@ wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) { if (selectedPanel_ != nullptr && listPanel_ != nullptr) { selectedPanel_->setCard(listPanel_->selected()); } + syncEditToolbarVisibility(); }); listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { wxWindow* owner = wxGetTopLevelParent(listPanel_); @@ -404,6 +405,11 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) { void YuGiOhGameView::onEditCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; + if (listPanel_->selectedCount() != 1) { + showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } auto sel = listPanel_->selected(); if (!sel) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", @@ -431,21 +437,25 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) { void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) { if (listPanel_ == nullptr) return; - auto sel = listPanel_->selected(); - if (!sel) { + const auto cards = listPanel_->selectedCards(); + if (cards.empty()) { showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); return; } - if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + if (showThemedConfirmDialog(parentWindow, + deleteCardsConfirmMessage(cards.size(), cards.front().name), "Confirm") != wxID_YES) { return; } - auto removed = collection_.remove(Game::YuGiOh, sel->id); - if (!removed) { - showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), - "Error", wxOK | wxICON_ERROR); - return; + for (const auto& card : cards) { + auto removed = collection_.remove(Game::YuGiOh, card.id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + refreshCollection(); + return; + } } refreshCollection(); } @@ -517,6 +527,11 @@ void YuGiOhGameView::nudgeSelection(int delta) { if (listPanel_) listPanel_->nudgeSelection(delta); } +void YuGiOhGameView::syncEditToolbarVisibility() { + const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1; + setToolbarEditVisible(toolbarButtons_[1], showEdit); +} + void YuGiOhGameView::applyTheme(const ThemePalette& palette) { if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme); if (listPanel_) listPanel_->applyTheme(palette);