mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 21:01:07 +00:00
Minor: Additional functions (#25)
* several functions. fixes #1 and #2 * multi selection functionality
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ Used by `PokemonCardPreviewSource` in two ways:
|
||||
|
||||
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build.
|
||||
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId` → `AutoDetectedPrint::setNo` and `rarity` → `AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped. The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId` → `AutoDetectedPrint::setNo` and `rarity` → `AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped.
|
||||
|
||||
3. **Reverse auto-detect (`detectVariantsBySetNo`, same Set # Auto detect button).** Requires a selected set. When **Name** is blank and **Set #** is filled, uses `GET /v2/en/cards/{setId}-{localId}` (then filtered search) to fill the card **name**. Returned `localId`s are post-filtered so a fuzzy hit cannot win on a shared digit prefix (`4` must not accept `14`). When Name is filled, behavior stays name → setNo as above. Set is always required for either direction.
|
||||
|
||||
The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
|
||||
|
||||
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component.
|
||||
|
||||
@@ -96,6 +100,8 @@ Used in two situations:
|
||||
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted.
|
||||
|
||||
3. **Reverse auto-detect (`detectVariantsBySetNo`).** When **Name** is blank and **Set #** is filled, the Set # Auto detect button looks up the offline `yugioh/set-catalog.json` checklist (same file as Set Completion) by `Set.id` + collector digits / full code, and fills the card **name** (and **rarity** when present on the catalog row or when YGOPRODeck `cardset=` enrichment succeeds). Digit matching strips leading zeros but is not a prefix match (`5` ↔ `LOB-005`, `1` does not match `LOB-011`). Name→Set # Auto detect also applies the matched print’s rarity. When **both** Name and Set # are filled, the field last typed by the user is the lookup key (so editing Set # after a name detect and clicking Auto detect again resolves by set number, not by re-running the name path). Requires a prior **Sets → Update Yu-Gi-Oh!** so the catalog exists (re-run Update to refresh rarities on older catalogs). Set is always required for both directions.
|
||||
|
||||
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedia’s MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
|
||||
|
||||
### Set-completion catalog (`cardinfo.php` all-cards dump)
|
||||
@@ -105,7 +111,7 @@ YGOPRODeck publishes rate limits and asks clients to cache responses and avoid a
|
||||
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
|
||||
2. A pack checklist at `<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 card’s `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
|
||||
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name, rarity? }` drawn from each card’s `card_sets[]` (`set_rarity` when present). European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
|
||||
|
||||
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
|
||||
|
||||
@@ -135,8 +141,8 @@ English Blue-Eyes is **not** a separate set — it is `ban3` card `#118` with la
|
||||
### Asset API (preview + auto-detect)
|
||||
|
||||
1. **Preview:** `pageimages` on preferred titles `Name (Bandai)` / `Name (English Bandai)` / `Name (Bandai Sealdass)`, falling back to SMW `ask` by English name then `pageimages` on the best hit.
|
||||
2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`.
|
||||
3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` → same fields.
|
||||
2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`. Requires a selected set.
|
||||
3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` (or promo gallery parse for `J*`/`TA*` codes) → same fields, then **filtered to the selected set**. Ask results are also dropped when the returned Bandai number does not match the requested one after normalization (`1` must not accept `11`). The Set # Auto detect button is bidirectional: blank name + number fills name; name filled fills number/rarity. Set is always required.
|
||||
|
||||
Card-back fallback URL: `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`.
|
||||
|
||||
@@ -188,6 +194,8 @@ where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also s
|
||||
|
||||
**Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity).
|
||||
|
||||
**Reverse auto-detect** (`detectVariantsBySetNo`): when Name is blank and Set # is filled, search with `card=` + `pack=` fills `AutoDetectedPrint::name` (and normalizes `setNo`). Hits are post-filtered so digits-only input matches the numeric suffix with leading zeros ignored (`1` ↔ `ST-01`, not `ST-11`). Set (pack display name) is always required for either direction.
|
||||
|
||||
Empty search array / `{"error":"..."}` → `NotFound`; bad JSON / HTTP → `Transient`.
|
||||
|
||||
## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend
|
||||
@@ -262,6 +270,8 @@ It does **not** substitute another printing of the same Pokémon when both TCGde
|
||||
|
||||
Auto-detect / Next uses the same set-detail `cards[]`, matching the typed name against catalog English names or TCGdex Japanese names. Catalog EN aliases are applied only when the catalog `name_ja` agrees with the TCGdex row (stale seed mappings like Charmander→`001` are ignored).
|
||||
|
||||
Reverse auto-detect (`detectVariantsBySetNo`) uses `GET /v2/ja/cards/{setId}-{localId}` (preferring catalog `nameEn` when present) or a catalog scan with leading-zero-insensitive `localId` matching (`1` ↔ `001`, not `011`) for catalog-only sets, filling Name when Set # is known and Name is blank. Set is always required.
|
||||
|
||||
Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ` → `Erika's Oddish`, `わるいリザードン` → `Dark Charizard`, `R団のサンダー` → `Rocket's Zapdos`, neo garbled `輝くセレビ` → `Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`).
|
||||
|
||||
That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1–PMCG6, neo1–neo4, VS1, web1, E1–E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 Magic’s splitter mounting.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<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 Magic’s splitter mounting. `attachSharedToolbarEdit` lets Magic wire MainFrame’s Edit button for multi-select hide/show; hostsOwnLayout games manage their own Edit button via `setToolbarEditVisible`.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. Edit is hidden when more than one list row is selected. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user