patch: Feature/pkm autodetect (#15)

This commit is contained in:
Sebastian Dine
2026-05-12 15:49:37 +02:00
committed by GitHub
parent 98f2575b5a
commit 8a50e8daba
21 changed files with 1052 additions and 18 deletions
@@ -12,6 +12,7 @@
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -19,10 +20,16 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
@@ -30,6 +37,11 @@ public:
std::string_view setId,
std::string_view setNo);
// Slimmer search URL for auto-detect: omits the number clause and asks the
// API for only the fields the print-variant parser needs.
static std::string buildDetectSearchUrl(std::string_view name,
std::string_view setId);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`. Errors are classified:
@@ -38,6 +50,13 @@ public:
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card
// name inside the chosen set. Exposed for unit testing without HTTP.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
@@ -6,6 +6,8 @@
#include <cctype>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
@@ -23,6 +25,19 @@ std::string normalizeNumber(std::string_view setNo) {
return s;
}
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;
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
@@ -48,6 +63,14 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
rfc3986PercentEncode(query);
}
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
std::string_view setId) {
std::string url = buildSearchUrl(name, setId, "");
url += "&select=name,number,rarity,set";
url += "&pageSize=50";
return url;
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
@@ -91,4 +114,87 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
return parseResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
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("Pokemon TCG returned no matching cards.");
}
const std::string wantedSetId = trim(std::string(setId));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushCard = [&collected](const nlohmann::json& card) {
AutoDetectedPrint out;
out.setNo = trim(card.value("number", ""));
out.rarity = trim(card.value("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 (!wantedSetId.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
}
if (cardSetId != wantedSetId) continue;
}
pushCard(card);
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::err("Pokemon TCG returned no matching cards.");
}
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("Pokemon TCG JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::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>> PokemonCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildDetectSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
}
const std::string fallbackUrl = buildDetectSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm