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,59 @@
|
||||
# core/AGENTS.md
|
||||
|
||||
`ccm_core` static library — domain types, ports, services, infra adapters. Hard rule: **no UI dependencies, ever**. Read the root `AGENTS.md` first.
|
||||
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/` and `pokemon/` are the reference implementations — both expose a fully working set source + card preview source.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`).
|
||||
- `src/` mirrors `include/ccm/` for non-template implementations.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **No throw across ports.** Return `ccm::Result<T>::ok(...)` / `Result<T>::err("msg")`. The caller propagates with `if (!r) return Result<T>::err(r.error());`.
|
||||
2. **JSON serde stays byte-for-byte stable.** When the C++ field name differs from the JSON key (`signed_` vs `"signed"`, `releaseDate`, `setNo`, `firstEdition`, `dataStorage`, `defaultGame`), write hand-rolled `to_json` / `from_json` instead of `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` so the alias is explicit. Round-trip tests in `tests/domain_json_tests.cpp` enforce this — extend them whenever you touch a domain type.
|
||||
3. **Filename rule for images** lives in `services/ImageService.hpp` and matches the Rust source exactly:
|
||||
- new entry -> `"{set}+{name}+{idx}.{ext}"`
|
||||
- existing -> `"{id}+{set}+{name}+{idx}.{ext}"`
|
||||
`ImageService::buildTargetName` is the single source of truth. Don't duplicate the rule elsewhere.
|
||||
4. **Templates stay header-only** (`CollectionService<T>`, `JsonCollectionRepository<T>`). Don't add `.cpp` files for them; explicit instantiation is not used.
|
||||
5. **Path strings** that get persisted (e.g. `Configuration::dataStorage`) use `std::filesystem::path::generic_string()`, never `string()` — keeps `/` separators on Windows so JSON round-trips and tests stay portable.
|
||||
6. **Compiler warnings**: every target in this package links `ccm_warnings` `PRIVATE`. Treat warnings as errors locally during dev (`-Werror` is opt-in but encouraged).
|
||||
7. **No `wx/...` includes** in headers or sources here. Verify with `rg "wx/" core/` — must be empty.
|
||||
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
|
||||
|
||||
## Adding a new game
|
||||
|
||||
The end-to-end procedure (core + UI + composition root + docs) lives in `docs/adding-a-new-game.md`. The core-side checklist is:
|
||||
|
||||
1. Add `Game::<Name>` plus `to_string` / `<Name>FromString` / `allGames()` entries in `include/ccm/domain/Enums.hpp` and `src/domain/Enums.cpp`.
|
||||
2. Create `include/ccm/games/<name>/<Name>SetSource.hpp` + `.cpp` implementing `ISetSource`. Mirror `MagicSetSource` / `PokemonSetSource`: expose a static `parseResponse(std::string)` helper so it's unit-testable without HTTP.
|
||||
3. (Optional) Create `include/ccm/games/<name>/<Name>CardPreviewSource.hpp` + `.cpp` implementing `ICardPreviewSource`. Mirror `MagicCardPreviewSource` / `PokemonCardPreviewSource`: expose static `buildSearchUrl` + `parseResponse` helpers for unit testing without HTTP.
|
||||
4. Create `include/ccm/games/<name>/<Name>GameModule.hpp` + `.cpp` implementing `IGameModule`. Pick a stable lowercase `dirName()` — it becomes the on-disk subdirectory and must never change. The module **owns** its set source and (optionally) its card preview source: override `cardPreviewSource()` to return `&previewSource_` when present (default returns `nullptr`).
|
||||
5. If the game has a card type with different fields, add a `<Name>Card` domain type with hand-rolled JSON aliases. Otherwise reuse an existing one.
|
||||
6. Add the new `.cpp` files to `core/CMakeLists.txt` (no glob).
|
||||
7. Add tests under `tests/<name>_set_source_tests.cpp` and `tests/<name>_card_preview_source_tests.cpp` modeled on the Magic / Pokemon versions.
|
||||
8. The composition root in `app/main.cpp` and the directory mapping in `app/main.cpp::dirNameForGame` must be updated too — see `app/AGENTS.md`. `CardPreviewService::registerModule(*module)` is the single registration call; modules whose `cardPreviewSource()` returns `nullptr` are silently skipped.
|
||||
|
||||
## Adding / changing a card-table column
|
||||
|
||||
When you add or rename a `tableFields` entry on a list panel (Magic or Pokemon), keep `core/`'s sort/filter helpers and their tests in lockstep:
|
||||
|
||||
1. Extend `MagicSortColumn` / `PokemonSortColumn` and add a `case` branch in `sortMagicCards` / `sortPokemonCards` (`core/include/ccm/services/CardSorter.hpp` + `.cpp`).
|
||||
2. Add the new value-key column to the matching `matchesMagicFilter` / `matchesPokemonFilter` (`core/include/ccm/services/CardFilter.hpp` + `.cpp`) — boolean-flag columns are *excluded* (the filtering rule only checks values equivalent to JS `typeof === "string" | "number"`).
|
||||
3. Add tests under `tests/card_sorter_tests.cpp` and `tests/card_filter_tests.cpp`.
|
||||
|
||||
## Adding a new port
|
||||
|
||||
1. Add the interface header under `include/ccm/ports/` with `virtual ~IFoo() = default;`.
|
||||
2. Implement the adapter under `include/ccm/infra/` + `src/infra/`. Mark it `final`.
|
||||
3. Update `core/CMakeLists.txt`. Wire it into the relevant service's constructor.
|
||||
4. Add a fake under `tests/fakes/` modeled on `InMemoryFileSystem` and write service-level tests against it.
|
||||
|
||||
## Commands
|
||||
|
||||
Build core only: `cmake --build build --target ccm_core`
|
||||
@@ -0,0 +1,49 @@
|
||||
# ccm_core: UI-agnostic domain, services, ports, and infra adapters.
|
||||
# This target MUST NOT depend on wxWidgets or any UI toolkit.
|
||||
|
||||
add_library(ccm_core STATIC
|
||||
src/domain/Enums.cpp
|
||||
src/domain/Set.cpp
|
||||
src/domain/MagicCard.cpp
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
src/services/ConfigService.cpp
|
||||
src/services/ImageService.cpp
|
||||
src/services/SetService.cpp
|
||||
src/services/CardPreviewService.cpp
|
||||
src/services/CardSorter.cpp
|
||||
src/services/CardFilter.cpp
|
||||
|
||||
src/infra/CprHttpClient.cpp
|
||||
src/infra/StdFileSystem.cpp
|
||||
src/infra/JsonSetRepository.cpp
|
||||
src/infra/LocalImageStore.cpp
|
||||
|
||||
src/games/magic/MagicSetSource.cpp
|
||||
src/games/magic/MagicCardPreviewSource.cpp
|
||||
src/games/magic/MagicGameModule.cpp
|
||||
src/games/pokemon/PokemonSetSource.cpp
|
||||
src/games/pokemon/PokemonCardPreviewSource.cpp
|
||||
src/games/pokemon/PokemonGameModule.cpp
|
||||
|
||||
src/util/FsNames.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core
|
||||
PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
)
|
||||
|
||||
target_link_libraries(ccm_core
|
||||
PUBLIC
|
||||
nlohmann_json::nlohmann_json
|
||||
PRIVATE
|
||||
cpr::cpr
|
||||
ccm_warnings
|
||||
)
|
||||
|
||||
target_compile_features(ccm_core PUBLIC cxx_std_20)
|
||||
|
||||
# JsonCollectionRepository<T> and CollectionService<T> are header-only templates
|
||||
# and live entirely under include/ccm/ - nothing to compile here for them.
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const Configuration& c) {
|
||||
j = nlohmann::json{
|
||||
{"dataStorage", c.dataStorage},
|
||||
{"defaultGame", c.defaultGame},
|
||||
{"theme", c.theme},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||
j.at("dataStorage").get_to(c.dataStorage);
|
||||
j.at("defaultGame").get_to(c.defaultGame);
|
||||
c.theme = j.value("theme", Theme::Light);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
}
|
||||
return "Magic";
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
switch (l) {
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::Chinese: return "Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
return "English";
|
||||
}
|
||||
|
||||
std::string_view to_string(Condition c) noexcept {
|
||||
switch (c) {
|
||||
case Condition::Mint: return "Mint";
|
||||
case Condition::NearMint: return "NearMint";
|
||||
case Condition::Excellent: return "Excellent";
|
||||
case Condition::Good: return "Good";
|
||||
case Condition::LightPlayed: return "LightPlayed";
|
||||
case Condition::Played: return "Played";
|
||||
case Condition::Poor: return "Poor";
|
||||
}
|
||||
return "Mint";
|
||||
}
|
||||
|
||||
std::string_view to_string(Theme t) noexcept {
|
||||
switch (t) {
|
||||
case Theme::Light: return "Light";
|
||||
case Theme::Dark: return "Dark";
|
||||
}
|
||||
return "Light";
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "Chinese") return Language::Chinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept {
|
||||
if (s == "Mint") return Condition::Mint;
|
||||
if (s == "NearMint") return Condition::NearMint;
|
||||
if (s == "Excellent") return Condition::Excellent;
|
||||
if (s == "Good") return Condition::Good;
|
||||
if (s == "LightPlayed") return Condition::LightPlayed;
|
||||
if (s == "Played") return Condition::Played;
|
||||
if (s == "Poor") return Condition::Poor;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept {
|
||||
if (s == "Light") return Theme::Light;
|
||||
if (s == "Dark") return Theme::Dark;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::array<Game, 2>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Language, 8>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 8> v{
|
||||
Language::English, Language::German, Language::French, Language::Spanish,
|
||||
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Condition, 7>& allConditions() noexcept {
|
||||
static constexpr std::array<Condition, 7> v{
|
||||
Condition::Mint, Condition::NearMint, Condition::Excellent,
|
||||
Condition::Good, Condition::LightPlayed, Condition::Played, Condition::Poor
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Theme, 2>& allThemes() noexcept {
|
||||
static constexpr std::array<Theme, 2> v{Theme::Light, Theme::Dark};
|
||||
return v;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
|
||||
void from_json(const nlohmann::json& j, Game& v) {
|
||||
auto parsed = gameFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Language& v) {
|
||||
auto parsed = languageFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Condition& v) {
|
||||
auto parsed = conditionFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Condition value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, Theme& v) {
|
||||
auto parsed = themeFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Theme value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const MagicCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"foil", c.foil},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, MagicCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("foil").get_to(c.foil);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const Set& s) {
|
||||
j = nlohmann::json{
|
||||
{"id", s.id},
|
||||
{"name", s.name},
|
||||
{"releaseDate", s.releaseDate},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, Set& s) {
|
||||
j.at("id").get_to(s.id);
|
||||
j.at("name").get_to(s.name);
|
||||
j.at("releaseDate").get_to(s.releaseDate);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
|
||||
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
|
||||
// which does not URL-encode the URL string we hand it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Apply the same name massaging as the legacy query path before sending.
|
||||
std::string sanitizeName(std::string_view name) {
|
||||
std::string s(name);
|
||||
std::string::size_type pos = 0;
|
||||
while ((pos = s.find('&', pos)) != std::string::npos) {
|
||||
s.replace(pos, 1, "and");
|
||||
pos += 3;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MagicCardPreviewSource::MagicCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId) {
|
||||
// Build the unencoded query first so the output matches what Scryfall
|
||||
// would parse: name:"<sanitized>" AND set:<setId>
|
||||
const std::string sanitized = sanitizeName(name);
|
||||
std::string query = "name:\"";
|
||||
query += sanitized;
|
||||
query += "\" AND set:";
|
||||
query += std::string(setId);
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Scryfall response missing 'data' array.");
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Scryfall returned no matching cards.");
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
|
||||
// Double-faced cards expose image_uris on each face; there is no
|
||||
// fallback for this and surfaces it as "no preview".
|
||||
return Result<std::string>::err("Card has no top-level image_uris.");
|
||||
}
|
||||
const auto& uris = first.at("image_uris");
|
||||
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
|
||||
return Result<std::string>::err("Card has no 'normal' image variant.");
|
||||
}
|
||||
return Result<std::string>::ok(uris.at("normal").get<std::string>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view /*setNo*/) {
|
||||
const std::string url = buildSearchUrl(name, setId);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
MagicGameModule::MagicGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "ccm/games/magic/MagicSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
MagicSetSource::MagicSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> MagicSetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::vector<Set>>::err("Scryfall response missing 'data' array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
// Filter out digital-only sets exactly like the Rust code.
|
||||
const bool digital = entry.value("digital", false);
|
||||
if (digital) continue;
|
||||
|
||||
Set s;
|
||||
s.id = entry.value("code", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Scryfall returns "released_at" as YYYY-MM-DD; persisted data stores YYYY/MM/DD.
|
||||
std::string releasedAt = entry.value("released_at", "");
|
||||
std::replace(releasedAt.begin(), releasedAt.end(), '-', '/');
|
||||
s.releaseDate = std::move(releasedAt);
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("Scryfall JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> MagicSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
|
||||
// Magic implementation; kept private so the two can drift independently if a
|
||||
// future API requires it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Strip everything after the first '/' in a Pokemon collector number.
|
||||
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
|
||||
// `4/102`. Without this, no API match is found.
|
||||
std::string normalizeNumber(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
s = s.substr(0, slash);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
// Build the unencoded query first so the output matches what the Pokemon
|
||||
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
|
||||
std::string query = "name:\"";
|
||||
query += std::string(name);
|
||||
query += "\"";
|
||||
if (!setId.empty()) {
|
||||
query += " set.id:";
|
||||
query += std::string(setId);
|
||||
}
|
||||
const std::string num = normalizeNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
query += " number:";
|
||||
query += num;
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("images") || !first.at("images").is_object()) {
|
||||
return Result<std::string>::err("Card has no 'images' object.");
|
||||
}
|
||||
const auto& images = first.at("images");
|
||||
if (images.contains("large") && images.at("large").is_string()) {
|
||||
return Result<std::string>::ok(images.at("large").get<std::string>());
|
||||
}
|
||||
if (images.contains("small") && images.at("small").is_string()) {
|
||||
return Result<std::string>::ok(images.at("small").get<std::string>());
|
||||
}
|
||||
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string url = buildSearchUrl(name, setId, setNo);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonGameModule::PokemonGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"Pokemon TCG API response missing 'data' array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
|
||||
// no separator rewrite needed (cf. Scryfall's "released_at").
|
||||
s.releaseDate = entry.value("releaseDate", "");
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
|
||||
#include <cpr/cpr.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
|
||||
|
||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
cpr::Response r = cpr::Get(
|
||||
cpr::Url{std::string(url)},
|
||||
cpr::Timeout{timeout_},
|
||||
// Identify ourselves; some APIs rate-limit unknown agents harshly.
|
||||
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
|
||||
{"Accept", "application/json"}}
|
||||
);
|
||||
|
||||
if (r.error) {
|
||||
return Result<std::string>::err("HTTP error: " + r.error.message);
|
||||
}
|
||||
if (r.status_code < 200 || r.status_code >= 300) {
|
||||
return Result<std::string>::err(
|
||||
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
|
||||
}
|
||||
return Result<std::string>::ok(std::move(r.text));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "ccm/infra/JsonSetRepository.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path JsonSetRepository::setsPath(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
|
||||
const auto p = setsPath(game);
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = sets;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "ccm/infra/LocalImageStore.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
LocalImageStore::LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path LocalImageStore::gameImageDir(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "images";
|
||||
}
|
||||
|
||||
Result<std::string> LocalImageStore::copyIn(Game game,
|
||||
const fs::path& srcPath,
|
||||
const std::string& targetName) {
|
||||
const auto dir = gameImageDir(game);
|
||||
auto ensure = fs_.ensureDirectory(dir);
|
||||
if (!ensure) return Result<std::string>::err(ensure.error());
|
||||
|
||||
// Preserve the source's extension - the original Rust code does the same.
|
||||
std::string ext = srcPath.extension().string();
|
||||
std::string finalName = targetName + ext;
|
||||
const auto dest = dir / finalName;
|
||||
|
||||
auto cp = fs_.copyFile(srcPath, dest, /*overwrite=*/true);
|
||||
if (!cp) return Result<std::string>::err(cp.error());
|
||||
return Result<std::string>::ok(std::move(finalName));
|
||||
}
|
||||
|
||||
Result<void> LocalImageStore::remove(Game game, const std::string& imageName) {
|
||||
const auto p = gameImageDir(game) / imageName;
|
||||
if (!fs_.exists(p)) return Result<void>::ok(); // be forgiving on stale entries
|
||||
return fs_.remove(p);
|
||||
}
|
||||
|
||||
fs::path LocalImageStore::resolvePath(Game game, const std::string& imageName) const {
|
||||
return gameImageDir(game) / imageName;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "ccm/infra/StdFileSystem.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
bool StdFileSystem::exists(const fs::path& p) const {
|
||||
std::error_code ec;
|
||||
return fs::exists(p, ec);
|
||||
}
|
||||
|
||||
bool StdFileSystem::isDirectory(const fs::path& p) const {
|
||||
std::error_code ec;
|
||||
return fs::is_directory(p, ec);
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::ensureDirectory(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
if (fs::exists(p, ec)) {
|
||||
if (fs::is_directory(p, ec)) return Result<void>::ok();
|
||||
return Result<void>::err("Path exists but is not a directory: " + p.string());
|
||||
}
|
||||
fs::create_directories(p, ec);
|
||||
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<std::string> StdFileSystem::readText(const fs::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return Result<std::string>::err("Unable to open file: " + p.string());
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
if (!in && !in.eof()) return Result<std::string>::err("Read error on: " + p.string());
|
||||
return Result<std::string>::ok(ss.str());
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::writeText(const fs::path& p, std::string_view contents) {
|
||||
std::error_code ec;
|
||||
if (p.has_parent_path()) {
|
||||
fs::create_directories(p.parent_path(), ec);
|
||||
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
|
||||
}
|
||||
std::ofstream out(p, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return Result<void>::err("Unable to create file: " + p.string());
|
||||
out.write(contents.data(), static_cast<std::streamsize>(contents.size()));
|
||||
if (!out) return Result<void>::err("Write error on: " + p.string());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) {
|
||||
std::error_code ec;
|
||||
if (to.has_parent_path()) {
|
||||
fs::create_directories(to.parent_path(), ec);
|
||||
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
|
||||
ec.clear();
|
||||
}
|
||||
const auto opt = overwrite ? fs::copy_options::overwrite_existing
|
||||
: fs::copy_options::none;
|
||||
fs::copy_file(from, to, opt, ec);
|
||||
if (ec) return Result<void>::err("copy_file failed: " + ec.message());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> StdFileSystem::remove(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
fs::remove(p, ec);
|
||||
if (ec) return Result<void>::err("remove failed: " + ec.message());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<std::vector<fs::path>> StdFileSystem::listDirectory(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(p, ec)) {
|
||||
return Result<std::vector<fs::path>>::err("Not a directory: " + p.string());
|
||||
}
|
||||
std::vector<fs::path> out;
|
||||
for (const auto& entry : fs::directory_iterator(p, ec)) {
|
||||
out.push_back(entry.path());
|
||||
}
|
||||
if (ec) return Result<std::vector<fs::path>>::err("directory_iterator: " + ec.message());
|
||||
return Result<std::vector<fs::path>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
|
||||
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
|
||||
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
|
||||
// identically.
|
||||
std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool containsLower(std::string_view haystack, std::string_view needleLower) {
|
||||
return asciiLower(haystack).find(needleLower) != std::string::npos;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool matchesMagicFilter(const MagicCard& card, std::string_view filter) {
|
||||
// `""`.includes(filter) is true for filter == "" in JS; mirror that so the
|
||||
// panel does not need a separate "no filter" branch.
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
// Order mirrors the MtgTable.tsx tableFields list (minus the boolean
|
||||
// flag columns, which `applyFilter` skips). Stops on first match for the
|
||||
// same short-circuit behavior as the JS for-loop with `break`.
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
|
||||
|
||||
void CardPreviewService::registerModule(IGameModule& module) {
|
||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
||||
sources_[module.id()] = src;
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto it = sources_.find(game);
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<std::string>::err("No preview source registered for this game.");
|
||||
}
|
||||
auto url = it->second->fetchImageUrl(name, setId, setNo);
|
||||
if (!url) return Result<std::string>::err(url.error());
|
||||
auto bytes = http_.get(url.value());
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
return Result<std::string>::ok(std::move(bytes).value());
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
|
||||
auto bytes = http_.get(url);
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
return Result<std::string>::ok(std::move(bytes).value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
|
||||
// We use ASCII-only tolower; the original TS app processed the same fields and
|
||||
// never special-cased Unicode either, so this stays byte-compatible for the
|
||||
// realistic data set (English/German/etc. names already lowercase identically).
|
||||
std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wrap a less-than predicate so that ascending=false flips its meaning,
|
||||
// mirroring `byField(field, asc)` in TableTemplate.tsx.
|
||||
template <typename Less>
|
||||
auto directional(Less less, bool ascending) {
|
||||
return [less, ascending](const auto& a, const auto& b) {
|
||||
return ascending ? less(a, b) : less(b, a);
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case MagicSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::SetReleaseDate:
|
||||
// Release dates are stored as "YYYY/MM/DD" so plain lexicographic
|
||||
// compare is chronological. The legacy JS path lowercased strings anyway; we
|
||||
// do the same for parity even though digits/'/' are unaffected.
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Foil:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.foil < b.foil; // false < true (asc puts unset first)
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case MagicSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const MagicCard& a, const MagicCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case PokemonSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case PokemonSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const PokemonCard& a, const PokemonCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
ConfigService::ConfigService(IFileSystem& fs,
|
||||
std::filesystem::path configFilePath,
|
||||
std::filesystem::path defaultDataStorage)
|
||||
: fs_(fs),
|
||||
path_(std::move(configFilePath)),
|
||||
defaultDataStorage_(std::move(defaultDataStorage)) {}
|
||||
|
||||
Configuration ConfigService::makeDefault() const {
|
||||
Configuration c;
|
||||
// generic_string() always uses '/' separators - keeps the value portable
|
||||
// across Windows / Unix and round-trip-friendly for tests and JSON.
|
||||
c.dataStorage = defaultDataStorage_.generic_string();
|
||||
c.defaultGame = Game::Magic;
|
||||
return c;
|
||||
}
|
||||
|
||||
Result<void> ConfigService::initialize() {
|
||||
if (!fs_.exists(path_)) {
|
||||
current_ = makeDefault();
|
||||
return store(current_);
|
||||
}
|
||||
auto text = fs_.readText(path_);
|
||||
if (!text) return Result<void>::err(text.error());
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
current_ = j.get<Configuration>();
|
||||
} catch (const std::exception& e) {
|
||||
return Result<void>::err(std::string("config.json parse error: ") + e.what());
|
||||
}
|
||||
return Result<void>::ok();
|
||||
}
|
||||
|
||||
Result<void> ConfigService::store(Configuration cfg) {
|
||||
current_ = std::move(cfg);
|
||||
const nlohmann::json j = current_;
|
||||
return fs_.writeText(path_, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,120 @@
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
|
||||
#include "ccm/util/FsNames.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
ImageService::ImageService(IImageStore& store) : store_(store) {}
|
||||
|
||||
std::uint8_t ImageService::nextImageIndex(const std::vector<std::string>& existingImages) {
|
||||
if (existingImages.empty()) return 0;
|
||||
const std::string& last = existingImages.back();
|
||||
// Preserve the compatibility shim for legacy image filenames that
|
||||
// contain "IMG_FRONT"/"IMG_BACK" markers - those start a fresh index.
|
||||
if (last.find("IMG_FRONT") != std::string::npos ||
|
||||
last.find("IMG_BACK") != std::string::npos) {
|
||||
return 0;
|
||||
}
|
||||
const std::uint8_t parsed = parseIndexFromFilename(last);
|
||||
// Saturating +1 since we hand back uint8 just like the Rust version.
|
||||
return parsed == 255 ? 255 : static_cast<std::uint8_t>(parsed + 1);
|
||||
}
|
||||
|
||||
std::string ImageService::buildTargetName(bool newEntry,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
std::uint8_t index) {
|
||||
const std::string set = formatTextForFs(setName);
|
||||
const std::string card = formatTextForFs(cardName);
|
||||
if (newEntry) {
|
||||
return set + "+" + card + "+" + std::to_string(static_cast<int>(index));
|
||||
}
|
||||
return std::to_string(cardId) + "+" + set + "+" + card + "+" +
|
||||
std::to_string(static_cast<int>(index));
|
||||
}
|
||||
|
||||
Result<std::string> ImageService::addImage(Game game,
|
||||
const std::filesystem::path& srcPath,
|
||||
bool newEntry,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
const std::vector<std::string>& existingImages) {
|
||||
const auto idx = nextImageIndex(existingImages);
|
||||
const auto target = buildTargetName(newEntry, cardId, setName, cardName, idx);
|
||||
return store_.copyIn(game, srcPath, target);
|
||||
}
|
||||
|
||||
Result<void> ImageService::removeImage(Game game, const std::string& imageName) {
|
||||
return store_.remove(game, imageName);
|
||||
}
|
||||
|
||||
Result<std::vector<std::string>> ImageService::normalizeNamesForPersistedCard(
|
||||
Game game,
|
||||
std::uint32_t cardId,
|
||||
const std::string& setName,
|
||||
const std::string& cardName,
|
||||
const std::vector<std::string>& imageNames) {
|
||||
const std::string idPrefix = std::to_string(cardId) + "+";
|
||||
std::vector<std::string> normalized = imageNames;
|
||||
struct RenameOp {
|
||||
std::string oldName;
|
||||
std::string newName;
|
||||
};
|
||||
std::vector<RenameOp> ops;
|
||||
ops.reserve(imageNames.size());
|
||||
|
||||
for (std::size_t i = 0; i < imageNames.size(); ++i) {
|
||||
const std::string& oldName = imageNames[i];
|
||||
if (oldName.starts_with(idPrefix)) {
|
||||
continue;
|
||||
}
|
||||
const std::uint8_t idx = parseIndexFromFilename(oldName);
|
||||
const std::filesystem::path oldPath(oldName);
|
||||
const std::string ext = oldPath.extension().string();
|
||||
const std::string newBase = buildTargetName(false, cardId, setName, cardName, idx);
|
||||
const std::string newName = newBase + ext;
|
||||
if (newName == oldName) {
|
||||
continue;
|
||||
}
|
||||
ops.push_back({oldName, newName});
|
||||
normalized[i] = newName;
|
||||
}
|
||||
|
||||
if (ops.empty()) {
|
||||
return Result<std::vector<std::string>>::ok(std::move(normalized));
|
||||
}
|
||||
|
||||
std::vector<std::string> created;
|
||||
created.reserve(ops.size());
|
||||
for (const auto& op : ops) {
|
||||
auto copied = store_.copyIn(game, store_.resolvePath(game, op.oldName),
|
||||
std::filesystem::path(op.newName).stem().string());
|
||||
if (!copied) {
|
||||
for (const auto& createdName : created) {
|
||||
(void)store_.remove(game, createdName);
|
||||
}
|
||||
return Result<std::vector<std::string>>::err(copied.error());
|
||||
}
|
||||
created.push_back(copied.value());
|
||||
}
|
||||
|
||||
for (const auto& op : ops) {
|
||||
auto removed = store_.remove(game, op.oldName);
|
||||
if (!removed) {
|
||||
return Result<std::vector<std::string>>::err(removed.error());
|
||||
}
|
||||
}
|
||||
|
||||
return Result<std::vector<std::string>>::ok(std::move(normalized));
|
||||
}
|
||||
|
||||
std::filesystem::path ImageService::resolveImagePath(Game game,
|
||||
const std::string& imageName) const {
|
||||
return store_.resolvePath(game, imageName);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "ccm/services/SetService.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
SetService::SetService(ISetRepository& repo) : repo_(repo) {}
|
||||
|
||||
void SetService::registerModule(IGameModule* module) {
|
||||
if (module) modules_[module->id()] = module;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::updateSets(Game game) {
|
||||
auto it = modules_.find(game);
|
||||
if (it == modules_.end() || it->second == nullptr) {
|
||||
return Result<std::vector<Set>>::err("No game module registered for this game.");
|
||||
}
|
||||
auto fetched = it->second->setSource().fetchAll();
|
||||
if (!fetched) return fetched;
|
||||
auto saved = repo_.save(game, fetched.value());
|
||||
if (!saved) return Result<std::vector<Set>>::err(saved.error());
|
||||
return fetched;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::getSets(Game game) {
|
||||
return repo_.load(game);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "ccm/util/FsNames.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Replacements ordered exactly like the Rust source's chained .replace(...) so
|
||||
// behavior is bit-identical for inputs that contain multiple of these chars.
|
||||
struct Replacement {
|
||||
std::string_view from;
|
||||
std::string_view to;
|
||||
};
|
||||
|
||||
constexpr std::array<Replacement, 14> kReplacements{{
|
||||
{"'", ""},
|
||||
{"`", ""},
|
||||
{",", ""},
|
||||
{" ", ""},
|
||||
{":", "-"},
|
||||
{"&", "And"},
|
||||
{"|", "Or"},
|
||||
{"\xC3\xA1", "a"}, // a-acute (UTF-8)
|
||||
{"\xC3\xA9", "e"}, // e-acute
|
||||
{"\xC3\xAD", "i"}, // i-acute
|
||||
{"\xC3\xB3", "o"}, // o-acute
|
||||
{"\xC3\xBA", "u"}, // u-acute
|
||||
{"\xC3\xBB", "u"}, // u-circumflex
|
||||
// Remaining accented vowels appear in modern Scryfall data but were not
|
||||
// listed in the Rust source. Keeping behavior 1:1 deliberately.
|
||||
}};
|
||||
|
||||
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
|
||||
if (from.empty()) return;
|
||||
std::string::size_type pos = 0;
|
||||
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||||
s.replace(pos, from.size(), to);
|
||||
pos += to.size();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string formatTextForFs(std::string_view text) {
|
||||
std::string out(text);
|
||||
for (const auto& r : kReplacements) {
|
||||
replaceAllInPlace(out, r.from, r.to);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
|
||||
const auto dot = filename.find_last_of('.');
|
||||
if (dot == std::string_view::npos || dot == 0) return 0;
|
||||
|
||||
// Walk backwards from the position before the dot, collecting digits.
|
||||
std::size_t end = dot;
|
||||
std::size_t begin = end;
|
||||
while (begin > 0) {
|
||||
unsigned char ch = static_cast<unsigned char>(filename[begin - 1]);
|
||||
if (std::isdigit(ch)) {
|
||||
--begin;
|
||||
// Rust source intentionally caps at 2-digit indices.
|
||||
if (end - begin >= 2) break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (begin == end) return 0;
|
||||
|
||||
unsigned int value = 0;
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
|
||||
}
|
||||
if (value > 255) value = 255;
|
||||
return static_cast<std::uint8_t>(value);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
Reference in New Issue
Block a user