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 1067 additions and 784 deletions
+2 -2
View File
@@ -8,7 +8,7 @@
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `parseCatalog` / `fetchAllWithCatalog` (or Asia equivalents) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations.
@@ -27,7 +27,7 @@
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface.
10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `<exeDir>/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `<hash>.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
## Adding a new game
+2
View File
@@ -36,6 +36,8 @@ add_library(ccm_core STATIC
src/games/magic/MagicSetSource.cpp
src/games/magic/MagicCardPreviewSource.cpp
src/games/magic/MagicGameModule.cpp
src/games/pokemon/PokemonWestSetId.cpp
src/games/pokemon/PokemonCollectionSetSync.cpp
src/games/pokemon/PokemonSetSource.cpp
src/games/pokemon/PokemonCardPreviewSource.cpp
src/games/pokemon/PokemonGameModule.cpp
@@ -1,11 +1,9 @@
#pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
// TCG. When set id + collector number are both known, prefers
// GET https://api.pokemontcg.io/v2/cards/{setId}-{number}
// then falls back to a name-less search `set.id:… number:…`. Name-based
// search is kept for lookups that lack a set number (or set id). Returns
// `images.large` (with `images.small` as a graceful fallback).
// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
// set-detail name match for auto-detect. Image URLs append /high.png (wxImage
// decodes PNG, not webp).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
@@ -31,40 +29,33 @@ public:
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.
// When both setId and setNo are non-empty, omits the name: clause so the
// Lucene query cannot miss on name∩number intersections.
// Exposed for unit testing and to keep encoding rules in one place.
// Strip everything after the first '/' (e.g. "4/102" -> "4").
static std::string normalizeCollectorNumber(std::string_view setNo);
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
static std::string buildSetDetailUrl(std::string_view setId);
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
static std::string imageUrlFromBase(std::string_view imageBase);
// Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}.
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
struct SetCardRow {
std::string localId;
std::string name;
std::string imageBase;
std::string rarity;
};
// Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by
// preview lookups, auto-detect, and set-completion ownership matching.
static std::string normalizeCollectorNumber(std::string_view setNo);
static Result<std::vector<SetCardRow>, PreviewLookupError>
parseSetCards(const std::string& body);
// 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 *search* response body (`data` array) and
// pull out the image URL for the first matching card. Prefers
// `images.large`, falls back to `images.small`. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array or missing image variants => NotFound.
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Parse a Pokemon TCG /v2/cards/{id} response (`data` object).
static Result<std::string, PreviewLookupError>
parseCardByIdResponse(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.
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
static Result<std::string, PreviewLookupError>
parseSearchResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
@@ -0,0 +1,21 @@
#pragma once
// Sync Pokemon collection cards against freshly fetched set lists:
// - West: canonicalize legacy pokemontcg set ids, then refresh name/date
// - Asia: refresh name/date when the set id is present in the Asia list
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/Set.hpp"
#include <cstddef>
#include <vector>
namespace ccm {
// Mutates cards in place. Returns how many cards changed at least one set field.
[[nodiscard]] std::size_t syncPokemonCollectionSets(
std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets);
} // namespace ccm
@@ -1,7 +1,7 @@
#pragma once
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
// and card preview source, both backed by api.pokemontcg.io/v2.
// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en).
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
@@ -1,12 +1,8 @@
#pragma once
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
// response into our `Set` domain type, and sorts by release date ascending.
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
// so no rewriting is needed (unlike Scryfall's `released_at`).
// Behavior matches `pokemon/set_services.rs::update_sets`.
// Set-completion catalog is built from a paginated /v2/cards dump.
// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
// dates and set-completion checklists come from per-set detail GETs.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
@@ -14,15 +10,14 @@
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
static constexpr const char* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards";
static constexpr int kCardsPageSize = 250;
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
@@ -33,28 +28,18 @@ public:
Result<std::vector<Set>> fetchAll() override;
// Sets endpoint + paginated cards dump for the offline checklist.
// List + per-set detail (cards + release date) for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseListResponse(const std::string& body);
static Result<std::string> parseReleaseDate(const std::string& detailBody);
static std::string rewriteReleaseDate(std::string_view isoDate);
static std::string buildSetDetailUrl(std::string_view setId);
// Build / merge checklist packs from one /v2/cards page body. Pass an
// accumulating catalog; returns page count metadata for pagination.
struct CardsPageMeta {
int page{1};
int pageSize{kCardsPageSize};
int count{0};
int totalCount{0};
};
static Result<CardsPageMeta> mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets);
static Result<PokemonSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize);
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set);
private:
IHttpClient& http_;
@@ -0,0 +1,16 @@
#pragma once
// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids.
// Identity when the id is already TCGdex (or unknown). Asia set ids must not
// be passed through this helper.
#include <string>
#include <string_view>
namespace ccm {
// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids
// and ids that already match TCGdex are returned unchanged.
[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId);
} // namespace ccm
@@ -80,6 +80,15 @@ public:
return repo_.save(game, map);
}
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
Result<void> saveAll(Game game, std::vector<TCard> cards) {
Map map;
for (auto& card : cards) {
map.insert_or_assign(card.id, std::move(card));
}
return repo_.save(game, map);
}
// Remove the card with the given id. Also deletes any associated images
// via the IImageStore (best-effort - image removal failures are logged in
// the error string but the card itself is still purged from the JSON).
+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);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
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 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);
}