minor: yugioh support added

This commit is contained in:
Sebastian Dine
2026-05-09 19:32:18 +02:00
committed by GitHub
parent 6f575f4cec
commit 6ff4406638
68 changed files with 4994 additions and 134 deletions
+4 -2
View File
@@ -9,6 +9,7 @@ std::string_view to_string(Game g) noexcept {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
}
return "Magic";
}
@@ -51,6 +52,7 @@ std::string_view to_string(Theme t) noexcept {
std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
return std::nullopt;
}
@@ -83,8 +85,8 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 2>& allGames() noexcept {
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
const std::array<Game, 3>& allGames() noexcept {
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
return v;
}
+42
View File
@@ -0,0 +1,42 @@
#include "ccm/domain/YuGiOhCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const YuGiOhCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"setNo", c.setNo},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"firstEdition", c.firstEdition},
{"rarity", c.rarity},
{"rarityCode", c.rarityCode},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, YuGiOhCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("setNo").get_to(c.setNo);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("firstEdition").get_to(c.firstEdition);
j.at("rarity").get_to(c.rarity);
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
else c.rarityCode.clear();
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+20 -11
View File
@@ -62,38 +62,47 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
}
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
Result<std::string, PreviewLookupError>
MagicCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::string>::err("Scryfall response missing 'data' array.");
// Treat schema deviation as transient: the API contract failed,
// not the user's record. Scryfall returns a JSON error object
// here on outage, which is rare but not stable.
return R::err({K::Transient, "Scryfall response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Scryfall returned no matching cards.");
return R::err({K::NotFound, "Scryfall returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
// Double-faced cards expose image_uris on each face; there is no
// fallback for this and surfaces it as "no preview".
return Result<std::string>::err("Card has no top-level image_uris.");
return R::err({K::NotFound, "Card has no top-level image_uris."});
}
const auto& uris = first.at("image_uris");
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
return Result<std::string>::err("Card has no 'normal' image variant.");
return R::err({K::NotFound, "Card has no 'normal' image variant."});
}
return Result<std::string>::ok(uris.at("normal").get<std::string>());
return R::ok(uris.at("normal").get<std::string>());
} catch (const std::exception& e) {
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
return R::err({K::Transient, std::string("Scryfall JSON parse error: ") + e.what()});
}
}
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view /*setNo*/) {
Result<std::string, PreviewLookupError>
MagicCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view /*setNo*/) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
}
@@ -70,40 +70,46 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
}
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return Result<std::string>::err("Card has no 'images' object.");
return R::err({K::NotFound, "Card has no 'images' object."});
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return Result<std::string>::ok(images.at("large").get<std::string>());
return R::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return Result<std::string>::ok(images.at("small").get<std::string>());
return R::ok(images.at("small").get<std::string>());
}
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
}
}
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string url = buildSearchUrl(name, setId, setNo);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
}
@@ -0,0 +1,565 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cctype>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace ccm {
namespace {
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
// here so the YGO and Magic code paths can drift independently if the future
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
// for the `|` separator inside `titles=`).
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
// always return card_images[0]: when no `cardset=` filter is applied, that
// slot is the original/standard artwork (alt-art passcodes follow), which is
// the closest fallback we have when Yugipedia has no scan for this printing.
std::string imageFromCard(const nlohmann::json& card) {
if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) {
return {};
}
const auto& first = card.at("card_images").at(0);
if (first.contains("image_url") && first.at("image_url").is_string()) {
return first.at("image_url").get<std::string>();
}
if (first.contains("image_url_small") && first.at("image_url_small").is_string()) {
return first.at("image_url_small").get<std::string>();
}
if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) {
return first.at("image_url_cropped").get<std::string>();
}
return {};
}
// Split a setNo encoded by the UI as `<setNo>||<rarity>||<edition>` into its
// three positional fields. Any missing trailing field becomes an empty
// string, so older callers that pass just `<setNo>` keep working.
struct ParsedSetNo {
std::string setNo;
std::string rarity;
std::string edition; // "1E" / "UE" / "" (unknown)
};
ParsedSetNo parseSetNoTuple(std::string_view raw) {
std::string s(raw);
ParsedSetNo p;
const auto a = s.find("||");
if (a == std::string::npos) {
p.setNo = trim(std::move(s));
return p;
}
p.setNo = trim(s.substr(0, a));
std::string rest = s.substr(a + 2);
const auto b = rest.find("||");
if (b == std::string::npos) {
p.rarity = trim(std::move(rest));
return p;
}
p.rarity = trim(rest.substr(0, b));
p.edition = trim(rest.substr(b + 2));
return p;
}
} // namespace
YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {}
// ============================================================================
// Yugipedia (image-preview path)
// ============================================================================
std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
// Yugipedia's image policy strips whitespace and a fixed set of
// punctuation from the displayed card name to produce the file slug.
// Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy
std::string out;
out.reserve(name.size());
for (unsigned char c : name) {
if (c <= 0x20) continue; // whitespace, including non-breaking
switch (c) {
case '#': case ',': case '.': case ':': case '\'': case '"':
case '?': case '!': case '&': case '@': case '%': case '=':
case '[': case ']': case '<': case '>': case '/': case '\\':
case '-': case '*': case ';': case '`':
continue;
default:
break;
}
out.push_back(static_cast<char>(c));
}
return out;
}
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
// Compare case-insensitively, ignoring whitespace, against a table of
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
// plus a few extras occasionally seen in imported collections. The codes
// are the ones Yugipedia uses in image filenames.
std::string lc;
lc.reserve(rarityName.size());
for (unsigned char c : rarityName) {
if (std::isspace(c)) continue;
lc.push_back(static_cast<char>(std::tolower(c)));
}
static const std::array<std::pair<std::string_view, std::string_view>, 32> kTable = {{
{"common", "C"},
{"shortprint", "SP"},
{"supershortprint", "SSP"},
{"normalrare", "NR"},
{"rare", "R"},
{"superrare", "SR"},
{"ultrarare", "UR"},
{"ultimaterare", "UtR"},
{"secretrare", "ScR"},
{"prismaticsecretrare", "PScR"},
{"extrasecretrare", "EScR"},
{"ultrasecretrare", "UScR"},
{"platinumsecretrare", "PtScR"},
{"goldsecretrare", "GScR"},
{"ghostrare", "GR"},
{"goldrare", "GUR"},
{"premiumgoldrare", "PGR"},
{"goldenrare", "GUR"},
{"starfoilrare", "SFR"},
{"shatterfoilrare", "SHR"},
{"mosaicrare", "MSR"},
{"parallelrare", "PR"},
{"superparallelrare", "SPR"},
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "ColR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
{"prismaticredsecretrare", "PRScR"},
{"silverletter", "SLR"},
}};
for (const auto& [k, v] : kTable) {
if (lc == k) return std::string(v);
}
return {};
}
std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) {
std::string s(setNo);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
const auto dash = s.find('-');
if (dash == std::string::npos) return s;
return s.substr(0, dash);
}
std::vector<std::string> YuGiOhCardPreviewSource::buildCandidateFilenames(
std::string_view name,
std::string_view setCode,
std::string_view rarityCode,
bool firstEdition) {
std::vector<std::string> out;
const std::string slug = normalizeName(name);
if (slug.empty() || setCode.empty()) return out;
// English-only region candidates, in rough usage order: EN is the
// current default, NA was used on most LOB-era prints, EU/AU show up
// sporadically. Always English regardless of the card's stored Language.
static constexpr std::array<std::string_view, 4> kRegions =
{"EN", "NA", "EU", "AU"};
// Edition candidates: prefer the printed edition the user has, then
// try the opposite, then fall back to LE for promo-type prints.
std::array<std::string_view, 3> editions = {"", "", "LE"};
if (firstEdition) {
editions[0] = "1E";
editions[1] = "UE";
} else {
editions[0] = "UE";
editions[1] = "1E";
}
// Two extension variants: Yugipedia has a mix of .png (modern) and .jpg
// (older uploads) for the same era. Both are common for LOB-era cards.
static constexpr std::array<std::string_view, 2> kExts = {"png", "jpg"};
auto pushCombos = [&](std::string_view rarity) {
for (auto edition : editions) {
for (auto region : kRegions) {
for (auto ext : kExts) {
std::string fn;
fn.reserve(slug.size() + setCode.size() + 16);
fn += slug;
fn += '-'; fn.append(setCode);
fn += '-'; fn.append(region);
if (!rarity.empty()) {
fn += '-'; fn.append(rarity);
}
fn += '-'; fn.append(edition);
fn += '.'; fn.append(ext);
out.push_back(std::move(fn));
}
}
}
};
// Primary attempts include the rarity slot. If we don't know the rarity
// we skip straight to the rarity-less fallback (some sets are uniform
// rarity and the upload omits the slot).
if (!rarityCode.empty()) {
pushCombos(rarityCode);
}
pushCombos("");
return out;
}
std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
const std::vector<std::string>& filenames) {
// MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded).
// One HTTP call returns imageinfo for every page whose file exists; the
// missing ones come back tagged with `"missing": ""`.
std::string joined;
for (size_t i = 0; i < filenames.size(); ++i) {
if (i > 0) joined += "|";
joined += "File:";
joined += filenames[i];
}
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += urlEncode(joined);
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaResponse(
const std::string& body,
const std::vector<std::string>& filenameOrder) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query' object."});
}
const auto& pages = j.at("query").value("pages", nlohmann::json::object());
if (!pages.is_object()) {
return R::err({K::Transient, "Yugipedia response missing 'query.pages'."});
}
// Build a name->URL map. MediaWiki returns the title with namespace
// ("File:...") and may have replaced spaces with underscores; our
// candidate filenames never contain spaces, so a direct compare on
// the bit after "File:" is sufficient.
std::unordered_map<std::string, std::string> resolved;
resolved.reserve(filenameOrder.size());
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (!page.contains("imageinfo")) continue;
const auto& info = page.at("imageinfo");
if (!info.is_array() || info.empty()) continue;
const auto& info0 = info.at(0);
if (!info0.contains("url") || !info0.at("url").is_string()) continue;
std::string title = page.value("title", "");
constexpr std::string_view kPrefix = "File:";
if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size());
resolved[title] = info0.at("url").get<std::string>();
}
// Walk our ordered candidate list and return the first hit. This is
// how priority works: 1E English first, then UE, then jpg, etc.
for (const auto& fn : filenameOrder) {
auto it = resolved.find(fn);
if (it != resolved.end() && !it->second.empty()) {
return R::ok(it->second);
}
}
// Every candidate was tagged "missing" => Yugipedia confirmed there
// is no English scan for this printing. Treat as NotFound; the
// YGOPRODeck fallback may still surface a generic art.
return R::err({K::NotFound, "No matching Yugipedia scan found."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia JSON parse error: ") + e.what()});
}
}
// ============================================================================
// YGOPRODeck (auto-detect path + last-resort fallback)
// ============================================================================
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName) {
std::string url =
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += urlEncode(setName);
}
return url;
}
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseFallbackImageUrl(
const std::string& body, std::string_view name) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err({K::Transient, "YGOPRODeck response missing 'data' array."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "YGOPRODeck returned no matching cards."});
}
const std::string wantedNameLower = toLower(trim(std::string(name)));
// Prefer the exact-name match: the fuzzy `fname=` search can mix in
// sibling cards (Dark Magician + Dark Magician Girl), and we don't
// want to land on a sibling's standard art.
for (const auto& card : data) {
const std::string cardName = trim(card.value("name", ""));
if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) {
const std::string image = imageFromCard(card);
if (!image.empty()) return R::ok(image);
}
}
// Failing that, take whatever YGOPRODeck ranked first.
const std::string image = imageFromCard(data.at(0));
if (!image.empty()) {
return R::ok(image);
}
return R::err({K::NotFound, "Card has no image variants."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("YGOPRODeck JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view preferredSetName,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("YGOPRODeck returned no matching cards.");
}
const std::string wantedSet = trim(std::string(preferredSetName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushPrint = [&collected](const nlohmann::json& print) {
AutoDetectedPrint out;
out.setNo = trim(print.value("set_code", ""));
out.rarity = trim(print.value("set_rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& print : card.at("card_sets")) {
const std::string setName = trim(print.value("set_name", ""));
if (!wantedSet.empty() && setName != wantedSet) continue;
pushPrint(print);
}
}
// Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take
// every print from `data[0]` without filtering by set_name.
//
// When the caller supplied an exact card name (edit-dialog variant
// listing), combining unrelated `card_sets[]` rows after a non-empty
// display-set filter missed would falsely imply multiple printings
// "in one set" (different real-world products share the same card).
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSet.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
const auto& firstCard = j.at("data").at(0);
if (!wantedNameLower.empty()) {
const std::string cardName = trim(firstCard.value("name", ""));
if (toLower(cardName) != wantedNameLower) {
return R::err("Could not auto-detect set print metadata.");
}
}
if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) {
for (const auto& print : firstCard.at("card_sets")) {
pushPrint(print);
}
}
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::parseFirstPrint(
const std::string& body, std::string_view preferredSetName) {
auto list = parsePrintVariants(body, preferredSetName, "");
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
// ============================================================================
// Public ICardPreviewSource API
// ============================================================================
Result<std::string, PreviewLookupError>
YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view /*setId*/,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const ParsedSetNo p = parseSetNoTuple(setNo);
const std::string setCode = extractSetCode(p.setNo);
const std::string rarityCode = rarityCodeFor(p.rarity);
const bool firstEdition = (p.edition == "1E");
// The overall classification needs the worst outcome across the two
// upstreams: NotFound only when *both* answered cleanly with no match,
// Transient as soon as either one couldn't speak. We track Yugipedia's
// outcome here and combine it with YGOPRODeck's below.
bool yugipediaSawTransient = false;
PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."};
// Step 1: Yugipedia per-printing scan. Build a batch of plausible English
// filenames and ask MediaWiki for them all in one call. This is the only
// source we know of that distinguishes art between same-passcode reprints
// (LOB Blue-Eyes vs SDK Blue-Eyes, etc.).
//
// No usable set code (or empty candidate list) is treated as an
// "inapplicable" Yugipedia step rather than a failure - we don't want a
// legitimate metadata gap to taint the final classification as transient.
if (!setCode.empty()) {
const auto candidates = buildCandidateFilenames(
name, setCode, rarityCode, firstEdition);
if (!candidates.empty()) {
const std::string url = buildYugipediaQueryUrl(candidates);
auto resp = http_.get(url);
if (!resp) {
yugipediaSawTransient = true;
yugipediaErr = {K::Transient, resp.error()};
} else {
auto parsed = parseYugipediaResponse(resp.value(), candidates);
if (parsed) return parsed;
yugipediaErr = std::move(parsed).error();
if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true;
}
}
}
// Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has
// no scan we can match (newly-added cards, OCG-only cards without an
// English release, transient Yugipedia errors). Always unfiltered, so
// card_images[0] is the original artwork rather than an alt-art reprint.
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
// YGOPRODeck failed at the network layer => the overall lookup is
// transient regardless of what Yugipedia did. Surface YGOPRODeck's
// error string because it's the most recent failure.
return R::err({K::Transient, fallback.error()});
}
auto parsed = parseFallbackImageUrl(fallback.value(), name);
if (parsed) return parsed;
// Both upstreams answered. If *either* one was transient, the overall
// outcome is transient (we can't conclude the record has no image).
PreviewLookupError fallbackErr = std::move(parsed).error();
if (yugipediaSawTransient || fallbackErr.kind == K::Transient) {
return R::err({K::Transient,
yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message});
}
// Otherwise both confirmed "no image" => safe to remember.
return R::err({K::NotFound, fallbackErr.message});
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
}
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
namespace ccm {
YuGiOhGameModule::YuGiOhGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
+47
View File
@@ -0,0 +1,47 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
namespace ccm {
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"YGOPRODeck response is not an array.");
}
std::vector<Set> out;
out.reserve(j.size());
for (const auto& entry : j) {
Set s;
s.id = entry.value("set_code", "");
s.name = entry.value("set_name", "");
std::string release = entry.value("tcg_date", "");
for (char& ch : release) {
if (ch == '-') ch = '/';
}
s.releaseDate = std::move(release);
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("YGOPRODeck set parse error: ") + e.what());
}
}
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+29 -8
View File
@@ -3,19 +3,40 @@
#include <cpr/cpr.h>
#include <string>
#include <utility>
namespace ccm {
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(std::make_unique<cpr::Session>()) {
// Configure session-wide options once; every Get() then only updates
// the URL. libcurl's connection cache lives inside the easy handle, so
// reusing one Session across calls is what gets us TLS keep-alive.
session_->SetTimeout(cpr::Timeout{timeout_});
// `Accept: application/json` breaks some CDNs that refuse non-JSON bodies
// (preview pipeline also GETs raw JPG/PNG). Wildcard keeps JSON APIs happy.
session_->SetHeader(cpr::Header{
{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "*/*"},
});
session_->SetRedirect(cpr::Redirect{/*max_redirects=*/10L,
/*follow=*/true,
/*cont_send_cred=*/false,
cpr::PostRedirectFlags::POST_ALL});
}
CprHttpClient::~CprHttpClient() = default;
Result<std::string> CprHttpClient::get(std::string_view url) {
cpr::Response r = cpr::Get(
cpr::Url{std::string(url)},
cpr::Timeout{timeout_},
// Identify ourselves; some APIs rate-limit unknown agents harshly.
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "application/json"}}
);
// libcurl easy handles (and therefore cpr::Session) are not thread-safe.
// We serialize callers here; the preview path is single-flight already
// (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_);
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
+228
View File
@@ -0,0 +1,228 @@
#include "ccm/infra/LocalPreviewByteCache.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <sstream>
#include <system_error>
#include <utility>
#include <vector>
namespace ccm {
namespace fs = std::filesystem;
namespace {
// FNV-1a 64-bit hash, hex-encoded. We don't need cryptographic strength
// here: the `.idx` sidecar file holds the original key and load() rejects
// any mismatch, so a hash collision degrades to a cache miss instead of a
// wrong-image return. FNV-1a was picked to keep this dependency-free
// (no openssl, no extra link).
std::string fnv1a64Hex(std::string_view in) {
constexpr std::uint64_t kOffsetBasis = 0xcbf29ce484222325ULL;
constexpr std::uint64_t kPrime = 0x100000001b3ULL;
std::uint64_t h = kOffsetBasis;
for (unsigned char c : in) {
h ^= c;
h *= kPrime;
}
std::ostringstream oss;
oss << std::hex << std::setw(16) << std::setfill('0') << h;
return oss.str();
}
// Best-effort mtime; returns the epoch on any error so callers can still
// sort consistently (oldest-first eviction stays well-defined).
fs::file_time_type mtimeOrEpoch(const fs::path& p) {
std::error_code ec;
auto t = fs::last_write_time(p, ec);
if (ec) return fs::file_time_type{};
return t;
}
std::uintmax_t fileSizeOrZero(const fs::path& p) {
std::error_code ec;
auto sz = fs::file_size(p, ec);
return ec ? 0u : sz;
}
void touchMtime(const fs::path& p) {
std::error_code ec;
fs::last_write_time(p, fs::file_time_type::clock::now(), ec);
// Ignored: touch is a best-effort hint to the LRU policy.
}
} // namespace
LocalPreviewByteCache::LocalPreviewByteCache(IFileSystem& fs,
fs::path cacheDir,
std::size_t maxBytes)
: fs_(fs), cacheDir_(std::move(cacheDir)), maxBytes_(maxBytes) {}
std::string LocalPreviewByteCache::hashKey(std::string_view key) {
return fnv1a64Hex(key);
}
fs::path LocalPreviewByteCache::payloadPath(const std::string& hash) const {
return cacheDir_ / (hash + ".bin");
}
fs::path LocalPreviewByteCache::negativePath(const std::string& hash) const {
return cacheDir_ / (hash + ".neg");
}
fs::path LocalPreviewByteCache::indexPath(const std::string& hash) const {
return cacheDir_ / (hash + ".idx");
}
IPreviewByteCache::LoadResult LocalPreviewByteCache::load(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
const bool hasBin = fs_.exists(bin);
const bool hasNeg = fs_.exists(neg);
if (!hasBin && !hasNeg) return {HitKind::Miss, {}};
// Sidecar must exist and match exactly. Anything else - missing,
// mismatched, empty - is treated as a miss so the next store() /
// storeNegative() will overwrite cleanly. This is what guarantees that
// a hash collision can never serve another card's bytes or stale
// "no image" verdict.
if (!fs_.exists(idx)) return {HitKind::Miss, {}};
auto idxRead = fs_.readText(idx);
if (!idxRead) return {HitKind::Miss, {}};
if (idxRead.value() != key) return {HitKind::Miss, {}};
if (hasBin) {
auto payload = fs_.readText(bin);
if (!payload) return {HitKind::Miss, {}};
// Touch mtime so this hit moves to the front of the LRU.
touchMtime(bin);
return {HitKind::Hit, std::move(payload).value()};
}
// Negative-only entry. Touch its mtime as well so frequently-checked
// negatives don't get aged out by an arbitrary directory sweep.
touchMtime(neg);
return {HitKind::NegativeHit, {}};
}
void LocalPreviewByteCache::store(std::string_view key, const std::string& payload) {
if (payload.empty()) return;
std::lock_guard<std::mutex> lock(mutex_);
auto ensure = fs_.ensureDirectory(cacheDir_);
if (!ensure) return;
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
// If a negative entry exists for this exact key, drop it before writing
// the positive payload so the two are never co-resident on disk.
if (fs_.exists(neg)) (void)fs_.remove(neg);
// Eviction runs against the *new* payload size, not the post-write
// total, so we make room before writing. If the same key is being
// overwritten the existing payload's bytes are released first.
evictIfNeededLocked(payload.size());
auto wrote = fs_.writeText(bin, payload);
if (!wrote) return;
auto wroteIdx = fs_.writeText(idx, std::string(key));
if (!wroteIdx) {
// Sidecar failure leaves us with bytes we can't safely serve later.
// Roll back the payload write so a future load() doesn't see it.
(void)fs_.remove(bin);
return;
}
}
void LocalPreviewByteCache::storeNegative(std::string_view key) {
std::lock_guard<std::mutex> lock(mutex_);
auto ensure = fs_.ensureDirectory(cacheDir_);
if (!ensure) return;
const std::string hash = hashKey(key);
const auto bin = payloadPath(hash);
const auto neg = negativePath(hash);
const auto idx = indexPath(hash);
// Replace any existing positive entry: storeNegative is the upstream
// saying "the previous bytes are no longer the correct answer for this
// record". Free the bytes from the size cap immediately.
if (fs_.exists(bin)) (void)fs_.remove(bin);
// Order matters: write the marker first, then the sidecar. If the
// sidecar write fails we delete the marker to avoid a half-written
// entry that load() would treat as a miss anyway but that contributes
// a stray file to the directory listing.
auto wroteNeg = fs_.writeText(neg, std::string{});
if (!wroteNeg) return;
auto wroteIdx = fs_.writeText(idx, std::string(key));
if (!wroteIdx) {
(void)fs_.remove(neg);
}
}
std::size_t LocalPreviewByteCache::currentSizeBytes() {
std::lock_guard<std::mutex> lock(mutex_);
auto entries = fs_.listDirectory(cacheDir_);
if (!entries) return 0;
std::size_t total = 0;
for (const auto& p : entries.value()) {
if (p.extension() == ".bin") total += static_cast<std::size_t>(fileSizeOrZero(p));
}
return total;
}
void LocalPreviewByteCache::evictIfNeededLocked(std::size_t incomingBytes) {
auto entries = fs_.listDirectory(cacheDir_);
if (!entries) return;
struct Entry {
fs::path bin;
fs::path idx;
std::uintmax_t size;
fs::file_time_type mtime;
};
std::vector<Entry> bins;
bins.reserve(entries.value().size());
std::size_t total = 0;
for (const auto& p : entries.value()) {
if (p.extension() != ".bin") continue;
Entry e;
e.bin = p;
e.idx = p;
e.idx.replace_extension(".idx");
e.size = fileSizeOrZero(p);
e.mtime = mtimeOrEpoch(p);
total += static_cast<std::size_t>(e.size);
bins.push_back(std::move(e));
}
if (total + incomingBytes <= maxBytes_) return;
std::sort(bins.begin(), bins.end(),
[](const Entry& a, const Entry& b) { return a.mtime < b.mtime; });
for (const auto& e : bins) {
if (total + incomingBytes <= maxBytes_) break;
// remove() is best-effort; if it fails we still drop our accounting
// for the entry so we don't loop forever on a stuck file.
(void)fs_.remove(e.bin);
(void)fs_.remove(e.idx);
total -= std::min<std::size_t>(static_cast<std::size_t>(e.size), total);
}
}
} // namespace ccm
+16
View File
@@ -63,4 +63,20 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
return false;
}
bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
} // namespace ccm
+217 -8
View File
@@ -1,8 +1,46 @@
#include "ccm/services/CardPreviewService.hpp"
#include <string>
#include <utility>
#include <vector>
namespace ccm {
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
namespace {
// Compose a stable cache key from the four lookup coordinates. Using NUL as
// a separator keeps the key unambiguous even if a card's name happens to
// contain `|` or other punctuation.
std::string makePreviewKey(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo) {
std::string k;
k.reserve(2 + name.size() + setId.size() + setNo.size() + 3);
k.push_back('p');
k.push_back(static_cast<char>(static_cast<int>(game)));
k.push_back('\0');
k.append(name);
k.push_back('\0');
k.append(setId);
k.push_back('\0');
k.append(setNo);
return k;
}
std::string makeUrlKey(std::string_view url) {
std::string k;
k.reserve(url.size() + 1);
k.push_back('u');
k.append(url);
return k;
}
} // namespace
CardPreviewService::CardPreviewService(IHttpClient& http,
IPreviewByteCache* persistentCache)
: http_(http), persistentCache_(persistentCache) {}
void CardPreviewService::registerModule(IGameModule& module) {
if (auto* src = module.cardPreviewSource(); src != nullptr) {
@@ -10,6 +48,80 @@ void CardPreviewService::registerModule(IGameModule& module) {
}
}
CardPreviewService::CacheLookupKind CardPreviewService::cacheLookup(
const std::string& key, std::string& outPayload) {
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it == cacheIndex_.end()) {
outPayload.clear();
return CacheLookupKind::Miss;
}
// Move-to-front to mark as most-recently-used.
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
if (it->second->negative) {
outPayload.clear();
return CacheLookupKind::NegativeHit;
}
outPayload = it->second->payload;
return CacheLookupKind::Hit;
}
void CardPreviewService::cacheStore(const std::string& key, std::string payload) {
if (payload.empty()) return;
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it != cacheIndex_.end()) {
// Overwrite existing entry (positive or negative) and bump it to
// the front. Replacing a negative entry is the "we got a real
// image after a previous NotFound" path - rare but valid.
it->second->payload = std::move(payload);
it->second->negative = false;
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
return;
}
cacheList_.push_front({key, std::move(payload), /*negative=*/false});
cacheIndex_.emplace(key, cacheList_.begin());
while (cacheList_.size() > kCacheCapacity) {
cacheIndex_.erase(cacheList_.back().key);
cacheList_.pop_back();
}
}
void CardPreviewService::cacheStoreNegative(const std::string& key) {
std::lock_guard<std::mutex> lock(cacheMutex_);
auto it = cacheIndex_.find(key);
if (it != cacheIndex_.end()) {
it->second->payload.clear();
it->second->negative = true;
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
return;
}
cacheList_.push_front({key, std::string{}, /*negative=*/true});
cacheIndex_.emplace(key, cacheList_.begin());
while (cacheList_.size() > kCacheCapacity) {
cacheIndex_.erase(cacheList_.back().key);
cacheList_.pop_back();
}
}
Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKey,
std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
std::string payload = std::move(bytes).value();
if (payload.empty()) {
return Result<std::string>::err("Empty response body from " + std::string(url));
}
cacheStore(cacheKey, payload);
// Best-effort persist to disk so the next app launch starts warm.
// The persistent tier is fire-and-forget: any I/O error is swallowed
// by the adapter, the in-memory tier still holds the bytes.
if (persistentCache_ != nullptr) {
persistentCache_->store(cacheKey, payload);
}
return Result<std::string>::ok(std::move(payload));
}
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
@@ -18,17 +130,114 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
if (it == sources_.end() || it->second == nullptr) {
return Result<std::string>::err("No preview source registered for this game.");
}
// Cache check before any HTTP call. The (game, name, setId, setNo) tuple
// uniquely identifies a printing for our purposes - the resolved image
// URL is always a deterministic function of those four inputs, and any
// edit to a lookup-relevant field changes the key automatically.
const std::string key = makePreviewKey(game, name, setId, setNo);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::ok(std::move(cached));
case CacheLookupKind::NegativeHit:
return Result<std::string>::err("No preview available for this card.");
case CacheLookupKind::Miss:
break;
}
// Disk-backed second tier: previews persisted by an earlier app run
// get promoted into the in-memory LRU on first access this session, so
// subsequent re-selections stay fast without re-touching the network.
// Negative entries on disk are likewise promoted - the user already
// knows from a previous session that this record has no upstream image.
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
switch (disk.kind) {
case IPreviewByteCache::HitKind::Hit:
cacheStore(key, disk.payload);
return Result<std::string>::ok(disk.payload);
case IPreviewByteCache::HitKind::NegativeHit:
cacheStoreNegative(key);
return Result<std::string>::err("No preview available for this card.");
case IPreviewByteCache::HitKind::Miss:
break;
}
}
auto url = it->second->fetchImageUrl(name, setId, setNo);
if (!url) return Result<std::string>::err(url.error());
auto bytes = http_.get(url.value());
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
if (!url) {
// The two error kinds split here:
// - NotFound: upstream answered cleanly that this record has no
// image. Persist the verdict so we don't keep retrying.
// - Transient: network/HTTP/parse failure. Surface the error
// unchanged and DO NOT cache anything; the next selection
// retries from scratch.
const auto err = std::move(url).error();
if (err.kind == PreviewLookupError::Kind::NotFound) {
cacheStoreNegative(key);
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
}
return Result<std::string>::err(err.message);
}
return fetchAndCache(key, url.value());
}
Result<AutoDetectedPrint> CardPreviewService::detectFirstPrint(Game game,
std::string_view name,
std::string_view setId) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
}
return it->second->detectFirstPrint(name, setId);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
Game game,
std::string_view name,
std::string_view setId) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::vector<AutoDetectedPrint>>::err(
"No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Auto-detect not enabled for this game.");
}
return it->second->detectPrintVariants(name, setId);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
// The by-URL path is used for fixed per-game card-back fallback images.
// A failure there is always transient (the URL itself is constant), so
// there is no negative-cache analogue to worry about; we just look up
// and, if needed, fetch+store.
const std::string key = makeUrlKey(url);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::ok(std::move(cached));
case CacheLookupKind::NegativeHit:
// Defensive: nothing in this code path ever stores a negative
// entry under a URL key, but if one ever ends up here (cache
// file tampering, future code paths) treat it as a miss so the
// fallback fetch can still run.
break;
case CacheLookupKind::Miss:
break;
}
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
cacheStore(key, disk.payload);
return Result<std::string>::ok(disk.payload);
}
}
return fetchAndCache(key, url);
}
} // namespace ccm
+60
View File
@@ -170,4 +170,64 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
}
}
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
bool ascending) {
switch (column) {
case YuGiOhSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case YuGiOhSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
}, ascending));
break;
case YuGiOhSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
}, ascending));
break;
case YuGiOhSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
}, ascending));
break;
case YuGiOhSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case YuGiOhSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case YuGiOhSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case YuGiOhSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
} // namespace ccm