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
+11 -5
View File
@@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin
## Project structure
- `core/``ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
- `ui_wx/``ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`.
- `ui_wx/``ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `<exeDir>/assets/` when linking `ccm`.
- `app/``ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
- `tests/``ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
@@ -52,13 +52,13 @@ Run from the **workspace root**.
- Run the app:
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
- Run tests (CCM_BUILD_TESTS defaults to ON):
`ctest --test-dir build --output-on-failure` — current baseline: **86 cases / 211 assertions, all green**.
`ctest --test-dir build --output-on-failure` — current baseline: **180 tests, all green**.
- Build tests only:
`cmake --build build --target ccm_core_tests`
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
>
> **Windows rebuild note**: linking `ccm.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm.exe` before rebuilding app targets.
> **Windows rebuild note**: linking `ccm3.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm3.exe` before rebuilding app targets.
>
> **Windows cold-start note**: first launch right after a fresh build is often slower than subsequent launches due to cold file cache and Windows security scanning (Defender/SmartScreen) on the new exe/dll set. Warm launches are the meaningful baseline for app-side perf changes.
@@ -69,8 +69,14 @@ Run from the **workspace root**.
- Preserve "select first row on startup" behavior without blocking first paint by scheduling the initial selection with `CallAfter(...)` instead of selecting synchronously during row rebuild.
- Avoid repeated set-list loads when opening Add/Edit: cache Magic sets in `MainFrame` and reuse them in `CardEditDialog`.
- Pass preloaded set data to dialogs by pointer/reference, not by value, to avoid copying large vectors on every open.
- `MainFrame` default window size is **1210×770** (`ui_wx/src/MainFrame.cpp`).
- Saving from **Edit** in `BaseCardEditDialog`: themed Yes/No confirmation when the card changed versus the snapshot taken at dialog open; Add mode does not prompt.
- While constructing/populating dialogs with many controls/choices, wrap with `Freeze()`/`Thaw()` and append choice items in bulk (`wxArrayString`) to reduce layout/repaint churn.
- Keep selected-card preview usable when remote lookup fails: show a per-game card-back fallback image (CCM2 parity), not a blank preview panel.
- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically.
- `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically.
- `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.cache/preview-cache/`**next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path.
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
## Windows UI theming guardrails
@@ -83,7 +89,7 @@ Run from the **workspace root**.
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
- After changing `ui_wx` theming behavior, rebuild the final app target (`cmake --build build --target ccm --parallel`), not just `ccm_ui_wx`, before validating runtime behavior.
- If linker fails with `Permission denied` on `build/bin/ccm.exe`, the app is still running; close it before rebuilding.
- If linker fails with `Permission denied` on `build/bin/ccm3.exe`, the app is still running; close it before rebuilding.
## Required follow-ups
+10
View File
@@ -6,6 +6,11 @@
Card Collection Manager 3 is an extensible desktop application for managing trading card game collections. It is designed as a practical way to track cards and manage per-card images for large collections, with local per-game data, set synchronization workflows, and a desktop-first UX. The app preserves the established JSON layout from earlier CCM versions so existing collections stay compatible.
Currently, the application supports the following TCGs:
- Magic the Gathering
- Pokemon TCG
- Yu-Gi-Oh!
## Screenshots
### Magic The Gathering
@@ -16,6 +21,11 @@ Card Collection Manager 3 is an extensible desktop application for managing trad
![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png)
### Yu-Gi-Oh!
![CCM3 Demo - YuGiOh](docs/assets/images/demo-ygo.png)
## Migrating From CCM1 And CCM2
CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory.
+6 -3
View File
@@ -5,20 +5,23 @@ The `ccm` executable — composition root only. The single place where concrete
## File pointers
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`.
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` there so Yu-Gi-Oh! preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonSetRepository`, `LocalImageStore`, `MagicGameModule`, `PokemonGameModule`, `MagicGameView`, `PokemonGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
8. **One `CprHttpClient` per app, shared by every consumer.** The single `http_` instance is handed to `SetService`, `CardPreviewService`, and every per-game module. Do **not** construct a second `CprHttpClient` (or pass `cpr::Get(...)` directly) from anywhere — the adapter holds a long-lived `cpr::Session` whose connection cache + TLS keep-alive is what makes repeat lookups fast (game-agnostic; see `core/AGENTS.md` convention 11). The shared instance also gives `CardPreviewService`'s in-memory LRU a single source of truth to cache against.
9. **One `LocalPreviewByteCache` per app**, rooted at `<exeDir>/.cache/preview-cache/` — i.e. **next to the executable**, in the same scope as `config.json`. **Do not** root the cache at `config_->current().dataStorage`: the user's data-storage path is user-configurable at runtime and is meant for the user's collection (cards, scans, set lists). Previews are downloaded-from-network artifacts that (a) must not move when the user relocates their collection, (b) must not be uploaded/synced together with the user's data dir, and (c) must not survive a fresh install elsewhere on disk. Pinning the cache to `exeDir` is what gives those properties without writing extra plumbing for each data-storage flow. The umbrella `.cache/` directory is reserved for any future computed-from-network caches (set-list snapshots, etc.); the leading dot keeps it out of the way for users poking around the install folder. Cache updates flow entirely through cache keys: `CardPreviewService` invalidates entries automatically when the cache key changes (record edits) and rewrites them when a same-key resolution flips between positive and negative — there is no `clearCache(...)` API. To wipe the cache manually, delete `<exeDir>/.cache/`; reinstalling / moving the executable also resets the cache by design. Construct the cache after `ConfigService` (so the dependency graph is the same as before; the cache itself only needs `*fs_` and the resolved `exeDir`) and before `CardPreviewService` (so the service can hold a stable raw pointer); declare the member after `config_`/`fs_` and before `previewSvc_` to keep destruction order correct. See `core/AGENTS.md` convention 10 and `docs/caching.md` ("Updating cached entries") for the full cache shape, policy, and update mechanic.
## Required follow-ups
- The **`POST_BUILD` copy of `ygo_card_back.png`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
@@ -32,4 +35,4 @@ The `ccm` executable — composition root only. The single place where concrete
## Commands
- Build the binary: `cmake --build build --target ccm`
- Run on Windows / MinGW-w64: `.\build\bin\ccm.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3".
- Run on Windows / MinGW-w64: `.\build\bin\ccm3.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3".
+7
View File
@@ -18,3 +18,10 @@ target_link_libraries(ccm
ccm_ui_wx
ccm_warnings
)
# Yu-Gi-Oh! preview fallback image (used when network card-back URLs fail).
add_custom_command(TARGET ccm POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png")
+36 -2
View File
@@ -4,12 +4,15 @@
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
#include "ccm/infra/JsonSetRepository.hpp"
#include "ccm/infra/LocalImageStore.hpp"
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
@@ -20,6 +23,7 @@
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/MainFrame.hpp"
#include "ccm/ui/PokemonGameView.hpp"
#include "ccm/ui/YuGiOhGameView.hpp"
#include <wx/app.h>
#include <wx/icon.h>
@@ -40,6 +44,7 @@ std::string dirNameForGame(ccm::Game g) {
switch (g) {
case ccm::Game::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
}
return "magic";
}
@@ -74,11 +79,14 @@ public:
http_ = std::make_unique<ccm::CprHttpClient>();
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
*fs_, *config_, &dirNameForGame);
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
*fs_, *config_, &dirNameForGame);
ygoRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhCard>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
@@ -87,19 +95,39 @@ public:
*magicRepo_, *imgStore_);
pokeCollSvc_ = std::make_unique<ccm::CollectionService<ccm::PokemonCard>>(
*pokeRepo_, *imgStore_);
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
*ygoRepo_, *imgStore_);
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
setSvc_->registerModule(magicMod_.get());
setSvc_->registerModule(pokeMod_.get());
setSvc_->registerModule(ygoMod_.get());
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_);
// Disk-backed preview cache lives next to the executable, in the same
// location scope as config.json - NOT inside the user's data-storage
// directory. Rationale: previews are downloaded artifacts, not user
// data, so they should not move when the user relocates their
// collection (data-storage path can be reconfigured at runtime), and
// they should not be uploaded together with the user's collection
// when the data dir is backed up / synced. The umbrella ".cache/"
// directory is reserved for any future computed-from-network caches
// (set-list snapshots, etc.); the leading dot keeps it out of the way
// for users poking around the install folder. Constructed before
// previewSvc_ so the service can hold a stable raw pointer to it.
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
*fs_,
exeDir / ".cache" / "preview-cache");
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_, previewCache_.get());
previewSvc_->registerModule(*magicMod_);
previewSvc_->registerModule(*pokeMod_);
previewSvc_->registerModule(*ygoMod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -108,7 +136,8 @@ public:
*previewSvc_,
*magicMod_,
*pokeMod_,
{ magicView_.get(), pokeView_.get() },
*ygoMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
@@ -128,17 +157,22 @@ private:
std::unique_ptr<ccm::CprHttpClient> http_;
std::unique_ptr<ccm::MagicGameModule> magicMod_;
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
std::unique_ptr<ccm::SetService> setSvc_;
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
std::unique_ptr<ccm::ui::AppContext> ctx_;
};
+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
+3 -2
View File
@@ -11,7 +11,8 @@ Long-form contributor documentation that lives outside the source tree.
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic and Pokemon modules, plus the runtime flow through `SetService` / `CardPreviewService` and the error-surface conventions.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions.
- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching).
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
## Subdirectories
@@ -27,7 +28,7 @@ Long-form contributor documentation that lives outside the source tree.
## Required follow-ups
- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`.
- After changing the Magic or Pokemon set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples.
- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete.
- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`).
+5 -1
View File
@@ -22,5 +22,9 @@ This folder contains contributor documentation for Card Collection Manager 3. St
- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`.
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic/Pokemon modules and their runtime purpose.
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, and Yu-Gi-Oh! modules, preview fallback URLs / bundled YGO card-back asset, and shared HTTP behavior (`CprHttpClient`).
## Performance & Caching
- [caching.md](caching.md): preview image caching (in-memory LRU, on-disk byte cache, HTTP connection reuse), lookup order, keys, eviction, and what is intentionally not cached.
+11
View File
@@ -37,6 +37,9 @@ A few traps to plan around now, before you write code:
- **Lookup precision.** Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result.
- **URL encoding.** All query strings must be RFC 3986 percent-encoded before they reach `IHttpClient::get` (`cpr::Url` does **not** re-encode). The Magic/Pokemon implementations have a private `urlEncode` helper you can copy.
- **Collector-number normalization.** Pokemon stores `4/102` but the API only accepts `4`. Whichever convention your domain type uses, normalize it inside `buildSearchUrl` so the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug.
- **Name-matching strictness.** Some APIs reject strict exact-name parameters for real-world card spelling variants (e.g. hyphenation/punctuation differences). If your provider supports fuzzy-name search, prefer that for the first request, then disambiguate in `parseResponse` using set/print metadata.
- **400 fallback strategy.** If adding optional set filters can produce request validation errors (`HTTP 400`), add a second request path that retries without the risky filter and keeps disambiguation local in `parseResponse`.
- **Image variant priority.** If the provider returns both cropped art and full-card images, prefer the full-card URL for selected-card preview. Use cropped variants only as fallback.
### 1.3 Flag icons
@@ -138,6 +141,8 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
- `static Result<std::string> parseResponse(const std::string& body);`
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Keep both optional per game — default behavior should remain an explicit unsupported error.
Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
- Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness).
@@ -328,6 +333,10 @@ In the constructor:
The reference implementation is `ui_wx/src/PokemonCardEditDialog.cpp`.
When an extra field is from a controlled vocabulary (rarity tiers, print types, etc.), prefer a dropdown (`wxChoice`) over free text to keep list/filter values consistent and reduce user-input variants.
If the external API expects a full print code (e.g. `LOB-001`) but users mostly edit only the numeric suffix, expose a numeric input plus a read-only derived preview (for example `(LOB-001)`) and compose/decompose the stored full value in `readExtraFromCard()` / `writeExtraToCard()`.
### 5.6 `<Name>GameView`
This is the polymorphic glue between the new game's panels and the rest of the app. Create:
@@ -452,6 +461,8 @@ Run, in order, from the workspace root. Do not skip any step.
These do not match a single seam in this guide but are worth calling out explicitly.
- **Stale set caches.** Each `IGameView` caches `std::vector<Set> setsCache_`. After `onUpdateSets` succeeds, refresh the cache (assign the new vector). The reference implementations do this.
- **Set ordering drift.** Keep set lists sorted by release date not only in `<Name>SetSource::parseResponse`, but also at UI consumption points (preloaded/cached vectors passed to `BaseCardEditDialog`). Older on-disk cache data or future parser changes can otherwise surface unsorted set pickers.
- **Auto-detect feature scope.** Treat print auto-detect as a per-game capability. Do not assume every game supports it; gate UI affordances behind game-specific dialog logic and source opt-in.
- **`signed_` / `signed`.** The C++ field is `signed_`; the JSON key is `"signed"`. This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key.
- **Spacer column index.** `BaseCardListPanel` reserves index `0` for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index `1`. If you ever need to call into `wxListCtrl` directly from a derived panel (you should not), remember this.
- **Preview-fetch threading.** The async preview fetch in `BaseSelectedCardPanel` uses a `shared_ptr<State>` + `std::atomic alive` + `std::atomic currentGen` triple. Do not capture `this` raw in any background work you add to a new game's selected panel; copy that pattern verbatim.
+71 -6
View File
@@ -26,27 +26,92 @@ Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` an
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
Yu-Gi-Oh! splits its remote calls across two upstreams. **Yugipedia** is the primary preview source because it hosts actual per-printing card scans; **YGOPRODeck** continues to drive set listings and the auto-detect-first-print helper, plus a last-resort image fallback.
Upstream documentation:
- [Yugipedia MediaWiki API help](https://yugipedia.com/api.php?action=help) (standard MediaWiki action API; we only need `prop=imageinfo`).
- [Yu-Gi-Oh! API Guide — YGOPRODeck](https://ygoprodeck.com/api-guide/). CCM3 uses **v7** endpoints only.
### Info API: YGOPRODeck `cardsets.php`
`https://db.ygoprodeck.com/api/v7/cardsets.php`
Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object maps `set_code` → internal `Set.id`, `set_name``Set.name`, and `tcg_date``Set.releaseDate` with `-` rewritten to `/` for consistency with other games date strings. Results are sorted ascending by `releaseDate`.
### Asset API: Yugipedia `api.php` (primary)
`https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...`
Used by `YuGiOhCardPreviewSource::fetchImageUrl` for the actual per-printing card scan. Yugipedia is the only public source we have found that distinguishes art between same-passcode reprints (LOB Blue-Eyes vs SDK Blue-Eyes, for example), and uses a **deterministic file-name convention** of the shape `<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>[-Misc].<png|jpg>` per [Yugipedias image policy](https://yugipedia.com/wiki/Yugipedia:Image_policy).
The UI passes a positional tuple in `setNo` of the form `set_code||rarity||edition` (for example `SDK-001||Ultra Rare||UE`); the source splits on `||` before building filenames. Field meanings:
- `set_code` — full code as printed (`LOB-005`, `SDK-001`, `RA04-EN001`). Everything before the first `-` becomes the Yugipedia `<SET>` slot (`LOB`, `SDK`, `RA04`).
- `rarity` — full English rarity name from the edit dialog (`Ultra Rare``UR`, `Quarter Century Secret Rare``QCScR`, …). The mapping table lives in `rarityCodeFor(...)`. Unknown values fall back to the rarity-less filename pattern.
- `edition``1E` when the user marked the card as 1st Edition, otherwise `UE` (Unlimited).
`buildCandidateFilenames(...)` then produces a priority-ordered list:
1. Printed edition first (`1E` then `UE`, or `UE` then `1E` for non-first), with `LE` last for promo-style prints.
2. English regions only — `EN`, then `NA`, then `EU`, then `AU`. **Yugipedia is queried with English regions regardless of the cards stored Language**, so a German-language card still shows the English scan; this matches the user-visible policy in the edit dialog and avoids querying region-specific scans that are sparser on Yugipedia.
3. Both `.png` and `.jpg` extensions per combo (older LOB-era uploads are `.jpg`, modern reprints are `.png`).
4. A rarity-less fallback round so cards with unknown rarities still resolve in single-rarity sets.
`buildYugipediaQueryUrl(...)` joins all candidates into one MediaWiki **batch query** (`titles=File:A|File:B|...` URL-encoded), so the entire list resolves in a single HTTP call. `parseYugipediaResponse(...)` walks the candidate list in order and returns the URL of the first filename that came back with `imageinfo[0].url`; missing files come back with `"missing": ""` and are skipped.
### Asset API: YGOPRODeck `cardinfo.php` (fallback + auto-detect)
`https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=...`
Used in two situations:
1. **Last-resort preview fallback.** If Yugipedia returns no candidate match (cards without an English scan yet, transient API errors), `fetchImageUrl` falls through to `parseFallbackImageUrl(...)`, which prefers an exact-name match in YGOPRODecks `data[]`, otherwise the first row, and returns the first entry from `card_images[0]`. This is intentionally **not** filtered by `cardset=`: when YGOPRODeck applies that filter, it reorders `card_images` so alt-art passcodes are promoted ahead of the standard art, which would re-introduce the “wrong artwork” bug we fixed by switching to Yugipedia.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted.
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
## Runtime Flow In CCM3
The app uses the same flow for both games:
The app uses the same flow for every game that registers a module:
- `SetService` asks the game's `ISetSource` (info API) for the latest set list.
- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL.
- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer.
- If preview lookup fails (or returns empty bytes), the UI fetches a per-game fallback card-back image URL through `CardPreviewService::fetchImageBytesByUrl(...)` and shows that image in the selected-card preview panel.
- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`).
Current fallback image URLs (kept in `BaseSelectedCardPanel` for CCM2 parity):
### Caching And Connection Reuse
See [caching.md](caching.md) for a dedicated reference on preview cache tiers, internal keys, eviction, clearing, and HTTP session reuse.
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon, Yu-Gi-Oh!). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews:
- **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview.
- **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, and `ms.yugipedia.com` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway.
`CardPreviewService` consults the tiers in order **memory → disk → source/HTTP**. On a disk hit (positive *or* negative) the entry is promoted into the in-memory LRU so the next click on the same row never re-touches the disk cache. On HTTP success the bytes are written through to both tiers in one shot. On a `NotFound` source error the **negative** marker is written through to both tiers; on `Transient` source errors nothing is written, so the next selection retries cleanly.
The combined effect on the preview path: first selection of a previously-unseen card pays one TLS handshake per *new* host this session (typically two hops for Yu-Gi-Oh!: `yugipedia.com` for the API, `ms.yugipedia.com` for the image), each subsequent fresh card on the same host skips the handshake, any re-selection of an already-viewed card is instant, after the first run with the disk cache populated **even a fresh app launch is decode-only for previously-seen cards** until eviction or a manual cache clear, and **records the upstream cleanly has no image for** stay "instant card-back" across restarts instead of re-paying the lookup every launch. Editing a lookup-relevant field of a record (name, set, setNo, or for Yu-Gi-Oh! the rarity / edition packed into setNo) changes the cache key automatically, so a fresh resolution attempt happens on the next click.
To clear the persistent cache (for example to recover from a bad upstream image), delete the `<exeDir>/.cache/preview-cache/` subdirectory or the umbrella `<exeDir>/.cache/` folder. Note: the in-app "Reset" / data-storage-relocation flow does **not** touch this directory — the cache is install-scoped, not collection-scoped, so it is preserved across data-dir moves and only cleared by deleting the directory above explicitly (or by reinstalling / relocating the executable).
For the full caching design and contributor rules see the dedicated [caching.md](caching.md).
Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match CCM2):
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg`
- Pokemon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
- Pokémon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
- Yu-Gi-Oh!: Yugipedia English TCG back — try `https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png`, then `https://ms.yugipedia.com/e/e5/Back-EN.png`; if both fail, load `<exeDir>/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string.
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
## Error Surface And Debugging Intent
Both source types return `Result<T, std::string>` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating:
All source types return `Result<T, std::string>` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating:
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions (`data`, `image_uris`, `images.large`/`images.small`). If the fallback fetch succeeds, the panel intentionally shows the card-back image and the inline label `(image preview unavailable)`.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the right code in `rarityCodeFor(...)` (extend the table when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

+167
View File
@@ -0,0 +1,167 @@
#documentation #performance #network #ccm3
# Caching In CCM3
This document describes **what CCM3 caches**, **how lookups are ordered**, and **what is deliberately not cached**. It focuses on the card **preview image** path (remote APIs → raw bytes → UI decode), which is where most explicit caching lives. For upstream URL shapes and API roles, see [assets-and-info-apis.md](assets-and-info-apis.md).
## Scope
| Mechanism | What it stores | Survives restart? |
|-----------|----------------|-------------------|
| In-memory preview LRU (`CardPreviewService`) | Successful preview / fallback-image **bytes** + negative markers for "no upstream image" | No |
| Disk preview byte cache (`LocalPreviewByteCache`) | Same bytes + negative markers, persisted under `<exeDir>/.cache/preview-cache/` | Yes |
| HTTP session reuse (`CprHttpClient`) | libcurl **connections** (TLS + TCP keep-alive), not response bodies | No (process lifetime only) |
Other persistence (for example `JsonSetRepository` after “Update sets”, `JsonCollectionRepository` for card JSON, `LocalImageStore` for **user-attached** scan files) is normal app data storage, not preview caching. Those layers are documented elsewhere via domain services; this page stays centered on **preview latency** and **repeat lookups**.
### Cache directory layout
The umbrella cache root is `<exeDir>/.cache/`, where `exeDir` is the directory containing the running `ccm3` executable — the same scope as `config.json`. It is reserved for any future computed-from-network caches (set-list response snapshots, etc.); the leading dot keeps it out of the way for users poking around the install folder. Today it contains a single subdirectory:
- `<exeDir>/.cache/preview-cache/` — owned by `LocalPreviewByteCache`. Files inside are `<hash>.bin` / `<hash>.neg` / `<hash>.idx` triples (see below).
The cache is deliberately **not** under the user-configurable `Configuration.dataStorage` path. The data-storage path is meant for the user's own data — collection JSON, attached scans, set lists — and is meant to be relocatable, syncable, and backup-friendly. Previews are downloaded artifacts from upstream APIs:
- They must **not** follow the user's collection when the data-storage path is reconfigured at runtime (the cache would otherwise either rebuild from cold every time the user moves the dir, or pollute every chosen target with a recurring `.cache/` directory).
- They must **not** be uploaded together with the user's collection if the user backs up / syncs / version-controls the data dir.
- They are install-scoped, not collection-scoped: a fresh install elsewhere on disk should start cold, and uninstalling / moving the executable should leave nothing stale behind.
Pinning the cache to `exeDir` is what gives all three properties without per-flow plumbing. The trade-off is that the cache is **not** wiped by the in-app data-storage reset / relocation flow; if you genuinely want a clean slate (e.g. you suspect cache corruption), delete `<exeDir>/.cache/` manually.
## Preview lookup order
For `CardPreviewService::fetchPreviewBytes(game, name, setId, setNo)`:
1. **In-memory LRU** — O(1) lookup. A positive entry returns the bytes immediately; a **negative** entry returns an error immediately (no network call). Either kind of hit moves the entry to the most-recently-used position.
2. **Disk cache** (`IPreviewByteCache`, production: `LocalPreviewByteCache`) — on memory miss, look up on disk. A positive disk hit is **promoted** into the in-memory LRU and returned; a negative disk hit is likewise promoted into memory as a negative entry and returned as an error. So the next selection of the same card stays in-memory only.
3. **Network** — ask `ICardPreviewSource` for an image URL, then `IHttpClient::get(url)` for bytes. On success, write through to **both** memory and disk tiers as a positive entry. **`CardPreviewService::fetchAndCache`** treats an HTTP **2xx with an empty body** as an error (no cache write) so empty payloads cannot populate the LRU as false positives.
For `fetchImageBytesByUrl(url)` (used for per-game **card-back fallbacks** when preview lookup fails):
- Same three-tier pattern, but the cache key is derived only from the URL. There is no negative-cache analogue here: the URL is a fixed constant, so any failure is by definition transient. Empty bodies are rejected the same way as on the preview-image GET path.
## Negative caching: transient vs. permanent failures
`fetchPreviewBytes` distinguishes two failure kinds via `PreviewLookupError::Kind`:
- **`NotFound`** — the upstream answered cleanly that this exact record has no preview image. Examples:
- Scryfall search returned `data: []`, or the matched card has no top-level `image_uris`.
- Pokémon TCG search returned `data: []`, or the card has no `images` object / no `large` / `small` URL.
- Yu-Gi-Oh!: **both** Yugipedia and YGOPRODeck answered cleanly with no match (Yugipedia tagged every candidate filename `missing`, *and* YGOPRODeck returned an empty `data` array or no usable image variants).
These are **negative-cached** in both tiers. Subsequent selections of the same record return an error instantly, without any HTTP call. The user-visible effect is that the per-game card-back fallback shows up immediately on every click.
The cache key is `(game, name, setId, setNo)` (with Yu-Gi-Oh! also packing rarity and edition into `setNo`). Any **edit to a lookup-relevant field** of the record changes the key automatically, which means the negative entry no longer matches and a fresh network resolution attempt runs the next time the user clicks the row. So if the user fixes a typo, changes the set, switches a YGO printing's edition or rarity, etc., the new fingerprint guarantees a re-fetch — no manual cache clear needed.
- **`Transient`** — we couldn't tell whether the record has an image because the upstream couldn't speak. Examples:
- HTTP / network / TLS / DNS failure.
- Malformed JSON, missing top-level fields (schema deviation that suggests an outage page rather than a real "no match" response).
- Yu-Gi-Oh!: **either** Yugipedia or YGOPRODeck failed at the network/parse layer. The cautious rule is that as soon as one upstream couldn't speak, the overall outcome is transient — we cannot conclude the record has no image, only that we couldn't reach the place that would tell us.
These are **never cached** (positive or negative). The next selection of the same row retries cleanly. This is the property that keeps a temporary connection drop from semi-permanently breaking previews.
This split is the reason the preview path doesn't keep retrying every click for cards whose printing genuinely has no upstream scan, *and* the reason a brief loss of connectivity doesn't poison the cache with bogus "no image" markers.
## Updating cached entries
There is no explicit "refresh" or "invalidate" API on `CardPreviewService` — by design. Every way an entry's state can change is driven by **what already happens** in the system, so contributors don't have to reason about a side-channel mutation API. The full set of transitions is:
### 1. Edit-driven invalidation (record changed → fresh lookup, automatic)
The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `<setNo>||<rarity>||<1E|UE>`. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
- Memory and disk lookups for the new key **miss** the old entry (positive or negative).
- A fresh `ICardPreviewSource::fetchImageUrl` call runs.
- The result is cached under the new key, leaving the old key's entry untouched but unreachable from the UI (it ages out via LRU / mtime eviction).
Concretely: fix a typo in the card name → re-fetch. Switch the printing's set → re-fetch. Toggle `1E``UE` on a YGO card → re-fetch the per-printing scan. **No manual cache clear needed**; the test `editing a lookup-relevant field invalidates the negative entry automatically` pins this behavior.
If you add a new disambiguator (say a future "art treatment" flag), the rule is to pack it into one of the existing key slots (`setNo`'s `||`-separated tuple is the established hook) so this auto-invalidation continues to apply. Adding it as a side parameter that the cache key *doesn't* see would silently break the update story.
### 2. Same-key positive ↔ negative state transitions
If a previously cached entry's verdict flips upstream (Yugipedia uploads a missing scan, a Scryfall printing's `image_uris` get fixed, etc.) **and** the user re-encounters it under the same key, the next fetch decides:
- **Positive → negative.** Source returns `NotFound` for a key that previously cached a positive entry: `cacheStoreNegative(key)` overwrites the in-memory entry's bytes with an empty payload and flips `negative=true`; on disk, `LocalPreviewByteCache::storeNegative` removes the existing `<hash>.bin` (releasing its bytes from the size cap) and writes a `<hash>.neg` marker.
- **Negative → positive.** Source returns a real URL, `IHttpClient` returns bytes, `cacheStore(key, payload)` overwrites the existing in-memory entry with the new bytes and flips `negative=false`; on disk, `LocalPreviewByteCache::store` removes the existing `<hash>.neg` and writes the new `<hash>.bin`.
Both paths preserve a key invariant: **`.bin` and `.neg` for the same hash are never co-resident**. `LocalPreviewByteCache` tests pin this down (`a later positive store overwrites an earlier negative entry`, `storeNegative for an existing positive entry replaces the bytes`).
The "trigger" for these transitions in production is one of: the user edits the record back to a previous key (so the still-cached old entry surfaces and a network attempt then re-reaches the upstream), or the in-memory tier was cleared by an app restart and the disk-tier verdict is now stale. There is no time-based revalidation today; the design relies on the upstream answer being stable enough that a stale verdict only hurts until the natural transitions above kick in.
### 3. Eviction-based aging (passive)
- **In-memory LRU.** Capacity is hard-capped at `CardPreviewService::kCacheCapacity = 128` entries (positive and negative share the count). When a new entry is inserted past the cap, the **least-recently-used** entry — the back of the list — is dropped. Any access (positive hit, negative hit, or store) moves the entry to the front, so heavily clicked cards are the last to go.
- **Disk cache.** Capacity is hard-capped by total `.bin` payload bytes (`LocalPreviewByteCache::kDefaultMaxBytes = 64 MiB`). When a `store` would push the total past the cap, oldest-by-mtime `.bin` files (with their `.idx` sidecars) are deleted until the new write fits. A successful `load` touches the entry's mtime, so frequently viewed cards rarely become eviction victims. `.neg` markers are not counted against the cap and are not actively evicted; their count is naturally bounded by the number of records the user has viewed whose upstream cleanly reported "no image".
Eviction is the only way an entry "ages out" without an explicit user action.
### 4. Manual / external invalidation
- **Delete the cache directory.** Removing `<exeDir>/.cache/preview-cache/` (or the umbrella `<exeDir>/.cache/`) is safe: `LocalPreviewByteCache` recreates the directory on the next store. The in-memory tier is unaffected by the disk delete during a running session, but a subsequent app restart starts cold.
- **Reinstall / move the executable.** Because the cache is rooted at `<exeDir>`, a fresh install elsewhere on disk starts cold by construction, and uninstalling / moving the exe leaves no stray cache in the user's data directory. (Note: **resetting or moving the user's data-storage directory does NOT clear the preview cache** — that's intentional; the cache is install-scoped, not collection-scoped.)
- **Tampering with sidecar files.** If a `<hash>.idx` is ever rewritten with a key that doesn't match the requested cache key (corruption, hash collision, filesystem hiccup), `LocalPreviewByteCache::load` reports `Miss` rather than serving the entry. A subsequent `store` / `storeNegative` overwrites the corrupted record cleanly. This is what makes FNV-1a (non-cryptographic) safe to use as the hash: the worst case is a one-time miss, never a wrong answer.
### What does *not* trigger an update
To keep the mental model crisp, the following cases **do not** invalidate or refresh anything:
- **Re-selecting the same row repeatedly.** That's a hit by design — the whole point of the cache. The only thing that changes is the entry's LRU position / mtime.
- **Transient errors on a row that is already negatively cached.** The negative entry is consulted first and short-circuits the call; the network is never touched, so a flaky network can't accidentally turn a `NotFound` verdict into a `Transient` outcome.
- **Restart with a populated disk cache.** This is a *warm start*, not an update. Both positive and negative entries flow back into memory on first re-access via the disk tier. No upstream is consulted, no entries are rewritten.
## In-memory LRU (`CardPreviewService`)
- **Implementation:** Doubly linked list + hash map, guarded by a mutex. Each entry is `{key, payload, negative}`; positive entries hold the bytes, negative entries hold an empty payload and a `negative=true` flag. Hits move the entry to the front regardless of kind.
- **Capacity:** `CardPreviewService::kCacheCapacity` (128 entries — positive and negative entries share this count).
- **Threading:** Preview work can run on a worker thread from the UI layer; all cache access goes through the mutex.
- **Keys:** Internal strings built in `CardPreviewService.cpp`:
- Preview path: prefix `'p'`, then NUL-separated fields: enum `game`, `name`, `setId`, `setNo`. The `setNo` string may embed game-specific disambiguators (for example Yu-Gi-Oh! packs rarity and edition into `setNo` before it reaches the service — see `YuGiOhSelectedCardPanel::previewKey()`).
- URL path: prefix `'u'` plus the full URL string.
Callers should treat the key as opaque; **correctness** depends on passing stable `(game, name, setId, setNo)` (and stable URL for fallback fetches) so the same printing always maps to the same cache entry.
## Disk byte cache (`LocalPreviewByteCache`)
- **Root directory:** `<exeDir>/.cache/preview-cache/` (created on first store). Pinned next to the executable, **not** under the user-configurable `Configuration.dataStorage` path — see "Cache directory layout" above for the rationale.
- **Files per logical entry:** mutually-exclusive `.bin` / `.neg`, plus an always-present `.idx` sidecar:
- `<hash>.bin` — raw image bytes (PNG/JPEG payload). **Positive** entry.
- `<hash>.neg` — zero-byte marker file. **Negative** entry (the upstream cleanly said "no image").
- `<hash>.idx` — text sidecar holding the **exact** cache key string used by `CardPreviewService`. Used to reject hash collisions on load — if `.idx` does not match the requested key, the entry is treated as a miss regardless of which marker file is present.
`store()` removes any existing `.neg` for the same hash; `storeNegative()` removes any existing `.bin`. The two states never co-exist. If they ever somehow did, `load()` prefers the `.bin` (more useful answer).
- **Hash:** FNV-1a 64-bit over the key, rendered as 16 hex digits. Not cryptographic; the `.idx` sidecar is the safety net that prevents collisions from serving the wrong card's bytes or the wrong card's "no image" verdict.
- **Size bound:** Default total payload cap `LocalPreviewByteCache::kDefaultMaxBytes` (64 MiB). Eviction removes **oldest by modification time** among `.bin` files (with their `.idx` sidecars) until the new write fits. **Negative entries** (`.neg` markers) are tiny and are not counted against the cap — their count is naturally bounded by the user's actively-viewed records.
- **Recency on read:** A successful `load` updates the corresponding `.bin` or `.neg` file's mtime (“touch”) so frequently viewed cards are less likely to be evicted.
- **Failure policy:** All adapter I/O failures are swallowed (miss on read, no-op on failed write). Preview still works from network; worst case is “cold” performance.
### Clearing the disk preview cache
- Delete the `<exeDir>/.cache/` folder (or just the `preview-cache/` subfolder inside it). Both options are safe: `LocalPreviewByteCache` recreates the directory on the next store.
- The in-app data-storage reset/relocation flow does **not** touch this directory — the cache is install-scoped (sits next to the exe), not collection-scoped. If you need a clean slate for the cache, delete the directory above explicitly.
## HTTP connection reuse (`CprHttpClient`)
The app constructs **one** `CprHttpClient` and shares it across set sources, preview sources, and image downloads. It owns a single long-lived `cpr::Session` (one libcurl easy handle per process).
- **Benefit:** Repeated HTTPS requests to the **same host** reuse TLS sessions / TCP connections where the server allows keep-alive, which materially reduces latency vs. a fresh session per GET (especially for Yu-Gi-Oh!, where preview resolution and the actual image often hit different hosts).
- **Thread safety:** All `get()` calls are serialized with a mutex because libcurl easy handles are not thread-safe.
This is **not** a response-body cache; it only amortizes connection setup.
## Design constraints (for contributors)
- **Classify source errors honestly.** A new game module's `ICardPreviewSource::fetchImageUrl` must return `PreviewLookupError::Kind::NotFound` only when the upstream answered cleanly (parsed response, no match / no image variants). Anything that could be the network — HTTP error, malformed body, schema deviation, timeout — is `Transient`.
- **Do not cache transient errors.** That's the rule that keeps a flaky connection from permanently disabling previews. If you ever need to record a failure, route it through `IPreviewByteCache::storeNegative` only on a confirmed `NotFound`.
- **Updates flow through the cache key, not a side channel.** Don't add a `clearCache(...)` / `invalidate(...)` API to `CardPreviewService` to "fix" a stale entry. The supported update mechanic is: edit-driven invalidation (key changes), same-key positive/negative replacement on the next successful resolution, and LRU/mtime eviction (see "Updating cached entries" above). A side-channel invalidation API would just be another way for callers to forget to keep the disk tier in sync with the memory tier.
- **Extend cache keys** by packing new disambiguators into existing coordinates (typically `setNo` / tuple encoding) rather than bypassing `CardPreviewService`, so memory and disk tiers stay aligned and editing the record continues to invalidate the negative entry automatically.
- **Tests:**
- `card_preview_service_tests.cpp` pins tier ordering and write-through using an in-memory `IPreviewByteCache` fake; it also exercises the negative-caching behavior end-to-end (NotFound is remembered, Transient is retried, edits invalidate the entry, warm-restart honors the disk negative entry, a later positive overwrites a previous negative).
- `local_preview_byte_cache_tests.cpp` exercises the real-disk adapter in isolated temp directories, including the `.bin`/`.neg`/`.idx` interactions (round-trip, restart, mutual replacement, sidecar collision rejection, eviction).
## Related reading
- [assets-and-info-apis.md](assets-and-info-apis.md) — external APIs and the same preview tiers in **runtime flow** context.
- Root `AGENTS.md` — UI performance guardrails summary.
- `core/AGENTS.md` — conventions for preview caching, source-error classification, and `CprHttpClient` session ownership.
+7 -3
View File
@@ -15,17 +15,20 @@
- `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`.
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
- `magic_card_preview_source_tests.cpp``MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule.
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (the only test in the suite that touches real disk; each case scopes itself to a unique `temp_directory_path()/ccm_preview_cache_test_*` directory and cleans up via an RAII `TempDir`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `rarityCodeFor` (CCM3 dialog rarity names → Yugipedia codes, unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
- `card_sorter_tests.cpp``sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key.
- `card_filter_tests.cpp``matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
- `card_filter_tests.cpp``matchesMagicFilter` / `matchesPokemonFilter` / `matchesYuGiOhFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
## Conventions
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. **One narrow exception**: `local_preview_byte_cache_tests.cpp` exercises the real filesystem because `LocalPreviewByteCache` uses `std::filesystem` directly for size + mtime queries that the `IFileSystem` port deliberately does not expose. Those tests scope themselves to a unique temp directory and clean up unconditionally — do not extend the exception to other test files.
3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it.
4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators.
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
@@ -37,6 +40,7 @@
- After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code.
- After adding a new service in `core/` you **must** add a corresponding `<name>_service_tests.cpp` with at least the happy-path and one error-path test.
- After adding a new game's set source / card preview source you **must** add `tests/<name>_set_source_tests.cpp` and (if applicable) `tests/<name>_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`.
- After adding or changing `ICardPreviewSource` optional capabilities (for example `detectFirstPrint` / `detectPrintVariants`), you **must** extend `card_preview_service_tests.cpp` and the corresponding game source tests to cover both supported and unsupported paths.
## Commands
+3
View File
@@ -16,8 +16,11 @@ add_executable(ccm_core_tests
magic_set_source_tests.cpp
magic_card_preview_source_tests.cpp
card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
yugioh_set_source_tests.cpp
yugioh_card_preview_source_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
+25
View File
@@ -8,6 +8,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/services/CardFilter.hpp"
#include <string>
@@ -52,6 +53,21 @@ PokemonCard pc(std::string name,
return c;
}
YuGiOhCard yc(std::string name,
std::string setName,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
YuGiOhCard c;
c.id = 1;
c.name = std::move(name);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
return c;
}
} // namespace
TEST_SUITE("CardFilter::matchesMagicFilter") {
@@ -158,3 +174,12 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
}
}
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
TEST_CASE("matches by set number and rarity") {
const YuGiOhCard c = yc("Dark Magician", "Legend of Blue Eyes", "LOB-005", "Ultra Rare");
CHECK(matchesYuGiOhFilter(c, "lob-005"));
CHECK(matchesYuGiOhFilter(c, "ultra"));
CHECK_FALSE(matchesYuGiOhFilter(c, "secret rare"));
}
}
+585 -5
View File
@@ -3,9 +3,13 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
using namespace ccm;
@@ -15,21 +19,54 @@ class FakeSource final : public ICardPreviewSource {
public:
std::string url = "https://example.com/preview.jpg";
bool ok = true;
// The kind of error returned when ok == false. Defaults to Transient
// because the historical tests assumed "errors don't get cached"; the
// negative-cache tests flip this to NotFound explicitly.
PreviewLookupError::Kind errKind = PreviewLookupError::Kind::Transient;
std::string err = "boom";
int calls{0};
// Capture inputs so tests can assert routing.
std::string lastName;
std::string lastSetId;
std::string lastSetNo;
std::string detectLastName;
std::string detectLastSetId;
AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
bool allowAutoDetect{true};
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 {
++calls;
lastName = std::string(name);
lastSetId = std::string(setId);
lastSetNo = std::string(setNo);
return ok ? Result<std::string>::ok(url)
: Result<std::string>::err(err);
if (ok) {
return Result<std::string, PreviewLookupError>::ok(url);
}
return Result<std::string, PreviewLookupError>::err({errKind, err});
}
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override {
detectLastName = std::string(name);
detectLastSetId = std::string(setId);
return Result<AutoDetectedPrint>::ok(detectedPrint);
}
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override {
detectLastName = std::string(name);
detectLastSetId = std::string(setId);
std::vector<AutoDetectedPrint> v;
v.push_back(detectedPrint);
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return allowAutoDetect;
}
};
@@ -39,14 +76,48 @@ public:
std::string body;
bool ok = true;
std::string err = "offline";
int calls{0};
Result<std::string> get(std::string_view url) override {
++calls;
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err(err);
}
};
// In-memory persistent cache stand-in. Real implementation lives in
// LocalPreviewByteCache (covered by local_preview_byte_cache_tests.cpp); the
// fake here lets us pin down CardPreviewService's wiring without dragging in
// the filesystem.
class InMemoryByteCache final : public IPreviewByteCache {
public:
struct Entry {
bool negative{false};
std::string payload;
};
std::unordered_map<std::string, Entry> entries;
int loadCalls{0};
int storeCalls{0};
int storeNegativeCalls{0};
[[nodiscard]] LoadResult load(std::string_view key) override {
++loadCalls;
auto it = entries.find(std::string(key));
if (it == entries.end()) return {HitKind::Miss, {}};
if (it->second.negative) return {HitKind::NegativeHit, {}};
return {HitKind::Hit, it->second.payload};
}
void store(std::string_view key, const std::string& payload) override {
++storeCalls;
entries[std::string(key)] = Entry{false, payload};
}
void storeNegative(std::string_view key) override {
++storeNegativeCalls;
entries[std::string(key)] = Entry{true, {}};
}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
@@ -150,3 +221,512 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
CHECK(out.error() == "net down");
}
}
TEST_SUITE("CardPreviewService caching") {
TEST_CASE("repeat fetchPreviewBytes for the same key serves from cache") {
// Re-selecting the same row in the table is the hot path: the
// (game, name, setId, setNo) tuple uniquely identifies a printing,
// and the resolved image URL is a deterministic function of those
// inputs - so we can safely cache the bytes and skip HTTP entirely
// on repeat. Each cache hit spares us two HTTPS round trips
// (Yugipedia API + image GET) plus a TLS handshake on a cold pool.
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
CardPreviewService svc{http};
svc.registerModule(module);
const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(first.isOk());
CHECK(first.value() == "PNG-bytes");
CHECK(http.calls == 1);
// Mutate the source's response to prove the second call doesn't go
// through the source either: only the cache should be consulted.
source.url = "https://example.com/CHANGED.png";
http.body = "DIFFERENT-bytes";
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "PNG-bytes");
CHECK(http.calls == 1); // no new GET issued
}
TEST_CASE("different cards share the same source but get separate cache slots") {
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
source.url = "https://example.com/a.png";
http.body = "A-bytes";
const auto a = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
REQUIRE(a.isOk());
CHECK(a.value() == "A-bytes");
source.url = "https://example.com/b.png";
http.body = "B-bytes";
const auto b = svc.fetchPreviewBytes(Game::Magic, "Card B", "lea", "");
REQUIRE(b.isOk());
CHECK(b.value() == "B-bytes");
CHECK(http.calls == 2);
// Re-fetching A returns the originally cached payload, not the most
// recent http.body; this pins down per-card cache scoping.
http.body = "stale";
const auto aAgain = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
REQUIRE(aAgain.isOk());
CHECK(aAgain.value() == "A-bytes");
CHECK(http.calls == 2);
}
TEST_CASE("source errors are not cached so a transient failure can recover") {
// If the per-game source returns an error we should not poison the
// cache - the next selection must be free to retry, otherwise a
// single network blip would permanently disable previews for that
// card until the user restarts the app.
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
CardPreviewService svc{http};
svc.registerModule(module);
source.ok = false;
source.err = "transient 503";
const auto firstErr = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
CHECK(firstErr.isErr());
CHECK(http.calls == 0); // source short-circuited before HTTP
source.ok = true;
source.url = "https://example.com/x.png";
const auto recovered = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
REQUIRE(recovered.isOk());
CHECK(recovered.value() == "PNG-bytes");
CHECK(http.calls == 1);
}
TEST_CASE("HTTP success writes through to the persistent cache") {
// The persistent tier is fire-and-forget on the way down (HTTP -> disk)
// and consulted on the way up (cache miss -> disk -> HTTP). This first
// sub-case exercises the write-through path.
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "PNG-bytes";
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(disk.storeCalls == 1);
CHECK(disk.entries.size() == 1);
// Key shape is service-internal so we don't assert it directly; only
// that *something* identifying this card was persisted with the right
// payload.
bool foundPayload = false;
for (const auto& [k, e] : disk.entries) {
if (!e.negative && e.payload == "PNG-bytes") foundPayload = true;
}
CHECK(foundPayload);
}
TEST_CASE("warm restart: a fresh service instance serves from disk without HTTP") {
// Simulates an app restart by constructing two CardPreviewService
// instances over the same persistent cache. The second instance
// must serve the previously-fetched bytes from disk - no source
// call, no HTTP call - which is the whole point of the persistent
// tier.
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::YuGiOh;
module.preview = &source;
FixedHttpClient http;
http.body = "DM-bytes";
InMemoryByteCache disk;
// Run 1: cold start, normal fetch path warms the persistent cache.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(
Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
REQUIRE(out.isOk());
}
REQUIRE(http.calls == 1);
REQUIRE(disk.entries.size() == 1);
// Run 2: pretend the app restarted. Make HTTP and the source both
// return obviously-wrong data so any unexpected fall-through to
// them shows up loudly in the assertion.
http.body = "UNEXPECTED";
source.url = "https://example.com/UNEXPECTED.png";
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto warm = svc.fetchPreviewBytes(
Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
REQUIRE(warm.isOk());
CHECK(warm.value() == "DM-bytes");
}
// No second HTTP request - disk tier covered it.
CHECK(http.calls == 1);
CHECK(disk.loadCalls >= 1);
}
TEST_CASE("disk hit promotes into the in-memory tier so subsequent calls skip even disk I/O") {
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = "X-bytes";
InMemoryByteCache disk;
// Pre-seed the disk cache with a value the service has never fetched
// this session. The first call should pull it off disk and copy it
// into memory; the second call should be served from memory without
// touching the disk cache at all.
// We can't compose the key from outside, so we go through the
// service once to learn the key shape via storeCalls.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
(void)svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
}
REQUIRE(http.calls == 1);
REQUIRE(disk.entries.size() == 1);
const auto storedKey = disk.entries.begin()->first;
// Clear the in-memory tier by constructing a fresh service. Disk
// entry stays.
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const int loadsBefore = disk.loadCalls;
// Force HTTP to fail loudly so we can prove neither call hits it.
http.ok = false;
const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(first.isOk());
CHECK(first.value() == "X-bytes");
CHECK(disk.loadCalls == loadsBefore + 1);
const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(second.isOk());
CHECK(second.value() == "X-bytes");
// No additional disk hit - in-memory tier served it.
CHECK(disk.loadCalls == loadsBefore + 1);
// Sanity: only the original storedKey ever made it to disk.
CHECK(disk.entries.count(storedKey) == 1);
}
TEST_CASE("transient source errors do not write through to the persistent cache") {
// A transient failure (HTTP 5xx, network drop, parse error) must
// never get cached - if it did, a single bad request would
// silently break this card's preview until the user restarts the
// app or edits the record.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::Transient;
source.err = "scryfall 503";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
CHECK(out.isErr());
CHECK(disk.storeCalls == 0);
CHECK(disk.storeNegativeCalls == 0);
CHECK(disk.entries.empty());
}
TEST_CASE("NotFound source errors are negative-cached and short-circuit subsequent calls") {
// A NotFound result means the upstream cleanly answered "no image
// for this record". The next selection of the *same* record must
// return immediately without invoking the source again - that's
// the whole point of negative caching, and the user-visible win
// (no spinner, no per-click HTTP) on every selection of a card
// whose printing has no scan upstream.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan exists";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto first = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
CHECK(first.isErr());
CHECK(source.calls == 1);
CHECK(disk.storeCalls == 0);
CHECK(disk.storeNegativeCalls == 1);
// Second call: the source must NOT be consulted again. The
// in-memory negative entry covers it.
const auto second = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
CHECK(second.isErr());
CHECK(source.calls == 1); // still 1 - cache short-circuited
CHECK(http.calls == 0);
}
TEST_CASE("editing a lookup-relevant field invalidates the negative entry automatically") {
// The cache key is (game, name, setId, setNo). The user fixing a
// typo in the card's name (or any other lookup-relevant field)
// changes the key, so the previously-cached "no image" verdict no
// longer matches and a fresh resolution attempt runs.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan exists";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
// First lookup: the typo name produces a clean NotFound -> negative cache.
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "").isErr());
REQUIRE(source.calls == 1);
// Now flip the source to "found" and ask for the corrected name.
source.ok = true;
source.url = "https://example.com/lb.png";
http.body = "LB-bytes";
const auto fixed = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(fixed.isOk());
CHECK(fixed.value() == "LB-bytes");
CHECK(source.calls == 2);
// And the original (typo) record stays negative-cached.
source.ok = false; // belt-and-braces: prove the typo path doesn't re-call the source
const auto stillNegative =
svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "");
CHECK(stillNegative.isErr());
CHECK(source.calls == 2); // typo path never re-resolved
}
TEST_CASE("warm restart honors a previously-stored negative entry without HTTP") {
// Persistent negative caching is the strongest motivation for the
// disk tier carrying negatives at all: a card with no upstream
// image stays "instant card-back" across app restarts, instead of
// re-paying the round-trip to learn the same thing every launch.
FakeSource source;
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan exists";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
// Run 1: negative outcome, persisted.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Z", "set", "").isErr());
}
REQUIRE(disk.storeNegativeCalls == 1);
const int sourceCallsAfterRun1 = source.calls;
// Run 2: fresh service over the same disk fake. Source must not be
// consulted at all - the negative entry on disk short-circuits it.
{
CardPreviewService svc{http, &disk};
svc.registerModule(module);
const auto warm = svc.fetchPreviewBytes(Game::Magic, "Z", "set", "");
CHECK(warm.isErr());
}
CHECK(source.calls == sourceCallsAfterRun1);
}
TEST_CASE("a later positive result for the same key replaces the negative entry") {
// If the upstream eventually grows a scan for a card that was
// previously NotFound, the very next successful lookup must
// overwrite the negative entry so it doesn't keep haunting the
// user. Realistic scenario: Yugipedia uploads a scan that didn't
// exist before; the user clicks the row again and we now serve
// the real image. (In production the cache key may also change
// due to a record edit, but that's a separate path; here we test
// the same-key recovery story.)
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
InMemoryByteCache disk;
CardPreviewService svc{http, &disk};
svc.registerModule(module);
// First call: NotFound.
source.ok = false;
source.errKind = PreviewLookupError::Kind::NotFound;
source.err = "no scan yet";
REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Card", "set", "").isErr());
// Imitate the upstream growing a scan, AND simulate the user
// retrying. To bypass the negative cache without a record edit,
// construct a fresh service: this models the next-launch case
// where the user reopens the app and the image now exists. (Within
// a single session, only an edit to the record clears the cache,
// which is the correct UX - we don't want a network round-trip on
// every click for cards we already know have no upstream image.)
source.ok = true;
source.url = "https://example.com/late.png";
http.body = "LATE-bytes";
// Drop the disk negative manually to simulate a scenario where
// the cache was cleared (e.g. the user manually wiped the cache
// directory). In production code paths, an edit to the record is
// the standard invalidation; both routes converge on the same
// expected behavior: a fresh successful lookup.
disk.entries.clear();
CardPreviewService svc2{http, &disk};
svc2.registerModule(module);
const auto out = svc2.fetchPreviewBytes(Game::Magic, "Card", "set", "");
REQUIRE(out.isOk());
CHECK(out.value() == "LATE-bytes");
}
TEST_CASE("fetchImageBytesByUrl caches by URL too (fallback card-back)") {
// The fallback card-back image is fetched via fetchImageBytesByUrl
// for every card without a preview; caching by URL makes the second
// fallback effectively free.
FixedHttpClient http;
http.body = "card-back-bytes";
CardPreviewService svc{http};
const auto first = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(first.isOk());
CHECK(http.calls == 1);
http.body = "stale";
const auto second = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
REQUIRE(second.isOk());
CHECK(second.value() == "card-back-bytes");
CHECK(http.calls == 1);
}
}
TEST_SUITE("CardPreviewService::detectFirstPrint") {
TEST_CASE("routes auto-detect to registered source") {
FakeSource source;
source.detectedPrint = {"LOB-005", "Secret Rare"};
FakeGameModule module;
module.gameId = Game::YuGiOh;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
CHECK(out.value().setNo == "LOB-005");
CHECK(out.value().rarity == "Secret Rare");
CHECK(source.detectLastName == "Dark Magician");
CHECK(source.detectLastSetId == "Legend of Blue Eyes White Dragon");
}
TEST_CASE("returns explicit error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectFirstPrint(Game::Magic, "Any", "Any Set");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectPrintVariants") {
TEST_CASE("routes variant listing to registered source") {
FakeSource source;
source.detectedPrint = {"LOB-005", "Secret Rare"};
FakeGameModule module;
module.gameId = Game::YuGiOh;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectPrintVariants(Game::YuGiOh, "Dark Magician",
"Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "LOB-005");
CHECK(out.value()[0].rarity == "Secret Rare");
}
TEST_CASE("detectPrintVariants returns error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectPrintVariants(Game::Magic, "Any", "Any Set");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
+36
View File
@@ -8,6 +8,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/CardSorter.hpp"
@@ -59,6 +60,22 @@ PokemonCard pc(std::uint32_t id, std::string name,
return c;
}
YuGiOhCard yc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::string setNo = "",
std::string rarity = "",
std::uint8_t amount = 1) {
YuGiOhCard c;
c.id = id;
c.name = std::move(name);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.setNo = std::move(setNo);
c.rarity = std::move(rarity);
c.amount = amount;
return c;
}
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
@@ -73,6 +90,13 @@ std::vector<std::uint32_t> ids(const std::vector<PokemonCard>& v) {
return out;
}
std::vector<std::uint32_t> ids(const std::vector<YuGiOhCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
for (const auto& c : v) out.push_back(c.id);
return out;
}
} // namespace
TEST_SUITE("CardSorter - Magic columns") {
@@ -246,3 +270,15 @@ TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
CHECK(v.front().id == 42);
}
}
TEST_SUITE("CardSorter - YuGiOh columns") {
TEST_CASE("Amount sorts numerically") {
std::vector<YuGiOhCard> v = {
yc(1, "a", "X", "2000/01/01", "", "", 9),
yc(2, "b", "X", "2000/01/01", "", "", 11),
yc(3, "c", "X", "2000/01/01", "", "", 1),
};
sortYuGiOhCards(v, YuGiOhSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
}
+33
View File
@@ -4,6 +4,7 @@
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
@@ -19,6 +20,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json j2 = "Pokemon";
CHECK(j2.get<Game>() == Game::Pokemon);
nlohmann::json jYgo = "YuGiOh";
CHECK(jYgo.get<Game>() == Game::YuGiOh);
nlohmann::json j3 = Theme::Dark;
CHECK(j3.get<std::string>() == "Dark");
CHECK(j3.get<Theme>() == Theme::Dark);
@@ -121,3 +125,32 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
CHECK(back == cfg);
}
}
TEST_SUITE("YuGiOhCard JSON") {
TEST_CASE("round-trips with setNo and rarity fields") {
YuGiOhCard c;
c.id = 77;
c.amount = 2;
c.name = "Blue-Eyes White Dragon";
c.set = Set{"SDK-001", "Starter Deck Kaiba", "2002/03/29"};
c.setNo = "SDK-001";
c.rarity = "Ultra Rare";
c.rarityCode = "(UR)";
c.note = "classic";
c.images = {"77+starter+blue-eyes+0.png"};
c.language = Language::English;
c.condition = Condition::NearMint;
c.firstEdition = true;
c.signed_ = false;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "SDK-001");
CHECK(j.at("rarity") == "Ultra Rare");
CHECK(j.at("rarityCode") == "(UR)");
CHECK(j.at("signed") == false);
const YuGiOhCard back = j.get<YuGiOhCard>();
CHECK(back == c);
}
}
+293
View File
@@ -0,0 +1,293 @@
#include <doctest/doctest.h>
// LocalPreviewByteCache is the only ccm_core test in this binary that
// exercises the real filesystem - by design. The adapter's reason for
// existing is to translate IPreviewByteCache calls into actual on-disk
// state (binary payloads, sidecar key files, mtime-driven LRU eviction
// via std::filesystem), and stubbing those primitives just to test the
// in-memory bookkeeping would defeat the whole point. We pin every test
// to a fresh, unique directory under the OS temp path and clean it up
// even on assertion failure.
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include <chrono>
#include <filesystem>
#include <random>
#include <string>
#include <thread>
using namespace ccm;
namespace fs = std::filesystem;
namespace {
// Creates and removes a unique temp directory with strong cleanup
// guarantees. Doctest does not have RAII fixtures by default; this struct
// provides the same effect via destructor.
struct TempDir {
fs::path path;
TempDir() {
std::random_device rd;
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
path = fs::temp_directory_path() /
(std::string("ccm_preview_cache_test_") + std::to_string(stamp) +
"_" + std::to_string(rd()));
std::error_code ec;
fs::create_directories(path, ec);
}
~TempDir() {
std::error_code ec;
fs::remove_all(path, ec);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
};
// Touch helper: nudges a file's mtime backwards so eviction-by-oldest is
// deterministic regardless of FS timestamp resolution.
void backdate(const fs::path& p, int seconds) {
std::error_code ec;
auto t = fs::last_write_time(p, ec);
if (ec) return;
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
}
} // namespace
TEST_SUITE("LocalPreviewByteCache") {
TEST_CASE("store then load round-trips bytes verbatim") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Lightning Bolt|lea|161";
const std::string body = std::string("\x89PNG\r\n\x1a\n", 8) + std::string(2048, '\xab');
cache.store(key, body);
const auto loaded = cache.load(key);
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
CHECK(loaded.payload == body);
}
TEST_CASE("missing key is a clean miss, not an error") {
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
CHECK(cache.load("never-stored").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("empty payload is silently skipped") {
// Empty would be an indistinguishable miss anyway, and writing it
// would waste an inode. The contract is: we treat it as a no-op.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("k", "");
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
CHECK(cache.currentSizeBytes() == 0);
}
TEST_CASE("hash collision check via sidecar mismatch is treated as a miss") {
// We fake a collision by writing the same hash file with a wrong
// sidecar key. Even though the .bin is present, load() must
// reject it - otherwise we'd serve the wrong card's bytes, which
// is much worse than re-fetching.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.store("real-key", "REAL");
// Find the .idx and corrupt the key inside.
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
StdFileSystem io;
(void)io.writeText(entry.path(), "different-key");
break;
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("survives an adapter restart over the same directory") {
// The whole point of the disk tier is persistence. Exercise it by
// dropping one cache instance and bringing up a new one against
// the same path - the previously-stored entry must come back.
TempDir td;
StdFileSystem fs;
const std::string key = "ygo|Dark Magician|SDY|6|1E";
const std::string body(4096, 'D');
{
LocalPreviewByteCache writer(fs, td.path);
writer.store(key, body);
}
{
LocalPreviewByteCache reader(fs, td.path);
const auto loaded = reader.load(key);
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
CHECK(loaded.payload == body);
}
}
TEST_CASE("storeNegative round-trips as NegativeHit, not a miss and not a payload") {
// Negative entries are the "we asked, the upstream said no image"
// marker. They must round-trip as their own load kind so the
// service can short-circuit without re-resolving the URL.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Bogus Card|lea|";
cache.storeNegative(key);
const auto loaded = cache.load(key);
CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit);
CHECK(loaded.payload.empty());
CHECK(cache.currentSizeBytes() == 0); // negatives don't take real space
}
TEST_CASE("negative entries survive an adapter restart") {
// The "warm restart, same negative answer instantly" property is
// the whole reason negatives go to disk. Verify it explicitly so
// it can never silently regress.
TempDir td;
StdFileSystem fs;
const std::string key = "ygo|Truly Missing|SET|999||UE";
{
LocalPreviewByteCache writer(fs, td.path);
writer.storeNegative(key);
}
{
LocalPreviewByteCache reader(fs, td.path);
CHECK(reader.load(key).kind == IPreviewByteCache::HitKind::NegativeHit);
}
}
TEST_CASE("a later positive store overwrites an earlier negative entry") {
// Upstream eventually grows a scan: the next positive store has
// to flip the entry. If the .neg lingered we'd keep returning a
// NegativeHit even after a successful network resolution, which
// would defeat the recovery story.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Late|lea|";
const std::string body = std::string(1024, 'L');
cache.storeNegative(key);
REQUIRE(cache.load(key).kind == IPreviewByteCache::HitKind::NegativeHit);
cache.store(key, body);
const auto loaded = cache.load(key);
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
CHECK(loaded.payload == body);
}
TEST_CASE("storeNegative for an existing positive entry replaces the bytes") {
// Symmetric to the previous case: an upstream that *had* a scan
// and now reports "no image" (e.g. file deleted) flips the entry
// back to negative. The .bin must go away so it doesn't keep
// serving stale bytes and doesn't keep wasting cap space.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
const std::string key = "magic|Recall|lea|";
cache.store(key, std::string(2048, 'R'));
REQUIRE(cache.currentSizeBytes() > 0);
cache.storeNegative(key);
const auto loaded = cache.load(key);
CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit);
CHECK(cache.currentSizeBytes() == 0);
}
TEST_CASE("collision check applies to negative entries too") {
// A hash collision must not let a negative entry stored under
// key A "leak" into a load of key B. We fake the collision by
// rewriting the .idx sidecar with a different key.
TempDir td;
StdFileSystem fs;
LocalPreviewByteCache cache(fs, td.path);
cache.storeNegative("real-key");
for (const auto& entry : fs::directory_iterator(td.path)) {
if (entry.path().extension() == ".idx") {
std::error_code ec;
fs::remove(entry.path(), ec);
StdFileSystem io;
(void)io.writeText(entry.path(), "different-key");
break;
}
}
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
}
TEST_CASE("evicts oldest entry when the size cap would be exceeded") {
TempDir td;
StdFileSystem fs;
// Cap is just big enough to hold 2 of the 1 KiB payloads but not 3.
constexpr std::size_t kCap = 2'400;
LocalPreviewByteCache cache(fs, td.path, kCap);
const std::string a(1024, 'a');
const std::string b(1024, 'b');
const std::string c(1024, 'c');
cache.store("k-a", a);
// Make 'a' look definitively older than 'b' regardless of the FS's
// mtime resolution (some filesystems round to whole seconds, which
// can otherwise make the test flaky).
for (const auto& entry : fs::directory_iterator(td.path)) {
backdate(entry.path(), /*seconds=*/10);
}
cache.store("k-b", b);
cache.store("k-c", c); // would push us to ~3 KiB; 'a' must go.
CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Miss);
CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Hit);
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
CHECK(cache.currentSizeBytes() <= kCap);
}
TEST_CASE("a hit refreshes mtime so the entry is not the next eviction victim") {
// Without the touch-on-load behavior, an entry that the user
// re-views constantly would still age out simply because newer
// entries piled on top. Verify we promote on hit.
TempDir td;
StdFileSystem fs;
constexpr std::size_t kCap = 2'400;
LocalPreviewByteCache cache(fs, td.path, kCap);
const std::string a(1024, 'a');
const std::string b(1024, 'b');
const std::string c(1024, 'c');
cache.store("k-a", a);
cache.store("k-b", b);
// Backdate both so the upcoming touch on 'a' is unambiguously newer.
for (const auto& entry : fs::directory_iterator(td.path)) {
backdate(entry.path(), /*seconds=*/10);
}
REQUIRE(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit); // touches 'a' to "now".
cache.store("k-c", c); // forces an eviction.
// 'a' was the youngest after the touch, so 'b' should be the victim.
CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Miss);
CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit);
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
}
}
+19 -10
View File
@@ -64,38 +64,47 @@ TEST_SUITE("MagicCardPreviewSource::parseResponse") {
CHECK(out.value() == "https://img.scryfall.io/normal.jpg");
}
TEST_CASE("empty data array returns an error") {
TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") {
const auto out = MagicCardPreviewSource::parseResponse(R"({"data":[]})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("missing data array returns an error") {
TEST_CASE("missing data array is classified as Transient (schema deviation)") {
// No `data` array at all means the API contract failed - this is
// not the user's record being weird, it's the upstream not
// talking to us right now.
const auto out = MagicCardPreviewSource::parseResponse(R"({"meta":{}})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("entry without image_uris returns an error (double-faced cards)") {
TEST_CASE("entry without image_uris is classified as NotFound (double-faced cards)") {
const std::string json = R"({
"data": [
{"name":"DoubleFace","card_faces":[{"image_uris":{"normal":"x"}}]}
]
})";
const auto out = MagicCardPreviewSource::parseResponse(json);
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("invalid JSON returns an error") {
TEST_CASE("invalid JSON is classified as Transient") {
const auto out = MagicCardPreviewSource::parseResponse("{not json");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("MagicCardPreviewSource::fetchImageUrl") {
TEST_CASE("network error is surfaced as a Result error") {
TEST_CASE("network error is surfaced as Transient") {
FixedHttpClient http;
http.ok = false;
MagicCardPreviewSource src{http};
CHECK(src.fetchImageUrl("Lightning Bolt", "lea", "").isErr());
const auto out = src.fetchImageUrl("Lightning Bolt", "lea", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
+16 -10
View File
@@ -85,34 +85,40 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
CHECK(out.value() == "https://small.only/img.png");
}
TEST_CASE("empty data array returns an error") {
TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("missing data array returns an error") {
TEST_CASE("missing data array is classified as Transient (schema deviation)") {
const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("entry without images returns an error") {
TEST_CASE("entry without images is classified as NotFound") {
const auto out = PokemonCardPreviewSource::parseResponse(
R"({"data":[{"name":"Pikachu"}]})");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("invalid JSON returns an error") {
TEST_CASE("invalid JSON is classified as Transient") {
const auto out = PokemonCardPreviewSource::parseResponse("{not json");
CHECK(out.isErr());
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("network error is surfaced as a Result error") {
TEST_CASE("network error is surfaced as Transient") {
FixedHttpClient http;
http.ok = false;
PokemonCardPreviewSource src{http};
CHECK(src.fetchImageUrl("Pikachu", "base1", "").isErr());
const auto out = src.fetchImageUrl("Pikachu", "base1", "");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
+30 -1
View File
@@ -26,7 +26,11 @@ public:
Game gameId;
explicit FakeGameModule(Game id) : gameId(id) {}
Game id() const noexcept override { return gameId; }
std::string dirName() const override { return gameId == Game::Magic ? "magic" : "pokemon"; }
std::string dirName() const override {
if (gameId == Game::Magic) return "magic";
if (gameId == Game::Pokemon) return "pokemon";
return "yugioh";
}
std::string displayName() const override { return dirName(); }
ISetSource& setSource() override { return source; }
};
@@ -116,4 +120,29 @@ TEST_SUITE("SetService") {
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
}
TEST_CASE("YuGiOh module routes independently when all games are registered") {
InMemSetRepo repo;
SetService svc{repo};
FakeGameModule magic{Game::Magic};
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
FakeGameModule pokemon{Game::Pokemon};
pokemon.source.result = Result<std::vector<Set>>::ok({{"base1", "Base", "1999/01/09"}});
FakeGameModule yugioh{Game::YuGiOh};
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
svc.registerModule(&magic);
svc.registerModule(&pokemon);
svc.registerModule(&yugioh);
REQUIRE(svc.updateSets(Game::Magic).isOk());
REQUIRE(svc.updateSets(Game::Pokemon).isOk());
const auto ygo = svc.updateSets(Game::YuGiOh);
REQUIRE(ygo.isOk());
CHECK(ygo.value().front().id == "LOB");
CHECK(magic.source.calls == 1);
CHECK(pokemon.source.calls == 1);
CHECK(yugioh.source.calls == 1);
}
}
+613
View File
@@ -0,0 +1,613 @@
#include <doctest/doctest.h>
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace ccm;
namespace {
// Single-shot HTTP fake matching the other game tests. Captures the last URL
// requested and returns a configurable body / failure flag.
class FixedHttpClient final : public IHttpClient {
public:
std::string lastUrl;
std::string body;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err("offline");
}
};
// Routing fake used to simulate the two-step Yugipedia -> YGOPRODeck flow.
// Both bodies are queued by URL substring so order-of-call assertions stay
// readable in the tests. Unmatched URLs return a 404-equivalent error so
// regressions are obvious instead of silently passing.
class RoutingHttpClient final : public IHttpClient {
public:
std::string yugipediaBody;
std::string ygoprodeckBody;
bool yugipediaOk = true;
bool ygoprodeckOk = true;
std::vector<std::string> calls;
Result<std::string> get(std::string_view url) override {
std::string s(url);
calls.push_back(s);
if (s.find("yugipedia.com") != std::string::npos) {
return yugipediaOk ? Result<std::string>::ok(yugipediaBody)
: Result<std::string>::err("yugipedia offline");
}
if (s.find("ygoprodeck.com") != std::string::npos) {
return ygoprodeckOk ? Result<std::string>::ok(ygoprodeckBody)
: Result<std::string>::err("ygoprodeck offline");
}
return Result<std::string>::err("RoutingHttpClient: unrouted URL");
}
};
} // namespace
TEST_SUITE("ygoPrintingSlotsMatch") {
TEST_CASE("matches CCM composed setNo to YGOPRODeck set_code with region infix") {
CHECK(ygoPrintingSlotsMatch("SOD-015", "SOD-EN015"));
CHECK(ygoPrintingSlotsMatch("sod-015", "SOD-EN015"));
CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "SOD-EN016"));
CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "IOC-EN015"));
}
TEST_CASE("matches German-style and digits-only suffix variants") {
CHECK(ygoPrintingSlotsMatch("LOB-005", "LOB-DE005"));
CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001"));
}
TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") {
CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
}
}
TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
TEST_CASE("strips whitespace and policy-banned punctuation") {
// Yugipedia's image policy: whitespace and a fixed punctuation set
// (#,.:'"?!&@%=[]<>/\- and friends) get dropped from the slug.
CHECK(YuGiOhCardPreviewSource::normalizeName("Blue-Eyes White Dragon")
== "BlueEyesWhiteDragon");
CHECK(YuGiOhCardPreviewSource::normalizeName("Dark Magician") == "DarkMagician");
CHECK(YuGiOhCardPreviewSource::normalizeName("Sasuke Samurai #4")
== "SasukeSamurai4");
CHECK(YuGiOhCardPreviewSource::normalizeName("Don't Talk to Me!")
== "DontTalktoMe");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
TEST_CASE("maps the dialog's rarity options to Yugipedia codes") {
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Common") == "C");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Rare") == "R");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Super Rare") == "SR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Ultra Rare") == "UR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
== "QCScR");
}
TEST_CASE("returns empty string for unknown rarity names") {
// Unknown rarity should fall through to the rarity-less filename
// pattern, not throw and not return a misleading code.
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty());
CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") {
TEST_CASE("returns everything before the first dash") {
CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-005") == "LOB");
CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-DE005") == "LOB");
CHECK(YuGiOhCardPreviewSource::extractSetCode("RA04-EN001") == "RA04");
}
TEST_CASE("returns the input unchanged when no dash is present") {
CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB") == "LOB");
CHECK(YuGiOhCardPreviewSource::extractSetCode("").empty());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::buildCandidateFilenames") {
TEST_CASE("primary candidate uses the printed edition + EN region + .png") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Blue-Eyes White Dragon", "LOB", "UR", /*firstEdition=*/false);
REQUIRE_FALSE(names.empty());
// Highest-priority filename: Yugipedia's modern English/UE/.png
// shape, which is what most LOB-era reprints actually use.
CHECK(names.front() == "BlueEyesWhiteDragon-LOB-EN-UR-UE.png");
}
TEST_CASE("includes 1E when the user marked the card as first edition") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Dark Magician", "SDY", "UR", /*firstEdition=*/true);
REQUIRE_FALSE(names.empty());
CHECK(names.front() == "DarkMagician-SDY-EN-UR-1E.png");
}
TEST_CASE("covers EN/NA/EU/AU regions and both png/jpg extensions") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Blue-Eyes White Dragon", "SDK", "UR", /*firstEdition=*/false);
// Sanity: the standard SDK Blue-Eyes scan is hosted at NA-UR-UE.png,
// so the candidate list must include that exact filename.
bool foundNaUe = false;
for (const auto& n : names) {
if (n == "BlueEyesWhiteDragon-SDK-NA-UR-UE.png") foundNaUe = true;
}
CHECK(foundNaUe);
}
TEST_CASE("falls back to a rarity-less pattern for unknown rarities") {
const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
"Token Card", "TKN", /*rarityCode=*/"", /*firstEdition=*/false);
REQUIRE_FALSE(names.empty());
// Rarity slot omitted -> filename has only one dash before edition.
CHECK(names.front() == "TokenCard-TKN-EN-UE.png");
}
TEST_CASE("returns empty list when the slug or set code is empty") {
// Don't waste an HTTP call on cards that haven't been filled in yet.
CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("", "LOB", "UR", false).empty());
CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("Dark Magician", "", "UR", false).empty());
}
}
TEST_SUITE("YuGiOhCardPreviewSource::buildYugipediaQueryUrl") {
TEST_CASE("encodes filenames into a single MediaWiki batch query") {
const std::vector<std::string> names = {
"BlueEyesWhiteDragon-LOB-EN-UR-UE.png",
"BlueEyesWhiteDragon-LOB-NA-UR-UE.png",
};
const std::string url = YuGiOhCardPreviewSource::buildYugipediaQueryUrl(names);
CHECK(url.find("https://yugipedia.com/api.php") == 0);
CHECK(url.find("action=query") != std::string::npos);
CHECK(url.find("prop=imageinfo") != std::string::npos);
CHECK(url.find("iiprop=url") != std::string::npos);
// The two filenames are joined with `|` (URL-encoded as %7C) and
// each one is namespaced with `File:` (encoded `File%3A`).
CHECK(url.find("File%3ABlueEyesWhiteDragon-LOB-EN-UR-UE.png") != std::string::npos);
CHECK(url.find("%7CFile%3ABlueEyesWhiteDragon-LOB-NA-UR-UE.png") != std::string::npos);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
TEST_CASE("returns the URL of the highest-priority filename that exists") {
// MediaWiki returns one entry per requested title. Missing files come
// back with `"missing": ""` and no imageinfo; existing files carry an
// imageinfo[0].url. Order matters: we must walk filenameOrder and
// pick the first entry that resolved, not whichever MediaWiki listed
// first in its hash-keyed `pages` object.
const std::string body = R"({
"query":{"pages":{
"-1":{"title":"File:DarkMagician-SDY-EN-UR-1E.png","missing":""},
"96018":{"title":"File:DarkMagician-SDY-NA-UR-UE.png",
"imageinfo":[{"url":"https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"}]},
"99999":{"title":"File:DarkMagician-SDY-EU-UR-UE.png",
"imageinfo":[{"url":"https://ms.yugipedia.com/eu/DarkMagician-SDY-EU-UR-UE.png"}]}
}}
})";
const std::vector<std::string> order = {
"DarkMagician-SDY-EN-UR-UE.png", // missing from response entirely
"DarkMagician-SDY-EN-UR-1E.png", // present but `missing`
"DarkMagician-SDY-NA-UR-UE.png", // first existing one
"DarkMagician-SDY-EU-UR-UE.png",
};
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(body, order);
REQUIRE(out.isOk());
CHECK(out.value() == "https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png");
}
TEST_CASE("pure-miss response is classified as NotFound") {
// Pure-miss response: every page is marked `missing`. The caller
// (fetchImageUrl) treats this as "no Yugipedia scan" and triggers
// the YGOPRODeck fallback. Yugipedia having explicitly answered
// "no such file" is a clean negative (the upstream contract was
// honored), not a transient failure.
const std::string body = R"({
"query":{"pages":{
"-1":{"title":"File:Whatever-XYZ-EN-UR-UE.png","missing":""},
"-2":{"title":"File:Whatever-XYZ-NA-UR-UE.png","missing":""}
}}
})";
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
body, {"Whatever-XYZ-EN-UR-UE.png", "Whatever-XYZ-NA-UR-UE.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("malformed JSON is classified as Transient") {
const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
"{not json", {"Anything-XYZ-EN-UR-UE.png"});
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
TEST_CASE("returns first print for preferred set name") {
const std::string json = R"({
"data":[
{"card_sets":[
{"set_name":"Metal Raiders","set_code":"MRD-001","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parseFirstPrint(
json, "Legend of Blue Eyes White Dragon");
REQUIRE(out.isOk());
CHECK(out.value().setNo == "LOB-001");
CHECK(out.value().rarity == "Ultra Rare");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
TEST_CASE("lists distinct set_code entries for an exact name in one display set") {
const std::string json = R"({
"data":[
{"name":"Test Goblin",
"card_sets":[
{"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"},
{"set_name":"Mega Pack","set_code":"MP21-EN002","set_rarity":"Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Mega Pack", "Test Goblin");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "MP21-EN001");
CHECK(out.value()[1].setNo == "MP21-EN002");
}
TEST_CASE("lists multiple rarities for one set_code") {
const std::string json = R"({
"data":[
{"name":"Odd-Eyes Pendulum Dragon",
"card_sets":[
{"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Super Rare"},
{"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Ultimate Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Duelist Alliance", "Odd-Eyes Pendulum Dragon");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].setNo == "DUEA-EN004");
CHECK(out.value()[0].rarity == "Super Rare");
CHECK(out.value()[1].setNo == "DUEA-EN004");
CHECK(out.value()[1].rarity == "Ultimate Rare");
}
TEST_CASE("dedupes identical print pairs") {
const std::string json = R"({
"data":[
{"name":"Mirror Force",
"card_sets":[
{"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"},
{"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"}
]}
]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Starter Deck", "Mirror Force");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].setNo == "SDY-043");
CHECK(out.value()[0].rarity == "Super Rare");
}
}
// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
// (same_card_same_set: distinct collector numbers vs distinct rarities).
[[nodiscard]] std::size_t countDistinctSetCodes(const std::vector<AutoDetectedPrint>& v) {
std::unordered_set<std::string> codes;
for (const auto& p : v) {
if (!p.setNo.empty()) codes.insert(p.setNo);
}
return codes.size();
}
[[nodiscard]] std::size_t maxRarityVariantsPerCode(const std::vector<AutoDetectedPrint>& v) {
std::unordered_map<std::string, std::unordered_set<std::string>> byCode;
for (const auto& p : v) {
if (p.setNo.empty() || p.rarity.empty()) continue;
byCode[p.setNo].insert(p.rarity);
}
std::size_t mx = 0;
for (const auto& e : byCode) {
mx = std::max(mx, e.second.size());
}
return mx;
}
TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_variant_tests") {
TEST_CASE("S1-001 Armed Dragon LV7 Soul of the Duelist Ultra vs Ultimate") {
const std::string json = R"({
"data":[{
"name":"Armed Dragon LV7",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Armed Dragon LV7");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 2);
}
TEST_CASE("S1-002 Horus LV8 Soul of the Duelist Ultra vs Ultimate") {
const std::string json = R"({
"data":[{
"name":"Horus the Black Flame Dragon LV8",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultimate Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Horus the Black Flame Dragon LV8");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 2);
}
TEST_CASE("S1-003 Mobius Soul of the Duelist Super vs Ultimate") {
const std::string json = R"({
"data":[{
"name":"Mobius the Frost Monarch",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Super Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Ultimate Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Mobius the Frost Monarch");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 2);
}
TEST_CASE("S2-001 Dark Magician Yugi's Legendary Decks three set codes") {
const std::string json = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENB02","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC09","set_rarity":"Common"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Yugi's Legendary Decks", "Dark Magician");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 3);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("S2-002 Dark Magician Girl Yugi's Legendary Decks two set codes") {
const std::string json = R"({
"data":[{
"name":"Dark Magician Girl",
"card_sets":[
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA04","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC10","set_rarity":"Common"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Yugi's Legendary Decks", "Dark Magician Girl");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 2);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("NEG-001 duplicate rarity lines collapse to one variant") {
const std::string json = R"({
"data":[{
"name":"Armed Dragon LV7",
"card_sets":[
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Soul of the Duelist", "Armed Dragon LV7");
REQUIRE(out.isOk());
CHECK(out.value().size() == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("NEG-002 duplicate set codes collapse to one collector slot") {
const std::string json = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"},
{"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}
]
}]
})";
const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Yugi's Legendary Decks", "Dark Magician");
REQUIRE(out.isOk());
CHECK(countDistinctSetCodes(out.value()) == 1);
CHECK(maxRarityVariantsPerCode(out.value()) == 1);
}
TEST_CASE("NEG-003 no cross-product merge when display set matches nothing") {
const std::string json = R"({
"data":[{
"name":"Dark Magician",
"card_sets":[
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"},
{"set_name":"Starter Deck: Yugi","set_code":"SDY-006","set_rarity":"Ultra Rare"}
]
}]
})";
const auto bogus = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Different Sets", "Dark Magician");
CHECK(bogus.isErr());
const auto lob = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Legend of Blue Eyes White Dragon", "Dark Magician");
REQUIRE(lob.isOk());
REQUIRE(lob.value().size() == 1);
CHECK(lob.value()[0].setNo == "LOB-005");
const auto sdy = YuGiOhCardPreviewSource::parsePrintVariants(
json, "Starter Deck: Yugi", "Dark Magician");
REQUIRE(sdy.isOk());
REQUIRE(sdy.value().size() == 1);
CHECK(sdy.value()[0].setNo == "SDY-006");
}
}
TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") {
// Two same-passcode reprints with genuinely different art (LOB vs
// SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the
// SDK one for the SDK input - no leak of the LOB scan into the SDK
// entry, which was the user-reported regression on YGOPRODeck.
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"1":{"title":"File:BlueEyesWhiteDragon-SDK-NA-UR-UE.png",
"imageinfo":[{"url":"https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"}]}
}}})";
// Make the YGOPRODeck fallback obviously wrong so we can assert it
// isn't being used when Yugipedia returns a hit.
http.ygoprodeckBody = R"({"data":[{"name":"Blue-Eyes White Dragon",
"card_images":[{"image_url":"https://example.invalid/wrong.png"}],
"card_sets":[{"set_code":"SDK-001","set_rarity":"Ultra Rare"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Blue-Eyes White Dragon", "Starter Deck: Kaiba", "SDK-001||Ultra Rare||UE");
REQUIRE(out.isOk());
CHECK(out.value() == "https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png");
// Verified: only the Yugipedia call was made, not the YGOPRODeck one.
REQUIRE(http.calls.size() == 1);
CHECK(http.calls.front().find("yugipedia.com") != std::string::npos);
}
TEST_CASE("falls back to YGOPRODeck standard art when Yugipedia has no match") {
// Empty `pages` -> Yugipedia parser returns an error -> fallback
// path kicks in. This is the safety net for OCG-only or just-released
// cards that don't yet have an English scan uploaded to Yugipedia.
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"-1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png","missing":""}
}}})";
http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician",
"card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}],
"card_sets":[{"set_code":"LOB-005","set_rarity":"Ultra Rare"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
// Two HTTP calls: Yugipedia first, YGOPRODeck second.
REQUIRE(http.calls.size() == 2);
CHECK(http.calls[0].find("yugipedia.com") != std::string::npos);
CHECK(http.calls[1].find("ygoprodeck.com") != std::string::npos);
}
TEST_CASE("falls back when Yugipedia errors out (transient HTTP failure)") {
// We don't want a Yugipedia outage to leave the user without any
// preview at all - YGOPRODeck's generic art is acceptable degraded
// behavior.
RoutingHttpClient http;
http.yugipediaOk = false;
http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician",
"card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
}
TEST_CASE("propagates the YGOPRODeck error as Transient when the fallback also fails") {
// Both upstreams unreachable -> the overall outcome is transient.
// CardPreviewService relies on this to avoid negative-caching when
// the user's connection is offline.
RoutingHttpClient http;
http.yugipediaOk = false;
http.ygoprodeckOk = false;
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("classifies as NotFound only when both upstreams answered cleanly with no match") {
// Yugipedia: clean miss (every candidate "missing").
// YGOPRODeck: clean miss (empty data array).
// -> Both upstreams agree -> NotFound, safe to remember.
RoutingHttpClient http;
http.yugipediaBody = R"({"query":{"pages":{
"-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
}}})";
http.ygoprodeckBody = R"({"data":[]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("Yugipedia transient + YGOPRODeck clean-miss is overall Transient") {
// We can't conclude the card has no image when one upstream
// couldn't speak. This is the cautious path that keeps the offline
// user out of the "permanent no-preview" trap.
RoutingHttpClient http;
http.yugipediaOk = false; // network failure
http.ygoprodeckBody = R"({"data":[]})"; // clean miss
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Possibly Real", "Some Set", "ABC-001||Ultra Rare||UE");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("skips Yugipedia entirely when the set code is missing") {
// Without a set code we can't construct any candidate filename - go
// straight to the YGOPRODeck fallback to avoid wasting an HTTP call.
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":[{"name":"Dark Magician",
"card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";
YuGiOhCardPreviewSource src{http};
const auto out = src.fetchImageUrl(
"Dark Magician", "Legend of Blue Eyes White Dragon", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos);
}
}
+62
View File
@@ -0,0 +1,62 @@
#include <doctest/doctest.h>
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
namespace {
class FixedHttpClient final : public IHttpClient {
public:
std::string lastUrl;
std::string body;
bool ok = true;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err("offline");
}
};
} // namespace
TEST_SUITE("YuGiOhSetSource::parseResponse") {
TEST_CASE("maps set_code/set_name/tcg_date") {
const std::string json = R"([
{"set_name":"Set A","set_code":"AAA","tcg_date":"2020-01-01"},
{"set_name":"Set B","set_code":"BBB","tcg_date":"2021-02-03"}
])";
const auto out = YuGiOhSetSource::parseResponse(json);
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].id == "AAA");
CHECK(out.value()[0].releaseDate == "2020/01/01");
}
TEST_CASE("sorts by release date ascending") {
const auto out = YuGiOhSetSource::parseResponse(R"([
{"set_name":"New","set_code":"N","tcg_date":"2024-01-01"},
{"set_name":"Old","set_code":"O","tcg_date":"2010-01-01"}
])");
REQUIRE(out.isOk());
CHECK(out.value().front().id == "O");
CHECK(out.value().back().id == "N");
}
TEST_CASE("missing array returns error") {
CHECK(YuGiOhSetSource::parseResponse(R"({"data":[]})").isErr());
}
}
TEST_SUITE("YuGiOhSetSource::fetchAll") {
TEST_CASE("network success parses and hits endpoint") {
FixedHttpClient http;
http.body = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])";
YuGiOhSetSource src{http};
const auto out = src.fetchAll();
REQUIRE(out.isOk());
CHECK(out.value().front().id == "X");
CHECK(http.lastUrl == "https://db.ygoprodeck.com/api/v7/cardsets.php");
}
}
+11 -6
View File
@@ -6,11 +6,11 @@
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window, menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel falls back to a per-game card-back image URL (Magic/Pokemon parity with CCM2) instead of leaving the preview empty. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`).
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png` (copied next to the executable by `app/CMakeLists.txt` on link — source file `ui_wx/assets/ygo_card_back.png`). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 lines of hook overrides on top of the matching base template.
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
@@ -64,13 +64,18 @@
- Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode.
- In High Contrast, use stronger hover/pressed deltas than regular dark mode and keep the button border in the foreground/text color for visibility (currently yellow in this palette).
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
15. **Preview fallback behavior (CCM2 parity):**
15. **Preview fallback behavior (CCM2 parity where applicable):**
- Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap.
- Current fallback URLs are intentionally aligned with CCM2: Magic uses `Magic_card_back.jpg`, Pokemon uses `Cardback.jpg`.
- If you change fallback sourcing (URL -> local asset, etc.), keep the "always show a reasonable card-back fallback" behavior intact for both games.
- Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails — keep that chain working when touching preview code.
- If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews.
16. **Per-game auto-detect controls:**
- Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game.
- Keep shared templates game-agnostic: put buttons and detection behavior in `<Name>CardEditDialog`, not in `BaseCardEditDialog`.
- For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input.
## Required follow-ups
- If you replace **`ui_wx/assets/ygo_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `<exeDir>/assets/`; do not remove the asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`.
- After adding a new dialog/panel `.cpp` you **must** add it to `ui_wx/CMakeLists.txt`.
- After adding a new menu action you **must** allocate an `Ids::*` value in `MainFrame.hpp` (don't reuse `wxID_HIGHEST` math inline) and `Bind` it in `buildMenuBar`. The dynamic Game / Sets menus consume the `IdGameMenuBase` / `IdSetsMenuBase` ranges; do not stomp on those id ranges.
- After changing `AppContext` you **must** update `app/main.cpp` so the composition root populates the new field.
+4
View File
@@ -15,6 +15,10 @@ add_library(ccm_ui_wx STATIC
src/PokemonSelectedCardPanel.cpp
src/PokemonCardEditDialog.cpp
src/PokemonGameView.cpp
src/YuGiOhCardListPanel.cpp
src/YuGiOhSelectedCardPanel.cpp
src/YuGiOhCardEditDialog.cpp
src/YuGiOhGameView.cpp
src/SettingsDialog.cpp
src/ImageViewerDialog.cpp
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+1
View File
@@ -25,6 +25,7 @@ struct AppContext {
CardPreviewService& cardPreview;
IGameModule& magicModule;
IGameModule& pokemonModule;
IGameModule& yuGiOhModule;
// Active per-game UI bundles. The order is the order shown in the
// Game menu; the composition root constructs them and hands raw
// pointers in. `MainFrame` does not own these — `app/main.cpp` does.
@@ -90,6 +90,7 @@ protected:
}
buildLayout();
populateChoices();
openingSnapshot_ = card_;
Thaw();
}
@@ -119,6 +120,11 @@ protected:
return "(no sets cached - use Sets > " + updateMenuName() + ")";
}
// Called when the card name field changes. Games that cache upstream print
// metadata keyed by `(name, set)` should clear it here so stale "Next"
// controls cannot outlive the lookup identity.
virtual void onCardLookupContextChanged() {}
// Common helpers ----------------------------------------------------------
void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) {
@@ -129,12 +135,26 @@ protected:
[[nodiscard]] TCard& mutableCard() noexcept { return card_; }
[[nodiscard]] const TCard& constCard() const noexcept { return card_; }
void syncCardFromControls() { writeFromControls(); }
[[nodiscard]] wxComboBox* setComboControl() const noexcept { return setCombo_; }
[[nodiscard]] const Set* selectedSetFromControls() const {
const auto& available = availableSets();
if (!setCombo_ || !setCombo_->IsEnabled()) return nullptr;
const int sel = setCombo_->GetSelection();
if (sel < 0 || static_cast<std::size_t>(sel) >= available.size()) return nullptr;
return &available[static_cast<std::size_t>(sel)];
}
private:
void readSets() {
auto loaded = setService_.getSets(game_);
if (loaded.isOk()) {
sets_ = std::move(loaded).value();
std::sort(sets_.begin(), sets_.end(),
[](const Set& a, const Set& b) {
return a.releaseDate < b.releaseDate;
});
}
}
@@ -148,6 +168,10 @@ private:
grid->AddGrowableCol(1, 1);
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) {
onCardLookupContextChanged();
ev.Skip();
});
appendRow(grid, "Name", nameCtrl_);
setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
@@ -393,6 +417,14 @@ private:
"Add card", wxOK | wxICON_INFORMATION);
return;
}
if (mode_ == EditMode::Edit && !(card_ == openingSnapshot_)) {
if (showThemedConfirmDialog(
this,
wxString::FromUTF8("Save changes to this card?"),
wxString::FromUTF8("Save changes")) != wxID_YES) {
return;
}
}
ev.Skip();
}
@@ -503,6 +535,7 @@ private:
SetService& setService_;
EditMode mode_;
TCard card_;
TCard openingSnapshot_{};
Game game_;
std::vector<Set> sets_;
+65 -11
View File
@@ -30,9 +30,12 @@
#include <wx/bitmap.h>
#include <wx/colour.h>
#include <wx/event.h>
#include <wx/filename.h>
#include <wx/image.h>
#include <wx/listbox.h>
#include <wx/log.h>
#include <wx/mstream.h>
#include <wx/stdpaths.h>
#include <wx/panel.h>
#include <wx/settings.h>
#include <wx/sizer.h>
@@ -43,8 +46,10 @@
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <memory>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <tuple>
@@ -221,6 +226,9 @@ protected:
imageService_(imageService),
cardPreview_(cardPreview),
state_(std::make_shared<PreviewState>()) {
wxFileName exe(wxStandardPaths::Get().GetExecutablePath());
exe.SetFullName(wxEmptyString);
exeDirForBundledAssets_ = exe.GetPathWithSep().ToStdString(wxConvUTF8);
state_->panel = this;
}
@@ -284,6 +292,9 @@ private:
case Game::Pokemon:
// Mirrors CCM2's unresolved-preview fallback image.
return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg";
case Game::YuGiOh:
// Yugipedia English TCG backing (thumbnail — smaller than full scan).
return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png";
default:
return {};
}
@@ -367,25 +378,58 @@ private:
CardPreviewService* svcPtr = &cardPreview_;
auto [name, setId, setNo] = previewKey(card);
const Game game = gameId();
const std::string exeDirCopy = exeDirForBundledAssets_;
std::thread([state, gen, svcPtr, name = std::move(name),
setId = std::move(setId), setNo = std::move(setNo), game]() {
setId = std::move(setId), setNo = std::move(setNo), game,
exeDirCopy]() {
auto bytes = svcPtr->fetchPreviewBytes(game, name, setId, setNo);
bool ok = bytes.isOk();
bool usedFallback = false;
std::string payload = ok ? std::move(bytes).value() : std::string{};
std::string err = ok ? std::string{} : bytes.error();
auto applyFallbackPayload = [&](std::string p) {
if (p.empty()) return;
payload = std::move(p);
ok = true;
usedFallback = true;
err.clear();
};
if (!ok || payload.empty()) {
const std::string fallbackUrl = fallbackImageUrlForGame(game);
if (!fallbackUrl.empty()) {
auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl);
if (fallbackBytes.isOk()) {
payload = std::move(fallbackBytes).value();
ok = !payload.empty();
if (ok) {
usedFallback = true;
err.clear();
if (game == Game::YuGiOh) {
if (auto fb =
svcPtr->fetchImageBytesByUrl(
"https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/"
"250px-Back-EN.png");
fb.isOk()) {
applyFallbackPayload(std::move(fb).value());
}
if (!ok || payload.empty()) {
if (auto fb = svcPtr->fetchImageBytesByUrl(
"https://ms.yugipedia.com/e/e5/Back-EN.png");
fb.isOk()) {
applyFallbackPayload(std::move(fb).value());
}
}
if (!ok || payload.empty()) {
namespace fs = std::filesystem;
const fs::path asset =
fs::path(exeDirCopy) / "assets" / "ygo_card_back.png";
std::ifstream in(asset, std::ios::binary);
if (in) {
std::ostringstream ss;
ss << in.rdbuf();
applyFallbackPayload(ss.str());
}
}
} else {
const std::string fallbackUrl = fallbackImageUrlForGame(game);
if (!fallbackUrl.empty()) {
auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl);
if (fallbackBytes.isOk()) {
applyFallbackPayload(std::move(fallbackBytes).value());
}
}
}
@@ -419,7 +463,16 @@ private:
wxMemoryInputStream stream(bytes.data(), bytes.size());
wxImage img;
if (!img.LoadFile(stream, wxBITMAP_TYPE_ANY)) {
// libpng emits iCCP warnings for some upstream PNGs (e.g. Yugipedia
// scans with embedded sRGB chunks wx considers invalid). wx forwards
// those as wxLogWarning -> modal dialog. Suppress logging for decode
// only; the pixels load correctly either way.
bool decoded = false;
{
wxLogNull suppressPngWarnings;
decoded = img.LoadFile(stream, wxBITMAP_TYPE_ANY);
}
if (!decoded) {
previewStatus_->SetLabelText("(preview decode failed)");
clearPreview();
Layout();
@@ -477,6 +530,7 @@ private:
ImageService& imageService_;
CardPreviewService& cardPreview_;
std::string exeDirForBundledAssets_;
std::optional<TCard> card_;
wxStaticBitmap* previewBitmap_{nullptr};
@@ -0,0 +1,73 @@
#pragma once
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <wx/stattext.h>
namespace ccm::ui {
class YuGiOhCardEditDialog final : public BaseCardEditDialog<YuGiOhCard> {
public:
YuGiOhCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
YuGiOhCard initial,
const std::vector<Set>* preloadedSets = nullptr);
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void appendExtraRows(wxFlexGridSizer* grid) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Yu-Gi-Oh!"; }
void onCardLookupContextChanged() override;
private:
void onAutoDetectSetNo(wxCommandEvent&);
void onAutoDetectRarity(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onNextRarity(wxCommandEvent&);
void onSetNoTextChanged(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
void refreshSetNoFullPreview();
void clearCachedPrintVariants();
bool fetchAndCachePrintVariants();
void rebuildVariantRingsFromCache();
void filterCachedVariantsForCardLanguage();
void syncRingPositionsToControls();
void applyRarityStringToChoice(const std::string& rarity);
[[nodiscard]] std::string currentFullSetNoFromControls() const;
void refreshVariantNextControls();
[[nodiscard]] std::string extractSetNoNumeric(std::string_view fullSetNo) const;
[[nodiscard]] std::string composeFullSetNo(std::string_view numeric) const;
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
wxTextCtrl* setNoCtrl_{nullptr};
wxStaticText* setNoFullPreview_{nullptr};
wxChoice* rarityChoice_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxButton* autoRarityBtn_{nullptr};
wxButton* nextRarityBtn_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetCodes_;
std::vector<std::string> raritiesForCurrentSetCode_;
std::size_t setCodeRingPos_{0};
std::size_t rarityRingPos_{0};
};
} // namespace ccm::ui
@@ -0,0 +1,22 @@
#pragma once
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
class YuGiOhCardListPanel final : public BaseCardListPanel<YuGiOhCard, YuGiOhSortColumn> {
public:
explicit YuGiOhCardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const YuGiOhCard& card, std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const YuGiOhCard& card, std::size_t idx) const override;
void sortBy(YuGiOhSortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const YuGiOhCard& card, std::string_view filter) const override;
};
} // namespace ccm::ui
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm::ui {
class YuGiOhCardListPanel;
class YuGiOhSelectedCardPanel;
class YuGiOhGameView final : public IGameView {
public:
YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; }
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<YuGiOhCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
YuGiOhCardListPanel* listPanel_{nullptr};
YuGiOhSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,24 @@
#pragma once
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class YuGiOhSelectedCardPanel final : public BaseSelectedCardPanel<YuGiOhCard> {
public:
YuGiOhSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview);
protected:
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
[[nodiscard]] std::vector<FlagIconSpec> declareFlagIcons() const override;
[[nodiscard]] std::string detailValueFor(const YuGiOhCard& card, DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const YuGiOhCard& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const YuGiOhCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; }
};
} // namespace ccm::ui
+7 -1
View File
@@ -4,6 +4,7 @@
#include <wx/button.h>
#include <wx/dcclient.h>
#include <wx/image.h>
#include <wx/log.h>
#include <wx/panel.h>
#include <wx/sizer.h>
@@ -110,7 +111,12 @@ bool ImageViewerDialog::loadImageAt(std::size_t index) {
if (imageCacheReady_[index]) return imageCache_[index].IsOk();
wxImage img;
if (!img.LoadFile(paths_[index].string())) {
bool ok = false;
{
wxLogNull suppressPngWarnings;
ok = img.LoadFile(paths_[index].string());
}
if (!ok) {
imageCacheReady_[index] = true;
return false;
}
+1 -1
View File
@@ -83,7 +83,7 @@ void ensureDataStorageScaffold(const Configuration& cfg) {
MainFrame::MainFrame(AppContext& ctx)
: wxFrame(nullptr, wxID_ANY, "Card Collection Manager 3",
wxDefaultPosition, wxSize(1210, 700)),
wxDefaultPosition, wxSize(1210, 770)),
ctx_(ctx),
activeGame_(ctx.config.current().defaultGame) {
buildMenuBar();
+378
View File
@@ -0,0 +1,378 @@
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <algorithm>
#include <cctype>
#include <unordered_set>
namespace ccm::ui {
namespace {
const char* const kRarityOptions[] = {
"Common",
"Rare",
"Super Rare",
"Ultra Rare",
"Secret Rare",
"Quarter Century Secret Rare",
"Starlight Rare",
"Collector's Rare",
"Ghost Rare",
"Ultimate Rare",
"Platinum Secret Rare",
"Prismatic Secret Rare",
};
}
YuGiOhCardEditDialog::YuGiOhCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
YuGiOhCard initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<YuGiOhCard>(
parent,
mode == EditMode::Create ? "Add Yu-Gi-Oh! Card" : "Edit Yu-Gi-Oh! Card",
imageService, setService, mode, std::move(initial), Game::YuGiOh, preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
void YuGiOhCardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void YuGiOhCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12);
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
}
void YuGiOhCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
setNoCtrl_->Bind(wxEVT_TEXT, &YuGiOhCardEditDialog::onSetNoTextChanged, this);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
setNoFullPreview_ = new wxStaticText(setNoPanel, wxID_ANY, "(n/a)");
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(setNoFullPreview_, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 8);
setNoPanel->SetSizer(setNoRow);
auto* rarityPanel = new wxPanel(this, wxID_ANY);
rarityChoice_ = new wxChoice(rarityPanel, wxID_ANY);
autoRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Auto detect");
autoRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onAutoDetectRarity, this);
nextRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Next");
nextRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onNextRarity, this);
nextRarityBtn_->Show(false);
wxArrayString rarityItems;
rarityItems.Alloc(static_cast<int>(sizeof(kRarityOptions) / sizeof(kRarityOptions[0])));
for (const char* rarity : kRarityOptions) {
rarityItems.Add(wxString::FromUTF8(rarity));
}
rarityChoice_->Append(rarityItems);
auto* rarityRow = new wxBoxSizer(wxHORIZONTAL);
rarityRow->Add(rarityChoice_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
rarityRow->Add(autoRarityBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
rarityRow->Add(nextRarityBtn_, 0, wxALIGN_CENTER_VERTICAL);
rarityPanel->SetSizer(rarityRow);
appendRow(grid, "Set #", setNoPanel);
appendRow(grid, "Rarity", rarityPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &YuGiOhCardEditDialog::onSetSelectionChanged, this);
}
}
void YuGiOhCardEditDialog::readExtraFromCard() {
clearCachedPrintVariants();
if (setNoCtrl_) setNoCtrl_->ChangeValue(extractSetNoNumeric(constCard().setNo));
if (rarityChoice_) {
const wxString rarity = wxString::FromUTF8(constCard().rarity.c_str());
int rarityIdx = rarityChoice_->FindString(rarity);
if (rarityIdx == wxNOT_FOUND && !constCard().rarity.empty()) {
rarityChoice_->Append(rarity);
rarityIdx = rarityChoice_->GetCount() - 1;
}
if (rarityIdx == wxNOT_FOUND) rarityIdx = 0;
if (rarityIdx != wxNOT_FOUND) rarityChoice_->SetSelection(rarityIdx);
}
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
refreshSetNoFullPreview();
}
void YuGiOhCardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8));
if (rarityChoice_) mutableCard().rarity = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8);
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void YuGiOhCardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetCodes_.clear();
raritiesForCurrentSetCode_.clear();
setCodeRingPos_ = 0;
rarityRingPos_ = 0;
refreshVariantNextControls();
}
void YuGiOhCardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void YuGiOhCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
if (card.name.empty() || card.set.name.empty()) return;
auto detected = cardPreview_.detectPrintVariants(Game::YuGiOh, card.name, card.set.name);
if (!detected) return;
if (capturedEpoch != variantFetchEpoch_) return;
cachedVariants_ = std::move(detected).value();
rebuildVariantRingsFromCache();
syncRingPositionsToControls();
refreshVariantNextControls();
}
bool YuGiOhCardEditDialog::fetchAndCachePrintVariants() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return false;
}
if (card.set.name.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return false;
}
auto detected = cardPreview_.detectPrintVariants(Game::YuGiOh, card.name, card.set.name);
if (!detected) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
return false;
}
cachedVariants_ = std::move(detected).value();
return true;
}
void YuGiOhCardEditDialog::filterCachedVariantsForCardLanguage() {
if (constCard().language != Language::English) return;
const auto removed = std::remove_if(
cachedVariants_.begin(), cachedVariants_.end(), [](const AutoDetectedPrint& p) {
return ygoLikelyEuropeanRegionalSetCode(p.setNo);
});
cachedVariants_.erase(removed, cachedVariants_.end());
}
void YuGiOhCardEditDialog::rebuildVariantRingsFromCache() {
syncCardFromControls();
uniqueSetCodes_.clear();
raritiesForCurrentSetCode_.clear();
if (cachedVariants_.empty()) return;
filterCachedVariantsForCardLanguage();
std::unordered_set<std::string> seenSlots;
seenSlots.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
const std::string digits = ygoCollectorDigitsOnly(p.setNo);
if (digits.empty()) continue;
const std::string slotKey = ygoAbbrevBeforeDash(p.setNo) + "|" + digits;
if (!seenSlots.insert(slotKey).second) continue;
uniqueSetCodes_.push_back(p.setNo);
}
const std::string full = currentFullSetNoFromControls();
std::unordered_set<std::string> seenRarity;
for (const auto& p : cachedVariants_) {
if (!ygoPrintingSlotsMatch(p.setNo, full)) continue;
if (p.rarity.empty()) continue;
if (seenRarity.insert(p.rarity).second) raritiesForCurrentSetCode_.push_back(p.rarity);
}
}
void YuGiOhCardEditDialog::syncRingPositionsToControls() {
const std::string full = currentFullSetNoFromControls();
setCodeRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetCodes_.size(); ++i) {
if (ygoPrintingSlotsMatch(uniqueSetCodes_[i], full)) {
setCodeRingPos_ = i;
break;
}
}
rarityRingPos_ = 0;
if (rarityChoice_) {
const std::string r = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8);
for (std::size_t i = 0; i < raritiesForCurrentSetCode_.size(); ++i) {
if (raritiesForCurrentSetCode_[i] == r) {
rarityRingPos_ = i;
break;
}
}
}
}
void YuGiOhCardEditDialog::applyRarityStringToChoice(const std::string& rarity) {
if (!rarityChoice_) return;
const wxString wxRare = wxString::FromUTF8(rarity.c_str());
int idx = rarityChoice_->FindString(wxRare);
if (idx == wxNOT_FOUND && !rarity.empty()) {
rarityChoice_->Append(wxRare);
idx = rarityChoice_->GetCount() - 1;
}
if (idx != wxNOT_FOUND) rarityChoice_->SetSelection(idx);
}
std::string YuGiOhCardEditDialog::currentFullSetNoFromControls() const {
if (!setNoCtrl_) return {};
return composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8));
}
void YuGiOhCardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_ || !nextRarityBtn_) return;
nextSetNoBtn_->Show(uniqueSetCodes_.size() > 1);
nextRarityBtn_->Show(raritiesForCurrentSetCode_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void YuGiOhCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi(true, false);
}
void YuGiOhCardEditDialog::onAutoDetectRarity(wxCommandEvent&) {
autoDetectFromApi(false, true);
}
void YuGiOhCardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetCodes_.size() <= 1) return;
setCodeRingPos_ = (setCodeRingPos_ + 1) % uniqueSetCodes_.size();
const std::string& code = uniqueSetCodes_[setCodeRingPos_];
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(code).c_str()));
}
for (const auto& p : cachedVariants_) {
if (p.setNo == code) {
applyRarityStringToChoice(p.rarity);
break;
}
}
rebuildVariantRingsFromCache();
syncRingPositionsToControls();
refreshSetNoFullPreview();
refreshVariantNextControls();
}
void YuGiOhCardEditDialog::onNextRarity(wxCommandEvent&) {
if (raritiesForCurrentSetCode_.size() <= 1) return;
rarityRingPos_ = (rarityRingPos_ + 1) % raritiesForCurrentSetCode_.size();
applyRarityStringToChoice(raritiesForCurrentSetCode_[rarityRingPos_]);
refreshVariantNextControls();
}
void YuGiOhCardEditDialog::autoDetectFromApi(bool fillSetNo, bool fillRarity) {
if (!fetchAndCachePrintVariants()) return;
if (fillSetNo && setNoCtrl_ && !cachedVariants_.empty()) {
const auto& p = cachedVariants_.front();
setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(p.setNo).c_str()));
if (fillRarity) applyRarityStringToChoice(p.rarity);
} else if (fillRarity && rarityChoice_) {
const std::string full = currentFullSetNoFromControls();
bool applied = false;
for (const auto& p : cachedVariants_) {
if (ygoPrintingSlotsMatch(p.setNo, full)) {
applyRarityStringToChoice(p.rarity);
applied = true;
break;
}
}
if (!applied && !cachedVariants_.empty()) {
applyRarityStringToChoice(cachedVariants_.front().rarity);
}
}
rebuildVariantRingsFromCache();
syncRingPositionsToControls();
refreshSetNoFullPreview();
refreshVariantNextControls();
}
void YuGiOhCardEditDialog::onSetNoTextChanged(wxCommandEvent&) {
refreshSetNoFullPreview();
if (!cachedVariants_.empty()) {
rebuildVariantRingsFromCache();
syncRingPositionsToControls();
refreshVariantNextControls();
}
}
void YuGiOhCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
refreshSetNoFullPreview();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
std::string YuGiOhCardEditDialog::extractSetNoNumeric(std::string_view fullSetNo) const {
std::string digits;
for (char ch : fullSetNo) {
if (std::isdigit(static_cast<unsigned char>(ch))) digits.push_back(ch);
}
return digits;
}
std::string YuGiOhCardEditDialog::composeFullSetNo(std::string_view numericRaw) const {
std::string numeric;
for (char ch : numericRaw) {
if (std::isdigit(static_cast<unsigned char>(ch))) numeric.push_back(ch);
}
const Set* set = selectedSetFromControls();
if (!set || set->id.empty() || numeric.empty()) return numeric;
return set->id + "-" + numeric;
}
void YuGiOhCardEditDialog::refreshSetNoFullPreview() {
if (!setNoFullPreview_ || !setNoCtrl_) return;
const std::string full = composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8));
if (full.empty()) {
setNoFullPreview_->SetLabel("(n/a)");
} else {
setNoFullPreview_->SetLabel(wxString::Format("(%s)", wxString::FromUTF8(full.c_str())));
}
setNoFullPreview_->GetParent()->Layout();
}
} // namespace ccm::ui
+69
View File
@@ -0,0 +1,69 @@
#include "ccm/ui/YuGiOhCardListPanel.hpp"
#include "ccm/services/CardFilter.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
YuGiOhCardListPanel::YuGiOhCardListPanel(wxWindow* parent)
: BaseCardListPanel<YuGiOhCard, YuGiOhSortColumn>(parent) {
buildLayout();
}
std::vector<YuGiOhCardListPanel::TextColumnSpec>
YuGiOhCardListPanel::declareTextColumns() const {
return {
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language},
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note},
};
}
std::vector<YuGiOhCardListPanel::IconColumnSpec>
YuGiOhCardListPanel::declareIconColumns() const {
constexpr int kFlagColWidth = 36;
return {
{kSvgFirstEdition, kFlagColWidth, YuGiOhSortColumn::FirstEdition},
{kSvgSigned, kFlagColWidth, YuGiOhSortColumn::Signed},
{kSvgAltered, kFlagColWidth, YuGiOhSortColumn::Altered},
};
}
std::string YuGiOhCardListPanel::renderTextCell(const YuGiOhCard& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.name;
case 1: return card.set.name;
case 2: return std::to_string(card.amount);
case 3: return std::string(to_string(card.condition));
case 4: return std::string(to_string(card.language));
case 5: return card.note;
}
return {};
}
bool YuGiOhCardListPanel::isIconColumnSet(const YuGiOhCard& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.firstEdition;
case 1: return card.signed_;
case 2: return card.altered;
}
return false;
}
void YuGiOhCardListPanel::sortBy(YuGiOhSortColumn column, bool ascending) {
sortYuGiOhCards(mutableCards(), column, ascending);
}
bool YuGiOhCardListPanel::matchesFilter(const YuGiOhCard& card,
std::string_view filter) const {
return matchesYuGiOhFilter(card, filter);
}
} // namespace ccm::ui
+209
View File
@@ -0,0 +1,209 @@
#include "ccm/ui/YuGiOhGameView.hpp"
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
#include "ccm/ui/YuGiOhCardListPanel.hpp"
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
#include <wx/msgdlg.h>
#include <optional>
#include <algorithm>
#include <string>
namespace ccm::ui {
YuGiOhGameView::YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module) {}
void YuGiOhGameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
attemptedInitialSetLoad_ = true;
auto cached = sets_.getSets(Game::YuGiOh);
if (cached) {
setsCache_ = std::move(cached).value();
std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
if (!setsCache_.empty()) return;
} else {
setsCache_.clear();
}
auto refreshed = sets_.updateSets(Game::YuGiOh);
if (refreshed) {
setsCache_ = std::move(refreshed).value();
std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
}
}
wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new YuGiOhCardListPanel(parent);
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
});
}
return listPanel_;
}
wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) {
if (selectedPanel_ == nullptr) {
selectedPanel_ = new YuGiOhSelectedCardPanel(parent, images_, cardPreview_);
}
return selectedPanel_;
}
void YuGiOhGameView::refreshCollection() {
if (listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::YuGiOh);
if (!loaded) {
showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! collection: " + loaded.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
listPanel_->setCards(std::move(loaded).value());
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
const std::vector<Set>& YuGiOhGameView::setsForDialog() {
ensureSetsLoaded();
if (!setsCache_.empty()) return setsCache_;
auto loaded = sets_.getSets(Game::YuGiOh);
if (loaded) {
setsCache_ = std::move(loaded).value();
std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
}
else setsCache_.clear();
return setsCache_;
}
void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
YuGiOhCard fresh;
fresh.amount = 1;
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
&setsForDialog());
{
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return;
auto added = collection_.add(Game::YuGiOh, dlg.card());
if (!added) {
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
YuGiOhCard persisted = dlg.card();
persisted.id = added.value();
auto normalized = images_.normalizeNamesForPersistedCard(
Game::YuGiOh, persisted.id, persisted.set.name, persisted.name, persisted.images);
if (normalized) {
if (normalized.value() != persisted.images) {
persisted.images = std::move(normalized).value();
auto updated = collection_.update(Game::YuGiOh, persisted);
if (!updated) {
showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(),
"Warning", wxOK | wxICON_WARNING);
}
}
} else {
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
}
void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
return;
}
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
&setsForDialog());
{
const Theme currentTheme = config_.current().theme;
const ThemePalette palette = paletteForTheme(currentTheme);
applyThemeToWindowTree(&dlg, palette, currentTheme);
dlg.SetBackgroundColour(palette.panelBg);
dlg.SetForegroundColour(palette.text);
}
if (dlg.ShowModal() != wxID_OK) return;
auto updated = collection_.update(Game::YuGiOh, dlg.card());
if (!updated) {
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::YuGiOh, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::YuGiOh);
if (!out) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
setsCache_ = out.value();
std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Yu-Gi-Oh! sets.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Yu-Gi-Oh! sets updated.";
}
void YuGiOhGameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void YuGiOhGameView::applyTheme(const ThemePalette& palette) {
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
}
} // namespace ccm::ui
+89
View File
@@ -0,0 +1,89 @@
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
namespace {
enum YuGiOhDetailKey : int {
kName = 0,
kSet,
kSetNo,
kRarity,
kLanguage,
kCondition,
kAmount,
kFirstEdition,
kSigned,
kAltered,
};
} // namespace
YuGiOhSelectedCardPanel::YuGiOhSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview)
: BaseSelectedCardPanel<YuGiOhCard>(parent, imageService, cardPreview) {
buildLayout();
}
std::vector<YuGiOhSelectedCardPanel::DetailRowSpec>
YuGiOhSelectedCardPanel::declareDetailRows() const {
return {
{"Name", kName, "(no card selected)"},
{"Set", kSet, ""},
{"Set #", kSetNo, ""},
{"Rarity", kRarity, ""},
{"Language", kLanguage, ""},
{"Condition", kCondition, ""},
{"Amount", kAmount, ""},
};
}
std::vector<YuGiOhSelectedCardPanel::FlagIconSpec>
YuGiOhSelectedCardPanel::declareFlagIcons() const {
return {
{kSvgFirstEdition, "1. Edition", kFirstEdition},
{kSvgSigned, "Signed", kSigned},
{kSvgAltered, "Altered", kAltered},
};
}
std::string YuGiOhSelectedCardPanel::detailValueFor(const YuGiOhCard& card,
DetailKey key) const {
switch (key) {
case kName: return card.name;
case kSet: return card.set.name;
case kSetNo: return card.setNo;
case kRarity: return card.rarity;
case kLanguage: return std::string(to_string(card.language));
case kCondition: return std::string(to_string(card.condition));
case kAmount: return std::to_string(card.amount);
case kNoteKey: return card.note;
}
return {};
}
bool YuGiOhSelectedCardPanel::isFlagSet(const YuGiOhCard& card, DetailKey key) const {
switch (key) {
case kFirstEdition: return card.firstEdition;
case kSigned: return card.signed_;
case kAltered: return card.altered;
}
return false;
}
std::tuple<std::string, std::string, std::string>
YuGiOhSelectedCardPanel::previewKey(const YuGiOhCard& card) const {
// Pack rarity and edition into the third tuple slot so the YGO preview
// source can build Yugipedia file names without changing the generic
// ICardPreviewSource interface. Format: "<setNo>||<rarity>||<1E|UE>".
// Yugipedia per-printing scans need the edition stamp to disambiguate
// 1st-Edition vs Unlimited reprints.
const char* const editionTag = card.firstEdition ? "1E" : "UE";
return {card.name, card.set.name,
card.setNo + "||" + card.rarity + "||" + editionTag};
}
} // namespace ccm::ui