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
+26
View File
@@ -0,0 +1,26 @@
#pragma once
// Configuration: matches the persisted `config.json` schema used by this app.
//
// { "dataStorage": "/abs/path", "defaultGame": "Magic" }
#include "ccm/domain/Enums.hpp"
#include <nlohmann/json.hpp>
#include <string>
namespace ccm {
struct Configuration {
std::string dataStorage;
Game defaultGame{Game::Magic};
Theme theme{Theme::Light};
friend bool operator==(const Configuration&, const Configuration&) = default;
};
void to_json(nlohmann::json& j, const Configuration& c);
void from_json(const nlohmann::json& j, Configuration& c);
} // namespace ccm
+77
View File
@@ -0,0 +1,77 @@
#pragma once
// Game / Language / Condition enums.
//
// String spellings are kept identical to the established Rust serde defaults
// (e.g. `NearMint`, `LightPlayed`, `Magic`, `Pokemon`) so existing JSON files
// remain interchangeable.
#include <nlohmann/json.hpp>
#include <array>
#include <optional>
#include <string>
#include <string_view>
namespace ccm {
enum class Game {
Magic,
Pokemon,
};
enum class Language {
English,
German,
French,
Spanish,
Italian,
Chinese,
Japanese,
Russian,
};
enum class Condition {
Mint,
NearMint,
Excellent,
Good,
LightPlayed,
Played,
Poor,
};
enum class Theme {
Light,
Dark,
};
std::string_view to_string(Game g) noexcept;
std::string_view to_string(Language l) noexcept;
std::string_view to_string(Condition c) noexcept;
std::string_view to_string(Theme t) noexcept;
std::optional<Game> gameFromString(std::string_view s) noexcept;
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<Language, 8>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
void to_json(nlohmann::json& j, Game v);
void from_json(const nlohmann::json& j, Game& v);
void to_json(nlohmann::json& j, Language v);
void from_json(const nlohmann::json& j, Language& v);
void to_json(nlohmann::json& j, Condition v);
void from_json(const nlohmann::json& j, Condition& v);
void to_json(nlohmann::json& j, Theme v);
void from_json(const nlohmann::json& j, Theme& v);
} // namespace ccm
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// MagicCard - faithful port of magic/card_services.rs::Card.
// JSON layout remains stable so collection.json files stay interchangeable.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct MagicCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool foil{false};
bool signed_{false}; // `signed` is a reserved keyword
bool altered{false};
friend bool operator==(const MagicCard&, const MagicCard&) = default;
};
void to_json(nlohmann::json& j, const MagicCard& c);
void from_json(const nlohmann::json& j, MagicCard& c);
} // namespace ccm
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct PokemonCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
};
void to_json(nlohmann::json& j, const PokemonCard& c);
void from_json(const nlohmann::json& j, PokemonCard& c);
} // namespace ccm
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// Set: identifier of a printing run shared by both supported games.
// JSON shape matches the original Rust Set struct exactly:
// { "id": "...", "name": "...", "releaseDate": "YYYY/MM/DD" }
#include <nlohmann/json.hpp>
#include <string>
namespace ccm {
struct Set {
std::string id;
std::string name;
std::string releaseDate; // formatted as "YYYY/MM/DD"
friend bool operator==(const Set&, const Set&) = default;
};
void to_json(nlohmann::json& j, const Set& s);
void from_json(const nlohmann::json& j, Set& s);
} // namespace ccm
+47
View File
@@ -0,0 +1,47 @@
#pragma once
// IGameModule + ISetSource: the seam that lets the UI handle Magic and Pokemon
// uniformly. New game support is "implement these interfaces and register the
// module in the composition root" - see the Rust `templates/` module for the
// pattern this is modeled after.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/util/Result.hpp"
#include <string>
#include <vector>
namespace ccm {
class ISetSource {
public:
virtual ~ISetSource() = default;
// Fetch the canonical set list from the game's external API.
// Implementations return a vector that has already been filtered
// (e.g. no digital-only sets) and sorted by release date ascending.
virtual Result<std::vector<Set>> fetchAll() = 0;
};
class IGameModule {
public:
virtual ~IGameModule() = default;
[[nodiscard]] virtual Game id() const noexcept = 0;
// Subdirectory name used inside the data storage root. Matches the Rust
// string literals "magic" / "pokemon".
[[nodiscard]] virtual std::string dirName() const = 0;
[[nodiscard]] virtual std::string displayName() const = 0;
virtual ISetSource& setSource() = 0;
// Optional preview source. Returning nullptr signals the game has no
// remote preview API (the UI then renders only locally stored images).
// The default keeps existing modules compiling without forcing every
// game to provide one.
virtual ICardPreviewSource* cardPreviewSource() noexcept { return nullptr; }
};
} // namespace ccm
@@ -0,0 +1,42 @@
#pragma once
// MagicCardPreviewSource: ICardPreviewSource implementation for Magic the
// Gathering. Calls Scryfall's search endpoint at
// https://api.scryfall.com/cards/search?q=name:"<name>" AND set:<setCode>
// and returns `data[0].image_uris.normal`. Mirrors the established
// `src/components/magic/SelectedMtgPanel.tsx::getImage`, including the
// `&` -> `and` substitution in the card name.
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
namespace ccm {
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;
// Build the fully URL-encoded Scryfall search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
static std::string buildSearchUrl(std::string_view name,
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);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,29 @@
#pragma once
// MagicGameModule: IGameModule for Magic the Gathering. Owns its set source
// and card preview source, reports the canonical "magic" subdirectory name
// used on disk.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include "ccm/games/magic/MagicSetSource.hpp"
namespace ccm {
class MagicGameModule final : public IGameModule {
public:
explicit MagicGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::Magic; }
[[nodiscard]] std::string dirName() const override { return "magic"; }
[[nodiscard]] std::string displayName() const override { return "Magic"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
MagicSetSource setSource_;
MagicCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,28 @@
#pragma once
// MagicSetSource: ISetSource implementation for Magic the Gathering.
// Calls the Scryfall API at https://api.scryfall.com/sets, drops digital-only
// sets, maps the response into our `Set` domain type, and sorts by release date
// ascending. Behavior matches `magic/set_services.rs::update_sets`.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
namespace ccm {
class MagicSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.scryfall.com/sets";
explicit MagicSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,42 @@
#pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
// TCG. Calls the Pokemon TCG search endpoint at
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
// and returns `data[0].images.large` (with `images.small` as a graceful
// fallback). Mirrors the established `getImage` flow in
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
namespace ccm {
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;
// 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.
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
// 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);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,28 @@
#pragma once
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
// and card preview source, both backed by api.pokemontcg.io/v2.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonSetSource.hpp"
namespace ccm {
class PokemonGameModule final : public IGameModule {
public:
explicit PokemonGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::Pokemon; }
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
[[nodiscard]] std::string displayName() const override { return "Pokemon"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
PokemonSetSource setSource_;
PokemonCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,30 @@
#pragma once
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
// response into our `Set` domain type, and sorts by release date ascending.
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
// so no rewriting is needed (unlike Scryfall's `released_at`).
// Behavior matches `pokemon/set_services.rs::update_sets`.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
+23
View File
@@ -0,0 +1,23 @@
#pragma once
// CprHttpClient: cpr-based implementation of IHttpClient.
// All cpr/libcurl symbols stay confined to the .cpp file - any TU that just
// needs to make HTTP calls only depends on the IHttpClient port.
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
namespace ccm {
class CprHttpClient final : public IHttpClient {
public:
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
Result<std::string> get(std::string_view url) override;
private:
std::chrono::milliseconds timeout_;
};
} // namespace ccm
@@ -0,0 +1,85 @@
#pragma once
// JsonCollectionRepository<T>: persists std::map<id, TCard> as a JSON object
// keyed by stringified id. Layout matches the established collection file:
//
// <dataStorage>/<gameDir>/collection.json -> { "0": {...}, "1": {...} }
//
// Header-only because it's a template over the card type.
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICollectionRepository.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <nlohmann/json.hpp>
#include <filesystem>
#include <functional>
#include <string>
namespace ccm {
template <typename TCard>
class JsonCollectionRepository final : public ICollectionRepository<TCard> {
public:
using Map = typename ICollectionRepository<TCard>::Map;
// The repository needs a way to translate a Game enum into the per-game
// subdirectory name ("magic" / "pokemon"); a small lookup function keeps
// this layer free of concrete game module types.
using DirNameFn = std::function<std::string(Game)>;
JsonCollectionRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
Result<Map> load(Game game) override {
const auto p = collectionPath(game);
if (!fs_.exists(p)) {
// Mirror the Rust "create on first read" semantics so the UI
// never sees a missing-file error on a fresh install.
Map empty;
auto saved = save(game, empty);
if (!saved) return Result<Map>::err(saved.error());
return Result<Map>::ok(std::move(empty));
}
auto text = fs_.readText(p);
if (!text) return Result<Map>::err(text.error());
try {
auto j = nlohmann::json::parse(text.value());
Map out;
for (auto it = j.begin(); it != j.end(); ++it) {
std::uint32_t key = static_cast<std::uint32_t>(std::stoul(it.key()));
out.emplace(key, it.value().template get<TCard>());
}
return Result<Map>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<Map>::err(std::string("JSON parse error: ") + e.what());
}
}
Result<void> save(Game game, const Map& collection) override {
nlohmann::json j = nlohmann::json::object();
for (const auto& [id, card] : collection) {
j[std::to_string(id)] = card;
}
const auto p = collectionPath(game);
auto dirRes = fs_.ensureDirectory(p.parent_path());
if (!dirRes) return dirRes;
return fs_.writeText(p, j.dump(2));
}
private:
std::filesystem::path collectionPath(Game game) const {
return std::filesystem::path(config_.current().dataStorage) /
dirName_(game) / "collection.json";
}
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
};
} // namespace ccm
@@ -0,0 +1,32 @@
#pragma once
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/ISetRepository.hpp"
#include "ccm/services/ConfigService.hpp"
#include <functional>
#include <string>
namespace ccm {
class JsonSetRepository final : public ISetRepository {
public:
using DirNameFn = std::function<std::string(Game)>;
JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<std::vector<Set>> load(Game game) override;
Result<void> save(Game game, const std::vector<Set>& sets) override;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
};
} // namespace ccm
@@ -0,0 +1,37 @@
#pragma once
// LocalImageStore: stores card images under
// `<dataStorage>/<game>/images/<filename>`.
// Implements IImageStore, used by ImageService.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/services/ConfigService.hpp"
#include <functional>
#include <string>
namespace ccm {
class LocalImageStore final : public IImageStore {
public:
using DirNameFn = std::function<std::string(Game)>;
LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<std::string> copyIn(Game game,
const std::filesystem::path& srcPath,
const std::string& targetName) override;
Result<void> remove(Game game, const std::string& imageName) override;
std::filesystem::path resolvePath(Game game, const std::string& imageName) const override;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path gameImageDir(Game game) const;
};
} // namespace ccm
+25
View File
@@ -0,0 +1,25 @@
#pragma once
// std::filesystem-backed implementation of IFileSystem.
#include "ccm/ports/IFileSystem.hpp"
namespace ccm {
class StdFileSystem final : public IFileSystem {
public:
[[nodiscard]] bool exists(const std::filesystem::path& p) const override;
[[nodiscard]] bool isDirectory(const std::filesystem::path& p) const override;
Result<void> ensureDirectory(const std::filesystem::path& p) override;
Result<std::string> readText(const std::filesystem::path& p) override;
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override;
Result<void> copyFile(const std::filesystem::path& from,
const std::filesystem::path& to,
bool overwrite) override;
Result<void> remove(const std::filesystem::path& p) override;
Result<std::vector<std::filesystem::path>> listDirectory(
const std::filesystem::path& p) override;
};
} // namespace ccm
@@ -0,0 +1,30 @@
#pragma once
// ICardPreviewSource - resolves the preview image URL for a single card via
// the active game's external API. Implementations stay HTTP-bound, no UI deps.
//
// Modeled after per-game `getImage` helpers in
// `src/components/{magic,pokemon}/Selected*Panel.tsx`. The resulting URL is
// then fetched as raw image bytes by `CardPreviewService` and decoded by the
// UI layer (wxImage in our wx adapter).
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
namespace ccm {
class ICardPreviewSource {
public:
virtual ~ICardPreviewSource() = default;
// 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;
};
} // namespace ccm
@@ -0,0 +1,25 @@
#pragma once
// ICollectionRepository<T> - persistence port for a per-game card collection,
// keyed by uint32_t id. Mirrors the HashMap<u32, T> in the original Rust code.
#include "ccm/domain/Enums.hpp"
#include "ccm/util/Result.hpp"
#include <cstdint>
#include <map>
namespace ccm {
template <typename TCard>
class ICollectionRepository {
public:
using Map = std::map<std::uint32_t, TCard>;
virtual ~ICollectionRepository() = default;
virtual Result<Map> load(Game game) = 0;
virtual Result<void> save(Game game, const Map& collection) = 0;
};
} // namespace ccm
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// IFileSystem - filesystem operations the services need, expressed as a
// narrow port. Real implementation is `StdFileSystem` (over <filesystem>).
// In-memory implementation can be plugged in for tests.
#include "ccm/util/Result.hpp"
#include <filesystem>
#include <string>
#include <vector>
namespace ccm {
class IFileSystem {
public:
virtual ~IFileSystem() = default;
[[nodiscard]] virtual bool exists(const std::filesystem::path& p) const = 0;
[[nodiscard]] virtual bool isDirectory(const std::filesystem::path& p) const = 0;
virtual Result<void> ensureDirectory(const std::filesystem::path& p) = 0;
virtual Result<std::string> readText(const std::filesystem::path& p) = 0;
virtual Result<void> writeText(const std::filesystem::path& p, std::string_view contents) = 0;
virtual Result<void> copyFile(const std::filesystem::path& from,
const std::filesystem::path& to,
bool overwrite) = 0;
virtual Result<void> remove(const std::filesystem::path& p) = 0;
virtual Result<std::vector<std::filesystem::path>> listDirectory(
const std::filesystem::path& p) = 0;
};
} // namespace ccm
+26
View File
@@ -0,0 +1,26 @@
#pragma once
// IHttpClient - tiny abstraction over HTTP GET so the rest of the codebase
// never sees libcurl/cpr directly. Tests can substitute a fake client.
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
namespace ccm {
class IHttpClient {
public:
virtual ~IHttpClient() = default;
// Issue a blocking HTTPS GET. Returns the response body on success, an
// error string on failure (no exceptions across the port boundary).
//
// The returned `std::string` is a raw byte buffer - it is NOT decoded as
// text. Binary payloads (e.g. PNG/JPEG image bytes for the card preview
// feature) round-trip through this method intact.
virtual Result<std::string> get(std::string_view url) = 0;
};
} // namespace ccm
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// IImageStore - manages card image files on disk inside the per-game
// `images/` subdirectory. Returns absolute paths so the UI can decode with
// whichever image library it likes (we use wxImage in the wx adapter); core
// stays free of any image decoding dependency.
#include "ccm/domain/Enums.hpp"
#include "ccm/util/Result.hpp"
#include <filesystem>
#include <string>
namespace ccm {
class IImageStore {
public:
virtual ~IImageStore() = default;
// Copy `srcPath` into the per-game image dir under filename `targetName`
// (extension preserved from `srcPath`). Returns the final filename
// (basename only, no path) on success.
virtual Result<std::string> copyIn(Game game,
const std::filesystem::path& srcPath,
const std::string& targetName) = 0;
// Delete `imageName` from the per-game image dir.
virtual Result<void> remove(Game game, const std::string& imageName) = 0;
// Resolve `imageName` to an absolute path inside the per-game image dir.
virtual std::filesystem::path resolvePath(Game game, const std::string& imageName) const = 0;
};
} // namespace ccm
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// ISetRepository - persistence port for the cached `sets.json` of a game.
// Stored as a flat list to mirror the original Rust file layout.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/util/Result.hpp"
#include <vector>
namespace ccm {
class ISetRepository {
public:
virtual ~ISetRepository() = default;
virtual Result<std::vector<Set>> load(Game game) = 0;
virtual Result<void> save(Game game, const std::vector<Set>& sets) = 0;
};
} // namespace ccm
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// CardFilter - row matcher aligned with TableTemplate.tsx::applyFilter semantics.
// kept a single `filter` string in the table component and, before rendering,
// kept only the rows where *any* of the `tableFields` value-key columns
// (string- or number-typed) contained the filter as a substring. Boolean flag
// columns (foil / signed / altered / holo / firstEdition) were skipped because
// `typeof` was neither "string" nor "number".
//
// We replicate that logic here as free functions so the UI can stay dumb (it
// just owns a wxTextCtrl and re-asks core which rows match).
//
// Differences worth knowing:
// * The match is case-insensitive on **both** sides — the old JS path only lowercased
// the cell value, leaving the filter as-typed, which made uppercase input
// never match. Lowercasing the filter too is the obvious-intent fix and
// keeps round-trip semantics for any filter the original UI would accept
// (lowercase filters behave identically).
// * An empty filter matches every row, exactly as in JS where every string
// `.includes("")` returns true.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include <string_view>
namespace ccm {
// Magic value-key columns from the MtgTable.tsx tableFields list:
// name, set.name, language, condition, amount, note.
// Foil/Signed/Altered are bool-typed and intentionally excluded.
[[nodiscard]] bool matchesMagicFilter(const MagicCard& card,
std::string_view filter);
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
// name, set.name, setNo, language, condition, amount, note.
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
std::string_view filter);
} // namespace ccm
@@ -0,0 +1,55 @@
#pragma once
// CardPreviewService: high-level operation for resolving and downloading a
// card's preview image from the active game's external API. Mirrors the
// `EntryPanelTemplate.tsx::useEffect([entry])` flow:
// 1. Per-game `ICardPreviewSource` resolves the card -> an image URL.
// 2. Service issues a GET against that URL through `IHttpClient`.
// 3. Raw bytes are returned to the caller (UI decodes them with whichever
// image lib it prefers - we use wxImage in the wx adapter).
//
// Sources are registered through `IGameModule::cardPreviewSource()`. Modules
// that return nullptr are silently skipped; games without a registered source
// produce an explicit error result on lookup rather than silently doing nothing.
#include "ccm/domain/Enums.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
#include <unordered_map>
namespace ccm {
class CardPreviewService {
public:
explicit CardPreviewService(IHttpClient& http);
// Register a game module's preview source. Calling this with a module
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
// no remote preview API). The module reference must remain valid for
// the lifetime of the service.
void registerModule(IGameModule& module);
// Resolve and download the preview image bytes for a single card.
// 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.
Result<std::string> fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution.
Result<std::string> fetchImageBytesByUrl(std::string_view url);
private:
IHttpClient& http_;
std::unordered_map<Game, ICardPreviewSource*> sources_;
};
} // namespace ccm
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// CardSorter - per-column sorting behavior matching the established table model
// `tableFields` configuration. The original TS code stored each column's
// (valueKey, sortKey) pair on a config object so that, e.g., the "Set" column
// could *display* `set.name` while sorting by `set.releaseDate`. We replicate
// that mapping here as two enums (one per supported game), each variant naming
// a sort key. The free functions below run a stable sort over a vector using
// the same per-type rules as the original `byField`:
//
// * strings -> case-insensitive (lowercased) `<` / `>`
// * numbers -> direct `<` / `>`
// * booleans -> direct `<` / `>` (so false < true; unset flags first when asc)
//
// Stable sort matches the JS `Array.prototype.sort` guarantee from ES2019; the
// UI relies on it so successive clicks on different columns compose predictably
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include <vector>
namespace ccm {
// One value per *real* (non-spacer) column of the Magic table, in the same
// order as the Magic tableFields list.
enum class MagicSortColumn {
Name,
SetReleaseDate, // value column shows set.name, sort key is set.releaseDate
Language,
Condition,
Amount,
Foil,
Signed,
Altered,
Note,
};
// Pokemon equivalent (PokemonTable.tsx tableFields). Adds Holo + FirstEdition,
// drops Foil.
enum class PokemonSortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
Holo,
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);
} // namespace ccm
@@ -0,0 +1,126 @@
#pragma once
// CollectionService<TCard>: high-level CRUD over the per-game collection.
// Header-only template that operates on the ICollectionRepository<T> port and
// delegates image cleanup to an IImageStore on remove.
//
// This is the C++ equivalent of the Rust generic helpers in
// `templates/card_service_templates.rs` (add_entry_to_collection,
// update_entry_in_collection, get_entry_by_id, delete_entry_by_id, ...).
//
// Each TCard must expose `std::uint32_t id` and `std::vector<std::string> images`.
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/ICollectionRepository.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/util/Result.hpp"
#include <algorithm>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace ccm {
template <typename TCard>
class CollectionService {
public:
using Map = std::map<std::uint32_t, TCard>;
CollectionService(ICollectionRepository<TCard>& repo, IImageStore& imageStore)
: repo_(repo), imageStore_(imageStore) {}
Result<std::vector<TCard>> list(Game game) {
auto loaded = repo_.load(game);
if (!loaded) return Result<std::vector<TCard>>::err(loaded.error());
Map map = std::move(loaded).value();
std::vector<TCard> out;
out.reserve(map.size());
for (auto& [id, card] : map) {
(void)id;
out.push_back(std::move(card));
}
return Result<std::vector<TCard>>::ok(std::move(out));
}
[[nodiscard]] static std::uint32_t nextId(const Map& map) noexcept {
if (map.empty()) return 0;
return map.rbegin()->first + 1;
}
// Add a card. The `id` field on the input is overwritten with the next
// free id (matching the Rust HashMap-keyed behavior).
Result<std::uint32_t> add(Game game, TCard card) {
auto loaded = repo_.load(game);
if (!loaded) return Result<std::uint32_t>::err(loaded.error());
Map map = std::move(loaded).value();
const std::uint32_t newId = nextId(map);
card.id = newId;
map.emplace(newId, std::move(card));
auto saved = repo_.save(game, map);
if (!saved) return Result<std::uint32_t>::err(saved.error());
return Result<std::uint32_t>::ok(newId);
}
// Update an existing entry, identified by `card.id`. If the id is not
// present, an error is returned.
Result<void> update(Game game, TCard card) {
auto loaded = repo_.load(game);
if (!loaded) return Result<void>::err(loaded.error());
Map map = std::move(loaded).value();
auto it = map.find(card.id);
if (it == map.end()) {
return Result<void>::err("Card with id " + std::to_string(card.id) +
" not found in collection.");
}
it->second = std::move(card);
return repo_.save(game, map);
}
// Remove the card with the given id. Also deletes any associated images
// via the IImageStore (best-effort - image removal failures are logged in
// the error string but the card itself is still purged from the JSON).
Result<void> remove(Game game, std::uint32_t id) {
auto loaded = repo_.load(game);
if (!loaded) return Result<void>::err(loaded.error());
Map map = std::move(loaded).value();
auto it = map.find(id);
if (it == map.end()) {
return Result<void>::err("Card with id " + std::to_string(id) + " not found.");
}
std::string imgErr;
for (const auto& image : it->second.images) {
auto rm = imageStore_.remove(game, image);
if (!rm) {
if (!imgErr.empty()) imgErr += "; ";
imgErr += rm.error();
}
}
map.erase(it);
auto saved = repo_.save(game, map);
if (!saved) return saved;
if (!imgErr.empty()) {
return Result<void>::err("Card removed but image cleanup had issues: " + imgErr);
}
return Result<void>::ok();
}
// Look up a card by id without mutating storage.
Result<std::optional<TCard>> findById(Game game, std::uint32_t id) {
auto loaded = repo_.load(game);
if (!loaded) return Result<std::optional<TCard>>::err(loaded.error());
const auto& m = loaded.value();
auto it = m.find(id);
if (it == m.end()) return Result<std::optional<TCard>>::ok(std::nullopt);
return Result<std::optional<TCard>>::ok(it->second);
}
private:
ICollectionRepository<TCard>& repo_;
IImageStore& imageStore_;
};
} // namespace ccm
@@ -0,0 +1,41 @@
#pragma once
// ConfigService: owns the live `Configuration`, persists it to `config.json`
// next to the executable. Mirrors `util/config.rs` behavior - a missing file
// is auto-created with sensible defaults on first launch.
#include "ccm/domain/Configuration.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/util/Result.hpp"
#include <filesystem>
namespace ccm {
class ConfigService {
public:
// `configFilePath` is the absolute path to `config.json`. The defaults
// applied to a freshly created config use `defaultDataStorage` for the
// dataStorage field.
ConfigService(IFileSystem& fs,
std::filesystem::path configFilePath,
std::filesystem::path defaultDataStorage);
// Load (or create) the configuration. Must be called once at startup.
Result<void> initialize();
[[nodiscard]] const Configuration& current() const noexcept { return current_; }
// Replace the live configuration and persist immediately.
Result<void> store(Configuration cfg);
private:
IFileSystem& fs_;
std::filesystem::path path_;
std::filesystem::path defaultDataStorage_;
Configuration current_{};
[[nodiscard]] Configuration makeDefault() const;
};
} // namespace ccm
@@ -0,0 +1,70 @@
#pragma once
// ImageService: applies the established image filename rules and delegates the actual
// disk operations to an IImageStore.
//
// Filename rule (mirrors Rust `magic/card_services.rs` and
// `pokemon/card_services.rs`):
// - new entry -> "{set}+{name}+{idx}.{ext}"
// - existing -> "{id}+{set}+{name}+{idx}.{ext}"
//
// The "+name+set+name+" convention is preserved byte-for-byte so legacy
// collections continue to display correctly when imported.
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/util/Result.hpp"
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace ccm {
class ImageService {
public:
explicit ImageService(IImageStore& store);
// Compute the next image index from the previously stored image filenames
// for a card. Implements the same logic as the Rust card_services modules.
static std::uint8_t nextImageIndex(const std::vector<std::string>& existingImages);
// Build the target filename (without extension) for a new image attached
// to a card. Pass the card's existing id (0 if not yet stored) and a
// `newEntry` flag matching the established semantic.
static std::string buildTargetName(bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
std::uint8_t index);
// Convenience: build target name + delegate copy to the IImageStore.
Result<std::string> 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);
Result<void> removeImage(Game game, const std::string& imageName);
// Ensure image filenames for a persisted card include the card id prefix.
// This upgrades "create-mode" names (`set+name+idx.ext`) to
// `id+set+name+idx.ext` to match the ccm2-compatible naming scheme.
Result<std::vector<std::string>> normalizeNamesForPersistedCard(
Game game,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& imageNames);
[[nodiscard]] std::filesystem::path resolveImagePath(Game game,
const std::string& imageName) const;
private:
IImageStore& store_;
};
} // namespace ccm
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// SetService: high-level operations for fetching and caching set data.
// Wraps an ISetRepository (cache) and dispatches to the correct ISetSource
// based on the active IGameModule.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ISetRepository.hpp"
#include "ccm/util/Result.hpp"
#include <unordered_map>
#include <vector>
namespace ccm {
class SetService {
public:
explicit SetService(ISetRepository& repo);
// Register a game module so this service can route fetch requests.
// Pointer must remain valid for the lifetime of the SetService.
void registerModule(IGameModule* module);
// Force a fresh fetch from the API for `game`, persist it via the
// repository, and return the new list.
Result<std::vector<Set>> updateSets(Game game);
// Cached read; returns an error if no local data exists yet.
Result<std::vector<Set>> getSets(Game game);
private:
ISetRepository& repo_;
std::unordered_map<Game, IGameModule*> modules_;
};
} // namespace ccm
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// Pure functions for filename munging. Ported from the original `util/fs.rs` so
// that file naming on disk stays identical between the two codebases.
#include <cstdint>
#include <string>
#include <string_view>
namespace ccm {
// Replace problematic characters so the result is safe to use as a filename
// component. Mirrors `format_text_for_fs` in Rust (drops apostrophes/commas,
// strips spaces, replaces `:` with `-`, `&` with `And`, `|` with `Or`,
// flattens accented vowels). Pure; safe at any call site.
std::string formatTextForFs(std::string_view text);
// Parse the trailing 1- or 2-digit numeric index from a filename. Examples:
// "Image1.png" -> 1
// "Image22.jpeg" -> 22
// Returns 0 on parse failure, matching the optimistic Rust behavior.
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept;
} // namespace ccm
+150
View File
@@ -0,0 +1,150 @@
#pragma once
// A minimal Result<T, E> type used as a sum-type for fallible operations.
// Mirrors the Rust `Result<T, &str>` style used in the original codebase.
//
// We intentionally do not depend on tl::expected or std::expected so this
// header stays buildable on every toolchain in our matrix (clang 14+, GCC 11+).
#include <new>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>
namespace ccm {
template <typename T, typename E = std::string>
class Result {
public:
using value_type = T;
using error_type = E;
static Result ok(T value) { return Result(OkTag{}, std::move(value)); }
static Result err(E error) { return Result(ErrTag{}, std::move(error)); }
Result(const Result& other) : has_value_(other.has_value_) {
if (has_value_) {
::new (static_cast<void*>(&value_)) T(other.value_);
} else {
::new (static_cast<void*>(&error_)) E(other.error_);
}
}
Result(Result&& other) noexcept : has_value_(other.has_value_) {
if (has_value_) {
::new (static_cast<void*>(&value_)) T(std::move(other.value_));
} else {
::new (static_cast<void*>(&error_)) E(std::move(other.error_));
}
}
Result& operator=(const Result& other) {
if (this == &other) return *this;
destroy();
has_value_ = other.has_value_;
if (has_value_) {
::new (static_cast<void*>(&value_)) T(other.value_);
} else {
::new (static_cast<void*>(&error_)) E(other.error_);
}
return *this;
}
Result& operator=(Result&& other) noexcept {
if (this == &other) return *this;
destroy();
has_value_ = other.has_value_;
if (has_value_) {
::new (static_cast<void*>(&value_)) T(std::move(other.value_));
} else {
::new (static_cast<void*>(&error_)) E(std::move(other.error_));
}
return *this;
}
~Result() { destroy(); }
[[nodiscard]] bool isOk() const noexcept { return has_value_; }
[[nodiscard]] bool isErr() const noexcept { return !has_value_; }
explicit operator bool() const noexcept { return has_value_; }
const T& value() const& {
if (!has_value_) throw std::logic_error("Result::value() on err");
return value_;
}
T& value() & {
if (!has_value_) throw std::logic_error("Result::value() on err");
return value_;
}
T&& value() && {
if (!has_value_) throw std::logic_error("Result::value() on err");
return std::move(value_);
}
const E& error() const& {
if (has_value_) throw std::logic_error("Result::error() on ok");
return error_;
}
E&& error() && {
if (has_value_) throw std::logic_error("Result::error() on ok");
return std::move(error_);
}
template <typename U>
T valueOr(U&& fallback) const& {
return has_value_ ? value_ : static_cast<T>(std::forward<U>(fallback));
}
private:
struct OkTag {};
struct ErrTag {};
Result(OkTag, T value) : has_value_(true) {
::new (static_cast<void*>(&value_)) T(std::move(value));
}
Result(ErrTag, E error) : has_value_(false) {
::new (static_cast<void*>(&error_)) E(std::move(error));
}
void destroy() noexcept {
if (has_value_) {
value_.~T();
} else {
error_.~E();
}
}
bool has_value_;
union {
T value_;
E error_;
};
};
// Specialization for void-returning fallible operations.
template <typename E>
class Result<void, E> {
public:
using value_type = void;
using error_type = E;
static Result ok() { return Result(true, E{}); }
static Result err(E error) { return Result(false, std::move(error)); }
[[nodiscard]] bool isOk() const noexcept { return has_value_; }
[[nodiscard]] bool isErr() const noexcept { return !has_value_; }
explicit operator bool() const noexcept { return has_value_; }
const E& error() const& {
if (has_value_) throw std::logic_error("Result::error() on ok");
return error_;
}
private:
Result(bool ok, E error) : has_value_(ok), error_(std::move(error)) {}
bool has_value_;
E error_;
};
} // namespace ccm