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

|
||||
|
||||
### Yu-Gi-Oh!
|
||||
|
||||

|
||||
|
||||
|
||||
## 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
@@ -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".
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace ccm {
|
||||
enum class Game {
|
||||
Magic,
|
||||
Pokemon,
|
||||
YuGiOh,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
@@ -56,7 +57,7 @@ std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
const std::array<Game, 2>& allGames() noexcept;
|
||||
const std::array<Game, 3>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhCard - Yu-Gi-Oh card model with print-level metadata.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
std::string rarityCode;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const YuGiOhCard&, const YuGiOhCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -19,9 +19,10 @@ class MagicCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit MagicCardPreviewSource(IHttpClient& http);
|
||||
|
||||
Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Build the fully URL-encoded Scryfall search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
@@ -29,11 +30,13 @@ public:
|
||||
std::string_view setId);
|
||||
|
||||
// Parse a Scryfall /cards/search response body and pull out the
|
||||
// `data[0].image_uris.normal` URL. Returns an error result when no
|
||||
// matching printing is found, when the JSON is malformed, or when the
|
||||
// entry has no top-level `image_uris` (double-faced cards expose them
|
||||
// on a face object - no fallback in this compatibility behavior either).
|
||||
static Result<std::string> parseResponse(const std::string& body);
|
||||
// `data[0].image_uris.normal` URL. Errors are classified:
|
||||
// - JSON parse failure or missing/non-array `data` => Transient.
|
||||
// - Empty `data` array, missing top-level `image_uris`, or missing
|
||||
// `image_uris.normal` => NotFound (the upstream answered, but the
|
||||
// printing simply has no preview we can use).
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -19,9 +19,10 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit PokemonCardPreviewSource(IHttpClient& http);
|
||||
|
||||
Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
@@ -31,9 +32,11 @@ public:
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
|
||||
// for the first matching card. Prefers `images.large`, falls back to
|
||||
// `images.small`, and returns an error result if neither is present, the
|
||||
// data array is empty, or the JSON is malformed.
|
||||
static Result<std::string> parseResponse(const std::string& body);
|
||||
// `images.small`. Errors are classified:
|
||||
// - JSON parse failure or missing/non-array `data` => Transient.
|
||||
// - Empty `data` array or missing image variants => NotFound.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// YuGiOhCardPreviewSource - resolves preview images for Yu-Gi-Oh! cards.
|
||||
//
|
||||
// The image-preview path is backed by Yugipedia's MediaWiki API
|
||||
// (https://yugipedia.com/api.php). Yugipedia hosts actual per-printing card
|
||||
// scans, with deterministic file names of the shape
|
||||
// `<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<ext>` (e.g.
|
||||
// `BlueEyesWhiteDragon-LOB-EN-UR-UE.png` vs `BlueEyesWhiteDragon-SDK-NA-UR-UE.png`),
|
||||
// which lets us return the right artwork for printings that share a passcode
|
||||
// but have visibly different art - a case YGOPRODeck cannot disambiguate (its
|
||||
// card_images array is keyed by art-treatment passcode, not by physical
|
||||
// printing).
|
||||
//
|
||||
// The auto-detect-first-print path keeps using YGOPRODeck (`cardinfo.php`):
|
||||
// that endpoint returns a richer set listing (with rarities and release
|
||||
// dates) than Yugipedia, and we don't need image data for it.
|
||||
//
|
||||
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
|
||||
// stored Language. Localized scans are intentionally not queried so the user
|
||||
// sees a consistent, well-stocked gallery (EN scans are the most complete).
|
||||
class YuGiOhCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit YuGiOhCardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
// ---- Yugipedia helpers (image preview path) ----------------------------
|
||||
|
||||
// Build the list of candidate Yugipedia file names to try, in priority
|
||||
// order (most likely first). Always uses English regions; the caller may
|
||||
// pass an empty rarityCode when the rarity is unknown, in which case the
|
||||
// returned list will skip rarity in the filename.
|
||||
static std::vector<std::string> buildCandidateFilenames(
|
||||
std::string_view name,
|
||||
std::string_view setCode,
|
||||
std::string_view rarityCode,
|
||||
bool firstEdition);
|
||||
|
||||
// Build a single MediaWiki batch query URL that asks for imageinfo.url
|
||||
// for every filename. MediaWiki's `titles=` parameter joins page titles
|
||||
// with `|`, so we issue exactly one HTTP call per preview lookup.
|
||||
static std::string buildYugipediaQueryUrl(
|
||||
const std::vector<std::string>& filenames);
|
||||
|
||||
// Parse a MediaWiki `query.pages` response and return the resolved URL of
|
||||
// the first filename in `filenameOrder` that exists. Missing pages have
|
||||
// the `missing` marker (no `imageinfo`); existing pages carry an
|
||||
// `imageinfo[0].url` we forward verbatim. Errors are classified:
|
||||
// - JSON parse failure or schema deviation => Transient.
|
||||
// - Every candidate came back missing => NotFound.
|
||||
static Result<std::string, PreviewLookupError> parseYugipediaResponse(
|
||||
const std::string& body,
|
||||
const std::vector<std::string>& filenameOrder);
|
||||
|
||||
// Strip a card name down to Yugipedia's image-slug shape: alphanumerics
|
||||
// (and parentheses) only, no whitespace, no policy-banned punctuation.
|
||||
static std::string normalizeName(std::string_view name);
|
||||
|
||||
// Map a CCM3 rarity name (e.g. "Ultra Rare") to the Yugipedia rarity
|
||||
// code used in image filenames (e.g. "UR"). Returns an empty string when
|
||||
// the rarity is unknown; the caller treats that as "skip rarity".
|
||||
static std::string rarityCodeFor(std::string_view rarityName);
|
||||
|
||||
// Pull the set abbreviation out of a CCM3 setNo such as "LOB-005" or
|
||||
// "LOB-DE005" - in both cases we want "LOB". Returns the trimmed input
|
||||
// unchanged if no dash is present.
|
||||
static std::string extractSetCode(std::string_view setNo);
|
||||
|
||||
// ---- YGOPRODeck helpers (auto-detect path + fallback) ------------------
|
||||
|
||||
// Build a fuzzy-name `cardinfo.php` URL. `setName` may be empty for an
|
||||
// unfiltered fuzzy lookup. Used by detectFirstPrint and by the
|
||||
// standard-art fallback when Yugipedia has no scan for this printing.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setName);
|
||||
|
||||
// Pick the standard artwork (card_images[0]) from a YGOPRODeck response,
|
||||
// preferring the exact-name match. Used only as a last-resort fallback
|
||||
// when Yugipedia returns nothing for any of our candidate filenames.
|
||||
// Errors are classified:
|
||||
// - JSON parse failure or schema deviation => Transient.
|
||||
// - Empty `data` array, or matched cards without a usable image
|
||||
// variant => NotFound.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseFallbackImageUrl(const std::string& body, std::string_view name);
|
||||
|
||||
// Pick the first printing for `preferredSetName` from a YGOPRODeck
|
||||
// response. Drives the "Auto detect" button in the YGO edit dialog.
|
||||
static Result<AutoDetectedPrint> parseFirstPrint(const std::string& body,
|
||||
std::string_view preferredSetName);
|
||||
|
||||
// Every `(set_code, set_rarity)` pair for cards whose name matches
|
||||
// `wantedCardName` (case-insensitive). When `wantedCardName` is empty,
|
||||
// scans every row in `data[]` like `parseFirstPrint` did historically.
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view preferredSetName,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit YuGiOhGameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOh; }
|
||||
[[nodiscard]] std::string dirName() const override { return "yugioh"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
YuGiOhSetSource setSource_;
|
||||
YuGiOhCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
|
||||
|
||||
explicit YuGiOhSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -7,17 +7,31 @@
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
namespace cpr { class Session; }
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Concrete IHttpClient backed by libcpr/libcurl. The single owned
|
||||
// `cpr::Session` keeps libcurl's connection pool alive across calls, so
|
||||
// repeat HTTPS requests to the same host (api.scryfall.com, yugipedia.com,
|
||||
// ms.yugipedia.com, …) reuse the existing TLS connection instead of paying
|
||||
// for a fresh handshake every time. Concurrent calls are serialized through
|
||||
// a mutex - libcurl easy handles are not thread-safe, and the preview path
|
||||
// only fires one outbound request at a time anyway.
|
||||
class CprHttpClient final : public IHttpClient {
|
||||
public:
|
||||
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
~CprHttpClient() override;
|
||||
|
||||
Result<std::string> get(std::string_view url) override;
|
||||
|
||||
private:
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::unique_ptr<cpr::Session> session_;
|
||||
std::mutex sessionMutex_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
// LocalPreviewByteCache - on-disk byte cache for CardPreviewService.
|
||||
//
|
||||
// Layout under the configured cache directory (composition root passes
|
||||
// `<exeDir>/.cache/preview-cache/` - next to the executable, NOT under
|
||||
// the user-configurable `dataStorage` path; see `docs/caching.md` and
|
||||
// `app/AGENTS.md` for the rationale):
|
||||
// <hash>.bin raw image bytes (PNG/JPEG payload), positive entries only
|
||||
// <hash>.neg zero-byte marker file, negative entries only
|
||||
// <hash>.idx one-line text sidecar holding the original cache key,
|
||||
// used to detect (and reject) hash collisions so we never
|
||||
// serve the wrong card's image and never honor a stale
|
||||
// negative entry across collisions
|
||||
//
|
||||
// Positive vs. negative entries are mutually exclusive for a given hash:
|
||||
// `store` removes any existing `.neg`, `storeNegative` removes any existing
|
||||
// `.bin`, and `load` prefers `.bin` on the off chance both somehow co-exist.
|
||||
//
|
||||
// The cache is bounded by total payload bytes (sum of `.bin` sizes). When
|
||||
// `store` would push it past the cap we evict by file mtime (oldest first)
|
||||
// until back under the cap; the `.idx` sidecar of an evicted entry is
|
||||
// removed too. Negative entries are tiny (effectively `.idx` only) and are
|
||||
// not subject to the byte cap directly - their count is naturally bounded
|
||||
// by the user's collection size since a negative entry only ever exists
|
||||
// for a card the user has actually looked at and the upstream answered
|
||||
// "no image" for. Reads update mtime via a touch on hit so frequently-
|
||||
// viewed cards survive eviction.
|
||||
//
|
||||
// All filesystem mutations go through `IFileSystem` (so the in-memory
|
||||
// fake works in tests). Size and mtime queries - which the port does not
|
||||
// expose - use `std::filesystem` directly inside this adapter. Tests that
|
||||
// need to drive eviction stay easy to write: just call `store` past the cap
|
||||
// and check the survivors.
|
||||
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class LocalPreviewByteCache final : public IPreviewByteCache {
|
||||
public:
|
||||
// Default soft cap: ~64 MiB. A typical preview is 80-200 KiB, so this
|
||||
// holds several hundred cards comfortably while keeping disk usage
|
||||
// bounded for users with very large collections.
|
||||
static constexpr std::size_t kDefaultMaxBytes = 64ull * 1024 * 1024;
|
||||
|
||||
LocalPreviewByteCache(IFileSystem& fs,
|
||||
std::filesystem::path cacheDir,
|
||||
std::size_t maxBytes = kDefaultMaxBytes);
|
||||
|
||||
[[nodiscard]] LoadResult load(std::string_view key) override;
|
||||
void store(std::string_view key, const std::string& payload) override;
|
||||
void storeNegative(std::string_view key) override;
|
||||
|
||||
// Test-visible knob: total payload bytes currently on disk (recomputed
|
||||
// from the directory listing so it stays accurate after external
|
||||
// tampering). Negative-entry markers do not count toward the total.
|
||||
[[nodiscard]] std::size_t currentSizeBytes();
|
||||
|
||||
private:
|
||||
std::filesystem::path payloadPath(const std::string& hash) const;
|
||||
std::filesystem::path negativePath(const std::string& hash) const;
|
||||
std::filesystem::path indexPath(const std::string& hash) const;
|
||||
|
||||
// Hex-encoded FNV-1a 64-bit hash of the key. We don't need cryptographic
|
||||
// strength; the sidecar `.idx` file rejects collisions on load so the
|
||||
// worst case is a one-time cache miss.
|
||||
static std::string hashKey(std::string_view key);
|
||||
|
||||
void evictIfNeededLocked(std::size_t incomingBytes);
|
||||
|
||||
IFileSystem& fs_;
|
||||
std::filesystem::path cacheDir_;
|
||||
std::size_t maxBytes_;
|
||||
std::mutex mutex_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -12,9 +12,38 @@
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct AutoDetectedPrint {
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
|
||||
// drives caching policy in CardPreviewService:
|
||||
//
|
||||
// NotFound -- the upstream answered cleanly that the card has no image
|
||||
// (or no matching record at all). Safe to remember: the
|
||||
// answer will not change until the user edits the card
|
||||
// record itself, which automatically invalidates the cache
|
||||
// key. Negative-cached so subsequent selections show the
|
||||
// fallback card-back instantly without another HTTP call.
|
||||
//
|
||||
// Transient -- the upstream did not answer cleanly (HTTP / network /
|
||||
// timeout failure, malformed response, parse error). The
|
||||
// record may well have an image; we just couldn't see it
|
||||
// this time. NOT cached, so the next selection retries.
|
||||
//
|
||||
// The `message` is opaque to the service and is forwarded to the UI as
|
||||
// the existing free-form `Result<std::string>::error()` string.
|
||||
struct PreviewLookupError {
|
||||
enum class Kind { NotFound, Transient };
|
||||
Kind kind{Kind::Transient};
|
||||
std::string message;
|
||||
};
|
||||
|
||||
class ICardPreviewSource {
|
||||
public:
|
||||
virtual ~ICardPreviewSource() = default;
|
||||
@@ -22,9 +51,34 @@ public:
|
||||
// Resolve the preview image URL for a single card. `setNo` is optional
|
||||
// (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as
|
||||
// a more precise lookup key, others (Magic/Scryfall) ignore it.
|
||||
virtual Result<std::string> fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) = 0;
|
||||
//
|
||||
// Errors carry a classification (`PreviewLookupError::Kind`) so
|
||||
// CardPreviewService can decide whether to remember the miss
|
||||
// (`NotFound`) or retry on the next call (`Transient`). See the doc
|
||||
// comment on PreviewLookupError above for the exact contract.
|
||||
virtual Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) = 0;
|
||||
|
||||
// Opt-in switch for per-game print metadata detection.
|
||||
[[nodiscard]] virtual bool supportsAutoDetectPrint() const noexcept { return false; }
|
||||
|
||||
// Optional metadata lookup used by game-specific edit dialogs. The default
|
||||
// implementation returns an explicit "unsupported" error so games without
|
||||
// print metadata APIs do not need to override it.
|
||||
virtual Result<AutoDetectedPrint> detectFirstPrint(std::string_view /*name*/,
|
||||
std::string_view /*setId*/) {
|
||||
return Result<AutoDetectedPrint>::err("Auto-detect not supported by this game.");
|
||||
}
|
||||
|
||||
// Optional listing of every distinct `(set_code, rarity)` print returned by
|
||||
// the upstream for an exact card name inside the chosen display set.
|
||||
virtual Result<std::vector<AutoDetectedPrint>>
|
||||
detectPrintVariants(std::string_view /*name*/, std::string_view /*setId*/) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Print variant listing not supported by this game.");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
// IPreviewByteCache - persistent byte cache used by CardPreviewService to
|
||||
// keep preview images alive across app restarts.
|
||||
//
|
||||
// The cache is keyed by an opaque string. CardPreviewService composes the
|
||||
// key from `(game, name, setId, setNo)` (preview lookups) or directly from
|
||||
// the URL (per-game card-back fallback fetches); the cache itself does not
|
||||
// interpret the key, only stores the byte payload behind it.
|
||||
//
|
||||
// Two kinds of entries are persisted:
|
||||
//
|
||||
// * Positive entries hold raw image bytes. Stored via `store(key, payload)`,
|
||||
// returned as `LoadResult{HitKind::Hit, payload}`.
|
||||
// * Negative entries record "we tried to resolve this exact card and the
|
||||
// upstream answered cleanly that it has no preview image" - i.e. the
|
||||
// `NotFound` half of `PreviewLookupError`. Stored via
|
||||
// `storeNegative(key)`, returned as `LoadResult{HitKind::NegativeHit, {}}`.
|
||||
// `Transient` errors (HTTP / network / parse failures) must NEVER reach
|
||||
// this cache: we cannot tell whether the record genuinely has no image
|
||||
// or just couldn't be reached, and persisting the miss would leave the
|
||||
// user staring at the card-back placeholder until they edit the card.
|
||||
//
|
||||
// A negative entry is implicitly invalidated when the cache key changes -
|
||||
// since the key includes `(game, name, setId, setNo)` (with game-specific
|
||||
// disambiguators packed into setNo), any edit that affects a lookup-relevant
|
||||
// field will hit a fresh key and re-attempt the network lookup automatically.
|
||||
//
|
||||
// Implementations must be thread-safe with respect to concurrent load/store
|
||||
// calls because CardPreviewService is invoked from a worker thread spawned
|
||||
// by `BaseSelectedCardPanel`.
|
||||
//
|
||||
// Errors are intentionally swallowed (load returns Miss; store and
|
||||
// storeNegative are fire-and-forget). A flaky or full disk must never break
|
||||
// the preview path - in the worst case the user sees the same speed as a
|
||||
// fresh app install.
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class IPreviewByteCache {
|
||||
public:
|
||||
enum class HitKind {
|
||||
Miss, // no entry for this key (or unrecoverable I/O error)
|
||||
Hit, // positive entry; bytes are in `payload`
|
||||
NegativeHit, // negative entry; `payload` is empty by contract
|
||||
};
|
||||
|
||||
struct LoadResult {
|
||||
HitKind kind{HitKind::Miss};
|
||||
std::string payload; // only meaningful when kind == Hit
|
||||
};
|
||||
|
||||
virtual ~IPreviewByteCache() = default;
|
||||
|
||||
// Returns the cached entry for `key`. On any error - missing files,
|
||||
// sidecar mismatch, malformed metadata, I/O failure - implementations
|
||||
// must report `HitKind::Miss` rather than surfacing the error.
|
||||
[[nodiscard]] virtual LoadResult load(std::string_view key) = 0;
|
||||
|
||||
// Best-effort persist of `payload` under `key`. Empty payloads are not
|
||||
// stored as positive entries. If a negative entry already exists for
|
||||
// this key it is replaced. Errors are swallowed.
|
||||
virtual void store(std::string_view key, const std::string& payload) = 0;
|
||||
|
||||
// Best-effort persist of "we tried, upstream cleanly said no image".
|
||||
// If a positive entry already exists for this key it is replaced.
|
||||
// Errors are swallowed. Must be invoked ONLY for `NotFound`-class
|
||||
// outcomes; never for transient failures.
|
||||
virtual void storeNegative(std::string_view key) = 0;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
#include <string_view>
|
||||
|
||||
@@ -37,5 +38,7 @@ namespace ccm {
|
||||
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||
std::string_view filter);
|
||||
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -16,17 +16,23 @@
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class CardPreviewService {
|
||||
public:
|
||||
explicit CardPreviewService(IHttpClient& http);
|
||||
explicit CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache = nullptr);
|
||||
|
||||
// Register a game module's preview source. Calling this with a module
|
||||
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
||||
@@ -38,18 +44,87 @@ public:
|
||||
// The returned `std::string` is a raw byte buffer (PNG/JPEG payload) -
|
||||
// it is NOT decoded text. Use std::string::data()/size() with whatever
|
||||
// image-decoding facility your UI provides.
|
||||
//
|
||||
// Successful results are cached in two tiers, both keyed by
|
||||
// (game, name, setId, setNo):
|
||||
// 1. In-memory LRU (bounded by `kCacheCapacity`) for instant hits
|
||||
// while the app is running.
|
||||
// 2. Optional persistent byte cache (passed at construction) so
|
||||
// previews survive app restarts.
|
||||
// Re-selecting the same row is then a memcpy away from the wxImage
|
||||
// decoder, no HTTP at all - this is the common user-facing case
|
||||
// (clicking around the table).
|
||||
//
|
||||
// Failures are split into two policies based on
|
||||
// `PreviewLookupError::Kind`:
|
||||
// * `NotFound` (the upstream answered cleanly that this record has
|
||||
// no preview) is *negative-cached* in both tiers, so subsequent
|
||||
// selections short-circuit without touching the network. The
|
||||
// cache key is invalidated automatically when the user edits a
|
||||
// lookup-relevant field of the record.
|
||||
// * `Transient` (HTTP / network / parse failure) is NEVER cached, so
|
||||
// the next selection retries cleanly once connectivity is back.
|
||||
Result<std::string> fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
Result<AutoDetectedPrint> detectFirstPrint(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
// Download image bytes from a fully-qualified URL without going through
|
||||
// per-game preview-source resolution.
|
||||
// per-game preview-source resolution. Cached by URL (same LRU bound).
|
||||
Result<std::string> fetchImageBytesByUrl(std::string_view url);
|
||||
|
||||
// Maximum number of cached preview entries kept in memory. Picked so a
|
||||
// typical Yu-Gi-Oh! collection page can scroll up and down without
|
||||
// re-hitting the network, while keeping a hard upper bound on RSS for
|
||||
// very large collections (each entry is roughly one PNG, <100 KiB).
|
||||
static constexpr std::size_t kCacheCapacity = 128;
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
enum class CacheLookupKind {
|
||||
Miss, // not in the in-memory tier
|
||||
Hit, // positive entry; bytes returned via outPayload
|
||||
NegativeHit, // negative entry; outPayload is empty
|
||||
};
|
||||
|
||||
Result<std::string> fetchAndCache(const std::string& cacheKey,
|
||||
std::string_view url);
|
||||
|
||||
// Returns the kind of in-memory cache entry for `key`. On Hit the
|
||||
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
|
||||
// cleared. Both Hit and NegativeHit move the entry to the front of
|
||||
// the LRU.
|
||||
CacheLookupKind cacheLookup(const std::string& key, std::string& outPayload);
|
||||
void cacheStore(const std::string& key, std::string payload);
|
||||
void cacheStoreNegative(const std::string& key);
|
||||
|
||||
IHttpClient& http_;
|
||||
IPreviewByteCache* persistentCache_{nullptr};
|
||||
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
||||
|
||||
// LRU: list holds entries in MRU-first order; map points at list nodes
|
||||
// for O(1) move-to-front. Mutex covers both list and map - lookups
|
||||
// happen on a worker thread spawned by BaseSelectedCardPanel.
|
||||
//
|
||||
// A `negative` entry has an empty payload by convention; we keep the
|
||||
// flag explicit (rather than abusing emptiness) so future invariants
|
||||
// around eviction or stats stay easy to reason about.
|
||||
struct CacheEntry {
|
||||
std::string key;
|
||||
std::string payload;
|
||||
bool negative{false};
|
||||
};
|
||||
using CacheList = std::list<CacheEntry>;
|
||||
CacheList cacheList_;
|
||||
std::unordered_map<std::string, CacheList::iterator> cacheIndex_;
|
||||
std::mutex cacheMutex_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -52,11 +53,25 @@ enum class PokemonSortColumn {
|
||||
Note,
|
||||
};
|
||||
|
||||
enum class YuGiOhSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Stable in-place sort. `ascending=false` runs the same comparator with
|
||||
// inverted sign, matching `byField(field, asc)` semantics.
|
||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
||||
bool ascending);
|
||||
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending);
|
||||
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
// Yu-Gi-Oh! collector slot equivalence for UI + metadata matching.
|
||||
//
|
||||
// The edit dialog composes `setNo` as `<set.id>-<digits>` using only numeric
|
||||
// characters from the text field (e.g. SOD + "015" -> "SOD-015"). YGOPRODeck
|
||||
// `set_code` values often embed region letters ("SOD-EN015"). Exact string
|
||||
// compare would miss that both refer to the same slot.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
[[nodiscard]] inline std::string_view trimAsciiSpaces(std::string_view s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string ygoAbbrevBeforeDash(std::string_view raw) {
|
||||
const std::string_view s = trimAsciiSpaces(raw);
|
||||
const auto dash = s.find('-');
|
||||
const std::string_view pref = dash == std::string_view::npos ? s : s.substr(0, dash);
|
||||
std::string out(pref);
|
||||
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string ygoCollectorDigitsOnly(std::string_view raw) {
|
||||
const std::string_view s = trimAsciiSpaces(raw);
|
||||
const auto dash = s.find('-');
|
||||
const std::string_view tail =
|
||||
dash == std::string_view::npos ? std::string_view{} : s.substr(dash + 1);
|
||||
std::string out;
|
||||
out.reserve(tail.size());
|
||||
for (unsigned char c : tail) {
|
||||
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// True when both strings designate the same printed slot: same abbreviation
|
||||
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
|
||||
// extracted from everything after that dash.
|
||||
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
|
||||
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
|
||||
return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b);
|
||||
}
|
||||
|
||||
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
|
||||
// the same English `set_name` (e.g. Dark Magician as "LOB-E003" vs NA "LOB-005").
|
||||
// The suffix uses a single leading `E` immediately followed by digits — distinct
|
||||
// from two-letter regions such as "EN" ("LOB-EN005") or "DE" ("LOB-DE005").
|
||||
[[nodiscard]] inline bool ygoLikelyEuropeanRegionalSetCode(std::string_view setCode) {
|
||||
const std::string_view s = trimAsciiSpaces(setCode);
|
||||
const auto dash = s.find('-');
|
||||
if (dash == std::string_view::npos || dash + 2 >= s.size()) return false;
|
||||
const std::string_view tail = s.substr(dash + 1);
|
||||
return tail.size() >= 2 && tail[0] == 'E'
|
||||
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [Yugipedia’s 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 card’s 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 YGOPRODeck’s `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. Yugipedia’s 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 Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ~50–100 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.
|
||||
|
||||
@@ -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 |
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user