mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
Minor: Add Asian Pokemon Card Support (#18)
This commit is contained in:
+3
-3
@@ -4,11 +4,11 @@
|
||||
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `DigiBattle99Card`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `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/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). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `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/`, and `digibattle99/` are the reference implementations — all four expose a fully working set source + card preview source.
|
||||
- `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. `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.
|
||||
- `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`, `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.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.
|
||||
|
||||
## Adding a new game
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ add_library(ccm_core STATIC
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/YuGiOhCard.cpp
|
||||
src/domain/DigiBattle99Card.cpp
|
||||
src/domain/JapanesePokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
src/services/ConfigService.cpp
|
||||
@@ -35,6 +36,10 @@ add_library(ccm_core STATIC
|
||||
src/games/digibattle99/DigiBattle99SetSource.cpp
|
||||
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
|
||||
src/games/digibattle99/DigiBattle99GameModule.cpp
|
||||
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
|
||||
src/games/pokemonjp/JapanesePokemonSetSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonGameModule.cpp
|
||||
|
||||
src/util/FsNames.cpp
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -20,6 +21,12 @@ enum class Game {
|
||||
Pokemon,
|
||||
YuGiOh,
|
||||
DigiBattle99,
|
||||
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
|
||||
};
|
||||
|
||||
enum class PokemonRegion {
|
||||
West,
|
||||
Asia,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
@@ -28,8 +35,10 @@ enum class Language {
|
||||
French,
|
||||
Spanish,
|
||||
Italian,
|
||||
Chinese,
|
||||
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
|
||||
TraditionalChinese, // JSON / display: "T-Chinese"
|
||||
Japanese,
|
||||
Korean,
|
||||
Russian,
|
||||
};
|
||||
|
||||
@@ -49,24 +58,34 @@ enum class Theme {
|
||||
};
|
||||
|
||||
std::string_view to_string(Game g) noexcept;
|
||||
std::string_view to_string(PokemonRegion r) noexcept;
|
||||
std::string_view to_string(Language l) noexcept;
|
||||
std::string_view to_string(Condition c) noexcept;
|
||||
std::string_view to_string(Theme t) noexcept;
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
|
||||
const std::array<Game, 4>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
const std::array<Language, 10>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
[[nodiscard]] std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Game pokemonBackendGame(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept;
|
||||
|
||||
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
|
||||
void to_json(nlohmann::json& j, Game v);
|
||||
void from_json(const nlohmann::json& j, Game& v);
|
||||
|
||||
void to_json(nlohmann::json& j, PokemonRegion v);
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Language v);
|
||||
void from_json(const nlohmann::json& j, Language& v);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCard - Japanese Pokémon TCG collection model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::Japanese};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c);
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases),
|
||||
// plus `region` (West/Asia) for unified West+Asia collections.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
@@ -28,6 +29,7 @@ struct PokemonCard {
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
|
||||
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,10 @@ public:
|
||||
// Implementations return a vector that has already been filtered
|
||||
// (e.g. no digital-only sets) and sorted by release date ascending.
|
||||
virtual Result<std::vector<Set>> fetchAll() = 0;
|
||||
|
||||
// Optional post-process for locally cached set lists (e.g. inject products
|
||||
// the upstream API omits). Default is a no-op. Called by SetService::getSets.
|
||||
virtual void augmentCachedSets(std::vector<Set>& /*sets*/) const {}
|
||||
};
|
||||
|
||||
class IGameModule {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCardPreviewSource: TCGdex ja localId-based preview + variants.
|
||||
// Image URLs use /high.png (wxImage decodes PNG/JPEG, not webp).
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
JapanesePokemonCardPreviewSource(IHttpClient& http,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
static std::string normalizeLocalId(std::string_view setNo);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
static std::string buildCardUrl(std::string_view setId, std::string_view localId);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
// Parse set-detail body; optionally filter by name (EN catalog / JA) and/or localId.
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string nameJa;
|
||||
std::string imageBase; // empty when TCGdex has no scan
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseCardImageUrl(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Catalog-only Auto-detect when TCGdex has no set detail (theme decks, etc.).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectPrintVariantsFromCatalog(std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonEnCatalog - bundled English name layer for Japanese Pokémon.
|
||||
// Loaded from assets/pokemon_jp_en_catalog.json (generated offline). Missing
|
||||
// entries fall through to TCGdex Japanese names at runtime.
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonSetEnInfo {
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string releaseDate; // YYYY/MM/DD when known; may be empty
|
||||
};
|
||||
|
||||
struct JapanesePokemonPrintEnInfo {
|
||||
std::string setId;
|
||||
std::string localId;
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string nameEnSource; // bulbapedia | species-table | manual
|
||||
// Classic JA gap-fill when TCGdex has no CDN scan (optional).
|
||||
std::string imageUrl; // explicit HTTPS URL, preferred when set
|
||||
std::string tcgplayerId; // TCGPlayer product id → product-images CDN
|
||||
};
|
||||
|
||||
class JapanesePokemonEnCatalog {
|
||||
public:
|
||||
[[nodiscard]] static Result<JapanesePokemonEnCatalog>
|
||||
parse(const std::string& jsonBody);
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept {
|
||||
return sets_.empty() && printsByKey_.empty();
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonSetEnInfo>
|
||||
findSet(std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonPrintEnInfo>
|
||||
findPrint(std::string_view setId, std::string_view localId) const;
|
||||
|
||||
// Case-insensitive match of nameEn or nameJa within a set.
|
||||
// Also matches qualified English titles: wanted "Mewtwo" hits
|
||||
// "Mewtwo (CoroCoro promo)" (prefix + " (").
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
findPrintsByName(std::string_view setId, std::string_view cardName) const;
|
||||
|
||||
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
|
||||
|
||||
// TCGPlayer product-image CDN URL for classic JA gap-fill.
|
||||
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
|
||||
|
||||
// Prefer imageUrl; else build from tcgplayerId; else empty.
|
||||
[[nodiscard]] static std::string previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print);
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, JapanesePokemonSetEnInfo> sets_;
|
||||
std::unordered_map<std::string, JapanesePokemonPrintEnInfo> printsByKey_;
|
||||
// setId -> print keys for name scans
|
||||
std::unordered_map<std::string, std::vector<std::string>> printKeysBySet_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonGameModule: Japanese Pokémon TCG via TCGdex ja + EN catalog.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog = {});
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::JapanesePokemon; }
|
||||
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Pokemon (Japan)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
[[nodiscard]] const JapanesePokemonEnCatalog& catalog() const noexcept {
|
||||
return catalog_;
|
||||
}
|
||||
|
||||
private:
|
||||
JapanesePokemonEnCatalog catalog_;
|
||||
JapanesePokemonSetSource setSource_;
|
||||
JapanesePokemonCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
|
||||
// dates. English display names come from JapanesePokemonEnCatalog when present.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
|
||||
|
||||
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
void augmentCachedSets(std::vector<Set>& sets) const override;
|
||||
|
||||
// Pure parsers for hermetic tests.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static bool shouldExcludeSetId(std::string_view setId) noexcept;
|
||||
static std::string applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
|
||||
static void appendMissingClassicProducts(std::vector<Set>& sets);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
|
||||
// JsonSetRepository: persists vector<Set> under `<dataStorage>/<dirName>/`.
|
||||
// Most games use `sets.json`. Pokemon West/Asia share dir `pokemon` with
|
||||
// `sets-west.json` / `sets-asia.json` (migrate-on-load from legacy paths).
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
@@ -27,6 +29,8 @@ private:
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
|
||||
[[nodiscard]] std::filesystem::path legacySetsPath(Game game) const;
|
||||
[[nodiscard]] Result<std::vector<Set>> parseSetsText(const std::string& text) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// ISetRepository - persistence port for the cached `sets.json` of a game.
|
||||
// ISetRepository - persistence port for the cached set list of a game.
|
||||
// Typical layout: `<dataStorage>/<dirName>/sets.json`. Pokemon West/Asia use
|
||||
// `sets-west.json` / `sets-asia.json` under the shared `pokemon/` directory.
|
||||
// Stored as a flat list to mirror the original Rust file layout.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// `.includes("")` returns true.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -35,7 +36,7 @@ namespace ccm {
|
||||
std::string_view filter);
|
||||
|
||||
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
|
||||
// name, set.name, setNo, language, condition, amount, note.
|
||||
// name, set.name, setNo, language, condition, amount, note, region.
|
||||
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||
std::string_view filter);
|
||||
@@ -46,4 +47,8 @@ namespace ccm {
|
||||
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -32,7 +34,9 @@ namespace ccm {
|
||||
class CardPreviewService {
|
||||
public:
|
||||
explicit CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache = nullptr);
|
||||
IPreviewByteCache* persistentCache = nullptr,
|
||||
IFileSystem* fs = nullptr,
|
||||
std::filesystem::path assetRoot = {});
|
||||
|
||||
// Register a game module's preview source. Calling this with a module
|
||||
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
||||
@@ -96,6 +100,9 @@ private:
|
||||
|
||||
Result<std::string> fetchAndCache(const std::string& cacheKey,
|
||||
std::string_view url);
|
||||
Result<std::string, PreviewLookupError> fetchAssetAndCache(
|
||||
const std::string& cacheKey,
|
||||
std::string_view assetUrl);
|
||||
|
||||
// Returns the kind of in-memory cache entry for `key`. On Hit the
|
||||
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
|
||||
@@ -107,6 +114,8 @@ private:
|
||||
|
||||
IHttpClient& http_;
|
||||
IPreviewByteCache* persistentCache_{nullptr};
|
||||
IFileSystem* fs_{nullptr};
|
||||
std::filesystem::path assetRoot_;
|
||||
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
||||
|
||||
// LRU: list holds entries in MRU-first order; map points at list nodes
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -81,6 +82,20 @@ enum class DigiBattle99SortColumn {
|
||||
Note,
|
||||
};
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon columns.
|
||||
enum class JapanesePokemonSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Stable in-place sort. `ascending=false` runs the same comparator with
|
||||
// inverted sign, matching `byField(field, asc)` semantics.
|
||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
||||
@@ -92,5 +107,8 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending);
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) {
|
||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||
j.at("dataStorage").get_to(c.dataStorage);
|
||||
j.at("defaultGame").get_to(c.defaultGame);
|
||||
// JapanesePokemon was folded into Pokemon (West/Asia region). Coerce so
|
||||
// older config.json files keep a valid user-facing default game.
|
||||
if (c.defaultGame == Game::JapanesePokemon) {
|
||||
c.defaultGame = Game::Pokemon;
|
||||
}
|
||||
c.theme = j.value("theme", Theme::Light);
|
||||
}
|
||||
|
||||
|
||||
+83
-31
@@ -13,24 +13,35 @@ namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::DigiBattle99: return "DigiBattle99";
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::DigiBattle99: return "DigiBattle99";
|
||||
case Game::JapanesePokemon: return "JapanesePokemon";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(PokemonRegion r) noexcept {
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return "West";
|
||||
case PokemonRegion::Asia: return "Asia";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
switch (l) {
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::Chinese: return "Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::SimplifiedChinese: return "S-Chinese";
|
||||
case Language::TraditionalChinese: return "T-Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Korean: return "Korean";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
@@ -57,22 +68,33 @@ std::string_view to_string(Theme t) noexcept {
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
||||
if (s == "JapanesePokemon") return Game::JapanesePokemon;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept {
|
||||
if (s == "West") return PokemonRegion::West;
|
||||
if (s == "Asia") return PokemonRegion::Asia;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "Chinese") return Language::Chinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "S-Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "T-Chinese") return Language::TraditionalChinese;
|
||||
// Legacy single Chinese spelling → Simplified.
|
||||
if (s == "Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Korean") return Language::Korean;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -99,10 +121,11 @@ const std::array<Game, 4>& allGames() noexcept {
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Language, 8>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 8> v{
|
||||
const std::array<Language, 10>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 10> v{
|
||||
Language::English, Language::German, Language::French, Language::Spanish,
|
||||
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
|
||||
Language::Italian, Language::SimplifiedChinese, Language::TraditionalChinese,
|
||||
Language::Japanese, Language::Korean, Language::Russian
|
||||
};
|
||||
return v;
|
||||
}
|
||||
@@ -120,16 +143,45 @@ const std::array<Theme, 2>& allThemes() noexcept {
|
||||
return v;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
|
||||
static constexpr std::array<Language, 6> kWest{
|
||||
Language::English, Language::German, Language::French,
|
||||
Language::Spanish, Language::Italian, Language::Russian};
|
||||
static constexpr std::array<Language, 4> kAsia{
|
||||
Language::Japanese, Language::SimplifiedChinese,
|
||||
Language::TraditionalChinese, Language::Korean};
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return kWest;
|
||||
case PokemonRegion::Asia: return kAsia;
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
return kWest;
|
||||
}
|
||||
|
||||
Game pokemonBackendGame(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Game::JapanesePokemon : Game::Pokemon;
|
||||
}
|
||||
|
||||
Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Language::Japanese : Language::English;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, PokemonRegion v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
|
||||
void from_json(const nlohmann::json& j, Game& v) {
|
||||
auto parsed = gameFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v) {
|
||||
auto parsed = pokemonRegionFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Language& v) {
|
||||
auto parsed = languageFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -17,6 +17,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
{"region", c.region},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +35,8 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
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);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string_view s) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return std::string(s.substr(i));
|
||||
}
|
||||
|
||||
bool localIdsMatch(std::string_view a, std::string_view b) {
|
||||
if (a == b) return true;
|
||||
return stripLeadingZeros(a) == stripLeadingZeros(b);
|
||||
}
|
||||
|
||||
bool catalogPrintMatchesRow(const JapanesePokemonPrintEnInfo& print,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row) {
|
||||
// Reject stale catalog rows whose Japanese name disagrees with TCGdex.
|
||||
// Seed data historically mapped Charmander→001 / Charizard→004; those
|
||||
// localIds are Bulbasaur / Weedle on PMCG1.
|
||||
if (print.nameJa.empty()) return true;
|
||||
return asciiLower(print.nameJa) == asciiLower(row.nameJa);
|
||||
}
|
||||
|
||||
bool nameMatchesRow(std::string_view wantedLower,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row,
|
||||
std::string_view setId,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
if (wantedLower.empty()) return true;
|
||||
if (asciiLower(row.nameJa) == wantedLower) return true;
|
||||
if (auto print = catalog.findPrint(setId, row.localId)) {
|
||||
if (!catalogPrintMatchesRow(*print, row)) return false;
|
||||
if (asciiLower(print->nameEn) == wantedLower) return true;
|
||||
if (asciiLower(print->nameJa) == wantedLower) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonCardPreviewSource::JapanesePokemonCardPreviewSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::normalizeLocalId(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) s.erase(slash);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildCardUrl(std::string_view setId,
|
||||
std::string_view localId) {
|
||||
std::string id = std::string(setId) + "-" + std::string(localId);
|
||||
return std::string("https://api.tcgdex.net/v2/ja/cards/") +
|
||||
rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::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";
|
||||
}
|
||||
|
||||
Result<std::vector<JapanesePokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::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.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex JA set detail missing 'cards' array."});
|
||||
}
|
||||
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()) {
|
||||
// Fallback: take suffix after last '-' from card id.
|
||||
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.nameJa = 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 R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseCardImageUrl(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_object()) {
|
||||
return R::err({K::Transient, "TCGdex JA card response is not a JSON object."});
|
||||
}
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex JA 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 JA card has no image."});
|
||||
}
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenCatalogUrls;
|
||||
|
||||
// Prefer catalog EN matches first so typed English names resolve — but
|
||||
// only when the catalog localId exists in the set and name_ja agrees
|
||||
// with TCGdex (guards against stale seed mappings).
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
const SetCardRow* row = nullptr;
|
||||
for (const auto& r : rows.value()) {
|
||||
if (localIdsMatch(r.localId, p.localId)) {
|
||||
row = &r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (row == nullptr) {
|
||||
// Set detail sometimes omits cards[]; keep catalog-only hits
|
||||
// (UnnumberedPromo). Dedupe only among non-empty preview URLs
|
||||
// so empty-image prints still appear in the Next ring.
|
||||
if (!rows.value().empty()) continue;
|
||||
if (!previewUrl.empty() &&
|
||||
!seenCatalogUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
} else if (!catalogPrintMatchesRow(p, *row)) {
|
||||
continue;
|
||||
}
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (row != nullptr) print.rarity = row->rarity;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!nameMatchesRow(wantedLower, row, setId, catalog)) continue;
|
||||
if (!seen.insert(row.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = row.localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty() && !wantedLower.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (!catalog.hasPrintsForSet(setId)) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenUrls;
|
||||
if (wantedLower.empty()) {
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
// Dedupe only among non-empty preview URLs so identical art is not
|
||||
// cycled; empty-image prints still join the Next ring (card-back).
|
||||
// Emit imaged prints first so Auto-detect lands on real art.
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (!previewUrl.empty() && !seenUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
if (out.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string localId = normalizeLocalId(setNo);
|
||||
if (setId.empty()) {
|
||||
return R::err({K::NotFound, "Japanese Pokemon preview requires a set id."});
|
||||
}
|
||||
|
||||
auto catalogPreviewFor = [&](std::string_view lid) -> Result<std::string, PreviewLookupError> {
|
||||
if (lid.empty()) return R::err({K::NotFound, "No catalog preview for print."});
|
||||
if (auto print = catalog_.findPrint(setId, lid)) {
|
||||
const std::string catalogUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(*print);
|
||||
if (!catalogUrl.empty()) return R::ok(catalogUrl);
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
};
|
||||
|
||||
// Prefer direct card fetch when we have a localId.
|
||||
if (!localId.empty()) {
|
||||
auto cardResp = http_.get(buildCardUrl(setId, localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
// NotFound from card object: fall through to set list / catalog.
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Synthetic / classic products: try catalog gap-fill before set detail.
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
// Known catalog print with no preview URL: honest miss (do not
|
||||
// borrow a sibling print's art via name match).
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
// Catalog-only products (City Gym theme decks, etc.) are not on TCGdex.
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
// Network failure and no catalog entry: Transient so a brief outage
|
||||
// is not negative-cached as a permanent miss.
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
auto rows = parseSetCards(setResp.value());
|
||||
if (!rows) return R::err(rows.error());
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
const SetCardRow* chosen = nullptr;
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!localId.empty() && localIdsMatch(row.localId, localId)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Name match only when setNo was not provided — never borrow a sibling
|
||||
// print's art for a concrete localId.
|
||||
if (chosen == nullptr && localId.empty() && !wantedLower.empty()) {
|
||||
for (const auto& row : rows.value()) {
|
||||
if (nameMatchesRow(wantedLower, row, setId, catalog_)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
// Empty cards[] with catalog prints: resolve from catalog.
|
||||
if (rows.value().empty() && catalog_.hasPrintsForSet(setId)) {
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!chosen->imageBase.empty()) {
|
||||
return R::ok(imageUrlFromBase(chosen->imageBase));
|
||||
}
|
||||
|
||||
// Try full card object — set résumé sometimes omits image.
|
||||
auto cardResp = http_.get(buildCardUrl(setId, chosen->localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Odd localId padding can 404; still try catalog gap-fill below.
|
||||
}
|
||||
|
||||
// Classic JA sets often have image:null on TCGdex. Prefer a catalog
|
||||
// printing-accurate TCGPlayer product image for this exact setId+localId
|
||||
// (never search other printings by Pokémon name).
|
||||
return catalogPreviewFor(chosen->localId);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint>
|
||||
JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto variants = detectPrintVariants(name, setId);
|
||||
if (!variants) return Result<AutoDetectedPrint>::err(variants.error());
|
||||
if (variants.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(variants.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) {
|
||||
if (setId.empty()) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Select a set before auto-detecting Japanese Pokemon prints.");
|
||||
}
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(setResp.error());
|
||||
}
|
||||
auto parsed = parsePrintVariants(setResp.value(), setId, name, catalog_);
|
||||
if (parsed) return parsed;
|
||||
// Empty/unusable TCGdex detail: fall back to catalog prints when present.
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,159 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string printKey(std::string_view setId, std::string_view localId) {
|
||||
return std::string(setId) + '\0' + std::string(localId);
|
||||
}
|
||||
|
||||
bool isAsciiAlnumToken(std::string_view s) {
|
||||
if (s.empty()) return false;
|
||||
for (unsigned char ch : s) {
|
||||
if (!std::isalnum(ch)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True when `needle` appears in `hay` as a whole alphanumeric token
|
||||
/// (e.g. "mewtwo" in "team gr's mewtwo" / "mewtwo strikes back", but not
|
||||
/// "mew" inside "mewtwo"). ASCII needles only.
|
||||
bool containsWholeAsciiToken(std::string_view hay, std::string_view needle) {
|
||||
if (!isAsciiAlnumToken(needle)) return false;
|
||||
const std::size_t n = needle.size();
|
||||
for (std::size_t i = 0; i + n <= hay.size(); ++i) {
|
||||
if (hay.compare(i, n, needle) != 0) continue;
|
||||
const bool leftOk = i == 0 || !std::isalnum(static_cast<unsigned char>(hay[i - 1]));
|
||||
const bool rightOk =
|
||||
i + n == hay.size() ||
|
||||
!std::isalnum(static_cast<unsigned char>(hay[i + n]));
|
||||
if (leftOk && rightOk) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Result<JapanesePokemonEnCatalog>
|
||||
JapanesePokemonEnCatalog::parse(const std::string& jsonBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(jsonBody);
|
||||
JapanesePokemonEnCatalog out;
|
||||
|
||||
if (j.contains("sets") && j.at("sets").is_object()) {
|
||||
for (auto it = j.at("sets").begin(); it != j.at("sets").end(); ++it) {
|
||||
JapanesePokemonSetEnInfo info;
|
||||
info.nameEn = it.value().value("name_en", "");
|
||||
info.nameJa = it.value().value("name_ja", "");
|
||||
info.releaseDate = it.value().value("releaseDate", "");
|
||||
out.sets_[it.key()] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (j.contains("prints") && j.at("prints").is_array()) {
|
||||
for (const auto& entry : j.at("prints")) {
|
||||
JapanesePokemonPrintEnInfo info;
|
||||
info.setId = entry.value("set_id", "");
|
||||
info.localId = entry.value("local_id", "");
|
||||
info.nameEn = entry.value("name_en", "");
|
||||
info.nameJa = entry.value("name_ja", "");
|
||||
info.nameEnSource = entry.value("name_en_source", "");
|
||||
info.imageUrl = entry.value("image_url", "");
|
||||
if (entry.contains("tcgplayer_id")) {
|
||||
const auto& tp = entry.at("tcgplayer_id");
|
||||
if (tp.is_string()) {
|
||||
info.tcgplayerId = tp.get<std::string>();
|
||||
} else if (tp.is_number_integer()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::int64_t>());
|
||||
} else if (tp.is_number_unsigned()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::uint64_t>());
|
||||
}
|
||||
}
|
||||
if (info.setId.empty() || info.localId.empty()) continue;
|
||||
const std::string key = printKey(info.setId, info.localId);
|
||||
out.printKeysBySet_[info.setId].push_back(key);
|
||||
out.printsByKey_[key] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
return Result<JapanesePokemonEnCatalog>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<JapanesePokemonEnCatalog>::err(
|
||||
std::string("Japanese Pokemon EN catalog JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonSetEnInfo>
|
||||
JapanesePokemonEnCatalog::findSet(std::string_view setId) const {
|
||||
const auto it = sets_.find(std::string(setId));
|
||||
if (it == sets_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrint(std::string_view setId,
|
||||
std::string_view localId) const {
|
||||
const auto it = printsByKey_.find(printKey(setId, localId));
|
||||
if (it == printsByKey_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrintsByName(std::string_view setId,
|
||||
std::string_view cardName) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
if (cardName.empty()) return out;
|
||||
const std::string wanted = asciiLower(std::string(cardName));
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
const auto& p = pit->second;
|
||||
const std::string enLower = asciiLower(p.nameEn);
|
||||
const std::string jaLower = asciiLower(p.nameJa);
|
||||
// Exact, qualified "Mewtwo (...)", or whole-token in a longer title
|
||||
// ("Team GR's Mewtwo", "Mewtwo Strikes Back (...)").
|
||||
if (enLower == wanted || jaLower == wanted ||
|
||||
enLower.starts_with(wanted + " (") ||
|
||||
containsWholeAsciiToken(enLower, wanted) ||
|
||||
containsWholeAsciiToken(jaLower, wanted)) {
|
||||
out.push_back(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noexcept {
|
||||
const auto it = printKeysBySet_.find(std::string(setId));
|
||||
return it != printKeysBySet_.end() && !it->second.empty();
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
|
||||
if (productId.empty()) return {};
|
||||
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
|
||||
std::string(productId) + ".jpg";
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print) {
|
||||
if (!print.imageUrl.empty()) return print.imageUrl;
|
||||
return tcgplayerImageUrl(print.tcgplayerId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
JapanesePokemonGameModule::JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog)
|
||||
: catalog_(std::move(catalog)),
|
||||
setSource_(http, catalog_),
|
||||
previewSource_(http, catalog_) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,207 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
struct ClassicMissingProduct {
|
||||
const char* id;
|
||||
const char* nameEn;
|
||||
const char* releaseDate; // YYYY/MM/DD
|
||||
};
|
||||
|
||||
// Keep in sync with tools/pokemon_jp/classic_missing_sets.json and
|
||||
// docs/assets-and-info-apis.md (Japanese Pokémon Info API).
|
||||
constexpr std::array<ClassicMissingProduct, 11> kMissingClassicProducts{{
|
||||
// Day after Pokémon Jungle (PMCG2, 1997/03/05) so the set list places
|
||||
// Unnumbered Promo immediately after Jungle when sorted by releaseDate.
|
||||
{"UnnumberedPromo", "Unnumbered Promotional cards", "1997/03/06"},
|
||||
{"ExpSheet1", "Expansion Sheet Series 1", "1998/03/23"},
|
||||
{"NiviCG", "Nivi City Gym", "1998/04/26"},
|
||||
{"HanadaCG", "Hanada City Gym", "1998/04/26"},
|
||||
{"ExpSheet2", "Expansion Sheet Series 2", "1998/06/17"},
|
||||
{"KuchibaCG", "Kuchiba City Gym", "1998/07/25"},
|
||||
{"TamamushiCG", "Tamamushi City Gym", "1998/07/25"},
|
||||
{"ExpSheet3", "Expansion Sheet Series 3", "1998/11/24"},
|
||||
{"YamabukiCG", "Yamabuki City Gym", "1999/02/26"},
|
||||
{"GurenTG", "Guren Town Gym", "1999/02/26"},
|
||||
{"SouthernIslands", "Southern Islands", "1999/07/17"},
|
||||
}};
|
||||
|
||||
const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
|
||||
// Field-level corrections for known TCGdex JA mislabels (never edit cache).
|
||||
static const std::unordered_map<std::string, std::string> kOverrides{
|
||||
{"SV4a", "シャイニートレジャーex"},
|
||||
};
|
||||
return kOverrides;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool containsCjk(std::string_view s) noexcept {
|
||||
// Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE3–0xE9).
|
||||
// Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK —
|
||||
// that used to wipe catalog English names back to the set id.
|
||||
for (unsigned char ch : s) {
|
||||
if (ch >= 0xE3 && ch <= 0xE9) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonSetSource::JapanesePokemonSetSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
bool JapanesePokemonSetSource::shouldExcludeSetId(std::string_view setId) noexcept {
|
||||
// Chinese-region CS* entries are mislabeled on the JA endpoint.
|
||||
return setId.size() >= 2 && setId[0] == 'C' && setId[1] == 'S';
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa) {
|
||||
const auto& overrides = setNameJaOverrides();
|
||||
const auto it = overrides.find(std::string(setId));
|
||||
if (it != overrides.end()) return it->second;
|
||||
return nameJa;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>>
|
||||
JapanesePokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"TCGdex JA 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() || shouldExcludeSetId(s.id)) continue;
|
||||
s.name = applySetNameOverride(s.id, entry.value("name", ""));
|
||||
s.releaseDate = {}; // filled from catalog or set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
appendMissingClassicProducts(out);
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("TCGdex JA sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::appendMissingClassicProducts(std::vector<Set>& sets) {
|
||||
for (const auto& product : kMissingClassicProducts) {
|
||||
auto it = std::find_if(sets.begin(), sets.end(), [&](const Set& s) {
|
||||
return s.id == product.id;
|
||||
});
|
||||
if (it != sets.end()) {
|
||||
// Keep curated display name / sort date in sync (e.g. UnnumberedPromo
|
||||
// placement after Pokémon Jungle) even when the id was already cached.
|
||||
it->name = product.nameEn;
|
||||
it->releaseDate = product.releaseDate;
|
||||
continue;
|
||||
}
|
||||
Set s;
|
||||
s.id = product.id;
|
||||
s.name = product.nameEn;
|
||||
s.releaseDate = product.releaseDate;
|
||||
sets.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string>
|
||||
JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex JA 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 JA set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JapanesePokemonSetSource::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) {
|
||||
// Prefer catalog English; never leave Japanese TCGdex names in Set.name
|
||||
// (the set picker must stay English-only).
|
||||
if (auto en = catalog_.findSet(s.id)) {
|
||||
if (!en->nameEn.empty()) s.name = en->nameEn;
|
||||
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
|
||||
}
|
||||
if (s.name.empty() || containsCjk(s.name)) {
|
||||
s.name = s.id;
|
||||
}
|
||||
if (!s.releaseDate.empty()) continue;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
|
||||
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
|
||||
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
|
||||
for (auto& s : sets) {
|
||||
if (auto en = catalog_.findSet(s.id)) {
|
||||
if (!en->nameEn.empty()) s.name = en->nameEn;
|
||||
if (!en->releaseDate.empty() && s.releaseDate.empty()) {
|
||||
s.releaseDate = en->releaseDate;
|
||||
}
|
||||
}
|
||||
if (s.name.empty() || containsCjk(s.name)) {
|
||||
s.name = s.id;
|
||||
}
|
||||
}
|
||||
appendMissingClassicProducts(sets);
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -12,24 +12,59 @@ JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, Dir
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path JsonSetRepository::setsPath(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
|
||||
const fs::path root = fs::path(config_.current().dataStorage) / dirName_(game);
|
||||
switch (game) {
|
||||
case Game::Pokemon: return root / "sets-west.json";
|
||||
case Game::JapanesePokemon: return root / "sets-asia.json";
|
||||
default: return root / "sets.json";
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
fs::path JsonSetRepository::legacySetsPath(Game game) const {
|
||||
const fs::path dataRoot(config_.current().dataStorage);
|
||||
switch (game) {
|
||||
case Game::Pokemon:
|
||||
// Pre-flatten: pokemon/sets.json
|
||||
return dataRoot / "pokemon" / "sets.json";
|
||||
case Game::JapanesePokemon:
|
||||
// Pre-flatten: pokemonjp/sets.json
|
||||
return dataRoot / "pokemonjp" / "sets.json";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::parseSetsText(const std::string& text) const {
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
auto j = nlohmann::json::parse(text);
|
||||
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (fs_.exists(p)) {
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
return parseSetsText(text.value());
|
||||
}
|
||||
|
||||
const auto legacy = legacySetsPath(game);
|
||||
if (!legacy.empty() && fs_.exists(legacy)) {
|
||||
auto text = fs_.readText(legacy);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
auto parsed = parseSetsText(text.value());
|
||||
if (!parsed) return parsed;
|
||||
// Best-effort promote to the new path; UI still gets the sets if write fails.
|
||||
(void)save(game, parsed.value());
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
}
|
||||
|
||||
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
|
||||
const auto p = setsPath(game);
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
|
||||
@@ -47,6 +47,7 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
if (containsLower(to_string(card.region), needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -82,4 +83,20 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||
std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -36,11 +37,18 @@ std::string makeUrlKey(std::string_view url) {
|
||||
return k;
|
||||
}
|
||||
|
||||
constexpr std::string_view kAssetScheme = "asset:";
|
||||
|
||||
} // namespace
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache)
|
||||
: http_(http), persistentCache_(persistentCache) {}
|
||||
IPreviewByteCache* persistentCache,
|
||||
IFileSystem* fs,
|
||||
std::filesystem::path assetRoot)
|
||||
: http_(http),
|
||||
persistentCache_(persistentCache),
|
||||
fs_(fs),
|
||||
assetRoot_(std::move(assetRoot)) {}
|
||||
|
||||
void CardPreviewService::registerModule(IGameModule& module) {
|
||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
||||
@@ -122,6 +130,37 @@ Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKe
|
||||
return Result<std::string>::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
|
||||
const std::string& cacheKey,
|
||||
std::string_view assetUrl) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
if (fs_ == nullptr || assetRoot_.empty()) {
|
||||
return R::err({K::Transient, "Asset preview path is not configured."});
|
||||
}
|
||||
if (!assetUrl.starts_with(kAssetScheme)) {
|
||||
return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."});
|
||||
}
|
||||
std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size())));
|
||||
const auto fullPath = assetRoot_ / rel;
|
||||
auto bytes = fs_->readText(fullPath);
|
||||
if (!bytes) {
|
||||
return R::err({K::NotFound,
|
||||
"Bundled preview asset not found: " + fullPath.generic_string()});
|
||||
}
|
||||
std::string payload = std::move(bytes).value();
|
||||
if (payload.empty()) {
|
||||
return R::err({K::NotFound,
|
||||
"Bundled preview asset is empty: " + fullPath.generic_string()});
|
||||
}
|
||||
cacheStore(cacheKey, payload);
|
||||
if (persistentCache_ != nullptr) {
|
||||
persistentCache_->store(cacheKey, payload);
|
||||
}
|
||||
return R::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
@@ -179,6 +218,18 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
if (url.value().starts_with(kAssetScheme)) {
|
||||
auto asset = fetchAssetAndCache(key, url.value());
|
||||
if (!asset) {
|
||||
const auto err = std::move(asset).error();
|
||||
if (err.kind == PreviewLookupError::Kind::NotFound) {
|
||||
cacheStoreNegative(key);
|
||||
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
return Result<std::string>::ok(std::move(asset).value());
|
||||
}
|
||||
return fetchAndCache(key, url.value());
|
||||
}
|
||||
|
||||
@@ -231,6 +282,11 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
|
||||
return Result<std::string>::ok(disk.payload);
|
||||
}
|
||||
}
|
||||
if (url.starts_with(kAssetScheme)) {
|
||||
auto asset = fetchAssetAndCache(key, url);
|
||||
if (!asset) return Result<std::string>::err(asset.error().message);
|
||||
return Result<std::string>::ok(std::move(asset).value());
|
||||
}
|
||||
return fetchAndCache(key, url);
|
||||
}
|
||||
|
||||
|
||||
@@ -293,4 +293,74 @@ void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
}
|
||||
}
|
||||
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case JapanesePokemonSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -21,7 +21,13 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::getSets(Game game) {
|
||||
return repo_.load(game);
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return loaded;
|
||||
auto it = modules_.find(game);
|
||||
if (it != modules_.end() && it->second != nullptr) {
|
||||
it->second->setSource().augmentCachedSets(loaded.value());
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
Reference in New Issue
Block a user