Minor: Add Asian Pokemon Card Support (#18)

This commit is contained in:
Sebastian Dine
2026-07-22 11:13:42 +02:00
committed by GitHub
parent e5c830e945
commit c9e6bc2b6b
87 changed files with 78068 additions and 162 deletions
+3 -3
View File
@@ -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
+5
View File
@@ -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
)
+25 -6
View File
@@ -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
+3 -1
View File
@@ -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;
};
+4
View File
@@ -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
+5 -1
View File
@@ -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
+3 -1
View File
@@ -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"
+6 -1
View File
@@ -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
+18
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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>());
+39
View File
@@ -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
+3
View File
@@ -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 0xE30xE9).
// 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
+43 -8
View File
@@ -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());
+17
View File
@@ -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
+58 -2
View File
@@ -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);
}
+70
View File
@@ -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
+7 -1
View File
@@ -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