mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
Fix: replace api.pokemontcg.io with api.tcgdex.net (#20)
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user