Minor: Additional functions (#25)

* several functions. fixes #1 and #2

* multi selection functionality
This commit is contained in:
Sebastian Dine
2026-08-03 08:35:38 +02:00
committed by GitHub
parent 2eb7c59f78
commit d3b4762b76
60 changed files with 1859 additions and 172 deletions
+1
View File
@@ -126,6 +126,7 @@ public:
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoCatalogStore_ =
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoMod_->setCatalogService(ygoCatalogStore_.get());
ygoBandaiCatalogStore_ =
std::make_unique<ccm::YuGiOhBandaiSetCatalogService>(*fs_, *config_, &dirNameForGame);
pokeCatalogStore_ =
@@ -16,6 +16,9 @@ namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
/// YGOPRODeck `set_rarity` for this printing when known (optional;
/// older `set-catalog.json` files omit it).
std::string rarity{};
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
@@ -35,6 +35,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setName) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setName,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setName,
std::string_view setNo) override;
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
// Does not invent zero-padding — CDN keys match API ids literally.
static std::string normalizeCardNumber(std::string_view setNo);
@@ -58,7 +64,8 @@ public:
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setName,
std::string_view wantedCardName);
std::string_view wantedCardName,
std::string_view wantedSetNo = {});
private:
IHttpClient& http_;
@@ -29,6 +29,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// Strip everything after the first '/' (e.g. "4/102" -> "4").
static std::string normalizeCollectorNumber(std::string_view setNo);
@@ -61,6 +67,9 @@ public:
std::string_view setId,
std::string_view wantedCardName);
// Parse TCGdex card-by-id JSON into print metadata (name + localId + rarity).
static Result<AutoDetectedPrint> parsePrintFromCardById(const std::string& body);
private:
IHttpClient& http_;
};
@@ -29,6 +29,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
static std::string normalizeLocalId(std::string_view setNo);
static std::string buildSetDetailUrl(std::string_view setId);
static std::string buildCardUrl(std::string_view setId, std::string_view localId);
@@ -60,6 +66,15 @@ public:
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog);
// Reverse lookup: set + localId → name via catalog (no HTTP).
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCatalog(std::string_view setId,
std::string_view localId,
const JapanesePokemonEnCatalog& catalog);
// Parse TCGdex JA card-by-id JSON into print metadata.
static Result<AutoDetectedPrint> parsePrintFromCardResponse(const std::string& body);
private:
IHttpClient& http_;
const JapanesePokemonEnCatalog& catalog_;
@@ -1,8 +1,11 @@
#pragma once
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -25,6 +28,9 @@ namespace ccm {
// that endpoint returns a richer set listing (with rarities and release
// dates) than Yugipedia, and we don't need image data for it.
//
// Reverse lookup (set + setNo → name) uses the offline set-completion catalog
// written by Sets → Update Yu-Gi-Oh! (`YuGiOhSetCatalogService`).
//
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
// stored Language. Localized scans are intentionally not queried so the user
// sees a consistent, well-stocked gallery (EN scans are the most complete).
@@ -32,6 +38,12 @@ class YuGiOhCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhCardPreviewSource(IHttpClient& http);
// Optional offline catalog for set+setNo → name reverse lookup. When null
// or empty, detectVariantsBySetNo returns a clear "Update Sets" error.
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
catalogStore_ = catalogStore;
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
@@ -43,6 +55,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// ---- Yugipedia helpers (image preview path) ----------------------------
// Build the list of candidate Yugipedia file names to try, in priority
@@ -116,8 +134,29 @@ public:
std::string_view preferredSetName,
std::string_view wantedCardName);
// Offline reverse lookup against a set-completion catalog. `setId` is the
// pack's set code (e.g. "LOB"); `setNo` may be digits ("005") or a full
// collector code ("LOB-005" / "LOB-EN005").
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCatalog(const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::string_view setNo);
// YGOPRODeck cardset= dump filtered by collector digits (HTTP fallback when
// the offline catalog is missing or has no match).
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCardset(const std::string& body,
std::string_view preferredSetName,
std::string_view setNo);
static std::string buildCardsetOnlyUrl(std::string_view setName);
private:
IHttpClient& http_;
IHttpClient& http_;
YuGiOhSetCatalogService* catalogStore_{nullptr};
// Cached offline catalog so reverse auto-detect does not re-parse a
// multi-MB JSON file on every button click.
mutable std::optional<YuGiOhSetCatalog> catalogCache_;
};
} // namespace ccm
@@ -3,6 +3,7 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
namespace ccm {
@@ -17,6 +18,15 @@ public:
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
// Wire offline set catalog for set+setNo → name reverse auto-detect.
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
previewSource_.setCatalogService(catalogStore);
}
[[nodiscard]] YuGiOhCardPreviewSource& previewSource() noexcept {
return previewSource_;
}
private:
YuGiOhSetSource setSource_;
YuGiOhCardPreviewSource previewSource_;
@@ -29,9 +29,11 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setNo) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// Prefer "<Name> (Bandai)" / English / Sealdass page depending on setId.
@@ -58,7 +60,9 @@ public:
parsePageImagesResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
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<std::vector<AutoDetectedPrint>> askByName(std::string_view name,
std::string_view setId);
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setNo);
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setId,
std::string_view setNo);
IHttpClient& http_;
};
@@ -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<AutoDetectedPrint> 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<AutoDetectedPrint> detectBySetNo(std::string_view /*setId*/,
std::string_view /*setNo*/) {
return Result<AutoDetectedPrint>::err(
"Detect-by-number not supported by this game.");
}
virtual Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNo(std::string_view /*setNo*/) {
detectVariantsBySetNo(std::string_view /*setId*/,
std::string_view /*setNo*/) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Detect-by-number variants not supported by this game.");
}
@@ -81,10 +81,14 @@ public:
std::string_view name,
std::string_view setId);
Result<AutoDetectedPrint> detectBySetNo(Game game, std::string_view setNo);
Result<AutoDetectedPrint> detectBySetNo(Game game,
std::string_view setId,
std::string_view setNo);
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(Game game,
std::string_view setNo);
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
Game game,
std::string_view setId,
std::string_view setNo);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution. Cached by URL (same LRU bound).
@@ -0,0 +1,24 @@
#pragma once
// Shared rule for bidirectional Set # Auto detect (name ↔ set number):
// when both fields are filled, the field the user last edited is the lookup key.
namespace ccm {
enum class CardLookupEditField { None, Name, SetNo };
// Returns true when Auto detect should run setNo → name (reverse).
// `nameEmpty` / `setNoEmpty` are already trimmed/normalized by the caller.
// When both are empty the result is false (caller shows a validation message).
// When only one is filled, that direction wins. When both are filled, SetNo
// wins only if it was the last edited lookup field; otherwise Name wins
// (including `None`, matching the historical default).
[[nodiscard]] inline bool preferDetectBySetNo(bool nameEmpty,
bool setNoEmpty,
CardLookupEditField lastEdited) noexcept {
if (nameEmpty) return !setNoEmpty;
if (setNoEmpty) return false;
return lastEdited == CardLookupEditField::SetNo;
}
} // namespace ccm
+28 -1
View File
@@ -48,12 +48,39 @@ namespace ccm {
return out;
}
// Digits from a full set code (after '-') or from a digits-only Set # field.
[[nodiscard]] inline std::string ygoCollectorDigitsFromInput(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
if (s.find('-') != std::string_view::npos) return ygoCollectorDigitsOnly(s);
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
[[nodiscard]] inline std::string ygoDigitsStripLeadingZeros(std::string digits) {
std::size_t i = 0;
while (i + 1 < digits.size() && digits[i] == '0') ++i;
if (i > 0) digits.erase(0, i);
return digits;
}
// True when both designate the same collector number, ignoring leading zeros
// ("5" == "005") and accepting either a full set code or digits-only input.
[[nodiscard]] inline bool ygoCollectorDigitsEqual(std::string_view a,
std::string_view b) {
return ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(a)) ==
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(b));
}
// True when both strings designate the same printed slot: same abbreviation
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
// extracted from everything after that dash.
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b);
return ygoCollectorDigitsEqual(a, b);
}
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
+2
View File
@@ -11,11 +11,13 @@ const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) c
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
if (!c.rarity.empty()) j["rarity"] = c.rarity;
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
c.rarity = j.value("rarity", "");
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
@@ -35,6 +35,44 @@ bool cardInPack(const nlohmann::json& card, std::string_view packName) {
return false;
}
// Numeric collector suffix: "ST-01" → "01", "01" → "01", "BO-115" → "115".
std::string numericSuffix(std::string_view setNo) {
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
const auto dash = n.find('-');
const std::string_view tail =
dash == std::string::npos ? std::string_view{n} : std::string_view{n}.substr(dash + 1);
std::string out;
out.reserve(tail.size());
for (unsigned char c : tail) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
std::string stripLeadingZeros(std::string digits) {
std::size_t i = 0;
while (i + 1 < digits.size() && digits[i] == '0') ++i;
if (i > 0) digits.erase(0, i);
return digits;
}
bool hasAlphabeticPrefix(std::string_view setNo) {
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
return !n.empty() && std::isalpha(static_cast<unsigned char>(n.front())) != 0;
}
// Exact id match, or digits-only input matched to the numeric suffix with
// leading zeros ignored ("1" ↔ "ST-01", but not "ST-11").
bool cardNumbersMatch(std::string_view wanted, std::string_view actual) {
const std::string a = DigiBattle99CardPreviewSource::normalizeCardNumber(wanted);
const std::string b = DigiBattle99CardPreviewSource::normalizeCardNumber(actual);
if (a.empty() || b.empty()) return false;
if (a == b) return true;
// Full id typed (ST-01): require exact normalized equality only.
if (hasAlphabeticPrefix(a)) return false;
return stripLeadingZeros(numericSuffix(a)) == stripLeadingZeros(numericSuffix(b));
}
} // namespace
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
@@ -144,7 +182,8 @@ DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setName,
std::string_view wantedCardName) {
std::string_view wantedCardName,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
@@ -157,6 +196,7 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrint
const std::string wantedPack = trim(std::string(setName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
const std::string wantedNo = normalizeCardNumber(wantedSetNo);
std::vector<AutoDetectedPrint> collected;
for (const auto& card : j) {
@@ -166,13 +206,20 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrint
}
if (!cardInPack(card, wantedPack)) continue;
AutoDetectedPrint out;
out.name = trim(card.value("name", ""));
out.setNo = normalizeCardNumber(card.value("id", ""));
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
if (out.setNo.empty()) continue;
// digimoncard.io `card=` is fuzzy (card=1 can return ST-01 and ST-11).
// When the user typed a number, keep only exact / zero-padded matches.
if (!wantedNo.empty() && !cardNumbersMatch(wantedNo, out.setNo)) continue;
collected.push_back(std::move(out));
}
if (collected.empty()) {
if (!wantedNo.empty()) {
return R::err("Could not auto-detect Digi-Battle card name from set number.");
}
if (!wantedNameLower.empty() && !wantedPack.empty()) {
return R::err("Could not auto-detect Digi-Battle set print metadata.");
}
@@ -219,4 +266,40 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrin
return parsePrintVariants(fallback.value(), setName, name);
}
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectBySetNo(
std::string_view setName,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setName, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Digi-Battle card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectVariantsBySetNo(
std::string_view setName,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (trim(std::string(setName)).empty()) return R::err("Select a set first.");
const std::string num = normalizeCardNumber(setNo);
if (num.empty()) return R::err("Card number is empty.");
const std::string url = buildSearchUrl("", setName, num);
auto resp = http_.get(url);
if (resp) {
auto parsed = parsePrintVariants(resp.value(), setName, "", num);
if (parsed && !parsed.value().empty()) return parsed;
}
// Retry number-only; still filter by pack + exact/padded number.
const std::string fallbackUrl = buildSearchUrl("", "", num);
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
if (resp) return R::err("Could not auto-detect Digi-Battle card name from set number.");
return R::err(fallback.error());
}
return parsePrintVariants(fallback.value(), setName, "", num);
}
} // namespace ccm
@@ -27,6 +27,22 @@ std::string toLower(std::string s) {
return s;
}
std::string stripLeadingZeros(std::string_view s) {
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return std::string(s.substr(i));
}
// Exact localId match after slash-normalization, or leading-zero-insensitive
// equality ("4" ↔ "04", not "4" ↔ "14").
bool localIdsMatch(std::string_view a, std::string_view b) {
const std::string na = PokemonCardPreviewSource::normalizeCollectorNumber(a);
const std::string nb = PokemonCardPreviewSource::normalizeCollectorNumber(b);
if (na.empty() || nb.empty()) return false;
if (na == nb) return true;
return stripLeadingZeros(na) == stripLeadingZeros(nb);
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
@@ -299,4 +315,101 @@ Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVari
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::parsePrintFromCardById(
const std::string& body) {
using R = Result<AutoDetectedPrint>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err("TCGdex EN card response is not a JSON object.");
}
AutoDetectedPrint print;
print.name = trim(j.value("name", ""));
print.setNo = normalizeCollectorNumber(j.value("localId", ""));
print.rarity = trim(j.value("rarity", ""));
if (print.name.empty()) {
return R::err("TCGdex EN card has no name.");
}
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
const std::string id = j.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
}
}
return R::ok(std::move(print));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN card JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectBySetNo(std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err("Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
if (idCanon.empty()) return R::err("Select a set first.");
if (num.empty()) return R::err("Card number is empty.");
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) {
auto parsed = parsePrintFromCardById(byId.value());
if (parsed && localIdsMatch(parsed.value().setNo, num)) {
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(parsed).value());
return R::ok(std::move(out));
}
}
// Fallback: filtered search by set.id + localId.
const std::string url = buildSearchUrl("", idCanon, num);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
try {
const auto j = nlohmann::json::parse(resp.value());
if (!j.is_array() || j.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& card : j) {
AutoDetectedPrint print;
print.name = trim(card.value("name", ""));
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
print.rarity = trim(card.value("rarity", ""));
if (print.name.empty()) continue;
if (print.setNo.empty() && card.contains("id") && card.at("id").is_string()) {
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
}
}
// Defense-in-depth: TCGdex search can be fuzzy; never accept a
// different localId (e.g. "14" when the user asked for "4").
if (!localIdsMatch(print.setNo, num)) continue;
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
}
}
} // namespace ccm
@@ -439,4 +439,113 @@ JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
return parsed;
}
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::parsePrintFromCardResponse(
const std::string& body) {
using R = Result<AutoDetectedPrint>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err("TCGdex JA card response is not a JSON object.");
}
AutoDetectedPrint print;
print.name = trim(j.value("name", ""));
print.setNo = normalizeLocalId(j.value("localId", ""));
print.rarity = trim(j.value("rarity", ""));
if (print.name.empty()) {
return R::err("TCGdex JA card has no name.");
}
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
const std::string id = j.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeLocalId(id.substr(dash + 1));
}
}
return R::ok(std::move(print));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex JA card JSON parse error: ") + e.what());
}
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
std::string_view setId,
std::string_view localId,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string id = normalizeLocalId(localId);
if (setId.empty()) return R::err("Select a set first.");
if (id.empty()) return R::err("Card number is empty.");
// Prefer exact key, then leading-zero-insensitive scan ("1" ↔ "001").
auto found = catalog.findPrint(setId, id);
if (!found) {
for (const auto& print : catalog.printsForSet(setId)) {
if (localIdsMatch(print.localId, id)) {
found = print;
break;
}
}
}
if (!found) {
return R::err("Could not auto-detect card name from set number.");
}
AutoDetectedPrint print;
print.name = !found->nameEn.empty() ? found->nameEn : found->nameJa;
print.setNo = found->localId.empty() ? id : found->localId;
if (print.name.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(print));
return R::ok(std::move(out));
}
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::detectBySetNo(
std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (setId.empty()) return R::err("Select a set first.");
const std::string id = normalizeLocalId(setNo);
if (id.empty()) return R::err("Card number is empty.");
auto cardResp = http_.get(buildCardUrl(setId, id));
if (cardResp) {
auto parsed = parsePrintFromCardResponse(cardResp.value());
if (parsed && localIdsMatch(parsed.value().setNo, id)) {
// Prefer EN catalog name when available (exact or zero-insensitive).
if (auto cat = catalog_.findPrint(setId, id); cat && !cat->nameEn.empty()) {
parsed.value().name = cat->nameEn;
} else {
for (const auto& p : catalog_.printsForSet(setId)) {
if (localIdsMatch(p.localId, id) && !p.nameEn.empty()) {
parsed.value().name = p.nameEn;
break;
}
}
}
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(parsed).value());
return R::ok(std::move(out));
}
}
if (catalog_.hasPrintsForSet(setId)) {
return detectVariantsBySetNoFromCatalog(setId, id, catalog_);
}
return R::err("Could not auto-detect card name from set number.");
}
} // namespace ccm
@@ -555,4 +555,176 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string packId = std::string(trimAsciiSpaces(setId));
if (packId.empty()) return R::err("Select a set first.");
const std::string rawNo = std::string(trimAsciiSpaces(setNo));
if (rawNo.empty()) return R::err("Card number is empty.");
const std::string wantDigits =
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo));
if (wantDigits.empty()) return R::err("Card number is empty.");
const YuGiOhSetCatalogPack* pack = catalog.findPack(packId);
if (pack == nullptr) {
// Allow callers to pass the display set name (HTTP fallback path).
for (const auto& candidate : catalog.packs) {
if (candidate.setName == packId) {
pack = &candidate;
break;
}
}
}
if (pack == nullptr) {
return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first.");
}
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seenNames;
for (const auto& card : pack->cards) {
if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue;
if (card.name.empty()) continue;
if (!seenNames.insert(card.name).second) continue;
AutoDetectedPrint print;
print.name = card.name;
print.setNo = card.setNo;
print.rarity = card.rarity;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
}
std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) {
return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") +
rfc3986PercentEncode(setName);
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
const std::string& body,
std::string_view preferredSetName,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string wantDigits =
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo));
if (wantDigits.empty()) return R::err("Card number is empty.");
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err("YGOPRODeck response missing 'data' array.");
}
const std::string preferredLower = toLower(trim(std::string(preferredSetName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& card : j.at("data")) {
const std::string cardName = trim(card.value("name", ""));
if (cardName.empty()) continue;
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& printing : card.at("card_sets")) {
const std::string setName = trim(printing.value("set_name", ""));
const std::string setCode = trim(printing.value("set_code", ""));
if (setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
if (!preferredLower.empty() && toLower(setName) != preferredLower) continue;
if (!ygoCollectorDigitsEqual(setCode, setNo)) continue;
AutoDetectedPrint print;
print.name = cardName;
print.setNo = setCode;
print.rarity = trim(printing.value("set_rarity", ""));
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
out.push_back(std::move(print));
}
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string setKey = std::string(trimAsciiSpaces(setId));
if (setKey.empty()) return R::err("Select a set first.");
if (ygoCollectorDigitsFromInput(setNo).empty()) {
return R::err("Card number is empty.");
}
// 1) Offline catalog (preferred — fast once cached).
if (catalogStore_ != nullptr) {
if (!catalogCache_) {
auto loaded = catalogStore_->load();
if (loaded) catalogCache_ = std::move(loaded).value();
}
if (catalogCache_ && !catalogCache_->empty()) {
auto fromCatalog =
detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo);
// Prefer YGOPRODeck when reachable so rarity (and multi-rarity
// variants) come through — the offline catalog may predate the
// rarity field or only keep one rarity per printing slot.
const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey);
std::string setName = setKey;
if (pack != nullptr) {
setName = pack->setName;
} else {
for (const auto& candidate : catalogCache_->packs) {
if (candidate.setName == setKey) {
setName = candidate.setName;
break;
}
}
}
if (!setName.empty()) {
auto resp = http_.get(buildCardsetOnlyUrl(setName));
if (resp) {
auto fromHttp =
detectVariantsBySetNoFromCardset(resp.value(), setName, setNo);
if (fromHttp) return fromHttp;
}
}
if (fromCatalog) return fromCatalog;
// Prefer catalog miss text when HTTP also missed / was unreachable.
return fromCatalog;
}
}
// 2) No catalog: treat setKey as display set name and query YGOPRODeck.
auto resp = http_.get(buildCardsetOnlyUrl(setKey));
if (!resp) {
return R::err(
"Set catalog missing and YGOPRODeck lookup failed. "
"Run Sets → Update Yu-Gi-Oh! or check your network.");
}
return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo);
}
} // namespace ccm
+9 -1
View File
@@ -166,7 +166,9 @@ Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& b
const auto existing = build.slotIndex.find(slot);
if (existing == build.slotIndex.end()) {
build.slotIndex.emplace(slot, build.cards.size());
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
const std::string setRarity(
trimAsciiSpaces(printing.value("set_rarity", "")));
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity});
continue;
}
@@ -175,6 +177,12 @@ Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& b
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
prev.setNo = setCode;
if (!cardName.empty()) prev.name = cardName;
const std::string setRarity(
trimAsciiSpaces(printing.value("set_rarity", "")));
if (!setRarity.empty()) prev.rarity = setRarity;
} else if (prev.rarity.empty()) {
prev.rarity = std::string(
trimAsciiSpaces(printing.value("set_rarity", "")));
}
}
}
@@ -238,7 +238,8 @@ YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body)
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body,
std::string_view preferredSetId) {
std::string_view preferredSetId,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
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<std::pair<int, AutoDetectedPrint>> 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<std::string>());
}
}
// Defense-in-depth: SMW ask should be exact, but never accept a
// different Bandai number (e.g. #11 when the user asked for #1).
if (!wantNo.empty() &&
YuGiOhBandaiSetSource::normalizeCardNumber(print.setNo) != wantNo) {
continue;
}
if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() &&
!printouts.at("Rarity").empty()) {
const auto& rar = printouts.at("Rarity").at(0);
@@ -328,6 +337,7 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByName(
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByNumber(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
@@ -336,19 +346,52 @@ Result<std::vector<AutoDetectedPrint>> 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<AutoDetectedPrint> filtered;
filtered.reserve(list.value().size());
for (auto& print : list.value()) {
if (print.setId == wantSet) filtered.push_back(std::move(print));
}
if (filtered.empty()) {
return R::err("No Bandai card matched that number in the selected set.");
}
return R::ok(std::move(filtered));
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Bandai card from number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
std::string_view setNo) {
return askByNumber(setId, setNo);
}
Result<std::string, PreviewLookupError>
@@ -407,20 +450,4 @@ YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name,
return askByName(name, setId);
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
std::string_view setNo) {
auto list = detectVariantsBySetNo(setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Bandai card from number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setNo) {
return askByNumber(setNo);
}
} // namespace ccm
+4 -2
View File
@@ -263,6 +263,7 @@ Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
}
Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
@@ -271,11 +272,12 @@ Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
if (!it->second->supportsAutoDetectPrint()) {
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
}
return it->second->detectBySetNo(setNo);
return it->second->detectBySetNo(setId, setNo);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
Game game,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
@@ -286,7 +288,7 @@ Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo
return Result<std::vector<AutoDetectedPrint>>::err(
"Auto-detect not enabled for this game.");
}
return it->second->detectVariantsBySetNo(setNo);
return it->second->detectVariantsBySetNo(setId, setNo);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
+3 -1
View File
@@ -18,7 +18,7 @@ struct Replacement {
std::string_view to;
};
constexpr std::array<Replacement, 14> kReplacements{{
constexpr std::array<Replacement, 16> kReplacements{{
{"'", ""},
{"`", ""},
{",", ""},
@@ -34,6 +34,8 @@ constexpr std::array<Replacement, 14> kReplacements{{
{"\xC3\xBB", "u"}, // u-circumflex
// Remaining accented vowels appear in modern Scryfall data but were not
// listed in the Rust source. Keeping behavior 1:1 deliberately.
{"\xE2\x99\x82", "male"}, // ♂ male sign
{"\xE2\x99\x80", "female"}, // ♀ female sign
}};
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
+1 -1
View File
@@ -141,7 +141,7 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
- `static Result<std::string> parseResponse(const std::string& body);`
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. 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:
+14 -4
View File
@@ -33,7 +33,11 @@ Used by `PokemonCardPreviewSource` in two ways:
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId``AutoDetectedPrint::setNo` and `rarity``AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped. The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId``AutoDetectedPrint::setNo` and `rarity``AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped.
3. **Reverse auto-detect (`detectVariantsBySetNo`, same Set # Auto detect button).** Requires a selected set. When **Name** is blank and **Set #** is filled, uses `GET /v2/en/cards/{setId}-{localId}` (then filtered search) to fill the card **name**. Returned `localId`s are post-filtered so a fuzzy hit cannot win on a shared digit prefix (`4` must not accept `14`). When Name is filled, behavior stays name → setNo as above. Set is always required for either direction.
The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component.
@@ -96,6 +100,8 @@ Used in two situations:
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted.
3. **Reverse auto-detect (`detectVariantsBySetNo`).** When **Name** is blank and **Set #** is filled, the Set # Auto detect button looks up the offline `yugioh/set-catalog.json` checklist (same file as Set Completion) by `Set.id` + collector digits / full code, and fills the card **name** (and **rarity** when present on the catalog row or when YGOPRODeck `cardset=` enrichment succeeds). Digit matching strips leading zeros but is not a prefix match (`5``LOB-005`, `1` does not match `LOB-011`). Name→Set # Auto detect also applies the matched prints rarity. When **both** Name and Set # are filled, the field last typed by the user is the lookup key (so editing Set # after a name detect and clicking Auto detect again resolves by set number, not by re-running the name path). Requires a prior **Sets → Update Yu-Gi-Oh!** so the catalog exists (re-run Update to refresh rarities on older catalogs). Set is always required for both directions.
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
### Set-completion catalog (`cardinfo.php` all-cards dump)
@@ -105,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 `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name }` drawn from each cards `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name, rarity? }` drawn from each cards `card_sets[]` (`set_rarity` when present). European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
@@ -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 (PMCG1PMCG6, neo1neo4, VS1, web1, E1E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
+1
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
#include <doctest/doctest.h>
#include "ccm/util/CardLookupDetect.hpp"
using namespace ccm;
TEST_SUITE("preferDetectBySetNo") {
TEST_CASE("only set number filled uses reverse lookup") {
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::None));
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::Name));
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::SetNo));
}
TEST_CASE("only name filled uses name lookup") {
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::None));
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::Name));
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::SetNo));
}
TEST_CASE("both empty does not prefer reverse") {
CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::None));
CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::SetNo));
}
TEST_CASE("both filled: last edited SetNo prefers reverse") {
CHECK(preferDetectBySetNo(false, false, CardLookupEditField::SetNo));
}
TEST_CASE("both filled: Name or None keeps name lookup") {
CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::Name));
CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::None));
}
}
+56
View File
@@ -34,6 +34,7 @@ public:
std::string lastSetNo;
std::string detectLastName;
std::string detectLastSetId;
std::string detectLastSetNo;
AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
bool allowAutoDetect{true};
@@ -67,6 +68,23 @@ public:
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override {
detectLastSetId = std::string(setId);
detectLastSetNo = std::string(setNo);
return Result<AutoDetectedPrint>::ok(detectedPrint);
}
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override {
detectLastSetId = std::string(setId);
detectLastSetNo = std::string(setNo);
std::vector<AutoDetectedPrint> v;
v.push_back(detectedPrint);
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return allowAutoDetect;
}
@@ -992,3 +1010,41 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") {
std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectVariantsBySetNo") {
TEST_CASE("routes set-scoped reverse lookup to registered source") {
FakeSource source;
source.detectedPrint = AutoDetectedPrint{"4", "Rare", "Pikachu"};
FakeGameModule module;
module.gameId = Game::Pokemon;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectVariantsBySetNo(Game::Pokemon, "base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Pikachu");
CHECK(out.value()[0].setNo == "4");
CHECK(source.detectLastSetId == "base1");
CHECK(source.detectLastSetNo == "4");
}
TEST_CASE("returns error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectVariantsBySetNo(Game::Magic, "lea", "1");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
@@ -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);
}
}
+14 -2
View File
@@ -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<YuGiOhSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("LOB") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
TEST_CASE("legacy catalog JSON without rarity still loads") {
const auto j = nlohmann::json::parse(R"({
"packs":[{"id":"LOB","name":"Legend of Blue Eyes White Dragon",
"cards":[{"setNo":"LOB-001","name":"Blue-Eyes White Dragon"}]}]
})");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
REQUIRE(back.packs.size() == 1);
REQUIRE(back.packs[0].cards.size() == 1);
CHECK(back.packs[0].cards[0].rarity.empty());
}
}
TEST_SUITE("PokemonSetCatalog JSON") {
+7
View File
@@ -28,6 +28,13 @@ TEST_SUITE("FsNames::formatTextForFs") {
TEST_CASE("idempotent on already-clean strings") {
CHECK(formatTextForFs("AlreadyClean") == "AlreadyClean");
}
TEST_CASE("male and female signs become male/female") {
CHECK(formatTextForFs("\xE2\x99\x82") == "male");
CHECK(formatTextForFs("\xE2\x99\x80") == "female");
CHECK(formatTextForFs("Nidoran \xE2\x99\x82") == "Nidoranmale");
CHECK(formatTextForFs("Nidoran \xE2\x99\x80") == "Nidoranfemale");
}
}
TEST_SUITE("FsNames::parseIndexFromFilename") {
@@ -591,3 +591,58 @@ TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only")
CHECK(shining.value()[0].setNo == "013");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog") {
TEST_CASE("resolves English name from setId + localId") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}
]
})");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "001", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Bulbasaur");
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("leading-zero-insensitive localId still resolves") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"},
{"set_id":"PMCG1","local_id":"011","name_en":"Weedle","name_ja":"ビードル"}
]
})");
REQUIRE(catalog.isOk());
const auto byOne = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "1", catalog.value());
REQUIRE(byOne.isOk());
REQUIRE(byOne.value().size() == 1);
CHECK(byOne.value()[0].name == "Bulbasaur");
CHECK(byOne.value()[0].setNo == "001");
const auto byEleven =
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "11", catalog.value());
REQUIRE(byEleven.isOk());
REQUIRE(byEleven.value().size() == 1);
CHECK(byEleven.value()[0].name == "Weedle");
}
TEST_CASE("unknown localId is an error") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}
]
})");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "999", catalog.value());
REQUIRE(out.isErr());
}
}
@@ -323,3 +323,78 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
CHECK(http.calls == 2);
}
}
TEST_SUITE("PokemonCardPreviewSource::detectVariantsBySetNo") {
TEST_CASE("card-by-id fills name from TCGdex response") {
FixedHttpClient http;
http.body = R"({
"id":"base1-4",
"localId":"4",
"name":"Charmander",
"rarity":"Common",
"image":"https://assets.tcgdex.net/en/base/base1/4"
})";
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Charmander");
CHECK(out.value()[0].setNo == "4");
CHECK(out.value()[0].rarity == "Common");
CHECK(http.lastUrl.find("/v2/en/cards/base1-4") != std::string::npos);
}
TEST_CASE("search fallback rejects localIds that only share a digit prefix") {
struct ScriptedHttp : IHttpClient {
int n = 0;
Result<std::string> get(std::string_view) override {
++n;
if (n == 1) {
return Result<std::string>::err("not found");
}
// Fuzzy search returns both "14" and "4"; only "4" may be kept.
return Result<std::string>::ok(R"([
{"id":"base1-14","localId":"14","name":"Wrong","rarity":"Common"},
{"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"}
])");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Charmander");
CHECK(out.value()[0].setNo == "4");
}
TEST_CASE("card-by-id response with mismatched localId falls through to search") {
struct ScriptedHttp : IHttpClient {
int n = 0;
Result<std::string> get(std::string_view) override {
++n;
if (n == 1) {
return Result<std::string>::ok(R"({
"id":"base1-14","localId":"14","name":"Wrong","rarity":"Rare"
})");
}
return Result<std::string>::ok(R"([
{"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"}
])");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Charmander");
CHECK(out.value()[0].setNo == "4");
}
TEST_CASE("empty set id is rejected") {
FixedHttpClient http;
PokemonCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("", "4");
REQUIRE(out.isErr());
CHECK(out.error().find("set") != std::string::npos);
}
}
+113
View File
@@ -1,5 +1,6 @@
#include <doctest/doctest.h>
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
@@ -86,6 +87,15 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001"));
}
TEST_CASE("matches collector numbers ignoring leading zeros") {
CHECK(ygoPrintingSlotsMatch("LOB-5", "LOB-005"));
CHECK(ygoPrintingSlotsMatch("LOB-EN005", "LOB-5"));
CHECK(ygoCollectorDigitsEqual("005", "5"));
CHECK(ygoCollectorDigitsEqual("LOB-EN005", "5"));
CHECK(ygoCollectorDigitsFromInput("005") == "005");
CHECK(ygoCollectorDigitsFromInput("LOB-EN005") == "005");
}
TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") {
CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005"));
@@ -935,3 +945,106 @@ TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
CHECK(out.error() == "offline");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog") {
TEST_CASE("matches digits within pack to card name") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician", "Ultra Rare"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN001", "Blue-Eyes White Dragon", "Ultra Rare"});
catalog.packs.push_back(std::move(pack));
const auto byDigits =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "005");
REQUIRE(byDigits.isOk());
REQUIRE(byDigits.value().size() == 1);
CHECK(byDigits.value()[0].name == "Dark Magician");
CHECK(byDigits.value()[0].setNo == "LOB-005");
CHECK(byDigits.value()[0].rarity == "Ultra Rare");
const auto byUnpadded =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "5");
REQUIRE(byUnpadded.isOk());
REQUIRE(byUnpadded.value().size() == 1);
CHECK(byUnpadded.value()[0].name == "Dark Magician");
const auto byRegionCode =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "LOB-001");
REQUIRE(byRegionCode.isOk());
REQUIRE(byRegionCode.value().size() == 1);
CHECK(byRegionCode.value()[0].name == "Blue-Eyes White Dragon");
}
TEST_CASE("digits-only 1 does not match collector 011") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-011", "Hitotsu-Me Giant"});
catalog.packs.push_back(std::move(pack));
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "1")
.isErr());
const auto byEleven =
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "11");
REQUIRE(byEleven.isOk());
REQUIRE(byEleven.value().size() == 1);
CHECK(byEleven.value()[0].name == "Hitotsu-Me Giant");
}
TEST_CASE("unknown pack or number returns error") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
catalog.packs.push_back(std::move(pack));
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "SDK", "001")
.isErr());
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "999")
.isErr());
}
TEST_CASE("detectVariantsBySetNo falls back to cardset HTTP without catalog") {
FixedHttpClient http;
http.body = R"({
"data":[
{"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"}
]}
]
})";
YuGiOhCardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Legend of Blue Eyes White Dragon", "5");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Dark Magician");
CHECK(out.value()[0].setNo == "LOB-EN005");
CHECK(out.value()[0].rarity == "Ultra Rare");
CHECK(http.lastUrl.find("cardset=") != std::string::npos);
CHECK(http.lastUrl.find("fname=") == std::string::npos);
}
TEST_CASE("cardset reverse keeps distinct rarities for the same set code") {
const std::string body = R"({
"data":[
{"name":"Elemental HERO Bubbleman",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
body, "Soul of the Duelist", "15");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].name == "Elemental HERO Bubbleman");
CHECK(out.value()[0].rarity == "Ultra Rare");
CHECK(out.value()[1].rarity == "Ultimate Rare");
}
}
+5
View File
@@ -213,11 +213,16 @@ TEST_SUITE("YuGiOhSetSource::parseCatalog") {
}
CHECK(sawBe);
CHECK(sawDm);
for (const auto& c : lob->cards) {
if (c.name == "Blue-Eyes White Dragon") CHECK(c.rarity == "Ultra Rare");
if (c.name == "Dark Magician") CHECK(c.rarity == "Ultra Rare");
}
const auto* mrd = out.value().findPack("MRD");
REQUIRE(mrd != nullptr);
REQUIRE(mrd->cards.size() == 1);
CHECK(mrd->cards[0].setNo == "MRD-010");
CHECK(mrd->cards[0].rarity == "Ultra Rare");
}
TEST_CASE("missing data array returns error") {
@@ -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"));
+5 -4
View File
@@ -5,9 +5,9 @@
## Layer pointers
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magics splitter mounting.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a games `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magics splitter mounting. `attachSharedToolbarEdit` lets Magic wire MainFrames Edit button for multi-select hide/show; hostsOwnLayout games manage their own Edit button via `setToolbarEditVisible`.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a games `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. Edit is hidden when more than one list row is selected. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. **Multi-select** is enabled (no `wxLC_SINGLE_SEL`): Ctrl toggles rows, Shift selects a range; `selected()` / `selectedCount()` / `selectedCards()` expose the selection; rebuild/sort/filter preserve all selected ids; Ctrl+C copies every selected row as TSV (one line per card). Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)``Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
@@ -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 `<Name>CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged.
- For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input.
- Bidirectional identify (Yu-Gi-Oh!, Bandai, Pokémon West/Asia, Digi-Battle): Set is always required. Set # **Auto detect** fills set number from Name, or fills Name from Set #. When **both** fields are filled, the field the user last typed is the lookup key (`CardLookupEditField` / `preferDetectBySetNo` in `ccm/util/CardLookupDetect.hpp`, tracked by `BaseCardEditDialog::markNameLookupEdited` / `markSetNoLookupEdited`). Programmatic `ChangeValue` from a detect result does not flip the key.
## Required follow-ups
@@ -22,6 +22,7 @@
#include "ccm/services/SetService.hpp"
#include "ccm/ui/ImageViewerDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/util/CardLookupDetect.hpp"
#include <wx/arrstr.h>
#include <wx/button.h>
@@ -136,6 +137,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<Set>* preloadedSets_{nullptr};
wxTextCtrl* nameCtrl_{nullptr};
CardLookupEditField lastLookupEditField_{CardLookupEditField::None};
wxComboBox* setCombo_{nullptr};
wxSpinCtrl* amountCtrl_{nullptr};
wxChoice* languageChoice_{nullptr};
+164 -37
View File
@@ -40,8 +40,10 @@
#include "ccm/ui/Theme.hpp"
#include <wx/bitmap.h>
#include <wx/clipbrd.h>
#include <wx/colour.h>
#include <wx/cursor.h>
#include <wx/dataobj.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listctrl.h>
@@ -59,6 +61,7 @@
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -74,6 +77,10 @@ wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
// `IGameView` implementations bind this to open Edit for `selected()`.
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
// Raised when the list wants a short status-bar note (e.g. clipboard copy).
// `event.GetString()` is the message; MainFrame shows it in the bottom strip.
wxDECLARE_EVENT(EVT_UI_STATUS, wxCommandEvent);
template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
@@ -81,14 +88,18 @@ public:
using sort_column_type = TSortColumn;
// Replace the displayed rows. When preferSelectId is set, selects that
// card if present (used after Add). Otherwise preserves the previously
// selected card by id when still present; the first-row CallAfter path in
// rebuildRows() runs only when there was no prior selection (startup).
// card exclusively if present (used after Add). Otherwise preserves the
// previously selected card ids when still present; the first-row CallAfter
// path in rebuildRows() runs only when there was no prior selection
// (startup).
void setCards(std::vector<TCard> cards,
std::optional<std::uint32_t> preferSelectId = std::nullopt) {
std::optional<std::uint32_t> keepId = preferSelectId;
if (!keepId) {
if (auto sel = selected()) keepId = sel->id;
std::optional<std::vector<std::uint32_t>> keepIds;
if (preferSelectId) {
keepIds = std::vector<std::uint32_t>{*preferSelectId};
} else {
auto ids = selectedIds();
if (!ids.empty()) keepIds = std::move(ids);
}
cards_ = std::move(cards);
// Drop sort state when the underlying data is replaced - the indicator
@@ -96,7 +107,7 @@ public:
// wxListCtrl keeps the indicator across DeleteAllItems().
nextDirByCol_.clear();
list_->RemoveSortIndicator();
rebuildRows(keepId);
rebuildRows(keepIds);
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
@@ -104,16 +115,17 @@ public:
}
// Update the filter string and rebuild the visible rows in place. The
// panel preserves the previously-selected card across the rebuild when
// it still matches the new filter; otherwise the first remaining row is
// panel preserves previously-selected cards across the rebuild when they
// still match the new filter; otherwise the first remaining row is
// selected, or none if the filter excluded everything. A single
// EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
void setFilter(std::string_view filter) {
if (filter_ == filter) return;
filter_.assign(filter);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
rebuildRows(keepIds);
}
void applyTheme(const ThemePalette& palette) {
@@ -123,22 +135,52 @@ public:
SetForegroundColour(palette.text);
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
refreshHeaderTheme(palette);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
rebuildRows(keepIds);
Refresh();
}
[[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
[[nodiscard]] const std::string& filter() const noexcept { return filter_; }
// First selected card (detail panel / single-edit primary).
[[nodiscard]] std::optional<TCard> selected() const {
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (const TCard* c = cardForRow(sel)) return *c;
return std::nullopt;
}
[[nodiscard]] std::size_t selectedCount() const {
if (list_ == nullptr) return 0;
std::size_t n = 0;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
++n;
}
return n;
}
[[nodiscard]] std::vector<TCard> selectedCards() const {
std::vector<TCard> out;
if (list_ == nullptr) return out;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
if (const TCard* c = cardForRow(row)) out.push_back(*c);
}
return out;
}
[[nodiscard]] std::vector<std::uint32_t> selectedIds() const {
std::vector<std::uint32_t> out;
if (list_ == nullptr) return out;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
if (const TCard* c = cardForRow(row)) out.push_back(c->id);
}
return out;
}
// Ensure the selected row is actively focused so Windows uses the active
// highlight color (blue in light mode), keeping selected-row icons legible.
// Ensure the first selected row is actively focused so Windows uses the
// active highlight color (blue in light mode), keeping selected-row icons
// legible. Does not clear a multi-selection.
void activateSelection() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
@@ -151,8 +193,9 @@ public:
}
// Move the selection by `delta` rows (+1 / -1). Used when Up/Down are
// pressed while focus is on the filter box. Clamps to the visible range;
// leaves list HWND focus alone so the caret can stay in the filter.
// pressed while focus is on the filter box. Collapses any multi-selection
// to a single row. Clamps to the visible range; leaves list HWND focus
// alone so the caret can stay in the filter.
void nudgeSelection(int delta) {
if (list_ == nullptr || list_->GetItemCount() <= 0 || delta == 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
@@ -161,12 +204,12 @@ public:
long next = row + delta;
if (next < 0) next = 0;
if (next >= count) next = count - 1;
if (next == row) {
list_->EnsureVisible(next);
return;
}
suppressListFocus_ = true;
list_->SetItemState(row, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
// Clear every selected row so filter nudge is always single-select.
long sel = -1;
while ((sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
list_->SetItemState(sel, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
}
list_->SetItemState(next,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
@@ -215,8 +258,10 @@ protected:
// Subclass calls this once from its constructor body (after virtual hooks
// are reachable) to wire up columns + the header row + custom-draw hooks.
void buildLayout() {
// Multi-select: native Ctrl (toggle) and Shift (range) without
// wxLC_SINGLE_SEL. Set-completion tables keep single-select separately.
list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
wxLC_REPORT | wxLC_NO_HEADER);
textCols_ = declareTextColumns();
iconCols_ = declareIconColumns();
@@ -274,6 +319,7 @@ protected:
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
list_->Bind(wxEVT_KEY_DOWN, &BaseCardListPanel::onListKeyDown, this);
}
// Forwarded helpers ------------------------------------------------------
@@ -523,11 +569,12 @@ private:
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
nextDirByCol_[*sortCol] = !ascending;
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
sortBy(*sortCol, ascending);
rebuildRows(keepId);
rebuildRows(keepIds);
if (list_ != nullptr) list_->SetFocus();
}
@@ -590,7 +637,9 @@ private:
// ----- row rendering -----------------------------------------------------
void rebuildRows(std::optional<std::uint32_t> keepId = std::nullopt) {
// nullopt keepIds → no prior selection (startup / empty): defer first-row
// select. Otherwise restore every id that is still visible after filter.
void rebuildRows(std::optional<std::vector<std::uint32_t>> keepIds = std::nullopt) {
// Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
// SELECTED (from the SetItemState below) events while we churn through
// the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
@@ -605,10 +654,16 @@ private:
}
}
std::unordered_set<std::uint32_t> keepSet;
if (keepIds) {
keepSet.insert(keepIds->begin(), keepIds->end());
}
long row = 0;
long rowToSelect = -1;
long firstRestored = -1;
const int firstText = firstTextColIdx();
const int noteCol = noteColIdx();
std::vector<long> rowsToSelect;
for (std::size_t srcIdx : filteredIndices_) {
const auto& c = cards_[srcIdx];
// Insert via the hidden column-0 spacer. We never set sub-item
@@ -632,16 +687,22 @@ private:
const std::string note = renderTextCell(c, textCols_.size() - 1);
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
if (keepId && c.id == *keepId) rowToSelect = idx;
if (!keepSet.empty() && keepSet.count(c.id) != 0) {
rowsToSelect.push_back(idx);
if (firstRestored < 0) firstRestored = idx;
}
++row;
}
bool deferredInitialSelect = false;
if (!filteredIndices_.empty() && rowToSelect >= 0) {
list_->SetItemState(rowToSelect,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(rowToSelect);
} else if (!filteredIndices_.empty() && !keepId.has_value()) {
if (!filteredIndices_.empty() && !rowsToSelect.empty()) {
for (long r : rowsToSelect) {
const long flags = (r == firstRestored)
? (wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED)
: wxLIST_STATE_SELECTED;
list_->SetItemState(r, flags, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
}
list_->EnsureVisible(firstRestored);
} else if (!filteredIndices_.empty() && !keepIds.has_value()) {
// Defer the initial selection to the next event turn so first
// paint stays responsive.
deferredInitialSelect = true;
@@ -705,6 +766,72 @@ private:
});
}
void onListKeyDown(wxKeyEvent& event) {
const int key = event.GetKeyCode();
const bool copyChord =
(event.ControlDown() || event.CmdDown()) && (key == 'C' || key == 'c');
if (!copyChord) {
event.Skip();
return;
}
copySelectedRowsToClipboard();
}
void copySelectedRowsToClipboard() {
const auto cards = selectedCards();
if (cards.empty() || textCols_.empty()) return;
auto formatRow = [&](const TCard& card) {
std::string line;
auto appendCell = [&](std::string_view cell) {
if (!line.empty()) line.push_back('\t');
line.append(cell);
};
// Leading text columns (everything except trailing Note).
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
appendCell(renderTextCell(card, i));
}
// Icon/flag columns — no list text; export as true/false.
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
appendCell(isIconColumnSet(card, i) ? "true" : "false");
}
// Trailing Note.
appendCell(renderTextCell(card, textCols_.size() - 1));
return line;
};
std::string payload = formatRow(cards.front());
for (std::size_t i = 1; i < cards.size(); ++i) {
payload.push_back('\n');
payload.append(formatRow(cards[i]));
}
wxClipboardLocker lock;
if (!lock) return;
if (!wxTheClipboard->SetData(
new wxTextDataObject(wxString::FromUTF8(payload.c_str())))) {
return;
}
if (cards.size() == 1) {
emitUiStatus("Saved entry to clipboard");
} else {
emitUiStatus(wxString::Format("Saved %zu entries to clipboard", cards.size()));
}
}
void emitUiStatus(const wxString& message) {
wxCommandEvent ev(EVT_UI_STATUS, GetId());
ev.SetEventObject(this);
ev.SetString(message);
// Same parent-hop as BaseSelectedCardPanel::emitPreviewStatus so the
// command event can propagate up to MainFrame's status strip.
if (auto* parent = GetParent()) {
parent->GetEventHandler()->ProcessEvent(ev);
} else {
ProcessWindowEvent(ev);
}
}
// ----- members ----------------------------------------------------------
static constexpr int kFlagIconSize = 14;
@@ -49,9 +49,14 @@ private:
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void requestBySetNoAsync(unsigned capturedEpoch,
std::string setName,
std::string setNo,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
@@ -63,6 +63,7 @@ public:
}
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
+5
View File
@@ -19,6 +19,7 @@
#include <string_view>
#include <vector>
class wxBitmapButton;
class wxPanel;
class wxWindow;
@@ -67,6 +68,10 @@ public:
virtual void onEditCard(wxWindow* parentWindow) = 0;
virtual void onDeleteCard(wxWindow* parentWindow) = 0;
// Magic uses MainFrame's shared Edit button; hostsOwnLayout games ignore
// this and manage their own toolbar. Default no-op.
virtual void attachSharedToolbarEdit(wxBitmapButton* edit) { (void)edit; }
// Sets menu action ("Update Magic" / "Update Pokemon"). Returns the
// user-visible status string for the parent's status bar.
virtual std::string onUpdateSets(wxWindow* parentWindow) = 0;
+3
View File
@@ -42,6 +42,7 @@ public:
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
void attachSharedToolbarEdit(wxBitmapButton* edit) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void nudgeSelection(int delta) override;
@@ -51,6 +52,7 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void syncEditToolbarVisibility();
ConfigService& config_;
CollectionService<MagicCard>& collection_;
@@ -61,6 +63,7 @@ private:
MagicCardListPanel* listPanel_{nullptr};
MagicSelectedCardPanel* selectedPanel_{nullptr};
wxBitmapButton* sharedEditButton_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
@@ -62,9 +62,14 @@ private:
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void requestBySetNoAsync(unsigned capturedEpoch,
std::string setId,
std::string setNo,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
+1
View File
@@ -66,6 +66,7 @@ public:
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; }
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog(PokemonRegion region);
void ensureSingleCardsMounted(wxWindow* splitterParent);
+12
View File
@@ -4,6 +4,11 @@
#include <wx/colour.h>
#include <cstddef>
#include <string>
#include <string_view>
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
@@ -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<std::vector<AutoDetectedPrint>> detected,
bool showFailureDialog, bool applyFirst);
@@ -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).
@@ -8,6 +8,11 @@
#include <wx/button.h>
#include <wx/stattext.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
class YuGiOhCardEditDialog final : public BaseCardEditDialog<YuGiOhCard> {
@@ -19,6 +24,7 @@ public:
EditMode mode,
YuGiOhCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~YuGiOhCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -31,6 +37,10 @@ protected:
void onSetSelectionApplied() override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onAutoDetectRarity(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
@@ -42,6 +52,10 @@ private:
void onSetCodeAutoDetect(wxCommandEvent&);
void syncSetModeHint();
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
void requestBySetNoAsync(unsigned capturedEpoch, std::string setId, std::string setName,
std::string setNo);
void applyReverseDetectedList(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected);
void refreshSetNoFullPreview();
void clearCachedPrintVariants();
bool fetchAndCachePrintVariants();
@@ -59,6 +73,7 @@ private:
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxStaticText* setNoFullPreview_{nullptr};
wxChoice* rarityChoice_{nullptr};
+1
View File
@@ -61,6 +61,7 @@ public:
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; }
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
+1
View File
@@ -12,6 +12,7 @@ namespace ccm::ui {
wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
wxDEFINE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
wxDEFINE_EVENT(EVT_UI_STATUS, wxCommandEvent);
wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent);
} // namespace ccm::ui
+64 -11
View File
@@ -4,6 +4,7 @@
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <cctype>
#include <thread>
#include <unordered_set>
@@ -60,6 +61,7 @@ void DigiBattle99CardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); });
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
@@ -148,7 +150,31 @@ void DigiBattle99CardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
fillSetNoOnSuccess, /*fillNameOnSuccess=*/false,
showFailureDialog);
});
}).detach();
}
void DigiBattle99CardEditDialog::requestBySetNoAsync(unsigned capturedEpoch,
std::string setName,
std::string setNo,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable();
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
DigiBattle99CardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, setName = std::move(setName),
setNo = std::move(setNo), showFailureDialog]() {
auto detected = svc->detectVariantsBySetNo(Game::DigiBattle99, setName, setNo);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
/*fillSetNoOnSuccess=*/true,
/*fillNameOnSuccess=*/true, showFailureDialog);
});
}).detach();
}
@@ -157,10 +183,11 @@ void DigiBattle99CardEditDialog::applyDetectedVariants(
unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
if ((fillSetNoOnSuccess || fillNameOnSuccess) && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
@@ -173,9 +200,16 @@ void DigiBattle99CardEditDialog::applyDetectedVariants(
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
if (!cachedVariants_.empty()) {
const auto& first = cachedVariants_.front();
if (fillNameOnSuccess && !first.name.empty()) {
if (auto* name = nameControl()) {
name->ChangeValue(wxString::FromUTF8(first.name.c_str()));
}
}
if (fillSetNoOnSuccess && setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(first.setNo.c_str()));
}
}
rebuildVariantRingFromCache();
@@ -231,19 +265,38 @@ void DigiBattle99CardEditDialog::onNextSetNo(wxCommandEvent&) {
void DigiBattle99CardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.name.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
std::string name = card.name;
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string setNo =
setNoCtrl_ ? storedSetNoFromControls(setNoCtrl_) : std::string();
const bool nameEmpty = name.empty();
const bool setNoEmpty = setNo.empty();
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.name, true, true);
if (nameEmpty && setNoEmpty) {
showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) {
requestBySetNoAsync(epoch, card.set.name, setNo, true);
return;
}
requestVariantsAsync(epoch, name, card.set.name, true, true);
}
void DigiBattle99CardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
+23 -8
View File
@@ -300,6 +300,7 @@ wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
@@ -400,6 +401,11 @@ void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) {
void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit",
@@ -427,21 +433,25 @@ void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete",
wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::DigiBattle99, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
for (const auto& card : cards) {
auto removed = collection_.remove(Game::DigiBattle99, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
@@ -511,6 +521,11 @@ void DigiBattle99GameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void DigiBattle99GameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(toolbarButtons_[1], showEdit);
}
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
+29 -8
View File
@@ -55,6 +55,7 @@ wxPanel* MagicGameView::listPanel(wxWindow* parent) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
@@ -71,6 +72,16 @@ wxPanel* MagicGameView::selectedPanel(wxWindow* parent) {
return selectedPanel_;
}
void MagicGameView::attachSharedToolbarEdit(wxBitmapButton* edit) {
sharedEditButton_ = edit;
syncEditToolbarVisibility();
}
void MagicGameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(sharedEditButton_, showEdit);
}
void MagicGameView::refreshCollection(std::optional<std::uint32_t> selectId) {
if (listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::Magic);
@@ -139,6 +150,11 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
void MagicGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
@@ -165,20 +181,24 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) {
void MagicGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::Magic, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
for (const auto& card : cards) {
auto removed = collection_.remove(Game::Magic, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
@@ -207,6 +227,7 @@ void MagicGameView::nudgeSelection(int delta) {
void MagicGameView::applyTheme(const ThemePalette& palette) {
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
syncEditToolbarVisibility();
}
} // namespace ccm::ui
+12 -4
View File
@@ -3,6 +3,9 @@
// BaseSelectedCardPanel.hpp is included for the shared EVT_PREVIEW_STATUS
// declaration so MainFrame can subscribe to preview-status updates from any
// active selected panel without depending on a specific game's view.
// BaseCardListPanel.hpp provides EVT_UI_STATUS for list-driven status notes
// (e.g. clipboard copy).
#include "ccm/ui/BaseCardListPanel.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
#include "ccm/ui/IGameView.hpp"
#include "ccm/ui/AppVersion.hpp"
@@ -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() {
+67 -10
View File
@@ -8,6 +8,7 @@
#include <wx/app.h>
#include <wx/panel.h>
#include <cctype>
#include <thread>
#include <unordered_set>
@@ -115,6 +116,7 @@ void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &PokemonCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
setNoCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { markSetNoLookupEdited(); });
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
@@ -311,7 +313,32 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
fillSetNoOnSuccess, /*fillNameOnSuccess=*/false,
showFailureDialog);
});
}).detach();
}
void PokemonCardEditDialog::requestBySetNoAsync(unsigned capturedEpoch,
std::string setId,
std::string setNo,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable();
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
PokemonCardEditDialog* self = this;
const Game game = backendGame();
std::thread([state, svc, self, capturedEpoch, setId = std::move(setId),
setNo = std::move(setNo), showFailureDialog, game]() {
auto detected = svc->detectVariantsBySetNo(game, setId, setNo);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
/*fillSetNoOnSuccess=*/true,
/*fillNameOnSuccess=*/true, showFailureDialog);
});
}).detach();
}
@@ -319,10 +346,11 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
if ((fillSetNoOnSuccess || fillNameOnSuccess) && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
@@ -335,8 +363,16 @@ void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch,
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && !cachedVariants_.empty()) {
applySelectedSetNo(cachedVariants_.front().setNo);
if (!cachedVariants_.empty()) {
const auto& first = cachedVariants_.front();
if (fillNameOnSuccess && !first.name.empty()) {
if (auto* name = nameControl()) {
name->ChangeValue(wxString::FromUTF8(first.name.c_str()));
}
}
if (fillSetNoOnSuccess) {
applySelectedSetNo(first.setNo);
}
}
rebuildVariantRingFromCache();
@@ -443,19 +479,40 @@ void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) {
void PokemonCardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.id.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
std::string name = card.name;
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string setNo = isUnnumberedPromoSelected()
? normalizedStoredSetNo(selectedSetNo_)
: (setNoCtrl_ ? storedSetNoFromControls(setNoCtrl_)
: normalizedStoredSetNo(selectedSetNo_));
const bool nameEmpty = name.empty();
const bool setNoEmpty = setNo.empty();
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.id, true, true);
if (nameEmpty && setNoEmpty) {
showThemedMessageDialog(this, "Enter a card name or set number.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (shouldDetectBySetNo(nameEmpty, setNoEmpty)) {
requestBySetNoAsync(epoch, card.set.id, setNo, true);
return;
}
requestVariantsAsync(epoch, name, card.set.id, true, true);
}
void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
+23 -8
View File
@@ -302,6 +302,7 @@ wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
syncEditToolbarVisibility();
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
@@ -402,6 +403,11 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
void PokemonGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
if (listPanel_->selectedCount() != 1) {
showThemedMessageDialog(parentWindow, "Select a single card to edit.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
@@ -429,20 +435,24 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) {
void PokemonGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
const auto cards = listPanel_->selectedCards();
if (cards.empty()) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
if (showThemedConfirmDialog(parentWindow,
deleteCardsConfirmMessage(cards.size(), cards.front().name),
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::Pokemon, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
for (const auto& card : cards) {
auto removed = collection_.remove(Game::Pokemon, card.id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
refreshCollection();
return;
}
}
refreshCollection();
}
@@ -594,6 +604,11 @@ void PokemonGameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void PokemonGameView::syncEditToolbarVisibility() {
const bool showEdit = listPanel_ == nullptr || listPanel_->selectedCount() <= 1;
setToolbarEditVisible(toolbarButtons_[1], showEdit);
}
void PokemonGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
+19
View File
@@ -22,6 +22,8 @@
#include <unordered_map>
#include <unordered_set>
#include <string>
#include <string_view>
#ifdef __WXMSW__
#include <windows.h>
@@ -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);
+41 -10
View File
@@ -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<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string setNo =
setNoCtrl_ ? setNoCtrl_->GetValue().ToStdString(wxConvUTF8) : std::string();
const bool nameEmpty = name.empty();
const bool setNoEmpty =
YuGiOhBandaiSetSource::normalizeCardNumber(setNo).empty();
if (nameEmpty && setNoEmpty) {
showThemedMessageDialog(this, "Enter a card name or Bandai number.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
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);
+23 -8
View File
@@ -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);
+102 -2
View File
@@ -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 <wx/app.h>
#include <wx/panel.h>
#include <algorithm>
#include <cctype>
#include <string>
#include <thread>
#include <unordered_set>
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<VariantFetchState>()) {
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<unsigned char>(name.front()))) {
name.erase(name.begin());
}
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
const std::string 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<std::vector<AutoDetectedPrint>> 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();
+23 -8
View File
@@ -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);