major: initial release

* initial development

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* improvements

* improvements

* ci/cd

* ci/cd

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

---------

Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
Sebastian Dine
2026-05-09 11:05:47 +02:00
committed by GitHub
parent 13262fa015
commit 55ace147bc
149 changed files with 12611 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
#include "ccm/domain/Configuration.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const Configuration& c) {
j = nlohmann::json{
{"dataStorage", c.dataStorage},
{"defaultGame", c.defaultGame},
{"theme", c.theme},
};
}
void from_json(const nlohmann::json& j, Configuration& c) {
j.at("dataStorage").get_to(c.dataStorage);
j.at("defaultGame").get_to(c.defaultGame);
c.theme = j.value("theme", Theme::Light);
}
} // namespace ccm
+139
View File
@@ -0,0 +1,139 @@
#include "ccm/domain/Enums.hpp"
#include <stdexcept>
#include <string>
namespace ccm {
std::string_view to_string(Game g) noexcept {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
}
return "Magic";
}
std::string_view to_string(Language l) noexcept {
switch (l) {
case Language::English: return "English";
case Language::German: return "German";
case Language::French: return "French";
case Language::Spanish: return "Spanish";
case Language::Italian: return "Italian";
case Language::Chinese: return "Chinese";
case Language::Japanese: return "Japanese";
case Language::Russian: return "Russian";
}
return "English";
}
std::string_view to_string(Condition c) noexcept {
switch (c) {
case Condition::Mint: return "Mint";
case Condition::NearMint: return "NearMint";
case Condition::Excellent: return "Excellent";
case Condition::Good: return "Good";
case Condition::LightPlayed: return "LightPlayed";
case Condition::Played: return "Played";
case Condition::Poor: return "Poor";
}
return "Mint";
}
std::string_view to_string(Theme t) noexcept {
switch (t) {
case Theme::Light: return "Light";
case Theme::Dark: return "Dark";
}
return "Light";
}
std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
return std::nullopt;
}
std::optional<Language> languageFromString(std::string_view s) noexcept {
if (s == "English") return Language::English;
if (s == "German") return Language::German;
if (s == "French") return Language::French;
if (s == "Spanish") return Language::Spanish;
if (s == "Italian") return Language::Italian;
if (s == "Chinese") return Language::Chinese;
if (s == "Japanese") return Language::Japanese;
if (s == "Russian") return Language::Russian;
return std::nullopt;
}
std::optional<Condition> conditionFromString(std::string_view s) noexcept {
if (s == "Mint") return Condition::Mint;
if (s == "NearMint") return Condition::NearMint;
if (s == "Excellent") return Condition::Excellent;
if (s == "Good") return Condition::Good;
if (s == "LightPlayed") return Condition::LightPlayed;
if (s == "Played") return Condition::Played;
if (s == "Poor") return Condition::Poor;
return std::nullopt;
}
std::optional<Theme> themeFromString(std::string_view s) noexcept {
if (s == "Light") return Theme::Light;
if (s == "Dark") return Theme::Dark;
return std::nullopt;
}
const std::array<Game, 2>& allGames() noexcept {
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
return v;
}
const std::array<Language, 8>& allLanguages() noexcept {
static constexpr std::array<Language, 8> v{
Language::English, Language::German, Language::French, Language::Spanish,
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
};
return v;
}
const std::array<Condition, 7>& allConditions() noexcept {
static constexpr std::array<Condition, 7> v{
Condition::Mint, Condition::NearMint, Condition::Excellent,
Condition::Good, Condition::LightPlayed, Condition::Played, Condition::Poor
};
return v;
}
const std::array<Theme, 2>& allThemes() noexcept {
static constexpr std::array<Theme, 2> v{Theme::Light, Theme::Dark};
return v;
}
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
void from_json(const nlohmann::json& j, Game& v) {
auto parsed = gameFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Language& v) {
auto parsed = languageFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Condition& v) {
auto parsed = conditionFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Condition value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Theme& v) {
auto parsed = themeFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Theme value: " + j.get<std::string>());
v = *parsed;
}
} // namespace ccm
+35
View File
@@ -0,0 +1,35 @@
#include "ccm/domain/MagicCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const MagicCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"foil", c.foil},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, MagicCard& 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("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("foil").get_to(c.foil);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/PokemonCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const PokemonCard& 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},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, PokemonCard& 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("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+19
View File
@@ -0,0 +1,19 @@
#include "ccm/domain/Set.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const Set& s) {
j = nlohmann::json{
{"id", s.id},
{"name", s.name},
{"releaseDate", s.releaseDate},
};
}
void from_json(const nlohmann::json& j, Set& s) {
j.at("id").get_to(s.id);
j.at("name").get_to(s.name);
j.at("releaseDate").get_to(s.releaseDate);
}
} // namespace ccm
@@ -0,0 +1,100 @@
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
// which does not URL-encode the URL string we hand it.
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();
}
// Apply the same name massaging as the legacy query path before sending.
std::string sanitizeName(std::string_view name) {
std::string s(name);
std::string::size_type pos = 0;
while ((pos = s.find('&', pos)) != std::string::npos) {
s.replace(pos, 1, "and");
pos += 3;
}
return s;
}
} // namespace
MagicCardPreviewSource::MagicCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId) {
// Build the unencoded query first so the output matches what Scryfall
// would parse: name:"<sanitized>" AND set:<setId>
const std::string sanitized = sanitizeName(name);
std::string query = "name:\"";
query += sanitized;
query += "\" AND set:";
query += std::string(setId);
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
}
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
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.");
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("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.");
}
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 Result<std::string>::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());
}
}
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view /*setNo*/) {
const std::string url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+8
View File
@@ -0,0 +1,8 @@
#include "ccm/games/magic/MagicGameModule.hpp"
namespace ccm {
MagicGameModule::MagicGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
+48
View File
@@ -0,0 +1,48 @@
#include "ccm/games/magic/MagicSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
namespace ccm {
MagicSetSource::MagicSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> MagicSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::vector<Set>>::err("Scryfall response missing 'data' array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
// Filter out digital-only sets exactly like the Rust code.
const bool digital = entry.value("digital", false);
if (digital) continue;
Set s;
s.id = entry.value("code", "");
s.name = entry.value("name", "");
// Scryfall returns "released_at" as YYYY-MM-DD; persisted data stores YYYY/MM/DD.
std::string releasedAt = entry.value("released_at", "");
std::replace(releasedAt.begin(), releasedAt.end(), '-', '/');
s.releaseDate = std::move(releasedAt);
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("Scryfall JSON parse error: ") + e.what());
}
}
Result<std::vector<Set>> MagicSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
@@ -0,0 +1,110 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
// Magic implementation; kept private so the two can drift independently if a
// future API requires it.
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();
}
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
std::string normalizeNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
// Build the unencoded query first so the output matches what the Pokemon
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
std::string query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
}
const std::string num = normalizeNumber(setNo);
if (!num.empty()) {
query += " number:";
query += num;
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
}
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
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.");
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("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.");
}
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>());
}
if (images.contains("small") && images.at("small").is_string()) {
return Result<std::string>::ok(images.at("small").get<std::string>());
}
return Result<std::string>::err("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());
}
}
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
const std::string url = buildSearchUrl(name, setId, setNo);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/pokemon/PokemonGameModule.hpp"
namespace ccm {
PokemonGameModule::PokemonGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,45 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
namespace ccm {
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::vector<Set>>::err(
"Pokemon TCG API response missing 'data' array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
Set s;
s.id = entry.value("id", "");
s.name = entry.value("name", "");
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
// no separator rewrite needed (cf. Scryfall's "released_at").
s.releaseDate = entry.value("releaseDate", "");
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("Pokemon TCG JSON parse error: ") + e.what());
}
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+30
View File
@@ -0,0 +1,30 @@
#include "ccm/infra/CprHttpClient.hpp"
#include <cpr/cpr.h>
#include <string>
namespace ccm {
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
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"}}
);
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
}
if (r.status_code < 200 || r.status_code >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
}
return Result<std::string>::ok(std::move(r.text));
}
} // namespace ccm
+41
View File
@@ -0,0 +1,41 @@
#include "ccm/infra/JsonSetRepository.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path JsonSetRepository::setsPath(Game game) const {
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
}
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
const auto p = setsPath(game);
if (!fs_.exists(p)) {
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
}
auto text = fs_.readText(p);
if (!text) return Result<std::vector<Set>>::err(text.error());
try {
auto j = nlohmann::json::parse(text.value());
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
}
}
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
const auto p = setsPath(game);
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = sets;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+43
View File
@@ -0,0 +1,43 @@
#include "ccm/infra/LocalImageStore.hpp"
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
LocalImageStore::LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path LocalImageStore::gameImageDir(Game game) const {
return fs::path(config_.current().dataStorage) / dirName_(game) / "images";
}
Result<std::string> LocalImageStore::copyIn(Game game,
const fs::path& srcPath,
const std::string& targetName) {
const auto dir = gameImageDir(game);
auto ensure = fs_.ensureDirectory(dir);
if (!ensure) return Result<std::string>::err(ensure.error());
// Preserve the source's extension - the original Rust code does the same.
std::string ext = srcPath.extension().string();
std::string finalName = targetName + ext;
const auto dest = dir / finalName;
auto cp = fs_.copyFile(srcPath, dest, /*overwrite=*/true);
if (!cp) return Result<std::string>::err(cp.error());
return Result<std::string>::ok(std::move(finalName));
}
Result<void> LocalImageStore::remove(Game game, const std::string& imageName) {
const auto p = gameImageDir(game) / imageName;
if (!fs_.exists(p)) return Result<void>::ok(); // be forgiving on stale entries
return fs_.remove(p);
}
fs::path LocalImageStore::resolvePath(Game game, const std::string& imageName) const {
return gameImageDir(game) / imageName;
}
} // namespace ccm
+88
View File
@@ -0,0 +1,88 @@
#include "ccm/infra/StdFileSystem.hpp"
#include <fstream>
#include <sstream>
#include <system_error>
namespace ccm {
namespace fs = std::filesystem;
bool StdFileSystem::exists(const fs::path& p) const {
std::error_code ec;
return fs::exists(p, ec);
}
bool StdFileSystem::isDirectory(const fs::path& p) const {
std::error_code ec;
return fs::is_directory(p, ec);
}
Result<void> StdFileSystem::ensureDirectory(const fs::path& p) {
std::error_code ec;
if (fs::exists(p, ec)) {
if (fs::is_directory(p, ec)) return Result<void>::ok();
return Result<void>::err("Path exists but is not a directory: " + p.string());
}
fs::create_directories(p, ec);
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
return Result<void>::ok();
}
Result<std::string> StdFileSystem::readText(const fs::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return Result<std::string>::err("Unable to open file: " + p.string());
std::ostringstream ss;
ss << in.rdbuf();
if (!in && !in.eof()) return Result<std::string>::err("Read error on: " + p.string());
return Result<std::string>::ok(ss.str());
}
Result<void> StdFileSystem::writeText(const fs::path& p, std::string_view contents) {
std::error_code ec;
if (p.has_parent_path()) {
fs::create_directories(p.parent_path(), ec);
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
}
std::ofstream out(p, std::ios::binary | std::ios::trunc);
if (!out) return Result<void>::err("Unable to create file: " + p.string());
out.write(contents.data(), static_cast<std::streamsize>(contents.size()));
if (!out) return Result<void>::err("Write error on: " + p.string());
return Result<void>::ok();
}
Result<void> StdFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) {
std::error_code ec;
if (to.has_parent_path()) {
fs::create_directories(to.parent_path(), ec);
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
ec.clear();
}
const auto opt = overwrite ? fs::copy_options::overwrite_existing
: fs::copy_options::none;
fs::copy_file(from, to, opt, ec);
if (ec) return Result<void>::err("copy_file failed: " + ec.message());
return Result<void>::ok();
}
Result<void> StdFileSystem::remove(const fs::path& p) {
std::error_code ec;
fs::remove(p, ec);
if (ec) return Result<void>::err("remove failed: " + ec.message());
return Result<void>::ok();
}
Result<std::vector<fs::path>> StdFileSystem::listDirectory(const fs::path& p) {
std::error_code ec;
if (!fs::is_directory(p, ec)) {
return Result<std::vector<fs::path>>::err("Not a directory: " + p.string());
}
std::vector<fs::path> out;
for (const auto& entry : fs::directory_iterator(p, ec)) {
out.push_back(entry.path());
}
if (ec) return Result<std::vector<fs::path>>::err("directory_iterator: " + ec.message());
return Result<std::vector<fs::path>>::ok(std::move(out));
}
} // namespace ccm
+66
View File
@@ -0,0 +1,66 @@
#include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp"
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
// identically.
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
bool containsLower(std::string_view haystack, std::string_view needleLower) {
return asciiLower(haystack).find(needleLower) != std::string::npos;
}
} // namespace
bool matchesMagicFilter(const MagicCard& card, std::string_view filter) {
// `""`.includes(filter) is true for filter == "" in JS; mirror that so the
// panel does not need a separate "no filter" branch.
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
// Order mirrors the MtgTable.tsx tableFields list (minus the boolean
// flag columns, which `applyFilter` skips). Stops on first match for the
// same short-circuit behavior as the JS for-loop with `break`.
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, 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;
}
bool matchesPokemonFilter(const PokemonCard& 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(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
+34
View File
@@ -0,0 +1,34 @@
#include "ccm/services/CardPreviewService.hpp"
namespace ccm {
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
void CardPreviewService::registerModule(IGameModule& module) {
if (auto* src = module.cardPreviewSource(); src != nullptr) {
sources_[module.id()] = src;
}
}
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::string>::err("No preview source registered for this game.");
}
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());
}
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());
}
} // namespace ccm
+173
View File
@@ -0,0 +1,173 @@
#include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
// We use ASCII-only tolower; the original TS app processed the same fields and
// never special-cased Unicode either, so this stays byte-compatible for the
// realistic data set (English/German/etc. names already lowercase identically).
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
// Wrap a less-than predicate so that ascending=false flips its meaning,
// mirroring `byField(field, asc)` in TableTemplate.tsx.
template <typename Less>
auto directional(Less less, bool ascending) {
return [less, ascending](const auto& a, const auto& b) {
return ascending ? less(a, b) : less(b, a);
};
}
} // namespace
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
bool ascending) {
switch (column) {
case MagicSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case MagicSortColumn::SetReleaseDate:
// Release dates are stored as "YYYY/MM/DD" so plain lexicographic
// compare is chronological. The legacy JS path lowercased strings anyway; we
// do the same for parity even though digits/'/' are unaffected.
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case MagicSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case MagicSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case MagicSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case MagicSortColumn::Foil:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.foil < b.foil; // false < true (asc puts unset first)
}, ascending));
break;
case MagicSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case MagicSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case MagicSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
bool ascending) {
switch (column) {
case PokemonSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case PokemonSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case PokemonSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case PokemonSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case PokemonSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case PokemonSortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.holo < b.holo;
}, ascending));
break;
case PokemonSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case PokemonSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case PokemonSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case PokemonSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
} // namespace ccm
+47
View File
@@ -0,0 +1,47 @@
#include "ccm/services/ConfigService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
ConfigService::ConfigService(IFileSystem& fs,
std::filesystem::path configFilePath,
std::filesystem::path defaultDataStorage)
: fs_(fs),
path_(std::move(configFilePath)),
defaultDataStorage_(std::move(defaultDataStorage)) {}
Configuration ConfigService::makeDefault() const {
Configuration c;
// generic_string() always uses '/' separators - keeps the value portable
// across Windows / Unix and round-trip-friendly for tests and JSON.
c.dataStorage = defaultDataStorage_.generic_string();
c.defaultGame = Game::Magic;
return c;
}
Result<void> ConfigService::initialize() {
if (!fs_.exists(path_)) {
current_ = makeDefault();
return store(current_);
}
auto text = fs_.readText(path_);
if (!text) return Result<void>::err(text.error());
try {
auto j = nlohmann::json::parse(text.value());
current_ = j.get<Configuration>();
} catch (const std::exception& e) {
return Result<void>::err(std::string("config.json parse error: ") + e.what());
}
return Result<void>::ok();
}
Result<void> ConfigService::store(Configuration cfg) {
current_ = std::move(cfg);
const nlohmann::json j = current_;
return fs_.writeText(path_, j.dump(2));
}
} // namespace ccm
+120
View File
@@ -0,0 +1,120 @@
#include "ccm/services/ImageService.hpp"
#include "ccm/util/FsNames.hpp"
#include <filesystem>
namespace ccm {
ImageService::ImageService(IImageStore& store) : store_(store) {}
std::uint8_t ImageService::nextImageIndex(const std::vector<std::string>& existingImages) {
if (existingImages.empty()) return 0;
const std::string& last = existingImages.back();
// Preserve the compatibility shim for legacy image filenames that
// contain "IMG_FRONT"/"IMG_BACK" markers - those start a fresh index.
if (last.find("IMG_FRONT") != std::string::npos ||
last.find("IMG_BACK") != std::string::npos) {
return 0;
}
const std::uint8_t parsed = parseIndexFromFilename(last);
// Saturating +1 since we hand back uint8 just like the Rust version.
return parsed == 255 ? 255 : static_cast<std::uint8_t>(parsed + 1);
}
std::string ImageService::buildTargetName(bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
std::uint8_t index) {
const std::string set = formatTextForFs(setName);
const std::string card = formatTextForFs(cardName);
if (newEntry) {
return set + "+" + card + "+" + std::to_string(static_cast<int>(index));
}
return std::to_string(cardId) + "+" + set + "+" + card + "+" +
std::to_string(static_cast<int>(index));
}
Result<std::string> ImageService::addImage(Game game,
const std::filesystem::path& srcPath,
bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& existingImages) {
const auto idx = nextImageIndex(existingImages);
const auto target = buildTargetName(newEntry, cardId, setName, cardName, idx);
return store_.copyIn(game, srcPath, target);
}
Result<void> ImageService::removeImage(Game game, const std::string& imageName) {
return store_.remove(game, imageName);
}
Result<std::vector<std::string>> ImageService::normalizeNamesForPersistedCard(
Game game,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& imageNames) {
const std::string idPrefix = std::to_string(cardId) + "+";
std::vector<std::string> normalized = imageNames;
struct RenameOp {
std::string oldName;
std::string newName;
};
std::vector<RenameOp> ops;
ops.reserve(imageNames.size());
for (std::size_t i = 0; i < imageNames.size(); ++i) {
const std::string& oldName = imageNames[i];
if (oldName.starts_with(idPrefix)) {
continue;
}
const std::uint8_t idx = parseIndexFromFilename(oldName);
const std::filesystem::path oldPath(oldName);
const std::string ext = oldPath.extension().string();
const std::string newBase = buildTargetName(false, cardId, setName, cardName, idx);
const std::string newName = newBase + ext;
if (newName == oldName) {
continue;
}
ops.push_back({oldName, newName});
normalized[i] = newName;
}
if (ops.empty()) {
return Result<std::vector<std::string>>::ok(std::move(normalized));
}
std::vector<std::string> created;
created.reserve(ops.size());
for (const auto& op : ops) {
auto copied = store_.copyIn(game, store_.resolvePath(game, op.oldName),
std::filesystem::path(op.newName).stem().string());
if (!copied) {
for (const auto& createdName : created) {
(void)store_.remove(game, createdName);
}
return Result<std::vector<std::string>>::err(copied.error());
}
created.push_back(copied.value());
}
for (const auto& op : ops) {
auto removed = store_.remove(game, op.oldName);
if (!removed) {
return Result<std::vector<std::string>>::err(removed.error());
}
}
return Result<std::vector<std::string>>::ok(std::move(normalized));
}
std::filesystem::path ImageService::resolveImagePath(Game game,
const std::string& imageName) const {
return store_.resolvePath(game, imageName);
}
} // namespace ccm
+27
View File
@@ -0,0 +1,27 @@
#include "ccm/services/SetService.hpp"
namespace ccm {
SetService::SetService(ISetRepository& repo) : repo_(repo) {}
void SetService::registerModule(IGameModule* module) {
if (module) modules_[module->id()] = module;
}
Result<std::vector<Set>> SetService::updateSets(Game game) {
auto it = modules_.find(game);
if (it == modules_.end() || it->second == nullptr) {
return Result<std::vector<Set>>::err("No game module registered for this game.");
}
auto fetched = it->second->setSource().fetchAll();
if (!fetched) return fetched;
auto saved = repo_.save(game, fetched.value());
if (!saved) return Result<std::vector<Set>>::err(saved.error());
return fetched;
}
Result<std::vector<Set>> SetService::getSets(Game game) {
return repo_.load(game);
}
} // namespace ccm
+85
View File
@@ -0,0 +1,85 @@
#include "ccm/util/FsNames.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <string>
#include <string_view>
#include <utility>
namespace ccm {
namespace {
// Replacements ordered exactly like the Rust source's chained .replace(...) so
// behavior is bit-identical for inputs that contain multiple of these chars.
struct Replacement {
std::string_view from;
std::string_view to;
};
constexpr std::array<Replacement, 14> kReplacements{{
{"'", ""},
{"`", ""},
{",", ""},
{" ", ""},
{":", "-"},
{"&", "And"},
{"|", "Or"},
{"\xC3\xA1", "a"}, // a-acute (UTF-8)
{"\xC3\xA9", "e"}, // e-acute
{"\xC3\xAD", "i"}, // i-acute
{"\xC3\xB3", "o"}, // o-acute
{"\xC3\xBA", "u"}, // u-acute
{"\xC3\xBB", "u"}, // u-circumflex
// Remaining accented vowels appear in modern Scryfall data but were not
// listed in the Rust source. Keeping behavior 1:1 deliberately.
}};
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
if (from.empty()) return;
std::string::size_type pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos) {
s.replace(pos, from.size(), to);
pos += to.size();
}
}
} // namespace
std::string formatTextForFs(std::string_view text) {
std::string out(text);
for (const auto& r : kReplacements) {
replaceAllInPlace(out, r.from, r.to);
}
return out;
}
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
const auto dot = filename.find_last_of('.');
if (dot == std::string_view::npos || dot == 0) return 0;
// Walk backwards from the position before the dot, collecting digits.
std::size_t end = dot;
std::size_t begin = end;
while (begin > 0) {
unsigned char ch = static_cast<unsigned char>(filename[begin - 1]);
if (std::isdigit(ch)) {
--begin;
// Rust source intentionally caps at 2-digit indices.
if (end - begin >= 2) break;
} else {
break;
}
}
if (begin == end) return 0;
unsigned int value = 0;
for (std::size_t i = begin; i < end; ++i) {
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
}
if (value > 255) value = 255;
return static_cast<std::uint8_t>(value);
}
} // namespace ccm