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