Fix: replace api.pokemontcg.io with api.tcgdex.net (#20)

This commit is contained in:
Sebastian Dine
2026-07-23 17:58:00 +02:00
committed by GitHub
parent 9917e364c1
commit ab0e3c5ae2
27 changed files with 1066 additions and 783 deletions
+7
View File
@@ -1,5 +1,7 @@
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const PokemonCard& c) {
@@ -37,6 +39,11 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
j.at("altered").get_to(c.altered);
// Missing `region` defaults to West so pre-merge West-only files still load.
c.region = j.value("region", PokemonRegion::West);
// Migrate legacy pokemontcg.io West set ids to TCGdex EN on load so the
// next collection save persists canonical ids. Asia ids are untouched.
if (c.region == PokemonRegion::West && !c.set.id.empty()) {
c.set.id = canonicalizeWestSetId(c.set.id);
}
}
} // namespace ccm
@@ -1,5 +1,6 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
@@ -26,29 +27,11 @@ std::string toLower(std::string s) {
return s;
}
Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::json& card) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
if (!card.contains("images") || !card.at("images").is_object()) {
return R::err({K::NotFound, "Card has no 'images' object."});
}
const auto& images = card.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return R::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
// Pokemon TCG search uses an unquoted `number:` clause (e.g. number:4 or
// number:TG14). Cards are commonly stored as `4/102`; strip the suffix.
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
@@ -57,68 +40,85 @@ std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view
return s;
}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
// When both set id and collector number are known, omit name: — Lucene
// name∩number intersections can miss even when the print is real, and
// collector numbers are unique within a set.
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
std::string query;
if (!setId.empty() && !num.empty()) {
query = "set.id:";
query += std::string(setId);
query += " number:";
query += num;
} else {
query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
}
if (!num.empty()) {
query += " number:";
query += num;
}
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
rfc3986PercentEncode(query);
std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
if (imageBase.empty()) return {};
std::string url(imageBase);
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
return url + "/high.png";
}
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
std::string_view setNo) {
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
std::string id = std::string(setId) + "-" + num;
return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id);
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
std::string id = idCanon + "-" + num;
return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id);
}
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";
std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(canonicalizeWestSetId(setId));
}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
std::string url = "https://api.tcgdex.net/v2/en/cards?";
bool first = true;
auto append = [&](std::string_view key, std::string_view value) {
if (value.empty()) return;
if (!first) url += '&';
first = false;
url += std::string(key);
url += "=eq:";
url += rfc3986PercentEncode(value);
};
if (!idCanon.empty() && !num.empty()) {
append("set.id", idCanon);
append("localId", num);
} else {
append("name", name);
append("set.id", idCanon);
append("localId", num);
}
return url;
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
PokemonCardPreviewSource::parseSetCards(const std::string& body) {
using R = Result<std::vector<SetCardRow>, 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, "Pokemon TCG response missing 'data' array."});
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
return R::err({K::Transient,
"TCGdex EN set detail missing 'cards' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
std::vector<SetCardRow> out;
out.reserve(j.at("cards").size());
for (const auto& card : j.at("cards")) {
SetCardRow row;
row.localId = card.value("localId", "");
if (row.localId.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) row.localId = id.substr(dash + 1);
}
row.name = card.value("name", "");
row.rarity = card.value("rarity", "");
if (card.contains("image") && card.at("image").is_string()) {
row.imageBase = card.at("image").get<std::string>();
}
if (row.localId.empty()) continue;
out.push_back(std::move(row));
}
return imageUrlFromCardObject(data.at(0));
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
std::string("TCGdex EN set detail JSON parse error: ") + e.what()});
}
}
@@ -128,13 +128,48 @@ PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_object()) {
return R::err({K::Transient, "Pokemon TCG card response missing 'data' object."});
if (!j.is_object()) {
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
}
return imageUrlFromCardObject(j.at("data"));
if (!j.contains("image") || j.at("image").is_null()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
if (!j.at("image").is_string()) {
return R::err({K::Transient, "TCGdex EN card image field is not a string."});
}
const std::string base = j.at("image").get<std::string>();
if (base.empty()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
return R::ok(imageUrlFromBase(base));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
std::string("TCGdex EN card JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return R::err({K::Transient, "TCGdex EN cards search response is not an array."});
}
if (j.empty()) {
return R::err({K::NotFound, "TCGdex EN returned no matching cards."});
}
for (const auto& card : j) {
if (!card.contains("image") || !card.at("image").is_string()) continue;
const std::string base = card.at("image").get<std::string>();
if (base.empty()) continue;
return R::ok(imageUrlFromBase(base));
}
return R::err({K::NotFound, "TCGdex EN matching cards have no image."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("TCGdex EN cards search JSON parse error: ") + e.what()});
}
}
@@ -145,79 +180,53 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
if (!setId.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(setId, num));
if (!idCanon.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) {
auto img = parseCardByIdResponse(byId.value());
if (img) return img;
// NotFound (no images) or Transient (schema): fall through to search.
// NotFound / Transient schema: fall through to search.
}
// HTTP failure (404/5xx/offline): fall through to search.
}
const std::string url = buildSearchUrl(name, setId, setNo);
const std::string url = buildSearchUrl(name, idCanon, num);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
return parseSearchResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
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());
auto rows = parseSetCards(body);
if (!rows) {
return R::err(rows.error().message);
}
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
if (!wantedLower.empty()) {
if (toLower(trim(row.name)) != wantedLower) continue;
}
const std::string localId = normalizeCollectorNumber(row.localId);
if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue;
AutoDetectedPrint print;
print.setNo = localId;
print.rarity = row.rarity;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::ok(std::move(out));
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
@@ -234,15 +243,60 @@ Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVari
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildDetectSearchUrl(name, setId);
const std::string idCanon = canonicalizeWestSetId(setId);
if (!idCanon.empty()) {
auto detail = http_.get(buildSetDetailUrl(idCanon));
if (detail) {
auto parsed = parsePrintVariants(detail.value(), idCanon, name);
if (parsed) return parsed;
}
}
// Fallback: filtered cards search by name (+ optional set).
const std::string url = buildSearchUrl(name, idCanon, "");
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
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("TCGdex EN returned no matching cards.");
}
const std::string wantedLower = toLower(trim(std::string(name)));
std::vector<AutoDetectedPrint> collected;
std::unordered_set<std::string> seen;
for (const auto& card : j) {
if (!wantedLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedLower) continue;
}
if (!idCanon.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
} else if (card.contains("id") && card.at("id").is_string()) {
// Slim search hits are "setId-localId".
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) cardSetId = id.substr(0, dash);
}
if (cardSetId != idCanon) continue;
}
AutoDetectedPrint print;
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
print.rarity = trim(card.value("rarity", ""));
if (print.setNo.empty() && print.rarity.empty()) continue;
const std::string key = print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
collected.push_back(std::move(print));
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::ok(std::move(collected));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
}
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
@@ -0,0 +1,70 @@
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
std::unordered_map<std::string, const Set*> indexById(const std::vector<Set>& sets) {
std::unordered_map<std::string, const Set*> out;
out.reserve(sets.size());
for (const auto& s : sets) {
if (s.id.empty()) continue;
out.emplace(s.id, &s);
}
return out;
}
bool applySetMetadata(PokemonCard& card, const Set& upstream) {
bool changed = false;
if (card.set.name != upstream.name) {
card.set.name = upstream.name;
changed = true;
}
if (card.set.releaseDate != upstream.releaseDate) {
card.set.releaseDate = upstream.releaseDate;
changed = true;
}
return changed;
}
} // namespace
std::size_t syncPokemonCollectionSets(std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets) {
const auto westById = indexById(westSets);
const auto asiaById = indexById(asiaSets);
std::size_t touched = 0;
for (auto& card : cards) {
bool changed = false;
if (card.region == PokemonRegion::West) {
if (!card.set.id.empty()) {
const std::string canon = canonicalizeWestSetId(card.set.id);
if (canon != card.set.id) {
card.set.id = canon;
changed = true;
}
}
if (!card.set.id.empty()) {
if (const auto it = westById.find(card.set.id); it != westById.end()) {
if (applySetMetadata(card, *it->second)) changed = true;
}
}
} else if (card.region == PokemonRegion::Asia) {
if (!card.set.id.empty()) {
if (const auto it = asiaById.find(card.set.id); it != asiaById.end()) {
if (applySetMetadata(card, *it->second)) changed = true;
}
}
}
if (changed) ++touched;
}
return touched;
}
} // namespace ccm
+142 -169
View File
@@ -1,193 +1,166 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace ccm {
namespace {
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
void finalizeCatalog(PokemonSetCatalog& catalog) {
for (auto& pack : catalog.packs) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
std::string out(isoDate);
for (char& ch : out) {
if (ch == '-') ch = '/';
}
return out;
}
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(setId);
}
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"TCGdex EN sets response is not a JSON array.");
}
std::vector<Set> out;
out.reserve(j.size());
for (const auto& entry : j) {
Set s;
s.id = entry.value("id", "");
if (s.id.empty()) continue;
s.name = entry.value("name", "");
s.releaseDate = {}; // filled from set detail
out.push_back(std::move(s));
}
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("TCGdex EN sets JSON parse error: ") + e.what());
}
}
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
try {
const auto j = nlohmann::json::parse(detailBody);
if (!j.is_object()) {
return Result<std::string>::err(
"TCGdex EN set detail response is not a JSON object.");
}
const std::string raw = j.value("releaseDate", "");
if (raw.empty()) {
return Result<std::string>::ok(std::string{});
}
return Result<std::string>::ok(rewriteReleaseDate(raw));
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set) {
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
PokemonCardPreviewSource::normalizeCollectorNumber(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = row.name;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return parsed;
std::vector<Set> out = std::move(parsed).value();
for (auto& s : out) {
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) continue; // keep set with empty date rather than fail all
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) continue;
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
}
} // namespace
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::vector<Set>>::err(
"Pokemon TCG API response missing 'data' array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
Set s;
s.id = entry.value("id", "");
s.name = entry.value("name", "");
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
// no separator rewrite needed (cf. Scryfall's "released_at").
s.releaseDate = entry.value("releaseDate", "");
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
}
}
std::string PokemonSetSource::buildCardsPageUrl(int page, int pageSize) {
return std::string(kCardsEndpoint) + "?select=name,number,set&pageSize=" +
std::to_string(pageSize) + "&page=" + std::to_string(page);
}
Result<PokemonSetSource::CardsPageMeta>
PokemonSetSource::mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<CardsPageMeta>::err(
"Pokemon TCG cards response missing 'data' array.");
}
std::unordered_map<std::string, std::string> idToName;
idToName.reserve(sets.size());
for (const auto& set : sets) {
if (!set.id.empty()) idToName.emplace(set.id, set.name);
}
// Index existing packs for multi-page merges.
std::unordered_map<std::string, std::size_t> packIndex;
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
packIndex.emplace(catalog.packs[i].setId, i);
}
std::vector<std::unordered_set<std::string>> seenByPack(catalog.packs.size());
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
for (const auto& card : catalog.packs[i].cards) {
seenByPack[i].insert(card.setNo);
}
}
for (const auto& entry : j.at("data")) {
const std::string name = entry.value("name", "");
const std::string number =
PokemonCardPreviewSource::normalizeCollectorNumber(entry.value("number", ""));
if (name.empty() || number.empty()) continue;
std::string setId;
std::string setName;
if (entry.contains("set") && entry.at("set").is_object()) {
setId = entry.at("set").value("id", "");
setName = entry.at("set").value("name", "");
}
if (setId.empty()) continue;
if (const auto it = idToName.find(setId); it != idToName.end() && !it->second.empty()) {
setName = it->second;
}
if (setName.empty()) setName = setId;
auto pit = packIndex.find(setId);
if (pit == packIndex.end()) {
PokemonSetCatalogPack pack;
pack.setId = setId;
pack.setName = setName;
pack.cards.push_back(PokemonCatalogCard{number, name});
packIndex.emplace(setId, catalog.packs.size());
seenByPack.emplace_back(std::unordered_set<std::string>{number});
catalog.packs.push_back(std::move(pack));
continue;
}
const std::size_t idx = pit->second;
if (!seenByPack[idx].insert(number).second) continue;
if (catalog.packs[idx].setName.empty() && !setName.empty()) {
catalog.packs[idx].setName = setName;
}
catalog.packs[idx].cards.push_back(PokemonCatalogCard{number, name});
}
CardsPageMeta meta;
meta.page = j.value("page", 1);
meta.pageSize = j.value("pageSize", kCardsPageSize);
meta.count = j.value("count", static_cast<int>(j.at("data").size()));
meta.totalCount = j.value("totalCount", meta.count);
return Result<CardsPageMeta>::ok(meta);
} catch (const std::exception& e) {
return Result<CardsPageMeta>::err(
std::string("Pokemon TCG cards JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalog> PokemonSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
PokemonSetCatalog catalog;
auto meta = mergeCardsPage(body, catalog, sets);
if (!meta) return Result<PokemonSetCatalog>::err(meta.error());
finalizeCatalog(catalog);
return Result<PokemonSetCatalog>::ok(std::move(catalog));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
PokemonSetCatalog catalog;
int page = 1;
int totalCount = 0;
int fetched = 0;
for (;;) {
auto cardsResp = http_.get(buildCardsPageUrl(page));
if (!cardsResp) return Result<FetchWithCatalog>::err(cardsResp.error());
auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value());
if (!meta) return Result<FetchWithCatalog>::err(meta.error());
fetched += meta.value().count;
totalCount = meta.value().totalCount;
if (meta.value().count <= 0 || fetched >= totalCount) break;
++page;
// Safety: avoid unbounded loops if the API lies about totals.
if (page > 10000) {
return Result<FetchWithCatalog>::err(
"Pokemon TCG cards pagination exceeded safety limit.");
}
}
finalizeCatalog(catalog);
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
@@ -0,0 +1,92 @@
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against
// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids
// (base1, swsh1, sv10, sve, …) pass through unchanged.
const std::unordered_map<std::string, std::string>& legacyAliases() {
static const std::unordered_map<std::string, std::string> kMap{
// Classic / EX / HGSS renames
{"base6", "lc"},
{"bp", "bog"},
{"tk1a", "tk-ex-latia"},
{"tk1b", "tk-ex-latio"},
{"tk2a", "tk-ex-p"},
{"tk2b", "tk-ex-m"},
{"hsp", "hgssp"},
// McDonald's Collections
{"mcd11", "2011bw"},
{"mcd12", "2012bw"},
{"mcd14", "2014xy"},
{"mcd15", "2015xy"},
{"mcd16", "2016xy"},
{"mcd17", "2017sm"},
{"mcd18", "2018sm"},
{"mcd19", "2019sm"},
{"mcd21", "2021swsh"},
{"mcd22", "2022swsh"},
{"mcd23", "2023sv"},
{"mcd24", "2024sv"},
// SM specials
{"sm35", "sm3.5"},
{"sm75", "sm7.5"},
// SWSH specials / galleries
{"swsh35", "swsh3.5"},
{"swsh45", "swsh4.5"},
{"swsh45sv", "swsh4.5sv"},
{"cel25c", "cel25cc"},
{"swsh9tg", "swsh9.5tg"},
{"swsh10tg", "swsh10.5tg"},
{"pgo", "swsh10.5"},
{"swsh11tg", "swsh11.5tg"},
{"swsh12tg", "swsh12.5tg"},
{"swsh12pt5", "swsh12.5"},
{"swsh12pt5gg", "swsh12.5gg"},
// Scarlet & Violet (pokemontcg used unpadded / pt5 forms)
{"sv1", "sv01"},
{"sv2", "sv02"},
{"sv3", "sv03"},
{"sv3pt5", "sv03.5"},
{"sv4", "sv04"},
{"sv4pt5", "sv04.5"},
{"sv5", "sv05"},
{"sv6", "sv06"},
{"sv6pt5", "sv06.5"},
{"sv7", "sv07"},
{"sv8", "sv08"},
{"sv8pt5", "sv08.5"},
{"sv9", "sv09"},
{"zsv10pt5", "sv10.5b"},
{"rsv10pt5", "sv10.5w"},
// Mega Evolution era
{"me1", "me01"},
{"me2", "me02"},
{"me2pt5", "me02.5"},
{"me3", "me03"},
{"me4", "me04"},
{"me5", "me05"},
};
return kMap;
}
} // namespace
std::string canonicalizeWestSetId(std::string_view setId) {
if (setId.empty()) return {};
const auto& map = legacyAliases();
const auto it = map.find(std::string(setId));
if (it != map.end()) return it->second;
return std::string(setId);
}
} // namespace ccm
+14 -3
View File
@@ -1,6 +1,7 @@
#include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include <algorithm>
@@ -29,6 +30,10 @@ std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
}
std::string westSetKey(std::string_view setId) {
return canonicalizeWestSetId(setId);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region,
std::optional<Language> languageFilter) {
@@ -39,7 +44,9 @@ OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
const std::string setKey =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
out[setKey].insert(setNo);
}
return out;
}
@@ -157,14 +164,18 @@ pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
std::optional<Language> languageFilter) {
const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
const auto* pack = catalog.findPack(setId);
const std::string wantSetId =
region == PokemonRegion::West ? westSetKey(setId) : std::string(setId);
const auto* pack = catalog.findPack(wantSetId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string cardSetId =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
if (cardSetId != wantSetId) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}