Files
Card-Collection-Manager-3-s…/core/src/games/yugioh/YuGiOhCardPreviewSource.cpp
T
2026-05-11 11:27:32 +02:00

559 lines
23 KiB
C++

#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cctype>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace ccm {
namespace {
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
std::string canonical = trim(std::string(setName));
constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
if (canonical.size() > k25thSuffix.size()
&& canonical.ends_with(k25thSuffix)) {
canonical.erase(canonical.size() - k25thSuffix.size());
canonical = trim(std::move(canonical));
}
return canonical;
}
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
// always return card_images[0]: when no `cardset=` filter is applied, that
// slot is the original/standard artwork (alt-art passcodes follow), which is
// the closest fallback we have when Yugipedia has no scan for this printing.
std::string imageFromCard(const nlohmann::json& card) {
if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) {
return {};
}
const auto& first = card.at("card_images").at(0);
if (first.contains("image_url") && first.at("image_url").is_string()) {
return first.at("image_url").get<std::string>();
}
if (first.contains("image_url_small") && first.at("image_url_small").is_string()) {
return first.at("image_url_small").get<std::string>();
}
if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) {
return first.at("image_url_cropped").get<std::string>();
}
return {};
}
// Split a setNo encoded by the UI as `<setNo>||<rarity>||<edition>` into its
// three positional fields. Any missing trailing field becomes an empty
// string, so older callers that pass just `<setNo>` keep working.
struct ParsedSetNo {
std::string setNo;
std::string rarity;
std::string edition; // "1E" / "UE" / "" (unknown)
};
ParsedSetNo parseSetNoTuple(std::string_view raw) {
std::string s(raw);
ParsedSetNo p;
const auto a = s.find("||");
if (a == std::string::npos) {
p.setNo = trim(std::move(s));
return p;
}
p.setNo = trim(s.substr(0, a));
std::string rest = s.substr(a + 2);
const auto b = rest.find("||");
if (b == std::string::npos) {
p.rarity = trim(std::move(rest));
return p;
}
p.rarity = trim(rest.substr(0, b));
p.edition = trim(rest.substr(b + 2));
return p;
}
} // namespace
YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {}
// ============================================================================
// Yugipedia (image-preview path)
// ============================================================================
std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
// Yugipedia's image policy strips whitespace and a fixed set of
// punctuation from the displayed card name to produce the file slug.
// Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy
std::string out;
out.reserve(name.size());
for (unsigned char c : name) {
if (c <= 0x20) continue; // whitespace, including non-breaking
switch (c) {
case '#': case ',': case '.': case ':': case '\'': case '"':
case '?': case '!': case '&': case '@': case '%': case '=':
case '[': case ']': case '<': case '>': case '/': case '\\':
case '-': case '*': case ';': case '`':
continue;
default:
break;
}
out.push_back(static_cast<char>(c));
}
return out;
}
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
return canonical;
}
// Compare case-insensitively, ignoring whitespace, against a table of
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
// plus a few extras occasionally seen in imported collections. The codes
// are the ones Yugipedia uses in image filenames.
std::string lc;
lc.reserve(rarityName.size());
for (unsigned char c : rarityName) {
if (std::isspace(c)) continue;
lc.push_back(static_cast<char>(std::tolower(c)));
}
static const std::array<std::pair<std::string_view, std::string_view>, 32> kTable = {{
{"common", "C"},
{"shortprint", "SP"},
{"supershortprint", "SSP"},
{"normalrare", "NR"},
{"rare", "R"},
{"superrare", "SR"},
{"ultrarare", "UR"},
{"ultimaterare", "UtR"},
{"secretrare", "ScR"},
{"prismaticsecretrare", "PScR"},
{"extrasecretrare", "EScR"},
{"ultrasecretrare", "UScR"},
{"platinumsecretrare", "PtScR"},
{"goldsecretrare", "GScR"},
{"ghostrare", "GR"},
{"goldrare", "GUR"},
{"premiumgoldrare", "PGR"},
{"goldenrare", "GUR"},
{"starfoilrare", "SFR"},
{"shatterfoilrare", "SHR"},
{"mosaicrare", "MSR"},
{"parallelrare", "PR"},
{"superparallelrare", "SPR"},
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "CR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
{"prismaticredsecretrare", "PRScR"},
{"silverletter", "SLR"},
}};
for (const auto& [k, v] : kTable) {
if (lc == k) return std::string(v);
}
return {};
}
std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) {
std::string s(setNo);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
const auto dash = s.find('-');
if (dash == std::string::npos) return s;
return s.substr(0, dash);
}
std::vector<std::string> YuGiOhCardPreviewSource::buildCandidateFilenames(
std::string_view name,
std::string_view setCode,
std::string_view rarityCode,
bool firstEdition) {
std::vector<std::string> out;
const std::string slug = normalizeName(name);
if (slug.empty() || setCode.empty()) return out;
// English-only region candidates, in rough usage order: EN is the
// current default, NA was used on most LOB-era prints, EU/AU show up
// sporadically. Always English regardless of the card's stored Language.
static constexpr std::array<std::string_view, 4> kRegions =
{"EN", "NA", "EU", "AU"};
// Edition candidates: prefer the printed edition the user has, then
// try the opposite, then fall back to LE for promo-type prints.
std::array<std::string_view, 3> editions = {"", "", "LE"};
if (firstEdition) {
editions[0] = "1E";
editions[1] = "UE";
} else {
editions[0] = "UE";
editions[1] = "1E";
}
// Two extension variants: Yugipedia has a mix of .png (modern) and .jpg
// (older uploads) for the same era. Both are common for LOB-era cards.
static constexpr std::array<std::string_view, 2> kExts = {"png", "jpg"};
auto pushCombos = [&](std::string_view rarity) {
for (auto edition : editions) {
for (auto region : kRegions) {
for (auto ext : kExts) {
std::string fn;
fn.reserve(slug.size() + setCode.size() + 16);
fn += slug;
fn += '-'; fn.append(setCode);
fn += '-'; fn.append(region);
if (!rarity.empty()) {
fn += '-'; fn.append(rarity);
}
fn += '-'; fn.append(edition);
fn += '.'; fn.append(ext);
out.push_back(std::move(fn));
}
}
}
};
// Primary attempts include the rarity slot. If we don't know the rarity
// we skip straight to the rarity-less fallback (some sets are uniform
// rarity and the upload omits the slot).
if (!rarityCode.empty()) {
pushCombos(rarityCode);
}
pushCombos("");
return out;
}
std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
const std::vector<std::string>& filenames) {
// MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded).
// One HTTP call returns imageinfo for every page whose file exists; the
// missing ones come back tagged with `"missing": ""`.
std::string joined;
for (size_t i = 0; i < filenames.size(); ++i) {
if (i > 0) joined += "|";
joined += "File:";
joined += filenames[i];
}
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += rfc3986PercentEncode(joined);
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaResponse(
const std::string& body,
const std::vector<std::string>& filenameOrder) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query' object."});
}
const auto& pages = j.at("query").value("pages", nlohmann::json::object());
if (!pages.is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query.pages'."});
}
// Build a name->URL map. MediaWiki returns the title with namespace
// ("File:...") and may have replaced spaces with underscores; our
// candidate filenames never contain spaces, so a direct compare on
// the bit after "File:" is sufficient.
std::unordered_map<std::string, std::string> resolved;
resolved.reserve(filenameOrder.size());
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (!page.contains("imageinfo")) continue;
const auto& info = page.at("imageinfo");
if (!info.is_array() || info.empty()) continue;
const auto& info0 = info.at(0);
if (!info0.contains("url") || !info0.at("url").is_string()) continue;
std::string title = page.value("title", "");
constexpr std::string_view kPrefix = "File:";
if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size());
resolved[title] = info0.at("url").get<std::string>();
}
// Walk our ordered candidate list and return the first hit. This is
// how priority works: 1E English first, then UE, then jpg, etc.
for (const auto& fn : filenameOrder) {
auto it = resolved.find(fn);
if (it != resolved.end() && !it->second.empty()) {
return R::ok(it->second);
}
}
// Every candidate was tagged "missing" => Yugipedia confirmed there
// is no English scan for this printing. Treat as NotFound; the
// YGOPRODeck fallback may still surface a generic art.
return R::err({K::NotFound, "No matching Yugipedia scan found."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia JSON parse error: ") + e.what()});
}
}
// ============================================================================
// YGOPRODeck (auto-detect path + last-resort fallback)
// ============================================================================
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName) {
std::string url =
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += rfc3986PercentEncode(setName);
}
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseFallbackImageUrl(
const std::string& body, std::string_view name) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err({K::Transient, "YGOPRODeck response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "YGOPRODeck returned no matching cards."});
}
const std::string wantedNameLower = toLower(trim(std::string(name)));
// Prefer the exact-name match: the fuzzy `fname=` search can mix in
// sibling cards (Dark Magician + Dark Magician Girl), and we don't
// want to land on a sibling's standard art.
for (const auto& card : data) {
const std::string cardName = trim(card.value("name", ""));
if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) {
const std::string image = imageFromCard(card);
if (!image.empty()) return R::ok(image);
}
}
// Failing that, take whatever YGOPRODeck ranked first.
const std::string image = imageFromCard(data.at(0));
if (!image.empty()) {
return R::ok(image);
}
return R::err({K::NotFound, "Card has no image variants."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("YGOPRODeck JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view preferredSetName,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("YGOPRODeck returned no matching cards.");
}
const std::string wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushPrint = [&collected](const nlohmann::json& print) {
AutoDetectedPrint out;
out.setNo = trim(print.value("set_code", ""));
out.rarity = trim(print.value("set_rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& print : card.at("card_sets")) {
const std::string setName = trim(print.value("set_name", ""));
if (!wantedSet.empty() && setName != wantedSet) continue;
pushPrint(print);
}
}
// Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take
// every print from `data[0]` without filtering by set_name.
//
// When the caller supplied an exact card name (edit-dialog variant
// listing), combining unrelated `card_sets[]` rows after a non-empty
// display-set filter missed would falsely imply multiple printings
// "in one set" (different real-world products share the same card).
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSet.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
const auto& firstCard = j.at("data").at(0);
if (!wantedNameLower.empty()) {
const std::string cardName = trim(firstCard.value("name", ""));
if (toLower(cardName) != wantedNameLower) {
return R::err("Could not auto-detect set print metadata.");
}
}
if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) {
for (const auto& print : firstCard.at("card_sets")) {
pushPrint(print);
}
}
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::parseFirstPrint(
const std::string& body, std::string_view preferredSetName) {
auto list = parsePrintVariants(body, preferredSetName, "");
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
// ============================================================================
// Public ICardPreviewSource API
// ============================================================================
Result<std::string, PreviewLookupError>
YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view /*setId*/,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const ParsedSetNo p = parseSetNoTuple(setNo);
const std::string setCode = extractSetCode(p.setNo);
const std::string rarityCode = rarityCodeFor(p.rarity);
const bool firstEdition = (p.edition == "1E");
// The overall classification needs the worst outcome across the two
// upstreams: NotFound only when *both* answered cleanly with no match,
// Transient as soon as either one couldn't speak. We track Yugipedia's
// outcome here and combine it with YGOPRODeck's below.
bool yugipediaSawTransient = false;
PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."};
// Step 1: Yugipedia per-printing scan. Build a batch of plausible English
// filenames and ask MediaWiki for them all in one call. This is the only
// source we know of that distinguishes art between same-passcode reprints
// (LOB Blue-Eyes vs SDK Blue-Eyes, etc.).
//
// No usable set code (or empty candidate list) is treated as an
// "inapplicable" Yugipedia step rather than a failure - we don't want a
// legitimate metadata gap to taint the final classification as transient.
if (!setCode.empty()) {
const auto candidates = buildCandidateFilenames(
name, setCode, rarityCode, firstEdition);
if (!candidates.empty()) {
const std::string url = buildYugipediaQueryUrl(candidates);
auto resp = http_.get(url);
if (!resp) {
yugipediaSawTransient = true;
yugipediaErr = {K::Transient, resp.error()};
} else {
auto parsed = parseYugipediaResponse(resp.value(), candidates);
if (parsed) return parsed;
yugipediaErr = std::move(parsed).error();
if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true;
}
}
}
// Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has
// no scan we can match (newly-added cards, OCG-only cards without an
// English release, transient Yugipedia errors). Always unfiltered, so
// card_images[0] is the original artwork rather than an alt-art reprint.
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
// YGOPRODeck failed at the network layer => the overall lookup is
// transient regardless of what Yugipedia did. Surface YGOPRODeck's
// error string because it's the most recent failure.
return R::err({K::Transient, fallback.error()});
}
auto parsed = parseFallbackImageUrl(fallback.value(), name);
if (parsed) return parsed;
// Both upstreams answered. If *either* one was transient, the overall
// outcome is transient (we can't conclude the record has no image).
PreviewLookupError fallbackErr = std::move(parsed).error();
if (yugipediaSawTransient || fallbackErr.kind == K::Transient) {
return R::err({K::Transient,
yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message});
}
// Otherwise both confirmed "no image" => safe to remember.
return R::err({K::NotFound, fallbackErr.message});
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
const std::string url = buildSearchUrl(name, canonicalSetName);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), canonicalSetName, name);
}
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
} // namespace ccm