mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
minor: yugioh support added
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user