minor: yugioh support added

This commit is contained in:
Sebastian Dine
2026-05-09 19:32:18 +02:00
committed by GitHub
parent 6f575f4cec
commit 6ff4406638
68 changed files with 4994 additions and 134 deletions
+7 -4
View File
@@ -4,11 +4,11 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `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`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `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`.
- `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/` and `pokemon/` are the reference implementations — both expose a fully working set source + card preview source.
- `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/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`).
- `src/` mirrors `include/ccm/` for non-template implementations.
@@ -25,6 +25,9 @@
6. **Compiler warnings**: every target in this package links `ccm_warnings` `PRIVATE`. Treat warnings as errors locally during dev (`-Werror` is opt-in but encouraged).
7. **No `wx/...` includes** in headers or sources here. Verify with `rg "wx/" core/` — must be empty.
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`) 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
@@ -6,6 +6,7 @@ add_library(ccm_core STATIC
src/domain/Set.cpp
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/Configuration.cpp
src/services/ConfigService.cpp
@@ -19,6 +20,7 @@ add_library(ccm_core STATIC
src/infra/StdFileSystem.cpp
src/infra/JsonSetRepository.cpp
src/infra/LocalImageStore.cpp
src/infra/LocalPreviewByteCache.cpp
src/games/magic/MagicSetSource.cpp
src/games/magic/MagicCardPreviewSource.cpp
@@ -26,6 +28,9 @@ add_library(ccm_core STATIC
src/games/pokemon/PokemonSetSource.cpp
src/games/pokemon/PokemonCardPreviewSource.cpp
src/games/pokemon/PokemonGameModule.cpp
src/games/yugioh/YuGiOhSetSource.cpp
src/games/yugioh/YuGiOhCardPreviewSource.cpp
src/games/yugioh/YuGiOhGameModule.cpp
src/util/FsNames.cpp
)
+2 -1
View File
@@ -18,6 +18,7 @@ namespace ccm {
enum class Game {
Magic,
Pokemon,
YuGiOh,
};
enum class Language {
@@ -56,7 +57,7 @@ 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;
const std::array<Game, 2>& allGames() noexcept;
const std::array<Game, 3>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// YuGiOhCard - Yu-Gi-Oh card model with print-level metadata.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct YuGiOhCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string rarity;
std::string rarityCode;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const YuGiOhCard&, const YuGiOhCard&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCard& c);
void from_json(const nlohmann::json& j, YuGiOhCard& c);
} // namespace ccm
@@ -19,9 +19,10 @@ class MagicCardPreviewSource final : public ICardPreviewSource {
public:
explicit MagicCardPreviewSource(IHttpClient& http);
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
// Build the fully URL-encoded Scryfall search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
@@ -29,11 +30,13 @@ public:
std::string_view setId);
// Parse a Scryfall /cards/search response body and pull out the
// `data[0].image_uris.normal` URL. Returns an error result when no
// matching printing is found, when the JSON is malformed, or when the
// entry has no top-level `image_uris` (double-faced cards expose them
// on a face object - no fallback in this compatibility behavior either).
static Result<std::string> parseResponse(const std::string& body);
// `data[0].image_uris.normal` URL. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array, missing top-level `image_uris`, or missing
// `image_uris.normal` => NotFound (the upstream answered, but the
// printing simply has no preview we can use).
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
private:
IHttpClient& http_;
@@ -19,9 +19,10 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
@@ -31,9 +32,11 @@ public:
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`, and returns an error result if neither is present, the
// data array is empty, or the JSON is malformed.
static Result<std::string> parseResponse(const std::string& body);
// `images.small`. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array or missing image variants => NotFound.
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
private:
IHttpClient& http_;
@@ -0,0 +1,123 @@
#pragma once
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
// YuGiOhCardPreviewSource - resolves preview images for Yu-Gi-Oh! cards.
//
// The image-preview path is backed by Yugipedia's MediaWiki API
// (https://yugipedia.com/api.php). Yugipedia hosts actual per-printing card
// scans, with deterministic file names of the shape
// `<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<ext>` (e.g.
// `BlueEyesWhiteDragon-LOB-EN-UR-UE.png` vs `BlueEyesWhiteDragon-SDK-NA-UR-UE.png`),
// which lets us return the right artwork for printings that share a passcode
// but have visibly different art - a case YGOPRODeck cannot disambiguate (its
// card_images array is keyed by art-treatment passcode, not by physical
// printing).
//
// The auto-detect-first-print path keeps using YGOPRODeck (`cardinfo.php`):
// that endpoint returns a richer set listing (with rarities and release
// dates) than Yugipedia, and we don't need image data for it.
//
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
// stored Language. Localized scans are intentionally not queried so the user
// sees a consistent, well-stocked gallery (EN scans are the most complete).
class YuGiOhCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhCardPreviewSource(IHttpClient& http);
[[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;
// ---- Yugipedia helpers (image preview path) ----------------------------
// Build the list of candidate Yugipedia file names to try, in priority
// order (most likely first). Always uses English regions; the caller may
// pass an empty rarityCode when the rarity is unknown, in which case the
// returned list will skip rarity in the filename.
static std::vector<std::string> buildCandidateFilenames(
std::string_view name,
std::string_view setCode,
std::string_view rarityCode,
bool firstEdition);
// Build a single MediaWiki batch query URL that asks for imageinfo.url
// for every filename. MediaWiki's `titles=` parameter joins page titles
// with `|`, so we issue exactly one HTTP call per preview lookup.
static std::string buildYugipediaQueryUrl(
const std::vector<std::string>& filenames);
// Parse a MediaWiki `query.pages` response and return the resolved URL of
// the first filename in `filenameOrder` that exists. Missing pages have
// the `missing` marker (no `imageinfo`); existing pages carry an
// `imageinfo[0].url` we forward verbatim. Errors are classified:
// - JSON parse failure or schema deviation => Transient.
// - Every candidate came back missing => NotFound.
static Result<std::string, PreviewLookupError> parseYugipediaResponse(
const std::string& body,
const std::vector<std::string>& filenameOrder);
// Strip a card name down to Yugipedia's image-slug shape: alphanumerics
// (and parentheses) only, no whitespace, no policy-banned punctuation.
static std::string normalizeName(std::string_view name);
// Map a CCM3 rarity name (e.g. "Ultra Rare") to the Yugipedia rarity
// code used in image filenames (e.g. "UR"). Returns an empty string when
// the rarity is unknown; the caller treats that as "skip rarity".
static std::string rarityCodeFor(std::string_view rarityName);
// Pull the set abbreviation out of a CCM3 setNo such as "LOB-005" or
// "LOB-DE005" - in both cases we want "LOB". Returns the trimmed input
// unchanged if no dash is present.
static std::string extractSetCode(std::string_view setNo);
// ---- YGOPRODeck helpers (auto-detect path + fallback) ------------------
// Build a fuzzy-name `cardinfo.php` URL. `setName` may be empty for an
// unfiltered fuzzy lookup. Used by detectFirstPrint and by the
// standard-art fallback when Yugipedia has no scan for this printing.
static std::string buildSearchUrl(std::string_view name,
std::string_view setName);
// Pick the standard artwork (card_images[0]) from a YGOPRODeck response,
// preferring the exact-name match. Used only as a last-resort fallback
// when Yugipedia returns nothing for any of our candidate filenames.
// Errors are classified:
// - JSON parse failure or schema deviation => Transient.
// - Empty `data` array, or matched cards without a usable image
// variant => NotFound.
static Result<std::string, PreviewLookupError>
parseFallbackImageUrl(const std::string& body, std::string_view name);
// Pick the first printing for `preferredSetName` from a YGOPRODeck
// response. Drives the "Auto detect" button in the YGO edit dialog.
static Result<AutoDetectedPrint> parseFirstPrint(const std::string& body,
std::string_view preferredSetName);
// Every `(set_code, set_rarity)` pair for cards whose name matches
// `wantedCardName` (case-insensitive). When `wantedCardName` is empty,
// scans every row in `data[]` like `parseFirstPrint` did historically.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view preferredSetName,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,25 @@
#pragma once
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
namespace ccm {
class YuGiOhGameModule final : public IGameModule {
public:
explicit YuGiOhGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOh; }
[[nodiscard]] std::string dirName() const override { return "yugioh"; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
YuGiOhSetSource setSource_;
YuGiOhCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,23 @@
#pragma once
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
namespace ccm {
class YuGiOhSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
explicit YuGiOhSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
static Result<std::vector<Set>> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
+14
View File
@@ -7,17 +7,31 @@
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
#include <memory>
#include <mutex>
namespace cpr { class Session; }
namespace ccm {
// Concrete IHttpClient backed by libcpr/libcurl. The single owned
// `cpr::Session` keeps libcurl's connection pool alive across calls, so
// repeat HTTPS requests to the same host (api.scryfall.com, yugipedia.com,
// ms.yugipedia.com, …) reuse the existing TLS connection instead of paying
// for a fresh handshake every time. Concurrent calls are serialized through
// a mutex - libcurl easy handles are not thread-safe, and the preview path
// only fires one outbound request at a time anyway.
class CprHttpClient final : public IHttpClient {
public:
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
~CprHttpClient() override;
Result<std::string> get(std::string_view url) override;
private:
std::chrono::milliseconds timeout_;
std::unique_ptr<cpr::Session> session_;
std::mutex sessionMutex_;
};
} // namespace ccm
@@ -0,0 +1,85 @@
#pragma once
// LocalPreviewByteCache - on-disk byte cache for CardPreviewService.
//
// Layout under the configured cache directory (composition root passes
// `<exeDir>/.cache/preview-cache/` - next to the executable, NOT under
// the user-configurable `dataStorage` path; see `docs/caching.md` and
// `app/AGENTS.md` for the rationale):
// <hash>.bin raw image bytes (PNG/JPEG payload), positive entries only
// <hash>.neg zero-byte marker file, negative entries only
// <hash>.idx one-line text sidecar holding the original cache key,
// used to detect (and reject) hash collisions so we never
// serve the wrong card's image and never honor a stale
// negative entry across collisions
//
// Positive vs. negative entries are mutually exclusive for a given hash:
// `store` removes any existing `.neg`, `storeNegative` removes any existing
// `.bin`, and `load` prefers `.bin` on the off chance both somehow co-exist.
//
// The cache is bounded by total payload bytes (sum of `.bin` sizes). When
// `store` would push it past the cap we evict by file mtime (oldest first)
// until back under the cap; the `.idx` sidecar of an evicted entry is
// removed too. Negative entries are tiny (effectively `.idx` only) and are
// not subject to the byte cap directly - their count is naturally bounded
// by the user's collection size since a negative entry only ever exists
// for a card the user has actually looked at and the upstream answered
// "no image" for. Reads update mtime via a touch on hit so frequently-
// viewed cards survive eviction.
//
// All filesystem mutations go through `IFileSystem` (so the in-memory
// fake works in tests). Size and mtime queries - which the port does not
// expose - use `std::filesystem` directly inside this adapter. Tests that
// need to drive eviction stay easy to write: just call `store` past the cap
// and check the survivors.
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include <cstddef>
#include <filesystem>
#include <mutex>
#include <string>
#include <string_view>
namespace ccm {
class LocalPreviewByteCache final : public IPreviewByteCache {
public:
// Default soft cap: ~64 MiB. A typical preview is 80-200 KiB, so this
// holds several hundred cards comfortably while keeping disk usage
// bounded for users with very large collections.
static constexpr std::size_t kDefaultMaxBytes = 64ull * 1024 * 1024;
LocalPreviewByteCache(IFileSystem& fs,
std::filesystem::path cacheDir,
std::size_t maxBytes = kDefaultMaxBytes);
[[nodiscard]] LoadResult load(std::string_view key) override;
void store(std::string_view key, const std::string& payload) override;
void storeNegative(std::string_view key) override;
// Test-visible knob: total payload bytes currently on disk (recomputed
// from the directory listing so it stays accurate after external
// tampering). Negative-entry markers do not count toward the total.
[[nodiscard]] std::size_t currentSizeBytes();
private:
std::filesystem::path payloadPath(const std::string& hash) const;
std::filesystem::path negativePath(const std::string& hash) const;
std::filesystem::path indexPath(const std::string& hash) const;
// Hex-encoded FNV-1a 64-bit hash of the key. We don't need cryptographic
// strength; the sidecar `.idx` file rejects collisions on load so the
// worst case is a one-time cache miss.
static std::string hashKey(std::string_view key);
void evictIfNeededLocked(std::size_t incomingBytes);
IFileSystem& fs_;
std::filesystem::path cacheDir_;
std::size_t maxBytes_;
std::mutex mutex_;
};
} // namespace ccm
+57 -3
View File
@@ -12,9 +12,38 @@
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct AutoDetectedPrint {
std::string setNo;
std::string rarity;
};
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
// drives caching policy in CardPreviewService:
//
// NotFound -- the upstream answered cleanly that the card has no image
// (or no matching record at all). Safe to remember: the
// answer will not change until the user edits the card
// record itself, which automatically invalidates the cache
// key. Negative-cached so subsequent selections show the
// fallback card-back instantly without another HTTP call.
//
// Transient -- the upstream did not answer cleanly (HTTP / network /
// timeout failure, malformed response, parse error). The
// record may well have an image; we just couldn't see it
// this time. NOT cached, so the next selection retries.
//
// The `message` is opaque to the service and is forwarded to the UI as
// the existing free-form `Result<std::string>::error()` string.
struct PreviewLookupError {
enum class Kind { NotFound, Transient };
Kind kind{Kind::Transient};
std::string message;
};
class ICardPreviewSource {
public:
virtual ~ICardPreviewSource() = default;
@@ -22,9 +51,34 @@ public:
// Resolve the preview image URL for a single card. `setNo` is optional
// (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as
// a more precise lookup key, others (Magic/Scryfall) ignore it.
virtual Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) = 0;
//
// Errors carry a classification (`PreviewLookupError::Kind`) so
// CardPreviewService can decide whether to remember the miss
// (`NotFound`) or retry on the next call (`Transient`). See the doc
// comment on PreviewLookupError above for the exact contract.
virtual Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) = 0;
// Opt-in switch for per-game print metadata detection.
[[nodiscard]] virtual bool supportsAutoDetectPrint() const noexcept { return false; }
// Optional metadata lookup used by game-specific edit dialogs. The default
// implementation returns an explicit "unsupported" error so games without
// print metadata APIs do not need to override it.
virtual Result<AutoDetectedPrint> detectFirstPrint(std::string_view /*name*/,
std::string_view /*setId*/) {
return Result<AutoDetectedPrint>::err("Auto-detect not supported by this game.");
}
// Optional listing of every distinct `(set_code, rarity)` print returned by
// the upstream for an exact card name inside the chosen display set.
virtual Result<std::vector<AutoDetectedPrint>>
detectPrintVariants(std::string_view /*name*/, std::string_view /*setId*/) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Print variant listing not supported by this game.");
}
};
} // namespace ccm
@@ -0,0 +1,75 @@
#pragma once
// IPreviewByteCache - persistent byte cache used by CardPreviewService to
// keep preview images alive across app restarts.
//
// The cache is keyed by an opaque string. CardPreviewService composes the
// key from `(game, name, setId, setNo)` (preview lookups) or directly from
// the URL (per-game card-back fallback fetches); the cache itself does not
// interpret the key, only stores the byte payload behind it.
//
// Two kinds of entries are persisted:
//
// * Positive entries hold raw image bytes. Stored via `store(key, payload)`,
// returned as `LoadResult{HitKind::Hit, payload}`.
// * Negative entries record "we tried to resolve this exact card and the
// upstream answered cleanly that it has no preview image" - i.e. the
// `NotFound` half of `PreviewLookupError`. Stored via
// `storeNegative(key)`, returned as `LoadResult{HitKind::NegativeHit, {}}`.
// `Transient` errors (HTTP / network / parse failures) must NEVER reach
// this cache: we cannot tell whether the record genuinely has no image
// or just couldn't be reached, and persisting the miss would leave the
// user staring at the card-back placeholder until they edit the card.
//
// A negative entry is implicitly invalidated when the cache key changes -
// since the key includes `(game, name, setId, setNo)` (with game-specific
// disambiguators packed into setNo), any edit that affects a lookup-relevant
// field will hit a fresh key and re-attempt the network lookup automatically.
//
// Implementations must be thread-safe with respect to concurrent load/store
// calls because CardPreviewService is invoked from a worker thread spawned
// by `BaseSelectedCardPanel`.
//
// Errors are intentionally swallowed (load returns Miss; store and
// storeNegative are fire-and-forget). A flaky or full disk must never break
// the preview path - in the worst case the user sees the same speed as a
// fresh app install.
#include <string>
#include <string_view>
namespace ccm {
class IPreviewByteCache {
public:
enum class HitKind {
Miss, // no entry for this key (or unrecoverable I/O error)
Hit, // positive entry; bytes are in `payload`
NegativeHit, // negative entry; `payload` is empty by contract
};
struct LoadResult {
HitKind kind{HitKind::Miss};
std::string payload; // only meaningful when kind == Hit
};
virtual ~IPreviewByteCache() = default;
// Returns the cached entry for `key`. On any error - missing files,
// sidecar mismatch, malformed metadata, I/O failure - implementations
// must report `HitKind::Miss` rather than surfacing the error.
[[nodiscard]] virtual LoadResult load(std::string_view key) = 0;
// Best-effort persist of `payload` under `key`. Empty payloads are not
// stored as positive entries. If a negative entry already exists for
// this key it is replaced. Errors are swallowed.
virtual void store(std::string_view key, const std::string& payload) = 0;
// Best-effort persist of "we tried, upstream cleanly said no image".
// If a positive entry already exists for this key it is replaced.
// Errors are swallowed. Must be invoked ONLY for `NotFound`-class
// outcomes; never for transient failures.
virtual void storeNegative(std::string_view key) = 0;
};
} // namespace ccm
+3
View File
@@ -21,6 +21,7 @@
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <string_view>
@@ -37,5 +38,7 @@ namespace ccm {
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
std::string_view filter);
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
std::string_view filter);
} // namespace ccm
@@ -16,17 +16,23 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include "ccm/util/Result.hpp"
#include <cstddef>
#include <list>
#include <mutex>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace ccm {
class CardPreviewService {
public:
explicit CardPreviewService(IHttpClient& http);
explicit CardPreviewService(IHttpClient& http,
IPreviewByteCache* persistentCache = nullptr);
// Register a game module's preview source. Calling this with a module
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
@@ -38,18 +44,87 @@ public:
// The returned `std::string` is a raw byte buffer (PNG/JPEG payload) -
// it is NOT decoded text. Use std::string::data()/size() with whatever
// image-decoding facility your UI provides.
//
// Successful results are cached in two tiers, both keyed by
// (game, name, setId, setNo):
// 1. In-memory LRU (bounded by `kCacheCapacity`) for instant hits
// while the app is running.
// 2. Optional persistent byte cache (passed at construction) so
// previews survive app restarts.
// Re-selecting the same row is then a memcpy away from the wxImage
// decoder, no HTTP at all - this is the common user-facing case
// (clicking around the table).
//
// Failures are split into two policies based on
// `PreviewLookupError::Kind`:
// * `NotFound` (the upstream answered cleanly that this record has
// no preview) is *negative-cached* in both tiers, so subsequent
// selections short-circuit without touching the network. The
// cache key is invalidated automatically when the user edits a
// lookup-relevant field of the record.
// * `Transient` (HTTP / network / parse failure) is NEVER cached, so
// the next selection retries cleanly once connectivity is back.
Result<std::string> fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo);
Result<AutoDetectedPrint> detectFirstPrint(Game game,
std::string_view name,
std::string_view setId);
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(Game game,
std::string_view name,
std::string_view setId);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution.
// per-game preview-source resolution. Cached by URL (same LRU bound).
Result<std::string> fetchImageBytesByUrl(std::string_view url);
// Maximum number of cached preview entries kept in memory. Picked so a
// typical Yu-Gi-Oh! collection page can scroll up and down without
// re-hitting the network, while keeping a hard upper bound on RSS for
// very large collections (each entry is roughly one PNG, <100 KiB).
static constexpr std::size_t kCacheCapacity = 128;
private:
IHttpClient& http_;
enum class CacheLookupKind {
Miss, // not in the in-memory tier
Hit, // positive entry; bytes returned via outPayload
NegativeHit, // negative entry; outPayload is empty
};
Result<std::string> fetchAndCache(const std::string& cacheKey,
std::string_view url);
// Returns the kind of in-memory cache entry for `key`. On Hit the
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
// cleared. Both Hit and NegativeHit move the entry to the front of
// the LRU.
CacheLookupKind cacheLookup(const std::string& key, std::string& outPayload);
void cacheStore(const std::string& key, std::string payload);
void cacheStoreNegative(const std::string& key);
IHttpClient& http_;
IPreviewByteCache* persistentCache_{nullptr};
std::unordered_map<Game, ICardPreviewSource*> sources_;
// LRU: list holds entries in MRU-first order; map points at list nodes
// for O(1) move-to-front. Mutex covers both list and map - lookups
// happen on a worker thread spawned by BaseSelectedCardPanel.
//
// A `negative` entry has an empty payload by convention; we keep the
// flag explicit (rather than abusing emptiness) so future invariants
// around eviction or stats stay easy to reason about.
struct CacheEntry {
std::string key;
std::string payload;
bool negative{false};
};
using CacheList = std::list<CacheEntry>;
CacheList cacheList_;
std::unordered_map<std::string, CacheList::iterator> cacheIndex_;
std::mutex cacheMutex_;
};
} // namespace ccm
+15
View File
@@ -18,6 +18,7 @@
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <vector>
@@ -52,11 +53,25 @@ enum class PokemonSortColumn {
Note,
};
enum class YuGiOhSortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
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,
bool ascending);
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
bool ascending);
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
bool ascending);
} // namespace ccm
@@ -0,0 +1,72 @@
#pragma once
// Yu-Gi-Oh! collector slot equivalence for UI + metadata matching.
//
// The edit dialog composes `setNo` as `<set.id>-<digits>` using only numeric
// characters from the text field (e.g. SOD + "015" -> "SOD-015"). YGOPRODeck
// `set_code` values often embed region letters ("SOD-EN015"). Exact string
// compare would miss that both refer to the same slot.
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
[[nodiscard]] inline std::string_view trimAsciiSpaces(std::string_view s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.remove_prefix(1);
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
s.remove_suffix(1);
}
return s;
}
[[nodiscard]] inline std::string ygoAbbrevBeforeDash(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
const auto dash = s.find('-');
const std::string_view pref = dash == std::string_view::npos ? s : s.substr(0, dash);
std::string out(pref);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return out;
}
[[nodiscard]] inline std::string ygoCollectorDigitsOnly(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
const auto dash = s.find('-');
const std::string_view tail =
dash == std::string_view::npos ? std::string_view{} : s.substr(dash + 1);
std::string out;
out.reserve(tail.size());
for (unsigned char c : tail) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
// True when both strings designate the same printed slot: same abbreviation
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
// extracted from everything after that dash.
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b);
}
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
// the same English `set_name` (e.g. Dark Magician as "LOB-E003" vs NA "LOB-005").
// The suffix uses a single leading `E` immediately followed by digits — distinct
// from two-letter regions such as "EN" ("LOB-EN005") or "DE" ("LOB-DE005").
[[nodiscard]] inline bool ygoLikelyEuropeanRegionalSetCode(std::string_view setCode) {
const std::string_view s = trimAsciiSpaces(setCode);
const auto dash = s.find('-');
if (dash == std::string_view::npos || dash + 2 >= s.size()) return false;
const std::string_view tail = s.substr(dash + 1);
return tail.size() >= 2 && tail[0] == 'E'
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
}
} // namespace ccm
+4 -2
View File
@@ -9,6 +9,7 @@ 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";
}
return "Magic";
}
@@ -51,6 +52,7 @@ 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;
return std::nullopt;
}
@@ -83,8 +85,8 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 2>& allGames() noexcept {
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
const std::array<Game, 3>& allGames() noexcept {
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
return v;
}
+42
View File
@@ -0,0 +1,42 @@
#include "ccm/domain/YuGiOhCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const YuGiOhCard& 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},
{"rarity", c.rarity},
{"rarityCode", c.rarityCode},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, YuGiOhCard& 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("rarity").get_to(c.rarity);
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
else c.rarityCode.clear();
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+20 -11
View File
@@ -62,38 +62,47 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
}
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
Result<std::string, PreviewLookupError>
MagicCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::string>::err("Scryfall response missing 'data' array.");
// Treat schema deviation as transient: the API contract failed,
// not the user's record. Scryfall returns a JSON error object
// here on outage, which is rare but not stable.
return R::err({K::Transient, "Scryfall response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Scryfall returned no matching cards.");
return R::err({K::NotFound, "Scryfall returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
// Double-faced cards expose image_uris on each face; there is no
// fallback for this and surfaces it as "no preview".
return Result<std::string>::err("Card has no top-level image_uris.");
return R::err({K::NotFound, "Card has no top-level image_uris."});
}
const auto& uris = first.at("image_uris");
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
return Result<std::string>::err("Card has no 'normal' image variant.");
return R::err({K::NotFound, "Card has no 'normal' image variant."});
}
return Result<std::string>::ok(uris.at("normal").get<std::string>());
return R::ok(uris.at("normal").get<std::string>());
} catch (const std::exception& e) {
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
return R::err({K::Transient, std::string("Scryfall JSON parse error: ") + e.what()});
}
}
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view /*setNo*/) {
Result<std::string, PreviewLookupError>
MagicCardPreviewSource::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 url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
}
@@ -70,40 +70,46 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
}
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return Result<std::string>::err("Card has no 'images' object.");
return R::err({K::NotFound, "Card has no 'images' object."});
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return Result<std::string>::ok(images.at("large").get<std::string>());
return R::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return Result<std::string>::ok(images.at("small").get<std::string>());
return R::ok(images.at("small").get<std::string>());
}
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
}
}
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::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 url = buildSearchUrl(name, setId, setNo);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
}
@@ -0,0 +1,565 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cctype>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace ccm {
namespace {
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
// here so the YGO and Magic code paths can drift independently if the future
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
// for the `|` separator inside `titles=`).
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
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 toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
// always return card_images[0]: when no `cardset=` filter is applied, that
// slot is the original/standard artwork (alt-art passcodes follow), which is
// the closest fallback we have when Yugipedia has no scan for this printing.
std::string imageFromCard(const nlohmann::json& card) {
if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) {
return {};
}
const auto& first = card.at("card_images").at(0);
if (first.contains("image_url") && first.at("image_url").is_string()) {
return first.at("image_url").get<std::string>();
}
if (first.contains("image_url_small") && first.at("image_url_small").is_string()) {
return first.at("image_url_small").get<std::string>();
}
if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) {
return first.at("image_url_cropped").get<std::string>();
}
return {};
}
// Split a setNo encoded by the UI as `<setNo>||<rarity>||<edition>` into its
// three positional fields. Any missing trailing field becomes an empty
// string, so older callers that pass just `<setNo>` keep working.
struct ParsedSetNo {
std::string setNo;
std::string rarity;
std::string edition; // "1E" / "UE" / "" (unknown)
};
ParsedSetNo parseSetNoTuple(std::string_view raw) {
std::string s(raw);
ParsedSetNo p;
const auto a = s.find("||");
if (a == std::string::npos) {
p.setNo = trim(std::move(s));
return p;
}
p.setNo = trim(s.substr(0, a));
std::string rest = s.substr(a + 2);
const auto b = rest.find("||");
if (b == std::string::npos) {
p.rarity = trim(std::move(rest));
return p;
}
p.rarity = trim(rest.substr(0, b));
p.edition = trim(rest.substr(b + 2));
return p;
}
} // namespace
YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {}
// ============================================================================
// Yugipedia (image-preview path)
// ============================================================================
std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
// Yugipedia's image policy strips whitespace and a fixed set of
// punctuation from the displayed card name to produce the file slug.
// Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy
std::string out;
out.reserve(name.size());
for (unsigned char c : name) {
if (c <= 0x20) continue; // whitespace, including non-breaking
switch (c) {
case '#': case ',': case '.': case ':': case '\'': case '"':
case '?': case '!': case '&': case '@': case '%': case '=':
case '[': case ']': case '<': case '>': case '/': case '\\':
case '-': case '*': case ';': case '`':
continue;
default:
break;
}
out.push_back(static_cast<char>(c));
}
return out;
}
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
// Compare case-insensitively, ignoring whitespace, against a table of
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
// plus a few extras occasionally seen in imported collections. The codes
// are the ones Yugipedia uses in image filenames.
std::string lc;
lc.reserve(rarityName.size());
for (unsigned char c : rarityName) {
if (std::isspace(c)) continue;
lc.push_back(static_cast<char>(std::tolower(c)));
}
static const std::array<std::pair<std::string_view, std::string_view>, 32> kTable = {{
{"common", "C"},
{"shortprint", "SP"},
{"supershortprint", "SSP"},
{"normalrare", "NR"},
{"rare", "R"},
{"superrare", "SR"},
{"ultrarare", "UR"},
{"ultimaterare", "UtR"},
{"secretrare", "ScR"},
{"prismaticsecretrare", "PScR"},
{"extrasecretrare", "EScR"},
{"ultrasecretrare", "UScR"},
{"platinumsecretrare", "PtScR"},
{"goldsecretrare", "GScR"},
{"ghostrare", "GR"},
{"goldrare", "GUR"},
{"premiumgoldrare", "PGR"},
{"goldenrare", "GUR"},
{"starfoilrare", "SFR"},
{"shatterfoilrare", "SHR"},
{"mosaicrare", "MSR"},
{"parallelrare", "PR"},
{"superparallelrare", "SPR"},
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "ColR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
{"prismaticredsecretrare", "PRScR"},
{"silverletter", "SLR"},
}};
for (const auto& [k, v] : kTable) {
if (lc == k) return std::string(v);
}
return {};
}
std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) {
std::string s(setNo);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
const auto dash = s.find('-');
if (dash == std::string::npos) return s;
return s.substr(0, dash);
}
std::vector<std::string> YuGiOhCardPreviewSource::buildCandidateFilenames(
std::string_view name,
std::string_view setCode,
std::string_view rarityCode,
bool firstEdition) {
std::vector<std::string> out;
const std::string slug = normalizeName(name);
if (slug.empty() || setCode.empty()) return out;
// English-only region candidates, in rough usage order: EN is the
// current default, NA was used on most LOB-era prints, EU/AU show up
// sporadically. Always English regardless of the card's stored Language.
static constexpr std::array<std::string_view, 4> kRegions =
{"EN", "NA", "EU", "AU"};
// Edition candidates: prefer the printed edition the user has, then
// try the opposite, then fall back to LE for promo-type prints.
std::array<std::string_view, 3> editions = {"", "", "LE"};
if (firstEdition) {
editions[0] = "1E";
editions[1] = "UE";
} else {
editions[0] = "UE";
editions[1] = "1E";
}
// Two extension variants: Yugipedia has a mix of .png (modern) and .jpg
// (older uploads) for the same era. Both are common for LOB-era cards.
static constexpr std::array<std::string_view, 2> kExts = {"png", "jpg"};
auto pushCombos = [&](std::string_view rarity) {
for (auto edition : editions) {
for (auto region : kRegions) {
for (auto ext : kExts) {
std::string fn;
fn.reserve(slug.size() + setCode.size() + 16);
fn += slug;
fn += '-'; fn.append(setCode);
fn += '-'; fn.append(region);
if (!rarity.empty()) {
fn += '-'; fn.append(rarity);
}
fn += '-'; fn.append(edition);
fn += '.'; fn.append(ext);
out.push_back(std::move(fn));
}
}
}
};
// Primary attempts include the rarity slot. If we don't know the rarity
// we skip straight to the rarity-less fallback (some sets are uniform
// rarity and the upload omits the slot).
if (!rarityCode.empty()) {
pushCombos(rarityCode);
}
pushCombos("");
return out;
}
std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
const std::vector<std::string>& filenames) {
// MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded).
// One HTTP call returns imageinfo for every page whose file exists; the
// missing ones come back tagged with `"missing": ""`.
std::string joined;
for (size_t i = 0; i < filenames.size(); ++i) {
if (i > 0) joined += "|";
joined += "File:";
joined += filenames[i];
}
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += urlEncode(joined);
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaResponse(
const std::string& body,
const std::vector<std::string>& filenameOrder) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query' object."});
}
const auto& pages = j.at("query").value("pages", nlohmann::json::object());
if (!pages.is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query.pages'."});
}
// Build a name->URL map. MediaWiki returns the title with namespace
// ("File:...") and may have replaced spaces with underscores; our
// candidate filenames never contain spaces, so a direct compare on
// the bit after "File:" is sufficient.
std::unordered_map<std::string, std::string> resolved;
resolved.reserve(filenameOrder.size());
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (!page.contains("imageinfo")) continue;
const auto& info = page.at("imageinfo");
if (!info.is_array() || info.empty()) continue;
const auto& info0 = info.at(0);
if (!info0.contains("url") || !info0.at("url").is_string()) continue;
std::string title = page.value("title", "");
constexpr std::string_view kPrefix = "File:";
if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size());
resolved[title] = info0.at("url").get<std::string>();
}
// Walk our ordered candidate list and return the first hit. This is
// how priority works: 1E English first, then UE, then jpg, etc.
for (const auto& fn : filenameOrder) {
auto it = resolved.find(fn);
if (it != resolved.end() && !it->second.empty()) {
return R::ok(it->second);
}
}
// Every candidate was tagged "missing" => Yugipedia confirmed there
// is no English scan for this printing. Treat as NotFound; the
// YGOPRODeck fallback may still surface a generic art.
return R::err({K::NotFound, "No matching Yugipedia scan found."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia JSON parse error: ") + e.what()});
}
}
// ============================================================================
// YGOPRODeck (auto-detect path + last-resort fallback)
// ============================================================================
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName) {
std::string url =
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += urlEncode(setName);
}
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseFallbackImageUrl(
const std::string& body, std::string_view name) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err({K::Transient, "YGOPRODeck response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "YGOPRODeck returned no matching cards."});
}
const std::string wantedNameLower = toLower(trim(std::string(name)));
// Prefer the exact-name match: the fuzzy `fname=` search can mix in
// sibling cards (Dark Magician + Dark Magician Girl), and we don't
// want to land on a sibling's standard art.
for (const auto& card : data) {
const std::string cardName = trim(card.value("name", ""));
if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) {
const std::string image = imageFromCard(card);
if (!image.empty()) return R::ok(image);
}
}
// Failing that, take whatever YGOPRODeck ranked first.
const std::string image = imageFromCard(data.at(0));
if (!image.empty()) {
return R::ok(image);
}
return R::err({K::NotFound, "Card has no image variants."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("YGOPRODeck JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view preferredSetName,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("YGOPRODeck returned no matching cards.");
}
const std::string wantedSet = trim(std::string(preferredSetName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushPrint = [&collected](const nlohmann::json& print) {
AutoDetectedPrint out;
out.setNo = trim(print.value("set_code", ""));
out.rarity = trim(print.value("set_rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& print : card.at("card_sets")) {
const std::string setName = trim(print.value("set_name", ""));
if (!wantedSet.empty() && setName != wantedSet) continue;
pushPrint(print);
}
}
// Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take
// every print from `data[0]` without filtering by set_name.
//
// When the caller supplied an exact card name (edit-dialog variant
// listing), combining unrelated `card_sets[]` rows after a non-empty
// display-set filter missed would falsely imply multiple printings
// "in one set" (different real-world products share the same card).
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSet.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
const auto& firstCard = j.at("data").at(0);
if (!wantedNameLower.empty()) {
const std::string cardName = trim(firstCard.value("name", ""));
if (toLower(cardName) != wantedNameLower) {
return R::err("Could not auto-detect set print metadata.");
}
}
if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) {
for (const auto& print : firstCard.at("card_sets")) {
pushPrint(print);
}
}
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::parseFirstPrint(
const std::string& body, std::string_view preferredSetName) {
auto list = parsePrintVariants(body, preferredSetName, "");
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
// ============================================================================
// Public ICardPreviewSource API
// ============================================================================
Result<std::string, PreviewLookupError>
YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view /*setId*/,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const ParsedSetNo p = parseSetNoTuple(setNo);
const std::string setCode = extractSetCode(p.setNo);
const std::string rarityCode = rarityCodeFor(p.rarity);
const bool firstEdition = (p.edition == "1E");
// The overall classification needs the worst outcome across the two
// upstreams: NotFound only when *both* answered cleanly with no match,
// Transient as soon as either one couldn't speak. We track Yugipedia's
// outcome here and combine it with YGOPRODeck's below.
bool yugipediaSawTransient = false;
PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."};
// Step 1: Yugipedia per-printing scan. Build a batch of plausible English
// filenames and ask MediaWiki for them all in one call. This is the only
// source we know of that distinguishes art between same-passcode reprints
// (LOB Blue-Eyes vs SDK Blue-Eyes, etc.).
//
// No usable set code (or empty candidate list) is treated as an
// "inapplicable" Yugipedia step rather than a failure - we don't want a
// legitimate metadata gap to taint the final classification as transient.
if (!setCode.empty()) {
const auto candidates = buildCandidateFilenames(
name, setCode, rarityCode, firstEdition);
if (!candidates.empty()) {
const std::string url = buildYugipediaQueryUrl(candidates);
auto resp = http_.get(url);
if (!resp) {
yugipediaSawTransient = true;
yugipediaErr = {K::Transient, resp.error()};
} else {
auto parsed = parseYugipediaResponse(resp.value(), candidates);
if (parsed) return parsed;
yugipediaErr = std::move(parsed).error();
if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true;
}
}
}
// Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has
// no scan we can match (newly-added cards, OCG-only cards without an
// English release, transient Yugipedia errors). Always unfiltered, so
// card_images[0] is the original artwork rather than an alt-art reprint.
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
// YGOPRODeck failed at the network layer => the overall lookup is
// transient regardless of what Yugipedia did. Surface YGOPRODeck's
// error string because it's the most recent failure.
return R::err({K::Transient, fallback.error()});
}
auto parsed = parseFallbackImageUrl(fallback.value(), name);
if (parsed) return parsed;
// Both upstreams answered. If *either* one was transient, the overall
// outcome is transient (we can't conclude the record has no image).
PreviewLookupError fallbackErr = std::move(parsed).error();
if (yugipediaSawTransient || fallbackErr.kind == K::Transient) {
return R::err({K::Transient,
yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message});
}
// Otherwise both confirmed "no image" => safe to remember.
return R::err({K::NotFound, fallbackErr.message});
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
}
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
namespace ccm {
YuGiOhGameModule::YuGiOhGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
+47
View File
@@ -0,0 +1,47 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
namespace ccm {
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"YGOPRODeck response is not an array.");
}
std::vector<Set> out;
out.reserve(j.size());
for (const auto& entry : j) {
Set s;
s.id = entry.value("set_code", "");
s.name = entry.value("set_name", "");
std::string release = entry.value("tcg_date", "");
for (char& ch : release) {
if (ch == '-') ch = '/';
}
s.releaseDate = std::move(release);
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("YGOPRODeck set parse error: ") + e.what());
}
}
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+29 -8
View File
@@ -3,19 +3,40 @@
#include <cpr/cpr.h>
#include <string>
#include <utility>
namespace ccm {
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(std::make_unique<cpr::Session>()) {
// Configure session-wide options once; every Get() then only updates
// the URL. libcurl's connection cache lives inside the easy handle, so
// reusing one Session across calls is what gets us TLS keep-alive.
session_->SetTimeout(cpr::Timeout{timeout_});
// `Accept: application/json` breaks some CDNs that refuse non-JSON bodies
// (preview pipeline also GETs raw JPG/PNG). Wildcard keeps JSON APIs happy.
session_->SetHeader(cpr::Header{
{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "*/*"},
});
session_->SetRedirect(cpr::Redirect{/*max_redirects=*/10L,
/*follow=*/true,
/*cont_send_cred=*/false,
cpr::PostRedirectFlags::POST_ALL});
}
CprHttpClient::~CprHttpClient() = default;
Result<std::string> CprHttpClient::get(std::string_view url) {
cpr::Response r = cpr::Get(
cpr::Url{std::string(url)},
cpr::Timeout{timeout_},
// Identify ourselves; some APIs rate-limit unknown agents harshly.
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "application/json"}}
);
// libcurl easy handles (and therefore cpr::Session) are not thread-safe.
// We serialize callers here; the preview path is single-flight already
// (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_);
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
+228
View File
@@ -0,0 +1,228 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <sstream>
#include <system_error>
#include <utility>
#include <vector>
namespace ccm {
namespace fs = std::filesystem;
namespace {
// FNV-1a 64-bit hash, hex-encoded. We don't need cryptographic strength
// here: the `.idx` sidecar file holds the original key and load() rejects
// any mismatch, so a hash collision degrades to a cache miss instead of a
// wrong-image return. FNV-1a was picked to keep this dependency-free
// (no openssl, no extra link).
std::string fnv1a64Hex(std::string_view in) {
constexpr std::uint64_t kOffsetBasis = 0xcbf29ce484222325ULL;
constexpr std::uint64_t kPrime = 0x100000001b3ULL;
std::uint64_t h = kOffsetBasis;
for (unsigned char c : in) {
h ^= c;
h *= kPrime;
}
std::ostringstream oss;
oss << std::hex << std::setw(16) << std::setfill('0') << h;
return oss.str();
}
// Best-effort mtime; returns the epoch on any error so callers can still
// sort consistently (oldest-first eviction stays well-defined).
fs::file_time_type mtimeOrEpoch(const fs::path& p) {
std::error_code ec;
auto t = fs::last_write_time(p, ec);
if (ec) return fs::file_time_type{};
return t;
}
std::uintmax_t fileSizeOrZero(const fs::path& p) {
std::error_code ec;
auto sz = fs::file_size(p, ec);
return ec ? 0u : sz;
}
void touchMtime(const fs::path& p) {
std::error_code ec;
fs::last_write_time(p, fs::file_time_type::clock::now(), ec);
// Ignored: touch is a best-effort hint to the LRU policy.
}
} // namespace
LocalPreviewByteCache::LocalPreviewByteCache(IFileSystem& fs,
fs::path cacheDir,
std::size_t maxBytes)
: fs_(fs), cacheDir_(std::move(cacheDir)), maxBytes_(maxBytes) {}
std::string LocalPreviewByteCache::hashKey(std::string_view key) {
return fnv1a64Hex(key);
}
fs::path LocalPreviewByteCache::payloadPath(const std::string& hash) const {
return cacheDir_ / (hash + ".bin");
}
fs::path LocalPreviewByteCache::negativePath(const std::string& hash) const {
return cacheDir_ / (hash + ".neg");
}
fs::path LocalPreviewByteCache::indexPath(const std::string& hash) const {
return cacheDir_ / (hash + ".idx");
}
IPreviewByteCache::LoadResult LocalPreviewByteCache::load(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
const bool hasBin = fs_.exists(bin);
const bool hasNeg = fs_.exists(neg);
if (!hasBin && !hasNeg) return {HitKind::Miss, {}};
// Sidecar must exist and match exactly. Anything else - missing,
// mismatched, empty - is treated as a miss so the next store() /
// storeNegative() will overwrite cleanly. This is what guarantees that
// a hash collision can never serve another card's bytes or stale
// "no image" verdict.
if (!fs_.exists(idx)) return {HitKind::Miss, {}};
auto idxRead = fs_.readText(idx);
if (!idxRead) return {HitKind::Miss, {}};
if (idxRead.value() != key) return {HitKind::Miss, {}};
if (hasBin) {
auto payload = fs_.readText(bin);
if (!payload) return {HitKind::Miss, {}};
// Touch mtime so this hit moves to the front of the LRU.
touchMtime(bin);
return {HitKind::Hit, std::move(payload).value()};
}
// Negative-only entry. Touch its mtime as well so frequently-checked
// negatives don't get aged out by an arbitrary directory sweep.
touchMtime(neg);
return {HitKind::NegativeHit, {}};
}
void LocalPreviewByteCache::store(std::string_view key, const std::string& payload) {
if (payload.empty()) return;
std::lock_guard<std::mutex> lock(mutex_);
auto ensure = fs_.ensureDirectory(cacheDir_);
if (!ensure) return;
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
// If a negative entry exists for this exact key, drop it before writing
// the positive payload so the two are never co-resident on disk.
if (fs_.exists(neg)) (void)fs_.remove(neg);
// Eviction runs against the *new* payload size, not the post-write
// total, so we make room before writing. If the same key is being
// overwritten the existing payload's bytes are released first.
evictIfNeededLocked(payload.size());
auto wrote = fs_.writeText(bin, payload);
if (!wrote) return;
auto wroteIdx = fs_.writeText(idx, std::string(key));
if (!wroteIdx) {
// Sidecar failure leaves us with bytes we can't safely serve later.
// Roll back the payload write so a future load() doesn't see it.
(void)fs_.remove(bin);
return;
}
}
void LocalPreviewByteCache::storeNegative(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
auto ensure = fs_.ensureDirectory(cacheDir_);
if (!ensure) return;
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
// Replace any existing positive entry: storeNegative is the upstream
// saying "the previous bytes are no longer the correct answer for this
// record". Free the bytes from the size cap immediately.
if (fs_.exists(bin)) (void)fs_.remove(bin);
// Order matters: write the marker first, then the sidecar. If the
// sidecar write fails we delete the marker to avoid a half-written
// entry that load() would treat as a miss anyway but that contributes
// a stray file to the directory listing.
auto wroteNeg = fs_.writeText(neg, std::string{});
if (!wroteNeg) return;
auto wroteIdx = fs_.writeText(idx, std::string(key));
if (!wroteIdx) {
(void)fs_.remove(neg);
}
}
std::size_t LocalPreviewByteCache::currentSizeBytes() {
std::lock_guard<std::mutex> lock(mutex_);
auto entries = fs_.listDirectory(cacheDir_);
if (!entries) return 0;
std::size_t total = 0;
for (const auto& p : entries.value()) {
if (p.extension() == ".bin") total += static_cast<std::size_t>(fileSizeOrZero(p));
}
return total;
}
void LocalPreviewByteCache::evictIfNeededLocked(std::size_t incomingBytes) {
auto entries = fs_.listDirectory(cacheDir_);
if (!entries) return;
struct Entry {
fs::path bin;
fs::path idx;
std::uintmax_t size;
fs::file_time_type mtime;
};
std::vector<Entry> bins;
bins.reserve(entries.value().size());
std::size_t total = 0;
for (const auto& p : entries.value()) {
if (p.extension() != ".bin") continue;
Entry e;
e.bin = p;
e.idx = p;
e.idx.replace_extension(".idx");
e.size = fileSizeOrZero(p);
e.mtime = mtimeOrEpoch(p);
total += static_cast<std::size_t>(e.size);
bins.push_back(std::move(e));
}
if (total + incomingBytes <= maxBytes_) return;
std::sort(bins.begin(), bins.end(),
[](const Entry& a, const Entry& b) { return a.mtime < b.mtime; });
for (const auto& e : bins) {
if (total + incomingBytes <= maxBytes_) break;
// remove() is best-effort; if it fails we still drop our accounting
// for the entry so we don't loop forever on a stuck file.
(void)fs_.remove(e.bin);
(void)fs_.remove(e.idx);
total -= std::min<std::size_t>(static_cast<std::size_t>(e.size), total);
}
}
} // namespace ccm
+16
View File
@@ -63,4 +63,20 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
return false;
}
bool matchesYuGiOhFilter(const YuGiOhCard& 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(card.rarity, 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
+217 -8
View File
@@ -1,8 +1,46 @@
#include "ccm/services/CardPreviewService.hpp"
#include <string>
#include <utility>
#include <vector>
namespace ccm {
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
namespace {
// Compose a stable cache key from the four lookup coordinates. Using NUL as
// a separator keeps the key unambiguous even if a card's name happens to
// contain `|` or other punctuation.
std::string makePreviewKey(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo) {
std::string k;
k.reserve(2 + name.size() + setId.size() + setNo.size() + 3);
k.push_back('p');
k.push_back(static_cast<char>(static_cast<int>(game)));
k.push_back('\0');
k.append(name);
k.push_back('\0');
k.append(setId);
k.push_back('\0');
k.append(setNo);
return k;
}
std::string makeUrlKey(std::string_view url) {
std::string k;
k.reserve(url.size() + 1);
k.push_back('u');
k.append(url);
return k;
}
} // namespace
CardPreviewService::CardPreviewService(IHttpClient& http,
IPreviewByteCache* persistentCache)
: http_(http), persistentCache_(persistentCache) {}
void CardPreviewService::registerModule(IGameModule& module) {
if (auto* src = module.cardPreviewSource(); src != nullptr) {
@@ -10,6 +48,80 @@ void CardPreviewService::registerModule(IGameModule& module) {
}
}
CardPreviewService::CacheLookupKind CardPreviewService::cacheLookup(
const std::string& key, std::string& outPayload) {
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it == cacheIndex_.end()) {
outPayload.clear();
return CacheLookupKind::Miss;
}
// Move-to-front to mark as most-recently-used.
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
if (it->second->negative) {
outPayload.clear();
return CacheLookupKind::NegativeHit;
}
outPayload = it->second->payload;
return CacheLookupKind::Hit;
}
void CardPreviewService::cacheStore(const std::string& key, std::string payload) {
if (payload.empty()) return;
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it != cacheIndex_.end()) {
// Overwrite existing entry (positive or negative) and bump it to
// the front. Replacing a negative entry is the "we got a real
// image after a previous NotFound" path - rare but valid.
it->second->payload = std::move(payload);
it->second->negative = false;
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
return;
}
cacheList_.push_front({key, std::move(payload), /*negative=*/false});
cacheIndex_.emplace(key, cacheList_.begin());
while (cacheList_.size() > kCacheCapacity) {
cacheIndex_.erase(cacheList_.back().key);
cacheList_.pop_back();
}
}
void CardPreviewService::cacheStoreNegative(const std::string& key) {
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it != cacheIndex_.end()) {
it->second->payload.clear();
it->second->negative = true;
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
return;
}
cacheList_.push_front({key, std::string{}, /*negative=*/true});
cacheIndex_.emplace(key, cacheList_.begin());
while (cacheList_.size() > kCacheCapacity) {
cacheIndex_.erase(cacheList_.back().key);
cacheList_.pop_back();
}
}
Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKey,
std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
std::string payload = std::move(bytes).value();
if (payload.empty()) {
return Result<std::string>::err("Empty response body from " + std::string(url));
}
cacheStore(cacheKey, payload);
// Best-effort persist to disk so the next app launch starts warm.
// The persistent tier is fire-and-forget: any I/O error is swallowed
// by the adapter, the in-memory tier still holds the bytes.
if (persistentCache_ != nullptr) {
persistentCache_->store(cacheKey, payload);
}
return Result<std::string>::ok(std::move(payload));
}
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
@@ -18,17 +130,114 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
if (it == sources_.end() || it->second == nullptr) {
return Result<std::string>::err("No preview source registered for this game.");
}
// Cache check before any HTTP call. The (game, name, setId, setNo) tuple
// uniquely identifies a printing for our purposes - the resolved image
// URL is always a deterministic function of those four inputs, and any
// edit to a lookup-relevant field changes the key automatically.
const std::string key = makePreviewKey(game, name, setId, setNo);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::ok(std::move(cached));
case CacheLookupKind::NegativeHit:
return Result<std::string>::err("No preview available for this card.");
case CacheLookupKind::Miss:
break;
}
// Disk-backed second tier: previews persisted by an earlier app run
// get promoted into the in-memory LRU on first access this session, so
// subsequent re-selections stay fast without re-touching the network.
// Negative entries on disk are likewise promoted - the user already
// knows from a previous session that this record has no upstream image.
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
switch (disk.kind) {
case IPreviewByteCache::HitKind::Hit:
cacheStore(key, disk.payload);
return Result<std::string>::ok(disk.payload);
case IPreviewByteCache::HitKind::NegativeHit:
cacheStoreNegative(key);
return Result<std::string>::err("No preview available for this card.");
case IPreviewByteCache::HitKind::Miss:
break;
}
}
auto url = it->second->fetchImageUrl(name, setId, setNo);
if (!url) return Result<std::string>::err(url.error());
auto bytes = http_.get(url.value());
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
if (!url) {
// The two error kinds split here:
// - NotFound: upstream answered cleanly that this record has no
// image. Persist the verdict so we don't keep retrying.
// - Transient: network/HTTP/parse failure. Surface the error
// unchanged and DO NOT cache anything; the next selection
// retries from scratch.
const auto err = std::move(url).error();
if (err.kind == PreviewLookupError::Kind::NotFound) {
cacheStoreNegative(key);
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
}
return Result<std::string>::err(err.message);
}
return fetchAndCache(key, url.value());
}
Result<AutoDetectedPrint> CardPreviewService::detectFirstPrint(Game game,
std::string_view name,
std::string_view setId) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
}
return it->second->detectFirstPrint(name, setId);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
Game game,
std::string_view name,
std::string_view setId) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::vector<AutoDetectedPrint>>::err(
"No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Auto-detect not enabled for this game.");
}
return it->second->detectPrintVariants(name, setId);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
// The by-URL path is used for fixed per-game card-back fallback images.
// A failure there is always transient (the URL itself is constant), so
// there is no negative-cache analogue to worry about; we just look up
// and, if needed, fetch+store.
const std::string key = makeUrlKey(url);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::ok(std::move(cached));
case CacheLookupKind::NegativeHit:
// Defensive: nothing in this code path ever stores a negative
// entry under a URL key, but if one ever ends up here (cache
// file tampering, future code paths) treat it as a miss so the
// fallback fetch can still run.
break;
case CacheLookupKind::Miss:
break;
}
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
cacheStore(key, disk.payload);
return Result<std::string>::ok(disk.payload);
}
}
return fetchAndCache(key, url);
}
} // namespace ccm
+60
View File
@@ -170,4 +170,64 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
}
}
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
bool ascending) {
switch (column) {
case YuGiOhSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case YuGiOhSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
}, ascending));
break;
case YuGiOhSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
}, ascending));
break;
case YuGiOhSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
}, ascending));
break;
case YuGiOhSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case YuGiOhSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case YuGiOhSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case YuGiOhSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
} // namespace ccm