mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user