major: initial release

* initial development

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* improvements

* improvements

* ci/cd

* ci/cd

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

---------

Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
Sebastian Dine
2026-05-09 11:05:47 +02:00
committed by GitHub
parent 13262fa015
commit 55ace147bc
149 changed files with 12611 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# AGENTS.md
GitHub Actions workflows for CI, release automation, and policy checks.
## Scope
- This file governs edits in `.github/workflows/*.yml`.
- Keep one top-level triggered workflow per CI intent:
- `feature-ci.yml` for non-`master` push CI
- `master-ci.yml` for merged-PR-to-`master` release CI
- Keep OS/platform splits in reusable workflows invoked via `workflow_call`.
## Current workflow map
- `feature-ci.yml` -> orchestrator for feature branch CI.
- `feature-linux.yml` -> reusable Linux build/test/package workflow.
- `feature-windows.yml` -> reusable Windows build/test/package workflow.
- `master-pr-title-guard.yml` -> validates semantic-prefix policy for PRs targeting `master`.
- `master-ci.yml` -> orchestrator for merged PRs into `master`.
- `master-windows.yml` -> reusable Windows build/test/package workflow for master release flow.
## CI invariants (do not break)
1. Keep workflow intent stable:
- feature workflows produce branch+sha artifacts
- master workflows produce semver-tagged release artifacts
2. Do not duplicate top-level triggers for the same intent (avoid split runs in Actions UI).
3. Preserve artifact naming conventions unless docs are updated in the same change:
- `ccm3-linux-<version>.zip`
- `ccm3-windows-<version>.zip`
- `ccm3-windows-installer-<version>` (feature) and `.exe` (master release asset)
4. Preserve embedded app version wiring via `-DCCM_APP_VERSION=...` in both feature and master build flows.
5. Keep `master` release flow gated to merged PRs only.
6. Keep PR title prefix policy aligned with `scripts/compute_master_semver.sh`.
## Editing rules
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
- Keep `permissions` least-privilege:
- reusable build workflows: `contents: read`
- release/tag orchestrator: `contents: write`
- Keep shells consistent with runner setup:
- Linux steps use standard bash-compatible shell.
- Windows MSYS2 jobs keep `shell: msys2 {0}` and run `msys2/setup-msys2` before MSYS2 commands.
## Required follow-ups
- After changing workflow topology, update `docs/ci-cd-guide.md`.
- If artifact names or release behavior change, update `docs/ci-cd-guide.md` and `docs/versioning.md` if needed.
- If semver prefix policy changes, update both `master-pr-title-guard.yml` and docs.
- If adding/removing workflow files, update this file's "Current workflow map".
## Anti-patterns
- Do not add a second top-level feature or master trigger file.
- Do not move release creation into a reusable workflow that lacks `contents: write`.
- Do not introduce MSVC-specific build commands; project CI targets Clang/MinGW toolchains.
- Do not silently change artifact names; downstream release/download steps depend on exact names.
+18
View File
@@ -0,0 +1,18 @@
name: Feature CI
on:
push:
branches-ignore:
- master
permissions:
contents: read
jobs:
linux:
name: Linux build + tests
uses: ./.github/workflows/feature-linux.yml
windows:
name: Windows build + tests
uses: ./.github/workflows/feature-windows.yml
+87
View File
@@ -0,0 +1,87 @@
name: Feature Linux Build, Test, and Package
on:
workflow_call:
permissions:
contents: read
jobs:
build-linux:
name: Linux build + tests
runs-on: ubuntu-latest
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute feature version
run: |
VERSION="$(bash scripts/compute_feature_version.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}")"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Install build tools
run: >
sudo apt-get update &&
sudo apt-get install -y
ninja-build
ccache
pkg-config
libgtk-3-dev
libwxgtk3.2-dev
- name: Restore Linux compiler cache
uses: actions/cache@v4
with:
path: ${{ env.CCACHE_DIR }}
key: ccache-linux-${{ github.ref_name }}-v1
restore-keys: |
ccache-linux-${{ github.ref_name }}-
ccache-linux-
- name: Configure
run: >
cmake -S . -B build -G Ninja
-DCCM_BUILD_TESTS=ON
-DCCM_USE_SYSTEM_WX=ON
-DCCM_APP_VERSION="${VERSION}"
-DCMAKE_C_COMPILER_LAUNCHER=ccache
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build
run: cmake --build build --parallel 3
- name: Run unit tests
run: ctest --test-dir build --output-on-failure
- name: Remove test executable from bundle
run: rm -f build/bin/ccm_core_tests
- name: Verify runtime dependencies are resolved
run: |
for f in build/bin/*; do
[ -f "$f" ] || continue
if file "$f" | grep -q "ELF"; then
echo "Checking $f"
ldd "$f"
if ldd "$f" | grep -q "not found"; then
echo "Missing runtime dependency detected in $f"
exit 1
fi
fi
done
- name: Stage Linux artifact folder
run: |
rm -rf dist/ccm3-linux
mkdir -p dist/ccm3-linux
cp -a build/bin/. dist/ccm3-linux/
- name: Upload Linux artifact
uses: actions/upload-artifact@v4
with:
name: ccm3-linux-${{ env.VERSION }}.zip
path: dist/ccm3-linux
if-no-files-found: error
+122
View File
@@ -0,0 +1,122 @@
name: Feature Windows Build, Test, and Package
on:
workflow_call:
permissions:
contents: read
jobs:
build-windows:
name: Windows build + tests
runs-on: windows-latest
env:
CCACHE_DIR_WIN: ${{ github.workspace }}\.ccache
defaults:
run:
shell: msys2 {0}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup MSYS2 (MinGW-w64 UCRT64)
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
cache: true
install: >-
mingw-w64-ucrt-x86_64-gcc
mingw-w64-ucrt-x86_64-cmake
mingw-w64-ucrt-x86_64-ninja
mingw-w64-ucrt-x86_64-make
mingw-w64-ucrt-x86_64-ccache
mingw-w64-ucrt-x86_64-wxwidgets3.2-msw
mingw-w64-ucrt-x86_64-nsis
- name: Compute feature version
run: |
VERSION="$(bash scripts/compute_feature_version.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}")"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Restore Windows compiler cache
uses: actions/cache@v4
with:
path: ${{ env.CCACHE_DIR_WIN }}
key: ccache-windows-${{ github.ref_name }}-v2
restore-keys: |
ccache-windows-${{ github.ref_name }}-
ccache-windows-
- name: Configure MSYS2 ccache directory
run: |
CCACHE_DIR_POSIX="$(cygpath -u "$CCACHE_DIR_WIN")"
echo "CCACHE_DIR=${CCACHE_DIR_POSIX}" >> "$GITHUB_ENV"
mkdir -p "$CCACHE_DIR_POSIX"
- name: Configure
run: >
cmake -S . -B build -G Ninja
-DCMAKE_BUILD_TYPE=Release
-DCCM_BUILD_TESTS=ON
-DCCM_USE_SYSTEM_WX=ON
-DCCM_APP_VERSION="${VERSION}"
-DCMAKE_C_COMPILER_LAUNCHER=ccache
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build
run: cmake --build build --parallel 4
- name: Run unit tests
run: ctest --test-dir build --output-on-failure
- name: Collect runtime DLL dependencies
run: |
rm -f deps.txt
for f in build/bin/*.exe build/bin/*.dll; do
[ -e "$f" ] || continue
ldd "$f" | awk '/\/ucrt64\/bin\// { print $3 }' >> deps.txt
done
sort -u deps.txt | while read -r dep; do
[ -f "$dep" ] && cp -n "$dep" build/bin/
done
- name: Verify runtime dependencies are resolved
run: |
for f in build/bin/*.exe build/bin/*.dll; do
[ -e "$f" ] || continue
echo "Checking $f"
ldd "$f"
if ldd "$f" | grep -q "not found"; then
echo "Missing runtime dependency detected in $f"
exit 1
fi
done
- name: Remove test executable from bundle
run: rm -f build/bin/ccm_core_tests.exe build/bin/ccm_core_tests
- name: Build Windows installer
run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi
- name: Stage Windows artifact folder
run: |
rm -rf dist/ccm3-windows
mkdir -p dist/ccm3-windows
cp -a build/bin/. dist/ccm3-windows/
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: ccm3-windows-${{ env.VERSION }}.zip
path: dist/ccm3-windows
if-no-files-found: error
- name: Upload Windows installer artifact
uses: actions/upload-artifact@v4
with:
name: ccm3-windows-installer-${{ env.VERSION }}
path: ccm3-windows-installer.exe
if-no-files-found: error
+71
View File
@@ -0,0 +1,71 @@
name: Master CI
on:
pull_request:
branches:
- master
types:
- closed
permissions:
contents: write
jobs:
compute-version:
name: Determine semantic version
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
release_tag: ${{ steps.version.outputs.release_tag }}
steps:
- name: Checkout tags
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve semantic version
id: version
run: bash scripts/compute_master_semver.sh "${{ github.event.pull_request.title }}"
build-windows:
name: Windows build + tests
needs: compute-version
uses: ./.github/workflows/master-windows.yml
with:
version: ${{ needs.compute-version.outputs.version }}
merge_commit_sha: ${{ github.event.pull_request.merge_commit_sha }}
release-master:
name: Tag and release on master
needs:
- compute-version
- build-windows
runs-on: ubuntu-latest
steps:
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: ccm3-windows-${{ needs.compute-version.outputs.version }}.zip
path: release/windows
- name: Download Windows installer artifact
uses: actions/download-artifact@v4
with:
name: ccm3-windows-installer-${{ needs.compute-version.outputs.version }}
path: release/installer
- name: Package release assets with semantic version
run: |
mkdir -p release-assets
VERSION="${{ needs.compute-version.outputs.version }}"
zip -r "release-assets/ccm3-windows-${VERSION}.zip" release/windows
cp release/installer/ccm3-windows-installer.exe "release-assets/ccm3-windows-installer-${VERSION}.exe"
- name: Create tag and GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.compute-version.outputs.release_tag }}
target_commitish: ${{ github.event.pull_request.merge_commit_sha }}
generate_release_notes: true
files: release-assets/*
@@ -0,0 +1,33 @@
name: Master PR Title Guard
on:
pull_request:
branches:
- master
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
jobs:
validate-title:
name: Require semver prefix in PR title
runs-on: ubuntu-latest
steps:
- name: Validate pull request title prefix
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
title_lower="$(echo "${PR_TITLE}" | tr '[:upper:]' '[:lower:]')"
if [[ "${title_lower}" == major* || "${title_lower}" == minor* || "${title_lower}" == fix* || "${title_lower}" == patch* || "${title_lower}" == path* ]]; then
echo "PR title is valid: ${PR_TITLE}"
exit 0
fi
echo "Invalid PR title: '${PR_TITLE}'"
echo "Title must start with one of: major, minor, fix, patch, or path."
exit 1
+128
View File
@@ -0,0 +1,128 @@
name: Master Windows Build, Test, and Package
on:
workflow_call:
inputs:
version:
required: true
type: string
merge_commit_sha:
required: true
type: string
permissions:
contents: read
jobs:
build-windows:
name: Windows build + tests
runs-on: windows-latest
env:
CCACHE_DIR_WIN: ${{ github.workspace }}\.ccache
VERSION: ${{ inputs.version }}
defaults:
run:
shell: msys2 {0}
steps:
- name: Checkout merge commit
uses: actions/checkout@v4
with:
ref: ${{ inputs.merge_commit_sha }}
- name: Setup MSYS2 (MinGW-w64 UCRT64)
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
cache: true
install: >-
mingw-w64-ucrt-x86_64-gcc
mingw-w64-ucrt-x86_64-cmake
mingw-w64-ucrt-x86_64-ninja
mingw-w64-ucrt-x86_64-make
mingw-w64-ucrt-x86_64-ccache
mingw-w64-ucrt-x86_64-wxwidgets3.2-msw
mingw-w64-ucrt-x86_64-nsis
- name: Restore Windows compiler cache
uses: actions/cache@v4
with:
path: ${{ env.CCACHE_DIR_WIN }}
key: ccache-windows-master-v2
restore-keys: |
ccache-windows-master-
ccache-windows-
- name: Configure MSYS2 ccache directory
run: |
CCACHE_DIR_POSIX="$(cygpath -u "$CCACHE_DIR_WIN")"
echo "CCACHE_DIR=${CCACHE_DIR_POSIX}" >> "$GITHUB_ENV"
mkdir -p "$CCACHE_DIR_POSIX"
- name: Configure
run: >
cmake -S . -B build -G Ninja
-DCMAKE_BUILD_TYPE=Release
-DCCM_BUILD_TESTS=ON
-DCCM_USE_SYSTEM_WX=ON
-DCCM_APP_VERSION="${VERSION}"
-DCMAKE_C_COMPILER_LAUNCHER=ccache
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build
run: cmake --build build --parallel 4
- name: Run unit tests
run: ctest --test-dir build --output-on-failure
- name: Collect runtime DLL dependencies
run: |
rm -f deps.txt
for f in build/bin/*.exe build/bin/*.dll; do
[ -e "$f" ] || continue
ldd "$f" | awk '/\/ucrt64\/bin\// { print $3 }' >> deps.txt
done
sort -u deps.txt | while read -r dep; do
[ -f "$dep" ] && cp -n "$dep" build/bin/
done
- name: Verify runtime dependencies are resolved
run: |
for f in build/bin/*.exe build/bin/*.dll; do
[ -e "$f" ] || continue
echo "Checking $f"
ldd "$f"
if ldd "$f" | grep -q "not found"; then
echo "Missing runtime dependency detected in $f"
exit 1
fi
done
- name: Remove test executable from bundle
run: rm -f build/bin/ccm_core_tests.exe build/bin/ccm_core_tests
- name: Build Windows installer
run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi
- name: Stage Windows artifact folder
run: |
rm -rf dist/ccm3-windows
mkdir -p dist/ccm3-windows
cp -a build/bin/. dist/ccm3-windows/
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: ccm3-windows-${{ env.VERSION }}.zip
path: dist/ccm3-windows
if-no-files-found: error
- name: Upload Windows installer artifact
uses: actions/upload-artifact@v4
with:
name: ccm3-windows-installer-${{ env.VERSION }}
path: ccm3-windows-installer.exe
if-no-files-found: error
+104
View File
@@ -0,0 +1,104 @@
# AGENTS.md
C++ desktop implementation (originally based on a Tauri Rust+TS version) — single wxWidgets binary built with CMake + FetchContent.
## Project structure
- `core/``ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
- `ui_wx/``ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`.
- `app/``ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
- `tests/``ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
- `.github/workflows/` — GitHub Actions CI/release workflows. See `.github/workflows/AGENTS.md` for orchestrator/reusable workflow rules and CI invariants.
- `cmake/``Toolchain.cmake` (Clang first, MinGW-w64 fallback), `Dependencies.cmake` (FetchContent pins), `CompilerWarnings.cmake` (`ccm_warnings` interface target).
- `CMakeLists.txt` — top-level. Defines options `CCM_USE_SYSTEM_WX` (default OFF) and `CCM_BUILD_TESTS` (default ON).
- Build metadata option: `CCM_APP_VERSION` (defaults to `${PROJECT_VERSION} (localbuild)` for local/manual builds, overridden by CI).
## Architecture rules (do not break)
1. Dependencies point inward only: `app` -> `ui_wx` -> `core`. `core` depends on no other first-party target.
2. `core` must not include any wx header. CI-equivalent: `rg "wx/" core/` must return zero hits.
3. Cross-boundary types are domain types and `ccm::ui::AppContext`. UI code consumes services via the references in `AppContext`**never** by including a concrete adapter header.
4. Errors cross port boundaries as `ccm::Result<T, E=std::string>` (see `core/include/ccm/util/Result.hpp`). Do not throw across ports; reserve exceptions for genuinely unrecoverable bugs.
5. JSON layout must stay byte-for-byte stable: aliases `releaseDate`, `setNo`, `firstEdition`, `dataStorage`, `defaultGame`, and the `signed` JSON key (mapped to C++ field `signed_`). If you touch a domain type, update the round-trip test in `tests/domain_json_tests.cpp`.
## Toolchain
- **Compilers**: Clang 14+ preferred, MinGW-w64 GCC 11+ fallback on Windows. **Do not** add MSVC support.
- **C++ standard**: C++20 (`CMAKE_CXX_STANDARD 20`, `CXX_EXTENSIONS OFF`).
- **Build system**: CMake 3.22+ with `FetchContent`. Pin every dep by tag in `cmake/Dependencies.cmake`; never use `master`.
## Key dependencies
| Library | Version | Purpose |
|---|---|---|
| nlohmann/json | v3.11.3 | All JSON serde |
| libcpr/cpr | 1.10.5 | HTTPS (libcurl built in-tree, Schannel on Windows) |
| wxWidgets | v3.2.5 | UI toolkit (only `ui_wx/` may use it) |
| doctest | v2.4.11 | Tests (only when `CCM_BUILD_TESTS=ON`) |
## Commands
Run from the **workspace root**.
- Configure (Clang/Ninja, FetchContent wx):
`cmake -S . -B build -G Ninja`
- Configure (Windows MinGW-w64 fallback — verified working with MSYS2 UCRT64 GCC 15.2 + CMake 4.x):
`cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release`
- Configure with system wx for fast iteration:
`cmake -S . -B build -G Ninja -DCCM_USE_SYSTEM_WX=ON`
- Build everything:
`cmake --build build --parallel`
- Run the app:
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
- Run tests (CCM_BUILD_TESTS defaults to ON):
`ctest --test-dir build --output-on-failure` — current baseline: **86 cases / 211 assertions, all green**.
- Build tests only:
`cmake --build build --target ccm_core_tests`
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
>
> **Windows rebuild note**: linking `ccm.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm.exe` before rebuilding app targets.
>
> **Windows cold-start note**: first launch right after a fresh build is often slower than subsequent launches due to cold file cache and Windows security scanning (Defender/SmartScreen) on the new exe/dll set. Warm launches are the meaningful baseline for app-side perf changes.
## UI performance guardrails
- Keep first paint responsive: avoid heavy synchronous work in constructors of top-level windows/dialogs.
- For startup, defer non-critical work with `CallAfter(...)` so the frame appears before data loading.
- Preserve "select first row on startup" behavior without blocking first paint by scheduling the initial selection with `CallAfter(...)` instead of selecting synchronously during row rebuild.
- Avoid repeated set-list loads when opening Add/Edit: cache Magic sets in `MainFrame` and reuse them in `CardEditDialog`.
- Pass preloaded set data to dialogs by pointer/reference, not by value, to avoid copying large vectors on every open.
- While constructing/populating dialogs with many controls/choices, wrap with `Freeze()`/`Thaw()` and append choice items in bulk (`wxArrayString`) to reduce layout/repaint churn.
- Keep selected-card preview usable when remote lookup fails: show a per-game card-back fallback image (CCM2 parity), not a blank preview panel.
## Windows UI theming guardrails
- `wxWidgets` native dark-mode behavior on Windows is inconsistent across controls and OS builds; prefer explicit app theming in `ui_wx/src/Theme.cpp` plus targeted native hints only where needed.
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
- After changing `ui_wx` theming behavior, rebuild the final app target (`cmake --build build --target ccm --parallel`), not just `ccm_ui_wx`, before validating runtime behavior.
- If linker fails with `Permission denied` on `build/bin/ccm.exe`, the app is still running; close it before rebuilding.
## Required follow-ups
- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests.
- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob.
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
## Anti-patterns
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
- Don't add tests that hit real network or real disk; use the fake `ccm::testing::InMemoryFileSystem` and the existing http/source fakes.
- Don't enable `-Wconversion` / `-Wsign-conversion`; they fight wxWidgets's `int` IDs. They were intentionally removed from `cmake/CompilerWarnings.cmake`.
- Don't use `master` for FetchContent tags. Bump deliberately.
- Don't bump `cpr` past `1.10.5` without re-doing the curl install/export plumbing: cpr 1.11.x adds `install(EXPORT cprTargets)` rules that reference `libcurl_shared`, which isn't in any export set when curl is built as a sub-project, breaking configure. The 1.10.5 + `HAVE_IOCTLSOCKET_FIONBIO=ON` workaround in `cmake/Dependencies.cmake` is the verified MinGW-w64 path — do not remove it without an end-to-end Windows build first.
- Don't create multiple top-level triggers for the same CI intent (feature or master). Keep one triggered orchestrator workflow (`feature-ci.yml`, `master-ci.yml`) and use `workflow_call` reusable workflows for OS-specific splits so GitHub Actions stays a single run per intent.
+58
View File
@@ -0,0 +1,58 @@
cmake_minimum_required(VERSION 3.22)
# Detect the toolchain BEFORE project() so we can pick clang or fall back to
# MinGW-w64 g++ on Windows. This file is no-op on non-Windows hosts.
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Toolchain.cmake)
project(ccm
VERSION 0.1.0
DESCRIPTION "Card Collection Manager 3 - C++/wxWidgets desktop app"
LANGUAGES CXX
)
if(WIN32)
enable_language(RC)
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
# Generate compile_commands.json for tooling (clangd, ccls, etc.).
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# All built artifacts land in a predictable place.
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# Project-wide options.
option(CCM_USE_SYSTEM_WX "Use a system-installed wxWidgets via find_package instead of FetchContent" OFF)
option(CCM_BUILD_TESTS "Build unit tests for ccm_core" ON)
set(CCM_APP_VERSION "${PROJECT_VERSION} (localbuild)" CACHE STRING "Application version string embedded in UI")
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/CompilerWarnings.cmake)
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Dependencies.cmake)
add_subdirectory(core)
add_subdirectory(ui_wx)
add_subdirectory(app)
if(CCM_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
message(STATUS "")
message(STATUS "============================================================")
message(STATUS " ${PROJECT_NAME} ${PROJECT_VERSION}")
message(STATUS " Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
message(STATUS " Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS " System wx: ${CCM_USE_SYSTEM_WX}")
message(STATUS " Build tests: ${CCM_BUILD_TESTS}")
message(STATUS "============================================================")
message(STATUS "")
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Card Collection Manager 3 contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+68
View File
@@ -0,0 +1,68 @@
# Card Collection Manager 3
Card Collection Manager 3 is an extensible desktop application for managing trading card game collections. It is designed as a practical way to track cards and manage per-card images for large collections, with local per-game data, set synchronization workflows, and a desktop-first UX. The app preserves the established JSON layout from earlier CCM versions so existing collections stay compatible.
## Screenshots
### Magic The Gathering
![CCM3 Demo - Magic: The Gathering](docs/assets/images/demo-mtg.png)
### Pokemon TCG
![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png)
## Migrating From CCM1 And CCM2
CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory.
Basic migration flow:
1. Close any running CCM app instance that points to the same data folder.
2. Copy your existing game data into the matching CCM3 game folder:
- from CCM1: export/copy your collection JSON and images into the corresponding CCM3 game directory.
- from CCM2: copy per-game `collection.json` and `images/` directories into the same per-game folders in CCM3.
3. Start CCM3 and confirm the data directory in `File > Settings`.
If your files are in the expected layout, collections should load without conversion.
## Technical Overview
This is a C++20 project with a wxWidgets UI:
- `core/`: domain logic, services, and infrastructure adapters
- `ui_wx/`: wxWidgets presentation layer
- `app/`: executable composition root
Key libraries used by the project:
- `cpr` (libcurl-based): REST/HTTP calls
- `nlohmann/json`: JSON serialization/deserialization
- `doctest`: unit testing
Dependency direction is strict: `app -> ui_wx -> core`.
## Documentation
Implementation details now live in `docs/`. Start here:
- [Documentation Index](docs/README.md)
- [Build Locally Guide](docs/dow-doc-build-locally.md)
- [Intro For New Developers](docs/intro-to-new-developers.md)
- [CI/CD Guide](docs/ci-cd-guide.md)
- [Versioning Guide](docs/versioning.md)
- [Testing Guide And Test Code Of Conduct](docs/testing-and-test-code-of-conduct.md)
- [Adding A New Game To Card Collection Manager](docs/adding-a-new-game.md)
## Previous Implementations
This project continues earlier versions of Card Collection Manager:
- [Card Collection Manager (CCM1)](https://github.com/sebastiandine/Card-Collection-Manager): original Java/Swing implementation.
- [Card Collection Manager 2 (CCM2)](https://github.com/sebastiandine/Card-Collection-Manager-2): Rust/TypeScript (Tauri) rewrite with multi-game support.
## License
This project is licensed under the [MIT License](LICENSE).
Third-party dependencies and assets remain under their respective licenses.
+35
View File
@@ -0,0 +1,35 @@
# app/AGENTS.md
The `ccm` executable — composition root only. The single place where concrete adapter types are mentioned. Read the root `AGENTS.md` first.
## File pointers
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`.
## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonSetRepository`, `LocalImageStore`, `MagicGameModule`, `PokemonGameModule`, `MagicGameView`, `PokemonGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
## Required follow-ups
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
- After changing the IGameView contract or the AppContext shape, update `docs/adding-a-new-game.md` so the canonical procedure stays in sync.
## Anti-patterns
- Don't add business logic here. If something is more than `std::make_unique` and a `register/Bind` call, it belongs in `core/`.
- Don't construct services on the stack inside `OnInit()` — they must outlive the `MainFrame`, so they live as `CcmApp` members.
## Commands
- Build the binary: `cmake --build build --target ccm`
- Run on Windows / MinGW-w64: `.\build\bin\ccm.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3".
+20
View File
@@ -0,0 +1,20 @@
# ccm: thin executable / composition root.
# Wires concrete adapters into the services and hands them to the UI.
add_executable(ccm
main.cpp
)
set_target_properties(ccm PROPERTIES OUTPUT_NAME ccm3)
# Subsystem WINDOWS on Windows so we don't get a stray console.
if(WIN32)
target_sources(ccm PRIVATE ccm.rc)
set_target_properties(ccm PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
target_link_libraries(ccm
PRIVATE
ccm_core
ccm_ui_wx
ccm_warnings
)
+1
View File
@@ -0,0 +1 @@
ccm_main_icon ICON "resources/ccm.ico"
+145
View File
@@ -0,0 +1,145 @@
// Composition root: builds the dependency graph from the bottom up and hands
// it to the wxWidgets UI layer. This is the only place where concrete adapter
// types are mentioned - everything downstream depends on interfaces.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
#include "ccm/infra/JsonSetRepository.hpp"
#include "ccm/infra/LocalImageStore.hpp"
#include "ccm/infra/StdFileSystem.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/AppContext.hpp"
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/MainFrame.hpp"
#include "ccm/ui/PokemonGameView.hpp"
#include <wx/app.h>
#include <wx/icon.h>
#include <wx/image.h>
#include <wx/msgdlg.h>
#include <wx/stdpaths.h>
#include <wx/utils.h>
#include <filesystem>
#include <memory>
#include <string>
namespace {
// All Game::X -> directory string mappings live in one place. Eliminates the
// need for the repositories to know about concrete game module classes.
std::string dirNameForGame(ccm::Game g) {
switch (g) {
case ccm::Game::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
}
return "magic";
}
} // namespace
class CcmApp : public wxApp {
public:
bool OnInit() override {
// wxImage knows about PNG/JPEG once these handlers are registered.
wxImage::AddHandler(new wxPNGHandler);
wxImage::AddHandler(new wxJPEGHandler);
// --- locate config next to exe, data in user home ----------------
const std::filesystem::path exeDir =
std::filesystem::path(wxStandardPaths::Get().GetExecutablePath().ToStdString())
.parent_path();
const auto configPath = exeDir / "config.json";
const auto defaultDataDir =
std::filesystem::path(wxGetHomeDir().ToStdString()) / "ccm3-data";
// --- build the dependency graph -----------------------------------
fs_ = std::make_unique<ccm::StdFileSystem>();
config_ = std::make_unique<ccm::ConfigService>(*fs_, configPath, defaultDataDir);
if (auto init = config_->initialize(); !init) {
wxMessageBox("Failed to load configuration: " + init.error(),
"Startup error", wxOK | wxICON_ERROR);
return false;
}
http_ = std::make_unique<ccm::CprHttpClient>();
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
*fs_, *config_, &dirNameForGame);
pokeRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::PokemonCard>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
magicCollSvc_ = std::make_unique<ccm::CollectionService<ccm::MagicCard>>(
*magicRepo_, *imgStore_);
pokeCollSvc_ = std::make_unique<ccm::CollectionService<ccm::PokemonCard>>(
*pokeRepo_, *imgStore_);
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
setSvc_->registerModule(magicMod_.get());
setSvc_->registerModule(pokeMod_.get());
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_);
previewSvc_->registerModule(*magicMod_);
previewSvc_->registerModule(*pokeMod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
*setSvc_,
*imgSvc_,
*previewSvc_,
*magicMod_,
*pokeMod_,
{ magicView_.get(), pokeView_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
#ifdef __WXMSW__
frame->SetIcon(wxICON(ccm_main_icon));
#endif
frame->Show(true);
return true;
}
private:
// Order matters - destruction is reverse, so put services that depend on
// others *after* their deps in the member list. Game views are torn down
// first so their panels release any references to the typed services.
std::unique_ptr<ccm::StdFileSystem> fs_;
std::unique_ptr<ccm::ConfigService> config_;
std::unique_ptr<ccm::CprHttpClient> http_;
std::unique_ptr<ccm::MagicGameModule> magicMod_;
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
std::unique_ptr<ccm::SetService> setSvc_;
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
std::unique_ptr<ccm::ui::AppContext> ctx_;
};
wxIMPLEMENT_APP(CcmApp);
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+33
View File
@@ -0,0 +1,33 @@
# Shared warning configuration as an INTERFACE library so each first-party
# target can opt in with `target_link_libraries(<tgt> PRIVATE ccm_warnings)`
# without duplicating flags.
add_library(ccm_warnings INTERFACE)
set(_CCM_GCC_CLANG_FLAGS
-Wall
-Wextra
-Wpedantic
-Wshadow
-Wnon-virtual-dtor
-Wcast-align
-Wunused
-Woverloaded-virtual
-Wnull-dereference
-Wdouble-promotion
-Wformat=2
# -Wconversion / -Wsign-conversion are intentionally NOT enabled - they
# fight badly with wxWidgets's wxWindowID = int API surface and the
# noise-to-signal ratio doesn't justify it for a single-binary app.
)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(ccm_warnings INTERFACE ${_CCM_GCC_CLANG_FLAGS})
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
target_compile_options(ccm_warnings INTERFACE /W4 /permissive-)
endif()
# UTF-8 source/exec encoding everywhere.
if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
target_compile_options(ccm_warnings INTERFACE /utf-8)
endif()
+145
View File
@@ -0,0 +1,145 @@
# Third-party dependencies pulled via FetchContent. Pinning to known-good tags
# is intentional - bump deliberately, never use `master`.
include(FetchContent)
# Avoid hammering the network on every reconfigure once content is downloaded.
set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL "" FORCE)
# CMake 4 dropped compatibility with cmake_minimum_required(VERSION < 3.5).
# Some pinned tags below (doctest 2.4.11, nlohmann/json 3.11.3, wxWidgets 3.2.5)
# still ship a `cmake_minimum_required(VERSION 3.0/3.1)` and would otherwise
# fail to configure. Override the floor for all FetchContent'd subprojects.
# See: https://cmake.org/cmake/help/latest/variable/CMAKE_POLICY_VERSION_MINIMUM.html
if(NOT DEFINED CMAKE_POLICY_VERSION_MINIMUM)
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
endif()
# ---------------------------------------------------------------------------
# nlohmann/json - JSON (de)serialization. Header-only.
# ---------------------------------------------------------------------------
message(STATUS "[ccm] Fetching nlohmann/json ...")
FetchContent_Declare(
nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_SHALLOW TRUE
)
set(JSON_BuildTests OFF CACHE INTERNAL "")
set(JSON_Install OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(nlohmann_json)
# ---------------------------------------------------------------------------
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need
# a system libcurl. Used for Scryfall + pokemontcg.io REST calls.
#
# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)`
# rule that references `libcurl_shared`, which isn't in any export set when
# curl is built as a sub-project here - configure fails with:
# "install(EXPORT \"cprTargets\" ...) includes target \"cpr\" which requires
# target \"libcurl_shared\" that is not in any export set."
# Bumping past 1.10.5 needs additional plumbing (either disable cpr's install
# rules or move curl to its own export set). Verified end-to-end on MSYS2
# UCRT64 GCC 15.2 + CMake 4.3 with this pin.
# ---------------------------------------------------------------------------
message(STATUS "[ccm] Fetching libcpr/cpr (this also fetches a curl source tree) ...")
FetchContent_Declare(
cpr
GIT_REPOSITORY https://github.com/libcpr/cpr.git
GIT_TAG 1.10.5
GIT_SHALLOW TRUE
)
set(CPR_USE_SYSTEM_CURL OFF CACHE INTERNAL "")
set(CPR_BUILD_TESTS OFF CACHE INTERNAL "")
set(CPR_ENABLE_SSL ON CACHE INTERNAL "")
# Let cpr fetch & build a matching curl, with a Windows-friendly SSL backend.
set(CPR_FORCE_USE_SYSTEM_CURL OFF CACHE INTERNAL "")
# Trim the curl build aggressively. We only need HTTPS GET, so anything else
# is dead weight at best and a MinGW-w64 link-failure source at worst.
set(BUILD_CURL_EXE OFF CACHE INTERNAL "")
set(BUILD_TESTING OFF CACHE INTERNAL "")
set(CURL_DISABLE_TESTS ON CACHE INTERNAL "")
set(CURL_DISABLE_DOCS ON CACHE INTERNAL "")
set(CURL_DISABLE_INSTALL ON CACHE INTERNAL "")
set(CURL_DISABLE_LDAP ON CACHE INTERNAL "")
set(CURL_DISABLE_LDAPS ON CACHE INTERNAL "")
set(CURL_DISABLE_FTP ON CACHE INTERNAL "")
set(CURL_DISABLE_TELNET ON CACHE INTERNAL "")
set(CURL_DISABLE_DICT ON CACHE INTERNAL "")
set(CURL_DISABLE_FILE ON CACHE INTERNAL "")
set(CURL_DISABLE_TFTP ON CACHE INTERNAL "")
set(CURL_DISABLE_GOPHER ON CACHE INTERNAL "")
set(CURL_DISABLE_IMAP ON CACHE INTERNAL "")
set(CURL_DISABLE_POP3 ON CACHE INTERNAL "")
set(CURL_DISABLE_SMB ON CACHE INTERNAL "")
set(CURL_DISABLE_SMTP ON CACHE INTERNAL "")
set(CURL_DISABLE_RTSP ON CACHE INTERNAL "")
set(CURL_DISABLE_MQTT ON CACHE INTERNAL "")
if(WIN32)
# Use the OS-provided TLS stack so we don't need OpenSSL on the build host.
set(CURL_USE_SCHANNEL ON CACHE INTERNAL "")
set(CMAKE_USE_SCHANNEL ON CACHE INTERNAL "")
set(CURL_USE_OPENSSL OFF CACHE INTERNAL "")
# Workaround: curl's CMake compile-test for ioctlsocket(FIONBIO) sometimes
# fails on MinGW-w64 UCRT64 due to winsock2.h header ordering, which then
# makes nonblock.c #error out with "no non-blocking method was found".
# Force-set the macro so the build proceeds; ioctlsocket+FIONBIO is the
# canonical Win32 API for non-blocking sockets and is always available.
set(HAVE_IOCTLSOCKET_FIONBIO ON CACHE INTERNAL "")
endif()
FetchContent_MakeAvailable(cpr)
# ---------------------------------------------------------------------------
# wxWidgets - cross-platform UI toolkit.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# doctest - lightweight, header-only unit testing framework. Only fetched when
# tests are enabled, so a default release build doesn't pull it.
# ---------------------------------------------------------------------------
if(CCM_BUILD_TESTS)
message(STATUS "[ccm] Fetching doctest ...")
FetchContent_Declare(
doctest
GIT_REPOSITORY https://github.com/doctest/doctest.git
GIT_TAG v2.4.11
GIT_SHALLOW TRUE
)
set(DOCTEST_WITH_TESTS OFF CACHE INTERNAL "")
set(DOCTEST_WITH_MAIN_IN_STATIC_LIB OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(doctest)
endif()
if(CCM_USE_SYSTEM_WX)
message(STATUS "[ccm] Using system wxWidgets via find_package")
find_package(wxWidgets REQUIRED COMPONENTS core base)
include(${wxWidgets_USE_FILE})
# Re-export as a cleaner imported target so the rest of the build can
# depend on `wx::wx` regardless of how it was provided.
add_library(wx::wx INTERFACE IMPORTED)
target_include_directories(wx::wx INTERFACE ${wxWidgets_INCLUDE_DIRS})
target_compile_definitions(wx::wx INTERFACE ${wxWidgets_DEFINITIONS})
target_link_libraries(wx::wx INTERFACE ${wxWidgets_LIBRARIES})
else()
message(STATUS "[ccm] Fetching wxWidgets (slow on first configure) ...")
set(wxBUILD_SHARED OFF CACHE INTERNAL "")
set(wxBUILD_PRECOMP OFF CACHE INTERNAL "")
set(wxBUILD_INSTALL OFF CACHE INTERNAL "")
set(wxUSE_STL ON CACHE INTERNAL "")
set(wxUSE_GUI ON CACHE INTERNAL "")
FetchContent_Declare(
wxWidgets
GIT_REPOSITORY https://github.com/wxWidgets/wxWidgets.git
GIT_TAG v3.2.5
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(wxWidgets)
# wxWidgets provides `wx::core`, `wx::base` etc. as targets when used as
# a sub-project. Aggregate the ones we need into `wx::wx`.
if(NOT TARGET wx::wx)
add_library(wx::wx INTERFACE IMPORTED)
target_link_libraries(wx::wx INTERFACE wx::core wx::base)
endif()
endif()
+49
View File
@@ -0,0 +1,49 @@
# Toolchain detection.
#
# Preferred compiler is Clang. On Windows hosts we additionally fall back to
# MinGW-w64 g++ when Clang is not present. MSVC is intentionally not supported.
#
# Run this BEFORE project() so the resulting CMAKE_C(XX)_COMPILER takes effect.
if(DEFINED CMAKE_CXX_COMPILER)
# Respect explicit user override.
return()
endif()
# Look for clang / clang++ first.
find_program(_CCM_CLANG NAMES clang clang-cl)
find_program(_CCM_CLANGXX NAMES clang++ clang-cl)
if(_CCM_CLANG AND _CCM_CLANGXX)
set(CMAKE_C_COMPILER "${_CCM_CLANG}" CACHE FILEPATH "C compiler" FORCE)
set(CMAKE_CXX_COMPILER "${_CCM_CLANGXX}" CACHE FILEPATH "C++ compiler" FORCE)
message(STATUS "[ccm] Toolchain: Clang at ${_CCM_CLANGXX}")
return()
endif()
if(WIN32)
# MinGW-w64 GCC fallback.
find_program(_CCM_GCC NAMES x86_64-w64-mingw32-gcc gcc)
find_program(_CCM_GXX NAMES x86_64-w64-mingw32-g++ g++)
if(_CCM_GCC AND _CCM_GXX)
set(CMAKE_C_COMPILER "${_CCM_GCC}" CACHE FILEPATH "C compiler" FORCE)
set(CMAKE_CXX_COMPILER "${_CCM_GXX}" CACHE FILEPATH "C++ compiler" FORCE)
message(STATUS "[ccm] Toolchain: MinGW-w64 GCC at ${_CCM_GXX}")
return()
endif()
message(WARNING
"[ccm] Neither Clang nor MinGW-w64 GCC was found in PATH. "
"Install LLVM (preferred) or MSYS2's mingw-w64-x86_64-gcc and re-run."
)
else()
# Unix-likes: GCC fallback.
find_program(_CCM_GCC NAMES gcc cc)
find_program(_CCM_GXX NAMES g++ c++)
if(_CCM_GCC AND _CCM_GXX)
set(CMAKE_C_COMPILER "${_CCM_GCC}" CACHE FILEPATH "C compiler" FORCE)
set(CMAKE_CXX_COMPILER "${_CCM_GXX}" CACHE FILEPATH "C++ compiler" FORCE)
message(STATUS "[ccm] Toolchain: GCC at ${_CCM_GXX}")
return()
endif()
message(WARNING "[ccm] Neither Clang nor GCC was found in PATH.")
endif()
+59
View File
@@ -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`
+49
View File
@@ -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.
+26
View File
@@ -0,0 +1,26 @@
#pragma once
// Configuration: matches the persisted `config.json` schema used by this app.
//
// { "dataStorage": "/abs/path", "defaultGame": "Magic" }
#include "ccm/domain/Enums.hpp"
#include <nlohmann/json.hpp>
#include <string>
namespace ccm {
struct Configuration {
std::string dataStorage;
Game defaultGame{Game::Magic};
Theme theme{Theme::Light};
friend bool operator==(const Configuration&, const Configuration&) = default;
};
void to_json(nlohmann::json& j, const Configuration& c);
void from_json(const nlohmann::json& j, Configuration& c);
} // namespace ccm
+77
View File
@@ -0,0 +1,77 @@
#pragma once
// Game / Language / Condition enums.
//
// String spellings are kept identical to the established Rust serde defaults
// (e.g. `NearMint`, `LightPlayed`, `Magic`, `Pokemon`) so existing JSON files
// remain interchangeable.
#include <nlohmann/json.hpp>
#include <array>
#include <optional>
#include <string>
#include <string_view>
namespace ccm {
enum class Game {
Magic,
Pokemon,
};
enum class Language {
English,
German,
French,
Spanish,
Italian,
Chinese,
Japanese,
Russian,
};
enum class Condition {
Mint,
NearMint,
Excellent,
Good,
LightPlayed,
Played,
Poor,
};
enum class Theme {
Light,
Dark,
};
std::string_view to_string(Game g) noexcept;
std::string_view to_string(Language l) noexcept;
std::string_view to_string(Condition c) noexcept;
std::string_view to_string(Theme t) noexcept;
std::optional<Game> gameFromString(std::string_view s) noexcept;
std::optional<Language> languageFromString(std::string_view s) noexcept;
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
const std::array<Game, 2>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
void to_json(nlohmann::json& j, Game v);
void from_json(const nlohmann::json& j, Game& v);
void to_json(nlohmann::json& j, Language v);
void from_json(const nlohmann::json& j, Language& v);
void to_json(nlohmann::json& j, Condition v);
void from_json(const nlohmann::json& j, Condition& v);
void to_json(nlohmann::json& j, Theme v);
void from_json(const nlohmann::json& j, Theme& v);
} // namespace ccm
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// MagicCard - faithful port of magic/card_services.rs::Card.
// JSON layout remains stable so collection.json files stay interchangeable.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct MagicCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool foil{false};
bool signed_{false}; // `signed` is a reserved keyword
bool altered{false};
friend bool operator==(const MagicCard&, const MagicCard&) = default;
};
void to_json(nlohmann::json& j, const MagicCard& c);
void from_json(const nlohmann::json& j, MagicCard& c);
} // namespace ccm
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct PokemonCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
};
void to_json(nlohmann::json& j, const PokemonCard& c);
void from_json(const nlohmann::json& j, PokemonCard& c);
} // namespace ccm
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// Set: identifier of a printing run shared by both supported games.
// JSON shape matches the original Rust Set struct exactly:
// { "id": "...", "name": "...", "releaseDate": "YYYY/MM/DD" }
#include <nlohmann/json.hpp>
#include <string>
namespace ccm {
struct Set {
std::string id;
std::string name;
std::string releaseDate; // formatted as "YYYY/MM/DD"
friend bool operator==(const Set&, const Set&) = default;
};
void to_json(nlohmann::json& j, const Set& s);
void from_json(const nlohmann::json& j, Set& s);
} // namespace ccm
+47
View File
@@ -0,0 +1,47 @@
#pragma once
// IGameModule + ISetSource: the seam that lets the UI handle Magic and Pokemon
// uniformly. New game support is "implement these interfaces and register the
// module in the composition root" - see the Rust `templates/` module for the
// pattern this is modeled after.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/util/Result.hpp"
#include <string>
#include <vector>
namespace ccm {
class ISetSource {
public:
virtual ~ISetSource() = default;
// Fetch the canonical set list from the game's external API.
// Implementations return a vector that has already been filtered
// (e.g. no digital-only sets) and sorted by release date ascending.
virtual Result<std::vector<Set>> fetchAll() = 0;
};
class IGameModule {
public:
virtual ~IGameModule() = default;
[[nodiscard]] virtual Game id() const noexcept = 0;
// Subdirectory name used inside the data storage root. Matches the Rust
// string literals "magic" / "pokemon".
[[nodiscard]] virtual std::string dirName() const = 0;
[[nodiscard]] virtual std::string displayName() const = 0;
virtual ISetSource& setSource() = 0;
// Optional preview source. Returning nullptr signals the game has no
// remote preview API (the UI then renders only locally stored images).
// The default keeps existing modules compiling without forcing every
// game to provide one.
virtual ICardPreviewSource* cardPreviewSource() noexcept { return nullptr; }
};
} // namespace ccm
@@ -0,0 +1,42 @@
#pragma once
// MagicCardPreviewSource: ICardPreviewSource implementation for Magic the
// Gathering. Calls Scryfall's search endpoint at
// https://api.scryfall.com/cards/search?q=name:"<name>" AND set:<setCode>
// and returns `data[0].image_uris.normal`. Mirrors the established
// `src/components/magic/SelectedMtgPanel.tsx::getImage`, including the
// `&` -> `and` substitution in the card name.
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
namespace ccm {
class MagicCardPreviewSource final : public ICardPreviewSource {
public:
explicit MagicCardPreviewSource(IHttpClient& http);
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
// Build the fully URL-encoded Scryfall search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
static std::string buildSearchUrl(std::string_view name,
std::string_view setId);
// Parse a Scryfall /cards/search response body and pull out the
// `data[0].image_uris.normal` URL. Returns an error result when no
// matching printing is found, when the JSON is malformed, or when the
// entry has no top-level `image_uris` (double-faced cards expose them
// on a face object - no fallback in this compatibility behavior either).
static Result<std::string> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,29 @@
#pragma once
// MagicGameModule: IGameModule for Magic the Gathering. Owns its set source
// and card preview source, reports the canonical "magic" subdirectory name
// used on disk.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include "ccm/games/magic/MagicSetSource.hpp"
namespace ccm {
class MagicGameModule final : public IGameModule {
public:
explicit MagicGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::Magic; }
[[nodiscard]] std::string dirName() const override { return "magic"; }
[[nodiscard]] std::string displayName() const override { return "Magic"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
MagicSetSource setSource_;
MagicCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,28 @@
#pragma once
// MagicSetSource: ISetSource implementation for Magic the Gathering.
// Calls the Scryfall API at https://api.scryfall.com/sets, drops digital-only
// sets, maps the response into our `Set` domain type, and sorts by release date
// ascending. Behavior matches `magic/set_services.rs::update_sets`.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
namespace ccm {
class MagicSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.scryfall.com/sets";
explicit MagicSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,42 @@
#pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
// TCG. Calls the Pokemon TCG search endpoint at
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
// and returns `data[0].images.large` (with `images.small` as a graceful
// fallback). Mirrors the established `getImage` flow in
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
namespace ccm {
class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`, and returns an error result if neither is present, the
// data array is empty, or the JSON is malformed.
static Result<std::string> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,28 @@
#pragma once
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
// and card preview source, both backed by api.pokemontcg.io/v2.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonSetSource.hpp"
namespace ccm {
class PokemonGameModule final : public IGameModule {
public:
explicit PokemonGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::Pokemon; }
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
[[nodiscard]] std::string displayName() const override { return "Pokemon"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
PokemonSetSource setSource_;
PokemonCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,30 @@
#pragma once
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
// response into our `Set` domain type, and sorts by release date ascending.
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
// so no rewriting is needed (unlike Scryfall's `released_at`).
// Behavior matches `pokemon/set_services.rs::update_sets`.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
private:
IHttpClient& http_;
};
} // namespace ccm
+23
View File
@@ -0,0 +1,23 @@
#pragma once
// CprHttpClient: cpr-based implementation of IHttpClient.
// All cpr/libcurl symbols stay confined to the .cpp file - any TU that just
// needs to make HTTP calls only depends on the IHttpClient port.
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
namespace ccm {
class CprHttpClient final : public IHttpClient {
public:
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
Result<std::string> get(std::string_view url) override;
private:
std::chrono::milliseconds timeout_;
};
} // namespace ccm
@@ -0,0 +1,85 @@
#pragma once
// JsonCollectionRepository<T>: persists std::map<id, TCard> as a JSON object
// keyed by stringified id. Layout matches the established collection file:
//
// <dataStorage>/<gameDir>/collection.json -> { "0": {...}, "1": {...} }
//
// Header-only because it's a template over the card type.
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICollectionRepository.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <nlohmann/json.hpp>
#include <filesystem>
#include <functional>
#include <string>
namespace ccm {
template <typename TCard>
class JsonCollectionRepository final : public ICollectionRepository<TCard> {
public:
using Map = typename ICollectionRepository<TCard>::Map;
// The repository needs a way to translate a Game enum into the per-game
// subdirectory name ("magic" / "pokemon"); a small lookup function keeps
// this layer free of concrete game module types.
using DirNameFn = std::function<std::string(Game)>;
JsonCollectionRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
Result<Map> load(Game game) override {
const auto p = collectionPath(game);
if (!fs_.exists(p)) {
// Mirror the Rust "create on first read" semantics so the UI
// never sees a missing-file error on a fresh install.
Map empty;
auto saved = save(game, empty);
if (!saved) return Result<Map>::err(saved.error());
return Result<Map>::ok(std::move(empty));
}
auto text = fs_.readText(p);
if (!text) return Result<Map>::err(text.error());
try {
auto j = nlohmann::json::parse(text.value());
Map out;
for (auto it = j.begin(); it != j.end(); ++it) {
std::uint32_t key = static_cast<std::uint32_t>(std::stoul(it.key()));
out.emplace(key, it.value().template get<TCard>());
}
return Result<Map>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<Map>::err(std::string("JSON parse error: ") + e.what());
}
}
Result<void> save(Game game, const Map& collection) override {
nlohmann::json j = nlohmann::json::object();
for (const auto& [id, card] : collection) {
j[std::to_string(id)] = card;
}
const auto p = collectionPath(game);
auto dirRes = fs_.ensureDirectory(p.parent_path());
if (!dirRes) return dirRes;
return fs_.writeText(p, j.dump(2));
}
private:
std::filesystem::path collectionPath(Game game) const {
return std::filesystem::path(config_.current().dataStorage) /
dirName_(game) / "collection.json";
}
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
};
} // namespace ccm
@@ -0,0 +1,32 @@
#pragma once
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/ISetRepository.hpp"
#include "ccm/services/ConfigService.hpp"
#include <functional>
#include <string>
namespace ccm {
class JsonSetRepository final : public ISetRepository {
public:
using DirNameFn = std::function<std::string(Game)>;
JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<std::vector<Set>> load(Game game) override;
Result<void> save(Game game, const std::vector<Set>& sets) override;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
};
} // namespace ccm
@@ -0,0 +1,37 @@
#pragma once
// LocalImageStore: stores card images under
// `<dataStorage>/<game>/images/<filename>`.
// Implements IImageStore, used by ImageService.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/services/ConfigService.hpp"
#include <functional>
#include <string>
namespace ccm {
class LocalImageStore final : public IImageStore {
public:
using DirNameFn = std::function<std::string(Game)>;
LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<std::string> copyIn(Game game,
const std::filesystem::path& srcPath,
const std::string& targetName) override;
Result<void> remove(Game game, const std::string& imageName) override;
std::filesystem::path resolvePath(Game game, const std::string& imageName) const override;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path gameImageDir(Game game) const;
};
} // namespace ccm
+25
View File
@@ -0,0 +1,25 @@
#pragma once
// std::filesystem-backed implementation of IFileSystem.
#include "ccm/ports/IFileSystem.hpp"
namespace ccm {
class StdFileSystem final : public IFileSystem {
public:
[[nodiscard]] bool exists(const std::filesystem::path& p) const override;
[[nodiscard]] bool isDirectory(const std::filesystem::path& p) const override;
Result<void> ensureDirectory(const std::filesystem::path& p) override;
Result<std::string> readText(const std::filesystem::path& p) override;
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override;
Result<void> copyFile(const std::filesystem::path& from,
const std::filesystem::path& to,
bool overwrite) override;
Result<void> remove(const std::filesystem::path& p) override;
Result<std::vector<std::filesystem::path>> listDirectory(
const std::filesystem::path& p) override;
};
} // namespace ccm
@@ -0,0 +1,30 @@
#pragma once
// ICardPreviewSource - resolves the preview image URL for a single card via
// the active game's external API. Implementations stay HTTP-bound, no UI deps.
//
// Modeled after per-game `getImage` helpers in
// `src/components/{magic,pokemon}/Selected*Panel.tsx`. The resulting URL is
// then fetched as raw image bytes by `CardPreviewService` and decoded by the
// UI layer (wxImage in our wx adapter).
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
namespace ccm {
class ICardPreviewSource {
public:
virtual ~ICardPreviewSource() = default;
// Resolve the preview image URL for a single card. `setNo` is optional
// (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as
// a more precise lookup key, others (Magic/Scryfall) ignore it.
virtual Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) = 0;
};
} // namespace ccm
@@ -0,0 +1,25 @@
#pragma once
// ICollectionRepository<T> - persistence port for a per-game card collection,
// keyed by uint32_t id. Mirrors the HashMap<u32, T> in the original Rust code.
#include "ccm/domain/Enums.hpp"
#include "ccm/util/Result.hpp"
#include <cstdint>
#include <map>
namespace ccm {
template <typename TCard>
class ICollectionRepository {
public:
using Map = std::map<std::uint32_t, TCard>;
virtual ~ICollectionRepository() = default;
virtual Result<Map> load(Game game) = 0;
virtual Result<void> save(Game game, const Map& collection) = 0;
};
} // namespace ccm
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// IFileSystem - filesystem operations the services need, expressed as a
// narrow port. Real implementation is `StdFileSystem` (over <filesystem>).
// In-memory implementation can be plugged in for tests.
#include "ccm/util/Result.hpp"
#include <filesystem>
#include <string>
#include <vector>
namespace ccm {
class IFileSystem {
public:
virtual ~IFileSystem() = default;
[[nodiscard]] virtual bool exists(const std::filesystem::path& p) const = 0;
[[nodiscard]] virtual bool isDirectory(const std::filesystem::path& p) const = 0;
virtual Result<void> ensureDirectory(const std::filesystem::path& p) = 0;
virtual Result<std::string> readText(const std::filesystem::path& p) = 0;
virtual Result<void> writeText(const std::filesystem::path& p, std::string_view contents) = 0;
virtual Result<void> copyFile(const std::filesystem::path& from,
const std::filesystem::path& to,
bool overwrite) = 0;
virtual Result<void> remove(const std::filesystem::path& p) = 0;
virtual Result<std::vector<std::filesystem::path>> listDirectory(
const std::filesystem::path& p) = 0;
};
} // namespace ccm
+26
View File
@@ -0,0 +1,26 @@
#pragma once
// IHttpClient - tiny abstraction over HTTP GET so the rest of the codebase
// never sees libcurl/cpr directly. Tests can substitute a fake client.
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
namespace ccm {
class IHttpClient {
public:
virtual ~IHttpClient() = default;
// Issue a blocking HTTPS GET. Returns the response body on success, an
// error string on failure (no exceptions across the port boundary).
//
// The returned `std::string` is a raw byte buffer - it is NOT decoded as
// text. Binary payloads (e.g. PNG/JPEG image bytes for the card preview
// feature) round-trip through this method intact.
virtual Result<std::string> get(std::string_view url) = 0;
};
} // namespace ccm
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// IImageStore - manages card image files on disk inside the per-game
// `images/` subdirectory. Returns absolute paths so the UI can decode with
// whichever image library it likes (we use wxImage in the wx adapter); core
// stays free of any image decoding dependency.
#include "ccm/domain/Enums.hpp"
#include "ccm/util/Result.hpp"
#include <filesystem>
#include <string>
namespace ccm {
class IImageStore {
public:
virtual ~IImageStore() = default;
// Copy `srcPath` into the per-game image dir under filename `targetName`
// (extension preserved from `srcPath`). Returns the final filename
// (basename only, no path) on success.
virtual Result<std::string> copyIn(Game game,
const std::filesystem::path& srcPath,
const std::string& targetName) = 0;
// Delete `imageName` from the per-game image dir.
virtual Result<void> remove(Game game, const std::string& imageName) = 0;
// Resolve `imageName` to an absolute path inside the per-game image dir.
virtual std::filesystem::path resolvePath(Game game, const std::string& imageName) const = 0;
};
} // namespace ccm
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// ISetRepository - persistence port for the cached `sets.json` of a game.
// Stored as a flat list to mirror the original Rust file layout.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/util/Result.hpp"
#include <vector>
namespace ccm {
class ISetRepository {
public:
virtual ~ISetRepository() = default;
virtual Result<std::vector<Set>> load(Game game) = 0;
virtual Result<void> save(Game game, const std::vector<Set>& sets) = 0;
};
} // namespace ccm
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// CardFilter - row matcher aligned with TableTemplate.tsx::applyFilter semantics.
// kept a single `filter` string in the table component and, before rendering,
// kept only the rows where *any* of the `tableFields` value-key columns
// (string- or number-typed) contained the filter as a substring. Boolean flag
// columns (foil / signed / altered / holo / firstEdition) were skipped because
// `typeof` was neither "string" nor "number".
//
// We replicate that logic here as free functions so the UI can stay dumb (it
// just owns a wxTextCtrl and re-asks core which rows match).
//
// Differences worth knowing:
// * The match is case-insensitive on **both** sides — the old JS path only lowercased
// the cell value, leaving the filter as-typed, which made uppercase input
// never match. Lowercasing the filter too is the obvious-intent fix and
// keeps round-trip semantics for any filter the original UI would accept
// (lowercase filters behave identically).
// * An empty filter matches every row, exactly as in JS where every string
// `.includes("")` returns true.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include <string_view>
namespace ccm {
// Magic value-key columns from the MtgTable.tsx tableFields list:
// name, set.name, language, condition, amount, note.
// Foil/Signed/Altered are bool-typed and intentionally excluded.
[[nodiscard]] bool matchesMagicFilter(const MagicCard& card,
std::string_view filter);
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
// name, set.name, setNo, language, condition, amount, note.
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
std::string_view filter);
} // namespace ccm
@@ -0,0 +1,55 @@
#pragma once
// CardPreviewService: high-level operation for resolving and downloading a
// card's preview image from the active game's external API. Mirrors the
// `EntryPanelTemplate.tsx::useEffect([entry])` flow:
// 1. Per-game `ICardPreviewSource` resolves the card -> an image URL.
// 2. Service issues a GET against that URL through `IHttpClient`.
// 3. Raw bytes are returned to the caller (UI decodes them with whichever
// image lib it prefers - we use wxImage in the wx adapter).
//
// Sources are registered through `IGameModule::cardPreviewSource()`. Modules
// that return nullptr are silently skipped; games without a registered source
// produce an explicit error result on lookup rather than silently doing nothing.
#include "ccm/domain/Enums.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
#include <unordered_map>
namespace ccm {
class CardPreviewService {
public:
explicit CardPreviewService(IHttpClient& http);
// Register a game module's preview source. Calling this with a module
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
// no remote preview API). The module reference must remain valid for
// the lifetime of the service.
void registerModule(IGameModule& module);
// Resolve and download the preview image bytes for a single card.
// The returned `std::string` is a raw byte buffer (PNG/JPEG payload) -
// it is NOT decoded text. Use std::string::data()/size() with whatever
// image-decoding facility your UI provides.
Result<std::string> fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution.
Result<std::string> fetchImageBytesByUrl(std::string_view url);
private:
IHttpClient& http_;
std::unordered_map<Game, ICardPreviewSource*> sources_;
};
} // namespace ccm
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// CardSorter - per-column sorting behavior matching the established table model
// `tableFields` configuration. The original TS code stored each column's
// (valueKey, sortKey) pair on a config object so that, e.g., the "Set" column
// could *display* `set.name` while sorting by `set.releaseDate`. We replicate
// that mapping here as two enums (one per supported game), each variant naming
// a sort key. The free functions below run a stable sort over a vector using
// the same per-type rules as the original `byField`:
//
// * strings -> case-insensitive (lowercased) `<` / `>`
// * numbers -> direct `<` / `>`
// * booleans -> direct `<` / `>` (so false < true; unset flags first when asc)
//
// Stable sort matches the JS `Array.prototype.sort` guarantee from ES2019; the
// UI relies on it so successive clicks on different columns compose predictably
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include <vector>
namespace ccm {
// One value per *real* (non-spacer) column of the Magic table, in the same
// order as the Magic tableFields list.
enum class MagicSortColumn {
Name,
SetReleaseDate, // value column shows set.name, sort key is set.releaseDate
Language,
Condition,
Amount,
Foil,
Signed,
Altered,
Note,
};
// Pokemon equivalent (PokemonTable.tsx tableFields). Adds Holo + FirstEdition,
// drops Foil.
enum class PokemonSortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
Holo,
FirstEdition,
Signed,
Altered,
Note,
};
// Stable in-place sort. `ascending=false` runs the same comparator with
// inverted sign, matching `byField(field, asc)` semantics.
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
bool ascending);
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
bool ascending);
} // namespace ccm
@@ -0,0 +1,126 @@
#pragma once
// CollectionService<TCard>: high-level CRUD over the per-game collection.
// Header-only template that operates on the ICollectionRepository<T> port and
// delegates image cleanup to an IImageStore on remove.
//
// This is the C++ equivalent of the Rust generic helpers in
// `templates/card_service_templates.rs` (add_entry_to_collection,
// update_entry_in_collection, get_entry_by_id, delete_entry_by_id, ...).
//
// Each TCard must expose `std::uint32_t id` and `std::vector<std::string> images`.
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/ICollectionRepository.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/util/Result.hpp"
#include <algorithm>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace ccm {
template <typename TCard>
class CollectionService {
public:
using Map = std::map<std::uint32_t, TCard>;
CollectionService(ICollectionRepository<TCard>& repo, IImageStore& imageStore)
: repo_(repo), imageStore_(imageStore) {}
Result<std::vector<TCard>> list(Game game) {
auto loaded = repo_.load(game);
if (!loaded) return Result<std::vector<TCard>>::err(loaded.error());
Map map = std::move(loaded).value();
std::vector<TCard> out;
out.reserve(map.size());
for (auto& [id, card] : map) {
(void)id;
out.push_back(std::move(card));
}
return Result<std::vector<TCard>>::ok(std::move(out));
}
[[nodiscard]] static std::uint32_t nextId(const Map& map) noexcept {
if (map.empty()) return 0;
return map.rbegin()->first + 1;
}
// Add a card. The `id` field on the input is overwritten with the next
// free id (matching the Rust HashMap-keyed behavior).
Result<std::uint32_t> add(Game game, TCard card) {
auto loaded = repo_.load(game);
if (!loaded) return Result<std::uint32_t>::err(loaded.error());
Map map = std::move(loaded).value();
const std::uint32_t newId = nextId(map);
card.id = newId;
map.emplace(newId, std::move(card));
auto saved = repo_.save(game, map);
if (!saved) return Result<std::uint32_t>::err(saved.error());
return Result<std::uint32_t>::ok(newId);
}
// Update an existing entry, identified by `card.id`. If the id is not
// present, an error is returned.
Result<void> update(Game game, TCard card) {
auto loaded = repo_.load(game);
if (!loaded) return Result<void>::err(loaded.error());
Map map = std::move(loaded).value();
auto it = map.find(card.id);
if (it == map.end()) {
return Result<void>::err("Card with id " + std::to_string(card.id) +
" not found in collection.");
}
it->second = std::move(card);
return repo_.save(game, map);
}
// Remove the card with the given id. Also deletes any associated images
// via the IImageStore (best-effort - image removal failures are logged in
// the error string but the card itself is still purged from the JSON).
Result<void> remove(Game game, std::uint32_t id) {
auto loaded = repo_.load(game);
if (!loaded) return Result<void>::err(loaded.error());
Map map = std::move(loaded).value();
auto it = map.find(id);
if (it == map.end()) {
return Result<void>::err("Card with id " + std::to_string(id) + " not found.");
}
std::string imgErr;
for (const auto& image : it->second.images) {
auto rm = imageStore_.remove(game, image);
if (!rm) {
if (!imgErr.empty()) imgErr += "; ";
imgErr += rm.error();
}
}
map.erase(it);
auto saved = repo_.save(game, map);
if (!saved) return saved;
if (!imgErr.empty()) {
return Result<void>::err("Card removed but image cleanup had issues: " + imgErr);
}
return Result<void>::ok();
}
// Look up a card by id without mutating storage.
Result<std::optional<TCard>> findById(Game game, std::uint32_t id) {
auto loaded = repo_.load(game);
if (!loaded) return Result<std::optional<TCard>>::err(loaded.error());
const auto& m = loaded.value();
auto it = m.find(id);
if (it == m.end()) return Result<std::optional<TCard>>::ok(std::nullopt);
return Result<std::optional<TCard>>::ok(it->second);
}
private:
ICollectionRepository<TCard>& repo_;
IImageStore& imageStore_;
};
} // namespace ccm
@@ -0,0 +1,41 @@
#pragma once
// ConfigService: owns the live `Configuration`, persists it to `config.json`
// next to the executable. Mirrors `util/config.rs` behavior - a missing file
// is auto-created with sensible defaults on first launch.
#include "ccm/domain/Configuration.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/util/Result.hpp"
#include <filesystem>
namespace ccm {
class ConfigService {
public:
// `configFilePath` is the absolute path to `config.json`. The defaults
// applied to a freshly created config use `defaultDataStorage` for the
// dataStorage field.
ConfigService(IFileSystem& fs,
std::filesystem::path configFilePath,
std::filesystem::path defaultDataStorage);
// Load (or create) the configuration. Must be called once at startup.
Result<void> initialize();
[[nodiscard]] const Configuration& current() const noexcept { return current_; }
// Replace the live configuration and persist immediately.
Result<void> store(Configuration cfg);
private:
IFileSystem& fs_;
std::filesystem::path path_;
std::filesystem::path defaultDataStorage_;
Configuration current_{};
[[nodiscard]] Configuration makeDefault() const;
};
} // namespace ccm
@@ -0,0 +1,70 @@
#pragma once
// ImageService: applies the established image filename rules and delegates the actual
// disk operations to an IImageStore.
//
// Filename rule (mirrors Rust `magic/card_services.rs` and
// `pokemon/card_services.rs`):
// - new entry -> "{set}+{name}+{idx}.{ext}"
// - existing -> "{id}+{set}+{name}+{idx}.{ext}"
//
// The "+name+set+name+" convention is preserved byte-for-byte so legacy
// collections continue to display correctly when imported.
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/util/Result.hpp"
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace ccm {
class ImageService {
public:
explicit ImageService(IImageStore& store);
// Compute the next image index from the previously stored image filenames
// for a card. Implements the same logic as the Rust card_services modules.
static std::uint8_t nextImageIndex(const std::vector<std::string>& existingImages);
// Build the target filename (without extension) for a new image attached
// to a card. Pass the card's existing id (0 if not yet stored) and a
// `newEntry` flag matching the established semantic.
static std::string buildTargetName(bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
std::uint8_t index);
// Convenience: build target name + delegate copy to the IImageStore.
Result<std::string> addImage(Game game,
const std::filesystem::path& srcPath,
bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& existingImages);
Result<void> removeImage(Game game, const std::string& imageName);
// Ensure image filenames for a persisted card include the card id prefix.
// This upgrades "create-mode" names (`set+name+idx.ext`) to
// `id+set+name+idx.ext` to match the ccm2-compatible naming scheme.
Result<std::vector<std::string>> normalizeNamesForPersistedCard(
Game game,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& imageNames);
[[nodiscard]] std::filesystem::path resolveImagePath(Game game,
const std::string& imageName) const;
private:
IImageStore& store_;
};
} // namespace ccm
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// SetService: high-level operations for fetching and caching set data.
// Wraps an ISetRepository (cache) and dispatches to the correct ISetSource
// based on the active IGameModule.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ISetRepository.hpp"
#include "ccm/util/Result.hpp"
#include <unordered_map>
#include <vector>
namespace ccm {
class SetService {
public:
explicit SetService(ISetRepository& repo);
// Register a game module so this service can route fetch requests.
// Pointer must remain valid for the lifetime of the SetService.
void registerModule(IGameModule* module);
// Force a fresh fetch from the API for `game`, persist it via the
// repository, and return the new list.
Result<std::vector<Set>> updateSets(Game game);
// Cached read; returns an error if no local data exists yet.
Result<std::vector<Set>> getSets(Game game);
private:
ISetRepository& repo_;
std::unordered_map<Game, IGameModule*> modules_;
};
} // namespace ccm
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// Pure functions for filename munging. Ported from the original `util/fs.rs` so
// that file naming on disk stays identical between the two codebases.
#include <cstdint>
#include <string>
#include <string_view>
namespace ccm {
// Replace problematic characters so the result is safe to use as a filename
// component. Mirrors `format_text_for_fs` in Rust (drops apostrophes/commas,
// strips spaces, replaces `:` with `-`, `&` with `And`, `|` with `Or`,
// flattens accented vowels). Pure; safe at any call site.
std::string formatTextForFs(std::string_view text);
// Parse the trailing 1- or 2-digit numeric index from a filename. Examples:
// "Image1.png" -> 1
// "Image22.jpeg" -> 22
// Returns 0 on parse failure, matching the optimistic Rust behavior.
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept;
} // namespace ccm
+150
View File
@@ -0,0 +1,150 @@
#pragma once
// A minimal Result<T, E> type used as a sum-type for fallible operations.
// Mirrors the Rust `Result<T, &str>` style used in the original codebase.
//
// We intentionally do not depend on tl::expected or std::expected so this
// header stays buildable on every toolchain in our matrix (clang 14+, GCC 11+).
#include <new>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>
namespace ccm {
template <typename T, typename E = std::string>
class Result {
public:
using value_type = T;
using error_type = E;
static Result ok(T value) { return Result(OkTag{}, std::move(value)); }
static Result err(E error) { return Result(ErrTag{}, std::move(error)); }
Result(const Result& other) : has_value_(other.has_value_) {
if (has_value_) {
::new (static_cast<void*>(&value_)) T(other.value_);
} else {
::new (static_cast<void*>(&error_)) E(other.error_);
}
}
Result(Result&& other) noexcept : has_value_(other.has_value_) {
if (has_value_) {
::new (static_cast<void*>(&value_)) T(std::move(other.value_));
} else {
::new (static_cast<void*>(&error_)) E(std::move(other.error_));
}
}
Result& operator=(const Result& other) {
if (this == &other) return *this;
destroy();
has_value_ = other.has_value_;
if (has_value_) {
::new (static_cast<void*>(&value_)) T(other.value_);
} else {
::new (static_cast<void*>(&error_)) E(other.error_);
}
return *this;
}
Result& operator=(Result&& other) noexcept {
if (this == &other) return *this;
destroy();
has_value_ = other.has_value_;
if (has_value_) {
::new (static_cast<void*>(&value_)) T(std::move(other.value_));
} else {
::new (static_cast<void*>(&error_)) E(std::move(other.error_));
}
return *this;
}
~Result() { destroy(); }
[[nodiscard]] bool isOk() const noexcept { return has_value_; }
[[nodiscard]] bool isErr() const noexcept { return !has_value_; }
explicit operator bool() const noexcept { return has_value_; }
const T& value() const& {
if (!has_value_) throw std::logic_error("Result::value() on err");
return value_;
}
T& value() & {
if (!has_value_) throw std::logic_error("Result::value() on err");
return value_;
}
T&& value() && {
if (!has_value_) throw std::logic_error("Result::value() on err");
return std::move(value_);
}
const E& error() const& {
if (has_value_) throw std::logic_error("Result::error() on ok");
return error_;
}
E&& error() && {
if (has_value_) throw std::logic_error("Result::error() on ok");
return std::move(error_);
}
template <typename U>
T valueOr(U&& fallback) const& {
return has_value_ ? value_ : static_cast<T>(std::forward<U>(fallback));
}
private:
struct OkTag {};
struct ErrTag {};
Result(OkTag, T value) : has_value_(true) {
::new (static_cast<void*>(&value_)) T(std::move(value));
}
Result(ErrTag, E error) : has_value_(false) {
::new (static_cast<void*>(&error_)) E(std::move(error));
}
void destroy() noexcept {
if (has_value_) {
value_.~T();
} else {
error_.~E();
}
}
bool has_value_;
union {
T value_;
E error_;
};
};
// Specialization for void-returning fallible operations.
template <typename E>
class Result<void, E> {
public:
using value_type = void;
using error_type = E;
static Result ok() { return Result(true, E{}); }
static Result err(E error) { return Result(false, std::move(error)); }
[[nodiscard]] bool isOk() const noexcept { return has_value_; }
[[nodiscard]] bool isErr() const noexcept { return !has_value_; }
explicit operator bool() const noexcept { return has_value_; }
const E& error() const& {
if (has_value_) throw std::logic_error("Result::error() on ok");
return error_;
}
private:
Result(bool ok, E error) : has_value_(ok), error_(std::move(error)) {}
bool has_value_;
E error_;
};
} // namespace ccm
+19
View File
@@ -0,0 +1,19 @@
#include "ccm/domain/Configuration.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const Configuration& c) {
j = nlohmann::json{
{"dataStorage", c.dataStorage},
{"defaultGame", c.defaultGame},
{"theme", c.theme},
};
}
void from_json(const nlohmann::json& j, Configuration& c) {
j.at("dataStorage").get_to(c.dataStorage);
j.at("defaultGame").get_to(c.defaultGame);
c.theme = j.value("theme", Theme::Light);
}
} // namespace ccm
+139
View File
@@ -0,0 +1,139 @@
#include "ccm/domain/Enums.hpp"
#include <stdexcept>
#include <string>
namespace ccm {
std::string_view to_string(Game g) noexcept {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
}
return "Magic";
}
std::string_view to_string(Language l) noexcept {
switch (l) {
case Language::English: return "English";
case Language::German: return "German";
case Language::French: return "French";
case Language::Spanish: return "Spanish";
case Language::Italian: return "Italian";
case Language::Chinese: return "Chinese";
case Language::Japanese: return "Japanese";
case Language::Russian: return "Russian";
}
return "English";
}
std::string_view to_string(Condition c) noexcept {
switch (c) {
case Condition::Mint: return "Mint";
case Condition::NearMint: return "NearMint";
case Condition::Excellent: return "Excellent";
case Condition::Good: return "Good";
case Condition::LightPlayed: return "LightPlayed";
case Condition::Played: return "Played";
case Condition::Poor: return "Poor";
}
return "Mint";
}
std::string_view to_string(Theme t) noexcept {
switch (t) {
case Theme::Light: return "Light";
case Theme::Dark: return "Dark";
}
return "Light";
}
std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
return std::nullopt;
}
std::optional<Language> languageFromString(std::string_view s) noexcept {
if (s == "English") return Language::English;
if (s == "German") return Language::German;
if (s == "French") return Language::French;
if (s == "Spanish") return Language::Spanish;
if (s == "Italian") return Language::Italian;
if (s == "Chinese") return Language::Chinese;
if (s == "Japanese") return Language::Japanese;
if (s == "Russian") return Language::Russian;
return std::nullopt;
}
std::optional<Condition> conditionFromString(std::string_view s) noexcept {
if (s == "Mint") return Condition::Mint;
if (s == "NearMint") return Condition::NearMint;
if (s == "Excellent") return Condition::Excellent;
if (s == "Good") return Condition::Good;
if (s == "LightPlayed") return Condition::LightPlayed;
if (s == "Played") return Condition::Played;
if (s == "Poor") return Condition::Poor;
return std::nullopt;
}
std::optional<Theme> themeFromString(std::string_view s) noexcept {
if (s == "Light") return Theme::Light;
if (s == "Dark") return Theme::Dark;
return std::nullopt;
}
const std::array<Game, 2>& allGames() noexcept {
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
return v;
}
const std::array<Language, 8>& allLanguages() noexcept {
static constexpr std::array<Language, 8> v{
Language::English, Language::German, Language::French, Language::Spanish,
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
};
return v;
}
const std::array<Condition, 7>& allConditions() noexcept {
static constexpr std::array<Condition, 7> v{
Condition::Mint, Condition::NearMint, Condition::Excellent,
Condition::Good, Condition::LightPlayed, Condition::Played, Condition::Poor
};
return v;
}
const std::array<Theme, 2>& allThemes() noexcept {
static constexpr std::array<Theme, 2> v{Theme::Light, Theme::Dark};
return v;
}
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
void from_json(const nlohmann::json& j, Game& v) {
auto parsed = gameFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Language& v) {
auto parsed = languageFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Condition& v) {
auto parsed = conditionFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Condition value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Theme& v) {
auto parsed = themeFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Theme value: " + j.get<std::string>());
v = *parsed;
}
} // namespace ccm
+35
View File
@@ -0,0 +1,35 @@
#include "ccm/domain/MagicCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const MagicCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"foil", c.foil},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, MagicCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("foil").get_to(c.foil);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/PokemonCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const PokemonCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"setNo", c.setNo},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"firstEdition", c.firstEdition},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, PokemonCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("setNo").get_to(c.setNo);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("firstEdition").get_to(c.firstEdition);
j.at("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
+19
View File
@@ -0,0 +1,19 @@
#include "ccm/domain/Set.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const Set& s) {
j = nlohmann::json{
{"id", s.id},
{"name", s.name},
{"releaseDate", s.releaseDate},
};
}
void from_json(const nlohmann::json& j, Set& s) {
j.at("id").get_to(s.id);
j.at("name").get_to(s.name);
j.at("releaseDate").get_to(s.releaseDate);
}
} // namespace ccm
@@ -0,0 +1,100 @@
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
// which does not URL-encode the URL string we hand it.
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Apply the same name massaging as the legacy query path before sending.
std::string sanitizeName(std::string_view name) {
std::string s(name);
std::string::size_type pos = 0;
while ((pos = s.find('&', pos)) != std::string::npos) {
s.replace(pos, 1, "and");
pos += 3;
}
return s;
}
} // namespace
MagicCardPreviewSource::MagicCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId) {
// Build the unencoded query first so the output matches what Scryfall
// would parse: name:"<sanitized>" AND set:<setId>
const std::string sanitized = sanitizeName(name);
std::string query = "name:\"";
query += sanitized;
query += "\" AND set:";
query += std::string(setId);
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
}
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::string>::err("Scryfall response missing 'data' array.");
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Scryfall returned no matching cards.");
}
const auto& first = data.at(0);
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
// Double-faced cards expose image_uris on each face; there is no
// fallback for this and surfaces it as "no preview".
return Result<std::string>::err("Card has no top-level image_uris.");
}
const auto& uris = first.at("image_uris");
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
return Result<std::string>::err("Card has no 'normal' image variant.");
}
return Result<std::string>::ok(uris.at("normal").get<std::string>());
} catch (const std::exception& e) {
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
}
}
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view /*setNo*/) {
const std::string url = buildSearchUrl(name, setId);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+8
View File
@@ -0,0 +1,8 @@
#include "ccm/games/magic/MagicGameModule.hpp"
namespace ccm {
MagicGameModule::MagicGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
+48
View File
@@ -0,0 +1,48 @@
#include "ccm/games/magic/MagicSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
namespace ccm {
MagicSetSource::MagicSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> MagicSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::vector<Set>>::err("Scryfall response missing 'data' array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
// Filter out digital-only sets exactly like the Rust code.
const bool digital = entry.value("digital", false);
if (digital) continue;
Set s;
s.id = entry.value("code", "");
s.name = entry.value("name", "");
// Scryfall returns "released_at" as YYYY-MM-DD; persisted data stores YYYY/MM/DD.
std::string releasedAt = entry.value("released_at", "");
std::replace(releasedAt.begin(), releasedAt.end(), '-', '/');
s.releaseDate = std::move(releasedAt);
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(std::string("Scryfall JSON parse error: ") + e.what());
}
}
Result<std::vector<Set>> MagicSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
@@ -0,0 +1,110 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
// Magic implementation; kept private so the two can drift independently if a
// future API requires it.
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
std::string normalizeNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
// Build the unencoded query first so the output matches what the Pokemon
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
std::string query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
}
const std::string num = normalizeNumber(setNo);
if (!num.empty()) {
query += " number:";
query += num;
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
}
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
}
const auto& data = j.at("data");
if (data.empty()) {
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return Result<std::string>::err("Card has no 'images' object.");
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return Result<std::string>::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return Result<std::string>::ok(images.at("small").get<std::string>());
}
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
}
}
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
const std::string url = buildSearchUrl(name, setId, setNo);
auto resp = http_.get(url);
if (!resp) return Result<std::string>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/pokemon/PokemonGameModule.hpp"
namespace ccm {
PokemonGameModule::PokemonGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,45 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
namespace ccm {
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<std::vector<Set>>::err(
"Pokemon TCG API response missing 'data' array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
Set s;
s.id = entry.value("id", "");
s.name = entry.value("name", "");
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
// no separator rewrite needed (cf. Scryfall's "released_at").
s.releaseDate = entry.value("releaseDate", "");
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
}
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+30
View File
@@ -0,0 +1,30 @@
#include "ccm/infra/CprHttpClient.hpp"
#include <cpr/cpr.h>
#include <string>
namespace ccm {
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
Result<std::string> CprHttpClient::get(std::string_view url) {
cpr::Response r = cpr::Get(
cpr::Url{std::string(url)},
cpr::Timeout{timeout_},
// Identify ourselves; some APIs rate-limit unknown agents harshly.
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
{"Accept", "application/json"}}
);
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
}
if (r.status_code < 200 || r.status_code >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
}
return Result<std::string>::ok(std::move(r.text));
}
} // namespace ccm
+41
View File
@@ -0,0 +1,41 @@
#include "ccm/infra/JsonSetRepository.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path JsonSetRepository::setsPath(Game game) const {
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
}
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
const auto p = setsPath(game);
if (!fs_.exists(p)) {
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
}
auto text = fs_.readText(p);
if (!text) return Result<std::vector<Set>>::err(text.error());
try {
auto j = nlohmann::json::parse(text.value());
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
}
}
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
const auto p = setsPath(game);
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = sets;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+43
View File
@@ -0,0 +1,43 @@
#include "ccm/infra/LocalImageStore.hpp"
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
LocalImageStore::LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path LocalImageStore::gameImageDir(Game game) const {
return fs::path(config_.current().dataStorage) / dirName_(game) / "images";
}
Result<std::string> LocalImageStore::copyIn(Game game,
const fs::path& srcPath,
const std::string& targetName) {
const auto dir = gameImageDir(game);
auto ensure = fs_.ensureDirectory(dir);
if (!ensure) return Result<std::string>::err(ensure.error());
// Preserve the source's extension - the original Rust code does the same.
std::string ext = srcPath.extension().string();
std::string finalName = targetName + ext;
const auto dest = dir / finalName;
auto cp = fs_.copyFile(srcPath, dest, /*overwrite=*/true);
if (!cp) return Result<std::string>::err(cp.error());
return Result<std::string>::ok(std::move(finalName));
}
Result<void> LocalImageStore::remove(Game game, const std::string& imageName) {
const auto p = gameImageDir(game) / imageName;
if (!fs_.exists(p)) return Result<void>::ok(); // be forgiving on stale entries
return fs_.remove(p);
}
fs::path LocalImageStore::resolvePath(Game game, const std::string& imageName) const {
return gameImageDir(game) / imageName;
}
} // namespace ccm
+88
View File
@@ -0,0 +1,88 @@
#include "ccm/infra/StdFileSystem.hpp"
#include <fstream>
#include <sstream>
#include <system_error>
namespace ccm {
namespace fs = std::filesystem;
bool StdFileSystem::exists(const fs::path& p) const {
std::error_code ec;
return fs::exists(p, ec);
}
bool StdFileSystem::isDirectory(const fs::path& p) const {
std::error_code ec;
return fs::is_directory(p, ec);
}
Result<void> StdFileSystem::ensureDirectory(const fs::path& p) {
std::error_code ec;
if (fs::exists(p, ec)) {
if (fs::is_directory(p, ec)) return Result<void>::ok();
return Result<void>::err("Path exists but is not a directory: " + p.string());
}
fs::create_directories(p, ec);
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
return Result<void>::ok();
}
Result<std::string> StdFileSystem::readText(const fs::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return Result<std::string>::err("Unable to open file: " + p.string());
std::ostringstream ss;
ss << in.rdbuf();
if (!in && !in.eof()) return Result<std::string>::err("Read error on: " + p.string());
return Result<std::string>::ok(ss.str());
}
Result<void> StdFileSystem::writeText(const fs::path& p, std::string_view contents) {
std::error_code ec;
if (p.has_parent_path()) {
fs::create_directories(p.parent_path(), ec);
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
}
std::ofstream out(p, std::ios::binary | std::ios::trunc);
if (!out) return Result<void>::err("Unable to create file: " + p.string());
out.write(contents.data(), static_cast<std::streamsize>(contents.size()));
if (!out) return Result<void>::err("Write error on: " + p.string());
return Result<void>::ok();
}
Result<void> StdFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) {
std::error_code ec;
if (to.has_parent_path()) {
fs::create_directories(to.parent_path(), ec);
if (ec) return Result<void>::err("create_directories failed: " + ec.message());
ec.clear();
}
const auto opt = overwrite ? fs::copy_options::overwrite_existing
: fs::copy_options::none;
fs::copy_file(from, to, opt, ec);
if (ec) return Result<void>::err("copy_file failed: " + ec.message());
return Result<void>::ok();
}
Result<void> StdFileSystem::remove(const fs::path& p) {
std::error_code ec;
fs::remove(p, ec);
if (ec) return Result<void>::err("remove failed: " + ec.message());
return Result<void>::ok();
}
Result<std::vector<fs::path>> StdFileSystem::listDirectory(const fs::path& p) {
std::error_code ec;
if (!fs::is_directory(p, ec)) {
return Result<std::vector<fs::path>>::err("Not a directory: " + p.string());
}
std::vector<fs::path> out;
for (const auto& entry : fs::directory_iterator(p, ec)) {
out.push_back(entry.path());
}
if (ec) return Result<std::vector<fs::path>>::err("directory_iterator: " + ec.message());
return Result<std::vector<fs::path>>::ok(std::move(out));
}
} // namespace ccm
+66
View File
@@ -0,0 +1,66 @@
#include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp"
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
// identically.
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
bool containsLower(std::string_view haystack, std::string_view needleLower) {
return asciiLower(haystack).find(needleLower) != std::string::npos;
}
} // namespace
bool matchesMagicFilter(const MagicCard& card, std::string_view filter) {
// `""`.includes(filter) is true for filter == "" in JS; mirror that so the
// panel does not need a separate "no filter" branch.
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
// Order mirrors the MtgTable.tsx tableFields list (minus the boolean
// flag columns, which `applyFilter` skips). Stops on first match for the
// same short-circuit behavior as the JS for-loop with `break`.
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
} // namespace ccm
+34
View File
@@ -0,0 +1,34 @@
#include "ccm/services/CardPreviewService.hpp"
namespace ccm {
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
void CardPreviewService::registerModule(IGameModule& module) {
if (auto* src = module.cardPreviewSource(); src != nullptr) {
sources_[module.id()] = src;
}
}
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::string>::err("No preview source registered for this game.");
}
auto url = it->second->fetchImageUrl(name, setId, setNo);
if (!url) return Result<std::string>::err(url.error());
auto bytes = http_.get(url.value());
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
auto bytes = http_.get(url);
if (!bytes) return Result<std::string>::err(bytes.error());
return Result<std::string>::ok(std::move(bytes).value());
}
} // namespace ccm
+173
View File
@@ -0,0 +1,173 @@
#include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
// We use ASCII-only tolower; the original TS app processed the same fields and
// never special-cased Unicode either, so this stays byte-compatible for the
// realistic data set (English/German/etc. names already lowercase identically).
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
// Wrap a less-than predicate so that ascending=false flips its meaning,
// mirroring `byField(field, asc)` in TableTemplate.tsx.
template <typename Less>
auto directional(Less less, bool ascending) {
return [less, ascending](const auto& a, const auto& b) {
return ascending ? less(a, b) : less(b, a);
};
}
} // namespace
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
bool ascending) {
switch (column) {
case MagicSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case MagicSortColumn::SetReleaseDate:
// Release dates are stored as "YYYY/MM/DD" so plain lexicographic
// compare is chronological. The legacy JS path lowercased strings anyway; we
// do the same for parity even though digits/'/' are unaffected.
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case MagicSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case MagicSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case MagicSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case MagicSortColumn::Foil:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.foil < b.foil; // false < true (asc puts unset first)
}, ascending));
break;
case MagicSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case MagicSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case MagicSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const MagicCard& a, const MagicCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
bool ascending) {
switch (column) {
case PokemonSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case PokemonSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case PokemonSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case PokemonSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case PokemonSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case PokemonSortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.holo < b.holo;
}, ascending));
break;
case PokemonSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case PokemonSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case PokemonSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case PokemonSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const PokemonCard& a, const PokemonCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
} // namespace ccm
+47
View File
@@ -0,0 +1,47 @@
#include "ccm/services/ConfigService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
ConfigService::ConfigService(IFileSystem& fs,
std::filesystem::path configFilePath,
std::filesystem::path defaultDataStorage)
: fs_(fs),
path_(std::move(configFilePath)),
defaultDataStorage_(std::move(defaultDataStorage)) {}
Configuration ConfigService::makeDefault() const {
Configuration c;
// generic_string() always uses '/' separators - keeps the value portable
// across Windows / Unix and round-trip-friendly for tests and JSON.
c.dataStorage = defaultDataStorage_.generic_string();
c.defaultGame = Game::Magic;
return c;
}
Result<void> ConfigService::initialize() {
if (!fs_.exists(path_)) {
current_ = makeDefault();
return store(current_);
}
auto text = fs_.readText(path_);
if (!text) return Result<void>::err(text.error());
try {
auto j = nlohmann::json::parse(text.value());
current_ = j.get<Configuration>();
} catch (const std::exception& e) {
return Result<void>::err(std::string("config.json parse error: ") + e.what());
}
return Result<void>::ok();
}
Result<void> ConfigService::store(Configuration cfg) {
current_ = std::move(cfg);
const nlohmann::json j = current_;
return fs_.writeText(path_, j.dump(2));
}
} // namespace ccm
+120
View File
@@ -0,0 +1,120 @@
#include "ccm/services/ImageService.hpp"
#include "ccm/util/FsNames.hpp"
#include <filesystem>
namespace ccm {
ImageService::ImageService(IImageStore& store) : store_(store) {}
std::uint8_t ImageService::nextImageIndex(const std::vector<std::string>& existingImages) {
if (existingImages.empty()) return 0;
const std::string& last = existingImages.back();
// Preserve the compatibility shim for legacy image filenames that
// contain "IMG_FRONT"/"IMG_BACK" markers - those start a fresh index.
if (last.find("IMG_FRONT") != std::string::npos ||
last.find("IMG_BACK") != std::string::npos) {
return 0;
}
const std::uint8_t parsed = parseIndexFromFilename(last);
// Saturating +1 since we hand back uint8 just like the Rust version.
return parsed == 255 ? 255 : static_cast<std::uint8_t>(parsed + 1);
}
std::string ImageService::buildTargetName(bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
std::uint8_t index) {
const std::string set = formatTextForFs(setName);
const std::string card = formatTextForFs(cardName);
if (newEntry) {
return set + "+" + card + "+" + std::to_string(static_cast<int>(index));
}
return std::to_string(cardId) + "+" + set + "+" + card + "+" +
std::to_string(static_cast<int>(index));
}
Result<std::string> ImageService::addImage(Game game,
const std::filesystem::path& srcPath,
bool newEntry,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& existingImages) {
const auto idx = nextImageIndex(existingImages);
const auto target = buildTargetName(newEntry, cardId, setName, cardName, idx);
return store_.copyIn(game, srcPath, target);
}
Result<void> ImageService::removeImage(Game game, const std::string& imageName) {
return store_.remove(game, imageName);
}
Result<std::vector<std::string>> ImageService::normalizeNamesForPersistedCard(
Game game,
std::uint32_t cardId,
const std::string& setName,
const std::string& cardName,
const std::vector<std::string>& imageNames) {
const std::string idPrefix = std::to_string(cardId) + "+";
std::vector<std::string> normalized = imageNames;
struct RenameOp {
std::string oldName;
std::string newName;
};
std::vector<RenameOp> ops;
ops.reserve(imageNames.size());
for (std::size_t i = 0; i < imageNames.size(); ++i) {
const std::string& oldName = imageNames[i];
if (oldName.starts_with(idPrefix)) {
continue;
}
const std::uint8_t idx = parseIndexFromFilename(oldName);
const std::filesystem::path oldPath(oldName);
const std::string ext = oldPath.extension().string();
const std::string newBase = buildTargetName(false, cardId, setName, cardName, idx);
const std::string newName = newBase + ext;
if (newName == oldName) {
continue;
}
ops.push_back({oldName, newName});
normalized[i] = newName;
}
if (ops.empty()) {
return Result<std::vector<std::string>>::ok(std::move(normalized));
}
std::vector<std::string> created;
created.reserve(ops.size());
for (const auto& op : ops) {
auto copied = store_.copyIn(game, store_.resolvePath(game, op.oldName),
std::filesystem::path(op.newName).stem().string());
if (!copied) {
for (const auto& createdName : created) {
(void)store_.remove(game, createdName);
}
return Result<std::vector<std::string>>::err(copied.error());
}
created.push_back(copied.value());
}
for (const auto& op : ops) {
auto removed = store_.remove(game, op.oldName);
if (!removed) {
return Result<std::vector<std::string>>::err(removed.error());
}
}
return Result<std::vector<std::string>>::ok(std::move(normalized));
}
std::filesystem::path ImageService::resolveImagePath(Game game,
const std::string& imageName) const {
return store_.resolvePath(game, imageName);
}
} // namespace ccm
+27
View File
@@ -0,0 +1,27 @@
#include "ccm/services/SetService.hpp"
namespace ccm {
SetService::SetService(ISetRepository& repo) : repo_(repo) {}
void SetService::registerModule(IGameModule* module) {
if (module) modules_[module->id()] = module;
}
Result<std::vector<Set>> SetService::updateSets(Game game) {
auto it = modules_.find(game);
if (it == modules_.end() || it->second == nullptr) {
return Result<std::vector<Set>>::err("No game module registered for this game.");
}
auto fetched = it->second->setSource().fetchAll();
if (!fetched) return fetched;
auto saved = repo_.save(game, fetched.value());
if (!saved) return Result<std::vector<Set>>::err(saved.error());
return fetched;
}
Result<std::vector<Set>> SetService::getSets(Game game) {
return repo_.load(game);
}
} // namespace ccm
+85
View File
@@ -0,0 +1,85 @@
#include "ccm/util/FsNames.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <string>
#include <string_view>
#include <utility>
namespace ccm {
namespace {
// Replacements ordered exactly like the Rust source's chained .replace(...) so
// behavior is bit-identical for inputs that contain multiple of these chars.
struct Replacement {
std::string_view from;
std::string_view to;
};
constexpr std::array<Replacement, 14> kReplacements{{
{"'", ""},
{"`", ""},
{",", ""},
{" ", ""},
{":", "-"},
{"&", "And"},
{"|", "Or"},
{"\xC3\xA1", "a"}, // a-acute (UTF-8)
{"\xC3\xA9", "e"}, // e-acute
{"\xC3\xAD", "i"}, // i-acute
{"\xC3\xB3", "o"}, // o-acute
{"\xC3\xBA", "u"}, // u-acute
{"\xC3\xBB", "u"}, // u-circumflex
// Remaining accented vowels appear in modern Scryfall data but were not
// listed in the Rust source. Keeping behavior 1:1 deliberately.
}};
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
if (from.empty()) return;
std::string::size_type pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos) {
s.replace(pos, from.size(), to);
pos += to.size();
}
}
} // namespace
std::string formatTextForFs(std::string_view text) {
std::string out(text);
for (const auto& r : kReplacements) {
replaceAllInPlace(out, r.from, r.to);
}
return out;
}
std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept {
const auto dot = filename.find_last_of('.');
if (dot == std::string_view::npos || dot == 0) return 0;
// Walk backwards from the position before the dot, collecting digits.
std::size_t end = dot;
std::size_t begin = end;
while (begin > 0) {
unsigned char ch = static_cast<unsigned char>(filename[begin - 1]);
if (std::isdigit(ch)) {
--begin;
// Rust source intentionally caps at 2-digit indices.
if (end - begin >= 2) break;
} else {
break;
}
}
if (begin == end) return 0;
unsigned int value = 0;
for (std::size_t i = begin; i < end; ++i) {
value = value * 10 + static_cast<unsigned int>(filename[i] - '0');
}
if (value > 255) value = 255;
return static_cast<std::uint8_t>(value);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md
Long-form contributor documentation that lives outside the source tree.
## Files
- `adding-a-new-game.md` — the **canonical, end-to-end procedure** for adding a brand-new game module to the project. Covers required external resources (sets API, optional card-preview API, icons), domain type with stable JSON, set source + tests, optional card preview source + tests, `IGameModule` glue, `Game` enum + `dirNameForGame` wiring, deriving the three UI panel templates (`BaseCardListPanel<TCard, TSortColumn>`, `BaseCardEditDialog<TCard>`, `BaseSelectedCardPanel<TCard>`), the `IGameView` adapter, composition-root wiring in `app/main.cpp`, CMake additions, AGENTS.md updates, and the test checklist.
- `ci-cd-guide.md` — user-facing CI/CD and release procedure: feature/master workflow split, orchestrator + reusable platform workflow structure, versioning conventions, PR title guard for `master`, artifact naming, and a concrete "create a new release" checklist.
- `versioning.md` — dedicated versioning reference: branch+sha scheme for feature builds, semantic version bump rules for `master`, tag format, and embedded app version behavior.
- `windows-installer.md` — how the NSIS-based Windows installer (`scripts/installer.nsi`) is built and configured: the `APP_VERSION` define, where the version is surfaced (window title, branding, Programs and Features), installed sections, registry layout, and CI vs. local invocation.
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic and Pokemon modules, plus the runtime flow through `SetService` / `CardPreviewService` and the error-surface conventions.
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
## Subdirectories
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
## Conventions
- These docs are reference material for contributors, not user-facing release notes. Keep them precise and dated implicitly by the source state they describe.
- Code examples should be C++20 and quote real file paths from the repo.
- Do **not** introduce dependencies on a specific upcoming feature, hypothetical game, or unmerged branch. The procedure must always describe the current code as it is on `main`.
## Required follow-ups
- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`.
- After changing the Magic or Pokemon set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples.
- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete.
- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`).
## Anti-patterns
- Don't sneak hypothetical or in-progress games into the doc as concrete examples; the guide is meant to be game-agnostic and must read as such.
- Don't link to the original Rust/Tauri repo as the source of truth — it is a historical reference, not the spec. The C++ code under `core/`, `ui_wx/`, and `app/` is the spec.
- Don't duplicate the per-package `AGENTS.md` content here; cross-link instead.
+26
View File
@@ -0,0 +1,26 @@
#documentation #contributors #ccm3
# Documentation Index
This folder contains contributor documentation for Card Collection Manager 3. Start with [Intro for New Developers](intro-to-new-developers.md) if you are new to the repository, then use the category sections below to navigate to specific tasks.
## CI/CD And Release
- [ci-cd-guide.md](ci-cd-guide.md): CI workflows, merge guards, artifacts, and release execution.
- [versioning.md](versioning.md): feature-build version format, semantic release rules on `master`, and tag/app-version behavior.
- [windows-installer.md](windows-installer.md): how the NSIS-based Windows installer is built, configured, and versioned.
## Build And Local Setup
- [dow-doc-build-locally.md](dow-doc-build-locally.md): local build setup for Windows/Linux, dependency model, build options, and troubleshooting.
## Onboarding & Development Workflows
- [intro-to-new-developers.md](intro-to-new-developers.md): architecture orientation, boundaries, common pitfalls, and first-week workflow guidance.
- [testing-and-test-code-of-conduct.md](testing-and-test-code-of-conduct.md): test workflow plus repository rules for deterministic, behavior-focused tests.
- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`.
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic/Pokemon modules and their runtime purpose.
+473
View File
@@ -0,0 +1,473 @@
#documentation #architecture #game-modules
# Adding A New Game To Card Collection Manager
This is the canonical end-to-end walkthrough for adding support for a new TCG to Card Collection Manager. The guide covers external API selection, core and UI integration, composition-root wiring, and required tests so a full implementation can land cleanly on `main`.
**Quick Setup:** gather required external resources first, then follow sections in order and run the full verification checklist before opening a PR.
The guide is prescriptive about file locations and seam shapes but game-agnostic in naming. Replace `<Name>` with your game type name and `<name>` with the lowercase key used for on-disk directories and `dirName()`. Both Magic and Pokemon follow this structure; use their implementations as references when needed.
Read the root `AGENTS.md`, `core/AGENTS.md`, `ui_wx/AGENTS.md`, `app/AGENTS.md`, and `tests/AGENTS.md` before starting. They define the architecture rules this guide is built on top of.
---
## 1. Prerequisites: pick your external resources
Before you write any C++, gather the following. The further along you discover that something is missing, the more work you throw away.
### 1.1 Set list API (required)
A public HTTP endpoint that returns the canonical set / expansion list for the game, with at least:
- a stable identifier (`id`) — used as the on-disk and JSON key. Must be stable across API revisions.
- a human-readable name.
- a release date — used to sort the set picker chronologically.
The endpoint must be callable without authentication, or you must accept a hard-coded API key (we do not currently expose a way to ask the user for one). It must support HTTPS. Plan for the response body to be JSON; we do not have an XML or CSV path.
The release date may be in any format **as long as you can rewrite it to `YYYY/MM/DD` during parsing**, because the `Set` domain type stores it that way (see `core/include/ccm/domain/Set.hpp`) and the rest of the code assumes lexicographic comparison sorts chronologically.
### 1.2 Card preview API (optional)
A public HTTP endpoint that returns the URL of a card's preview image given some lookup key (typically `name`, `set id`, and possibly a printed collector number). If the game does not expose one, the UI will simply skip the remote preview and only show locally-stored images — the implementation is allowed to omit this seam entirely.
A few traps to plan around now, before you write code:
- **Lookup precision.** Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result.
- **URL encoding.** All query strings must be RFC 3986 percent-encoded before they reach `IHttpClient::get` (`cpr::Url` does **not** re-encode). The Magic/Pokemon implementations have a private `urlEncode` helper you can copy.
- **Collector-number normalization.** Pokemon stores `4/102` but the API only accepts `4`. Whichever convention your domain type uses, normalize it inside `buildSearchUrl` so the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug.
### 1.3 Flag icons
Identify any boolean flag columns the game needs (Magic: `Foil`, `Signed`, `Altered`; Pokemon: `Holo`, `1. Edition`, `Signed`, `Altered`). For each one that doesn't already exist, plan an SVG glyph. SVG art with a single fillable path works best — see `ui_wx/src/SvgIcons.cpp` for the established style. Re-use existing glyphs across games where the meaning is identical (`Signed` and `Altered` are shared between Magic and Pokemon).
### 1.4 Domain shape decision
Decide whether the new game can re-use an existing card type or needs its own. Re-use is allowed when **every** field has identical semantics; in practice, every game we have shipped has needed its own type because at least one flag or extra column differs (e.g. Pokemon adds `setNo`, `holo`, `firstEdition`).
If you create a new type, freeze the JSON layout now. The root `AGENTS.md` rule is unambiguous: **JSON layout must stay byte-for-byte stable** once you ship. Pick names that match any pre-existing on-disk format (this app may inherit data from a previous tool), and decide which C++ field names need a JSON alias (the canonical example: the C++ field `signed_` maps to the JSON key `"signed"` because `signed` is a C++ keyword).
---
## 2. Core: domain types and the `Game` enum
Everything below this point assumes you have already gathered the resources from §1.
### 2.1 Extend the `Game` enum
Edit `core/include/ccm/domain/Enums.hpp`:
- Add a new enumerator to `enum class Game`.
- Update the size of `allGames()` (`std::array<Game, N>`).
Edit `core/src/domain/Enums.cpp`:
- Add a `case` to `to_string(Game)`.
- Add a branch to `gameFromString(std::string_view)`.
- Add the new enumerator to the `allGames()` constexpr array.
The `Game` enum is the only place in `core/` that hardcodes which games exist. Adding a new entry here is what makes the rest of the registries (`SetService`, `CardPreviewService`, `AppContext::gameViews`, `dirNameForGame`) accept it.
### 2.2 Add the card domain type (only if needed)
If you decided in §1.4 that an existing card type fits, skip this section.
Otherwise, create `core/include/ccm/domain/<Name>Card.hpp`:
- A `struct <Name>Card` with `id`, `amount`, `name`, `set`, `note`, `images`, `language`, `condition` (these seven fields are required — the UI templates assume them) and any game-specific extras.
- Forward-declare `to_json` and `from_json` for `nlohmann::json`.
- Add a defaulted `friend bool operator==(const <Name>Card&, const <Name>Card&) = default;` so the JSON round-trip test can compare values.
Then create `core/src/domain/<Name>Card.cpp`:
- Hand-roll `to_json` and `from_json` using `nlohmann::json`. **Do not** use `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` — the explicit form keeps JSON aliases visible and makes future stability bugs easier to catch in code review.
A real example to cargo-cult from is `core/src/domain/PokemonCard.cpp`. Note how `signed` (the JSON key) maps to `signed_` (the C++ field), and how every JSON key is spelled out. If your game uses a printed collector number, follow Pokemon's lead and store it as `setNo` (string) so the format `"4/102"` can survive a round-trip even when the API only consumes `"4"`.
### 2.3 Update the JSON round-trip test
Open `tests/domain_json_tests.cpp`. Add a `TEST_CASE` that:
1. Constructs a `<Name>Card` with **every** field non-default (including `set.id`, `set.name`, `set.releaseDate`).
2. Serializes it to `nlohmann::json`.
3. Asserts the JSON contains the expected keys with the expected literal spellings (in particular, any C++/JSON aliases like `"signed"`).
4. Round-trips it back into a `<Name>Card` and `CHECK(roundTripped == original)`.
This is the **byte-for-byte stability gate**. If you skip it, the alias bugs only surface in production after you ship and someone's collection.json fails to parse.
---
## 3. Core: set source and (optional) card preview source
### 3.1 `<Name>SetSource`
Mirrors `core/include/ccm/games/pokemon/PokemonSetSource.hpp` and the matching `.cpp`. Put your files at:
- `core/include/ccm/games/<name>/<Name>SetSource.hpp`
- `core/src/games/<name>/<Name>SetSource.cpp`
The header should declare:
- `class <Name>SetSource final : public ISetSource`
- `static constexpr const char* kEndpoint = "<your full HTTPS URL>";`
- `explicit <Name>SetSource(IHttpClient& http);`
- `Result<std::vector<Set>> fetchAll() override;`
- `static Result<std::vector<Set>> parseResponse(const std::string& body);`
The static `parseResponse` is mandatory. It is the seam you unit-test (no HTTP, no fakes — just a string in, a `Result` out). `fetchAll()` is a thin wrapper that calls `http_.get(kEndpoint)` and forwards to `parseResponse` on success.
In `parseResponse`:
1. Parse the body with `nlohmann::json::parse(body)` inside a `try`/`catch (const std::exception&)` block. Throwing across the port boundary is forbidden; catch and return `Result<std::vector<Set>>::err(...)` with a useful message.
2. Walk the response, mapping each entry to a `Set { id, name, releaseDate }`. Rewrite the release date to `YYYY/MM/DD` if the API uses a different format.
3. `std::sort` ascending by release date.
4. Return `Result<std::vector<Set>>::ok(std::move(out))`.
A note on quirky APIs: some endpoints return a top-level array, some wrap it in `{ "data": [...] }`, and some put it under a different key. The two reference implementations diverge on exactly this: Magic walks the Scryfall-shaped response, Pokemon walks `data[]`. Do whatever your API requires; it is fine for `parseResponse` to be game-specific.
### 3.2 `<Name>CardPreviewSource` (optional)
Skip this section if the game has no remote preview API.
Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header should declare:
- `class <Name>CardPreviewSource final : public ICardPreviewSource`
- `explicit <Name>CardPreviewSource(IHttpClient& http);`
- `Result<std::string> fetchImageUrl(std::string_view name, std::string_view setId, std::string_view setNo) override;`
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
- `static Result<std::string> parseResponse(const std::string& body);`
Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
- Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness).
- An empty `setId` (don't append the `set.id:` clause).
- An empty `setNo`, and a `setNo` that needs normalization (strip everything after `/`, strip leading zeros, etc.).
- Response with the preferred image variant present.
- Response with only the fallback image variant present.
- Empty `data[]` array.
- Malformed JSON (parse error path).
`fetchImageUrl` is a thin wrapper: build URL → `http_.get(url)``parseResponse(body)`.
### 3.3 `<Name>GameModule`
Wire the two sources together. Create:
- `core/include/ccm/games/<name>/<Name>GameModule.hpp`
- `core/src/games/<name>/<Name>GameModule.cpp`
Header:
```cpp
#pragma once
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/<name>/<Name>CardPreviewSource.hpp" // omit if no preview
#include "ccm/games/<name>/<Name>SetSource.hpp"
namespace ccm {
class <Name>GameModule final : public IGameModule {
public:
explicit <Name>GameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::<Name>; }
[[nodiscard]] std::string dirName() const override { return "<name>"; }
[[nodiscard]] std::string displayName() const override { return "<Display>"; }
ISetSource& setSource() override { return setSource_; }
// Omit the override below if the game has no remote preview API.
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
<Name>SetSource setSource_;
<Name>CardPreviewSource previewSource_;
};
} // namespace ccm
```
Two subtle requirements:
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short.
- `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available".
The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`.
### 3.4 Register the new sources in `core/CMakeLists.txt`
There is no glob. Add the new `.cpp` files (set source, card preview source, game module, and the card domain `.cpp` if you added one) to the `add_library(ccm_core ...)` argument list. Configure the build before moving on; this catches any missing headers immediately.
---
## 4. Core: tests for the new sources
Writing the tests now, before the UI work, makes the next sections noticeably faster — every later UI debugging session benefits from already knowing the parser and the URL builder are correct.
### 4.1 `<name>_set_source_tests.cpp`
Create `tests/<name>_set_source_tests.cpp` modeled on `tests/pokemon_set_source_tests.cpp`. The required cases are:
- `parseResponse` happy path with two or three sets, including the date rewrite if your API uses a non-`YYYY/MM/DD` format.
- `parseResponse` already-sorted output (input out of order, output ascending by release date).
- `parseResponse` empty array → empty `Result::ok(...)`.
- `parseResponse` missing top-level container → `Result::err(...)`.
- `parseResponse` malformed JSON → `Result::err(...)`.
- `fetchAll` happy path through a `FixedHttpClient` fake (in-file, ~10 lines — see the existing tests). Assert the `lastUrl` equals `kEndpoint`.
- `fetchAll` HTTP error → `Result::err(...)` propagation.
### 4.2 `<name>_card_preview_source_tests.cpp` (if you have a preview source)
Create `tests/<name>_card_preview_source_tests.cpp` modeled on `tests/pokemon_card_preview_source_tests.cpp`. The required cases are:
- `buildSearchUrl` percent-encodes names with spaces and reserved characters.
- `buildSearchUrl` includes / omits the `setId` clause based on whether `setId` is empty.
- `buildSearchUrl` includes / omits / normalizes `setNo` according to your normalization rules.
- `parseResponse` returns the preferred image variant.
- `parseResponse` falls back to the secondary variant when the primary is absent.
- `parseResponse` errors on empty `data[]`, missing `images`, malformed JSON.
- `fetchImageUrl` round-trips through `FixedHttpClient` and asserts the URL was percent-encoded as expected.
- `fetchImageUrl` propagates HTTP errors.
### 4.3 Register the new test files
Add `<name>_set_source_tests.cpp` (and `<name>_card_preview_source_tests.cpp` if applicable) to `tests/CMakeLists.txt` `add_executable(ccm_core_tests ...)`. Build and run `ctest --test-dir build --output-on-failure`. **Do not** continue until these pass.
### 4.4 Sorter / filter tests
If you introduced a new card type in §2.2, add cases to `tests/card_sorter_tests.cpp` and `tests/card_filter_tests.cpp` covering the new sort columns and filter columns introduced by your domain type. The boolean-flag exclusion rule (filter only checks string/number columns; flag columns are skipped) must be covered explicitly so future refactors don't quietly break it.
### 4.5 Set-service routing test
Append a case to `tests/set_service_tests.cpp` that registers your new module alongside Magic and verifies that `updateSets(Game::<Name>)` does not perturb cached data for the other game. Routing isolation is what `SetService` exists for; one test per game keeps it honest.
---
## 5. UI: derive from the three base templates
The UI layer is built on three header-only class templates that own all the wxWidgets-specific machinery. Each has a small, well-documented set of virtual hooks; deriving for a new game is a hook-implementation exercise, not a wxWidgets exercise. Read `ui_wx/include/ccm/ui/Base*.hpp` once before starting.
### 5.1 SVG icons
If your game introduces flag columns whose glyphs do not already exist in `ui_wx/include/ccm/ui/SvgIcons.hpp`, add them now:
- Declare each new icon as `extern const char* const kSvg<Name>;` in the header.
- Define them in `ui_wx/src/SvgIcons.cpp`. Keep the `@FILL@` placeholder so the rasterizer can substitute the active palette text color at draw time. **Do not** bake a color into the SVG — that breaks dark mode.
- Re-use existing glyphs (`kSvgSigned`, `kSvgAltered`, `kSvgFoil`, `kSvgHolo`) when the meaning matches.
### 5.2 Sort and filter helpers
Add a `<Name>SortColumn` enum to `core/include/ccm/services/CardSorter.hpp`, plus the corresponding `sort<Name>Cards(std::vector<<Name>Card>&, <Name>SortColumn, bool)` declaration. Implement it in `core/src/services/CardSorter.cpp` mirroring the existing per-column dispatch (each enum entry maps to a comparator).
Add `[[nodiscard]] bool matches<Name>Filter(const <Name>Card&, std::string_view)` to `core/include/ccm/services/CardFilter.hpp` and implement it in `core/src/services/CardFilter.cpp`. Walk the same value-key columns that the list panel will display, lowercase both sides, and short-circuit on any substring hit. Boolean flag columns are intentionally excluded — only string/number columns participate in filtering.
These functions are also the targets of §4.4's tests; you'll have already written the tests if you followed the order.
### 5.3 `<Name>CardListPanel`
Create:
- `ui_wx/include/ccm/ui/<Name>CardListPanel.hpp`
- `ui_wx/src/<Name>CardListPanel.cpp`
The header declares a `final class` deriving from `BaseCardListPanel<<Name>Card, <Name>SortColumn>`, with overrides for:
- `declareTextColumns()` — return a `std::vector<TextColumnSpec>` of `{label, width, format, optional<sortColumn>}`. The order is left-to-right on screen. The **last** entry must be the `Note` column; the base reserves it.
- `declareIconColumns()` — return a `std::vector<IconColumnSpec>` of `{svg, width, optional<sortColumn>}`. These render between the leading text columns and the Note column.
- `renderTextCell(card, idx)` — return the cell text for the `idx`-th text column. The base passes index `0..textCols-1` for the leading rows and `textCols-1` for the Note row, so you usually `switch (idx)`.
- `isIconColumnSet(card, idx)` — return whether the `idx`-th icon column should render its glyph for this row. Index `0` is the first icon column declared in `declareIconColumns()`.
- `sortBy(column, ascending)` — call `sort<Name>Cards(mutableCards(), column, ascending)`. **Use `mutableCards()`**, not `cards()`, because `sortBy` writes through the underlying vector.
- `matchesFilter(card, filter)` — call `matches<Name>Filter(card, filter)`.
In the constructor body, call `buildLayout()` (from `BaseCardListPanel`) so the base wires up the `wxListCtrl`, the themed header row, and the image lists.
The reference implementation at `ui_wx/src/PokemonCardListPanel.cpp` is ~75 lines including the icon-column declaration and the `switch`-based renderer. Yours should land in the same ballpark.
### 5.4 `<Name>SelectedCardPanel`
Create:
- `ui_wx/include/ccm/ui/<Name>SelectedCardPanel.hpp`
- `ui_wx/src/<Name>SelectedCardPanel.cpp`
Inside the `.cpp`, define an unnamed-namespace `enum <Name>DetailKey : int { ... };` with one entry per detail row and one per flag column. Keep these names local — they're only used between this file's hook overrides.
Override:
- `declareDetailRows()` — return a `std::vector<DetailRowSpec>` of `{label, key, emptyLabel}`. The **first** row should typically be `Name`; its `emptyLabel` is what the panel shows when no card is selected.
- `declareFlagIcons()` — return a `std::vector<FlagIconSpec>` of `{svg, tooltip, key}`.
- `detailValueFor(card, key)``switch (key)` and return the appropriate string. **Also handle `kNoteKey`** (defined in `BaseSelectedCardPanel` as `-1`); the base calls `detailValueFor(card, kNoteKey)` to populate the bottom Note row.
- `isFlagSet(card, key)``switch (key)` and return the matching boolean.
- `previewKey(card)` — return `std::tuple<std::string, std::string, std::string>` of `(name, setId, setNo)`. Use empty `setNo` for games whose preview API does not need a collector number.
- `gameId()` — return `Game::<Name>`.
In the constructor body, call `buildLayout()` so the base wires up the preview area, detail grid, flag strip, and image list.
The reference implementation is `ui_wx/src/PokemonSelectedCardPanel.cpp`.
### 5.5 `<Name>CardEditDialog`
Create:
- `ui_wx/include/ccm/ui/<Name>CardEditDialog.hpp`
- `ui_wx/src/<Name>CardEditDialog.cpp`
Derive from `BaseCardEditDialog<<Name>Card>`. Override:
- `buildFlagsRow(wxBoxSizer* flagsBox)` — create your `wxCheckBox`es and `flagsBox->Add(...)` them. The base owns the surrounding `Flags` label and sizer.
- `appendExtraRows(wxFlexGridSizer* grid)` — only if your game has fields beyond the standard set. Use the inherited `appendRow(grid, label, ctrl)` helper. (Pokemon adds a `Set #` text input here.)
- `readExtraFromCard()` — copy fields from `constCard()` into your widgets.
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
In the constructor:
1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add <Display> Card"` or `"Edit <Display> Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::<Name>`, and the optional `preloadedSets` pointer.
2. Call `buildAndPopulate()` (from the base) to build the form, populate the choices, and call `readExtraFromCard()`.
The reference implementation is `ui_wx/src/PokemonCardEditDialog.cpp`.
### 5.6 `<Name>GameView`
This is the polymorphic glue between the new game's panels and the rest of the app. Create:
- `ui_wx/include/ccm/ui/<Name>GameView.hpp`
- `ui_wx/src/<Name>GameView.cpp`
Derive from `IGameView`. The constructor takes references to the shared services (`ConfigService`, `SetService`, `ImageService`, `CardPreviewService`), the typed `CollectionService<<Name>Card>&`, and the `IGameModule&`. Members:
- `<Name>CardListPanel* listPanel_{nullptr};`
- `<Name>SelectedCardPanel* selectedPanel_{nullptr};`
- `std::vector<Set> setsCache_;` — populated lazily by `setsForDialog()` so each Add/Edit open does not re-read from `SetService`.
Implement the virtuals:
- `gameId()` returns `Game::<Name>`.
- `displayName()` returns `"<Display>"`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `selectedPanel(parent)` — lazily allocates the selected panel.
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success.
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
- `updateSetsMenuLabel()` — returns `"Update <Display>"`. This is what the `Sets` menu entry shows.
The reference implementation is `ui_wx/src/PokemonGameView.cpp`. It's about 160 lines and is the same shape for every game.
### 5.7 Register the new UI sources
Add **all** new UI `.cpp` files to `ui_wx/CMakeLists.txt`:
- `<Name>CardListPanel.cpp`
- `<Name>SelectedCardPanel.cpp`
- `<Name>CardEditDialog.cpp`
- `<Name>GameView.cpp`
There is no glob.
---
## 6. Composition root
Edit `app/main.cpp` to wire the new game in. Read `app/AGENTS.md` first — destruction-order rules apply.
### 6.1 New members
Add `std::unique_ptr<...>` members to `CcmApp`. Order matters (destruction is reverse — deps before dependents):
```cpp
std::unique_ptr<ccm::<Name>GameModule> <name>Mod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::<Name>Card>> <name>Repo_;
std::unique_ptr<ccm::CollectionService<ccm::<Name>Card>> <name>CollSvc_;
std::unique_ptr<ccm::ui::<Name>GameView> <name>View_;
```
Place them next to the existing Magic/Pokemon members in the matching position — game module after `http_`, repo after the module, collection service after the repo and the image store, view at the end before `ctx_`.
### 6.2 New constructions in `OnInit()`
```cpp
<name>Mod_ = std::make_unique<ccm::<Name>GameModule>(*http_);
<name>Repo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::<Name>Card>>(
*fs_, *config_, &dirNameForGame);
<name>CollSvc_ = std::make_unique<ccm::CollectionService<ccm::<Name>Card>>(
*<name>Repo_, *imgStore_);
setSvc_->registerModule(<name>Mod_.get());
previewSvc_->registerModule(*<name>Mod_); // no-op when the module has no preview source
<name>View_ = std::make_unique<ccm::ui::<Name>GameView>(
*config_, *<name>CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *<name>Mod_);
```
### 6.3 `dirNameForGame`
Add a `case ccm::Game::<Name>: return "<name>";` arm. The string must match `<Name>GameModule::dirName()`.
### 6.4 `AppContext`
`AppContext` (`ui_wx/include/ccm/ui/AppContext.hpp`) currently holds explicit references to `magicModule` and `pokemonModule`. Add an `<Name>Module` reference field — keep the alphabetical / canonical order — and pass `*<name>Mod_` for it in the `AppContext{...}` brace-init in `OnInit()`. Also append `<name>View_.get()` to the `gameViews` vector.
The `Game` and `Sets` menus in `MainFrame` are built dynamically from `gameViews`, so once the new view is in the vector its menu entries (the `Game > <Display>` radio item and the `Sets > Update <Display>` action) appear automatically.
---
## 7. AGENTS.md and tests housekeeping
After all the above compiles and tests pass:
1. **`AGENTS.md`** (root) — the "After adding a new game module you must" required follow-up should already cover your work; read it and confirm. If you added a new domain type, the matching `tests/domain_json_tests.cpp` round-trip test is required (per `core/AGENTS.md`).
2. **`core/AGENTS.md`** — update only if your game required a new core seam shape (a new port, a new service, a new shared helper). Describing the new game itself is not required; the doc is meant to stay game-agnostic.
3. **`ui_wx/AGENTS.md`** — same: update only if you needed a new template hook or had to teach the `Base*` templates a new behaviour. Describing the new game's panel set is not required.
4. **`app/AGENTS.md`** — confirm the "Composition root is the only place" allowlist still mentions the concrete adapter types. If you added new ones (a new `<Name>GameView`, `<Name>GameModule`), append them.
5. **`tests/AGENTS.md`** — add the new test file names to the file map and the "Required follow-ups" list.
6. **This file** (`docs/adding-a-new-game.md`) — only edit when the procedure itself changes (new template hook, new service registration, new composition-root step). Do not insert your specific game's quirks here; capture those in code comments next to the relevant overrides.
---
## 8. Verification checklist
Run, in order, from the workspace root. Do not skip any step.
1. **Configure**: `cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release` (or your usual generator). Configuration must succeed without warnings about a missing source file.
2. **Build**: `cmake --build build --parallel`. Must succeed cleanly. Pay close attention to template-instantiation errors — those usually indicate one of the `Base*` hooks is missing or wrongly-typed.
3. **Tests**: `ctest --test-dir build --output-on-failure`. Every existing test plus the new `<name>_set_source_tests`, `<name>_card_preview_source_tests`, the extended `domain_json_tests`, the extended `card_sorter_tests`, the extended `card_filter_tests`, and the extended `set_service_tests` must pass.
4. **Smoke test**: launch `./build/bin/ccm3` (or `.\build\bin\ccm3.exe`). Note: the CMake target is `ccm` but the executable is renamed to `ccm3` via `set_target_properties(... OUTPUT_NAME ccm3)`.
- The `Game` menu shows your new game alongside Magic and Pokemon and switching is instantaneous (no panel re-creation cost on subsequent switches).
- The `Sets > Update <Display>` action fetches sets and reports a count.
- With sets cached, opening the new game's Add dialog populates the set picker and the dialog can be dismissed with `OK`.
- Adding, editing, and deleting a card all round-trip through disk: close and re-open the app and the card persists.
- Selecting a card kicks off a preview fetch (if the game has a preview source) and renders the image; the status line returns to `"Ready"` when the preview lands.
- The flag-icon strip shows / hides per card depending on which flags are set.
- Theme switching applies to all of the new game's panels (light → dark → light).
---
## 9. Common traps
These do not match a single seam in this guide but are worth calling out explicitly.
- **Stale set caches.** Each `IGameView` caches `std::vector<Set> setsCache_`. After `onUpdateSets` succeeds, refresh the cache (assign the new vector). The reference implementations do this.
- **`signed_` / `signed`.** The C++ field is `signed_`; the JSON key is `"signed"`. This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key.
- **Spacer column index.** `BaseCardListPanel` reserves index `0` for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index `1`. If you ever need to call into `wxListCtrl` directly from a derived panel (you should not), remember this.
- **Preview-fetch threading.** The async preview fetch in `BaseSelectedCardPanel` uses a `shared_ptr<State>` + `std::atomic alive` + `std::atomic currentGen` triple. Do not capture `this` raw in any background work you add to a new game's selected panel; copy that pattern verbatim.
- **First-paint perf.** `MainFrame` defers initial collection load with `CallAfter(...)` and `BaseCardListPanel` defers the initial selection the same way. Don't move that work back into the constructor for "convenience" — it makes startup visibly slower.
- **`previewKey` for games without `setNo`.** If your preview API only needs `(name, setId)`, return an empty string for the third tuple element. The base will pass `""` through to the source, which is exactly what `MagicCardPreviewSource` is built to handle.
- **Filter exclusion.** The filter intentionally ignores boolean-flag columns. If you find yourself wanting `signed:true` style filters, that is a future feature, not a fix; don't smuggle it into `matches<Name>Filter` without a design discussion.
- **Theming dialogs.** Always `applyThemeToWindowTree(&dlg, palette, theme)` before `ShowModal()` for any dialog you open. The reference `<Name>GameView::onAddCard` / `onEditCard` show the canonical pattern.
---
## 10. Where to read first when something does not work
- The new game compiles but its menu entries do not appear — check that the view was appended to `AppContext::gameViews` in `app/main.cpp`.
- The list panel is empty even after `Sets > Update <Display>` succeeds — check `dirNameForGame`. The repository writes to `<dataStorage>/<dirName>/collection.json`, and a typo here makes the load silently return an empty list on next launch.
- The filter input does nothing on the new game — check that `<Name>GameView::setFilter(...)` forwards to the list panel and that `matches<Name>Filter` actually evaluates the active filter substring (the empty filter must match every row).
- The Add dialog shows `(no sets cached - use Sets > Update <Display>)` even after a successful update — `setsCache_` was not refreshed in `onUpdateSets`. The reference views assign `out.value()` into the cache.
- Preview never resolves — first add a unit test that hits `parseResponse` with a real captured response body. If that passes, log the URL `IHttpClient::get` is called with and try it in a browser or `curl`. Most "broken preview" bugs are URL encoding or a wrong shape in `buildSearchUrl`.
- Sort works but its arrow indicator is wrong — column `0` is the spacer, so the visual column index sort key cares about is one higher than you might expect. The base handles this; if it goes wrong, check that your `TextColumnSpec`/`IconColumnSpec` order matches `renderTextCell` / `isIconColumnSet` indexing exactly.
- Tests pass but the app crashes on shutdown — destruction order in `CcmApp` is wrong. Move `<name>View_` so it is declared **after** `<name>CollSvc_`, `setSvc_`, `imgSvc_`, `previewSvc_`, `<name>Mod_` — the view must be torn down before any of its referenced services.
+52
View File
@@ -0,0 +1,52 @@
#documentation #apis #integrations #ccm3
# Asset And Info APIs
This document explains which external APIs Card Collection Manager 3 uses, and what each API is responsible for in the app. Use this page with [adding-a-new-game.md](adding-a-new-game.md) when you are wiring a new game module or debugging API behavior.
## API Roles
The code separates remote APIs into two roles: **info APIs** and **asset APIs**. Info APIs provide set metadata used to populate local set lists (ID, name, release date). Asset APIs resolve a card lookup into an image URL, then `CardPreviewService` downloads the raw preview image bytes for the UI.
## Magic: The Gathering APIs
**Info API:** `https://api.scryfall.com/sets`
Used by `MagicSetSource` to fetch all sets. The parser drops digital-only sets, maps Scryfall fields to the internal `Set` type, rewrites `released_at` from `YYYY-MM-DD` to `YYYY/MM/DD`, and sorts ascending by release date.
**Asset API:** `https://api.scryfall.com/cards/search?q=...`
Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`, then extract `data[0].image_uris.normal` as the preview URL. The search query is percent-encoded and card names apply `&` -> `and` normalization before lookup.
## Pokemon APIs
**Info API:** `https://api.pokemontcg.io/v2/sets`
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed.
The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component.
## Runtime Flow In CCM3
The app uses the same flow for both games:
- `SetService` asks the game's `ISetSource` (info API) for the latest set list.
- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL.
- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer.
- If preview lookup fails (or returns empty bytes), the UI fetches a per-game fallback card-back image URL through `CardPreviewService::fetchImageBytesByUrl(...)` and shows that image in the selected-card preview panel.
Current fallback image URLs (kept in `BaseSelectedCardPanel` for CCM2 parity):
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg`
- Pokemon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
## Error Surface And Debugging Intent
Both source types return `Result<T, std::string>` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating:
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions (`data`, `image_uris`, `images.large`/`images.small`). If the fallback fetch succeeds, the panel intentionally shows the card-back image and the inline label `(image preview unavailable)`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

+90
View File
@@ -0,0 +1,90 @@
#documentation #ci-cd #github-actions
# CI/CD Guide
This guide defines the CI/CD behavior for Card Collection Manager 3. For the complete versioning policy, including prefix semantics and tag rules, see [Versioning Guide](versioning.md). For how the Windows installer artifact is produced and configured, see [Windows Installer Guide](windows-installer.md).
**Quick Setup:** protect `master` with the required check `Require semver prefix in PR title`, then keep release PR titles aligned with the accepted prefixes.
## Workflow Map
The repository uses GitHub Actions workflows split by branch intent, with one orchestrator per branch intent:
- `feature-ci.yml`: single workflow run for non-`master` pushes; orchestrates Linux and Windows feature builds.
- `feature-linux.yml`: reusable Linux build/test/package workflow invoked by `feature-ci.yml`.
- `feature-windows.yml`: reusable Windows build/test/package workflow invoked by `feature-ci.yml`.
- `master-pr-title-guard.yml`: validate PR title prefixes for PRs targeting `master`.
- `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets.
- `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`.
## Version Flow
Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases.
### Feature Branch Builds
On pushes to non-`master` branches, `feature-ci.yml` fans out to Linux and Windows reusable workflows. Each platform workflow computes a version string in the format `<branch-name>-<short-sha>` via `scripts/compute_feature_version.sh` (for example `feature-dark-mode-a1b2c3d`).
That version is used in two places:
- artifact file names
- app embedded version via `-DCCM_APP_VERSION=...`
### Master Releases
When a PR is merged into `master`, `master-ci.yml` computes the next semantic version from the latest semver tag and the PR title prefix using `scripts/compute_master_semver.sh`.
Accepted prefix mapping:
- `major...` -> major bump
- `minor...` -> minor bump
- `fix...` -> patch bump
- `patch...` -> patch bump
- `path...` -> patch bump (accepted alias in current setup)
The resulting version is embedded in the app (`CCM_APP_VERSION`), used in artifact names, and tagged as `v<version>`.
## Master Merge Guard
PRs targeting `master` must start with one of the accepted prefixes:
- `major`
- `minor`
- `fix`
- `patch`
- `path`
If a title does not match, `master-pr-title-guard.yml` fails and the PR should not be merged.
## Artifact Naming
Feature workflow artifacts (produced by jobs inside `feature-ci.yml`):
- Windows: `ccm3-windows-<version>.zip`, `ccm3-windows-installer-<version>`
- Linux: `ccm3-linux-<version>.zip`
Master release artifacts (`master-ci.yml`, built via `master-windows.yml`):
- `ccm3-windows-<semver>.zip`
- `ccm3-windows-installer-<semver>.exe`
## Release Procedure
Follow this flow for every release:
1. Open a PR to `master`.
2. Use a valid semver prefix in the PR title (`major:`, `minor:`, `fix:`, `patch:`, or `path:`).
3. Wait for all required checks to pass.
4. Merge the PR.
5. Wait for `master-ci.yml` to complete semver computation, Windows build/test, tag creation, and release publishing.
6. Verify the `vX.Y.Z` tag and release assets in GitHub.
## Local Build Version Behavior
Outside CI, the app version defaults to `${PROJECT_VERSION} (localbuild)` through `CCM_APP_VERSION` in the top-level `CMakeLists.txt`. This keeps local binaries easy to distinguish from CI and release outputs.
## Troubleshooting
- **`msys2: command not found` in workflow logs:** ensure the MSYS2 setup step runs before jobs that use `shell: msys2 {0}`.
- **`Permission denied` while linking `ccm3.exe`:** the app is still running; close it and rebuild.
- **No release after merge:** verify the PR merged into `master` and used a valid prefix, then inspect `master-ci.yml` logs for version/tag/release failures.
+129
View File
@@ -0,0 +1,129 @@
#documentation #build #cmake
# Build Locally Guide
This guide explains how to build Card Collection Manager 3 on Windows and Linux, how dependencies are resolved, and how to run tests locally. For architecture orientation, see [Intro For New Developers](intro-to-new-developers.md).
**Quick Setup:** run CMake configure, build, launch `ccm3`, then run `ctest` from the same build directory.
## Build Model
The project uses CMake and builds one desktop executable:
- executable target: `ccm` (output binary: `ccm3` / `ccm3.exe`)
- language standard: C++20
- layered targets: `ccm_core` (logic), `ccm_ui_wx` (wx UI), `ccm` (composition root)
## Dependency Model
Dependencies are managed with CMake `FetchContent` in `cmake/Dependencies.cmake`.
Pinned versions:
- `nlohmann/json` `v3.11.3`
- `libcpr/cpr` `1.10.5`
- `wxWidgets` `v3.2.5`
- `doctest` `v2.4.11` (only when tests are enabled)
On first configure, CMake downloads sources. On first full build, heavy dependencies (especially wxWidgets and curl) build locally. Later builds reuse cached dependencies under `build/_deps`.
## Use System wxWidgets
By default, the build fetches wxWidgets. For faster local iteration with an installed wxWidgets, set `-DCCM_USE_SYSTEM_WX=ON`.
## Prerequisites
### Windows
Recommended: Clang + Ninja
- LLVM/Clang 14+ on `PATH`
- CMake 3.22+
- Ninja on `PATH`
Verified fallback: MSYS2 UCRT64 + MinGW-w64 GCC
- MSYS2 UCRT64 toolchain (`gcc`, `cmake`, `make` or `ninja`)
- CMake 3.22+
MSVC is intentionally not supported.
### Linux
- CMake 3.22+
- Clang or GCC with C++20 support
- Ninja recommended
- required system packages when using system wxWidgets (for example GTK/wx dev packages)
## Build Commands
Run from repository root.
### Windows (Clang + Ninja)
```powershell
cmake -S . -B build -G Ninja
cmake --build build --parallel
.\build\bin\ccm3.exe
```
### Windows (MinGW Makefiles)
```powershell
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel 4
.\build\bin\ccm3.exe
```
### Linux (Ninja)
```bash
cmake -S . -B build -G Ninja
cmake --build build --parallel
./build/bin/ccm3
```
## Build Options
- `CCM_USE_SYSTEM_WX` (default `OFF`): use installed wxWidgets instead of fetched wxWidgets.
- `CCM_BUILD_TESTS` (default `ON`): build `ccm_core_tests`.
- `CMAKE_BUILD_TYPE` (commonly `Release`): standard CMake build type.
- `CCM_APP_VERSION` (default `${PROJECT_VERSION} (localbuild)`): app version string shown in About dialog.
Example for faster local iteration:
```bash
cmake -S . -B build -DCCM_USE_SYSTEM_WX=ON -DCCM_BUILD_TESTS=OFF
cmake --build build
```
## Run Tests Locally
From repository root:
```bash
cmake -S . -B build -DCCM_BUILD_TESTS=ON
cmake --build build --target ccm_core_tests
ctest --test-dir build --output-on-failure
```
Automated tests primarily cover `core/` and infrastructure adapters. UI testing is currently manual.
## Runtime Notes
### Windows Runtime DLLs
`cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`.
For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`).
## Troubleshooting
- **First build is slow:** expected on cold dependency fetch/build, especially wxWidgets and curl.
- **`Permission denied` while linking `ccm3.exe`:** the app is still running; close it and rebuild.
- **Generator mismatch:** reuse the same generator for a build directory or create a new build directory.
- **Windows CI/local shell mismatch:** in CI jobs using `shell: msys2 {0}`, ensure MSYS2 setup runs before shell commands.
## Related Docs
- [CI/CD Guide](ci-cd-guide.md)
- [Versioning Guide](versioning.md)
- [Adding a new game to Card Collection Manager](adding-a-new-game.md)
+108
View File
@@ -0,0 +1,108 @@
#documentation #onboarding #architecture
# Intro For New Developers
This document orients new contributors to Card Collection Manager 3. For local setup and build commands, start with [Build Locally Guide](dow-doc-build-locally.md); then use this guide to understand architecture boundaries and daily workflows.
**Quick Setup:** read `AGENTS.md` files first, build once, run tests once, then make one small layer-scoped change to validate your environment.
## Toolchain Snapshot
Card Collection Manager 3 builds as a native C++ desktop binary with CMake. The project standard is C++20, with Clang as the preferred compiler on Windows and Linux, plus a verified MinGW-w64 fallback path on Windows.
- build system: CMake 3.22+ with `FetchContent`
- language/toolchain: C++20, Clang preferred, MinGW-w64 GCC fallback on Windows
- UI toolkit: wxWidgets (`ui_wx/` only)
- REST/HTTP client library: `cpr` (libcurl-based, used through `IHttpClient`/`CprHttpClient`)
- JSON library: `nlohmann/json`
- test framework: `doctest`
Use [Build Locally Guide](dow-doc-build-locally.md) for exact commands, generator options, runtime DLL notes, and troubleshooting details.
## Project Shape
Card Collection Manager 3 is a native desktop app written in C++20 with wxWidgets. The architecture is intentionally layered so core logic stays UI-agnostic.
- `core/`: domain logic, services, and infrastructure adapters
- `ui_wx/`: wxWidgets UI code only
- `app/`: composition root that wires adapters, services, and views
Dependency direction is strict: `app -> ui_wx -> core`.
## Architecture Rules
These rules are the baseline for all feature work:
- `core/` must never include or depend on wxWidgets.
- UI code consumes services through `ccm::ui::AppContext`.
- Concrete adapter wiring belongs in `app/main.cpp`.
- JSON keys and aliases are contract-sensitive and must stay stable.
## Repository Map
### `core/`
`core/` contains domain and non-UI behavior:
- `domain/`: value types and enums (`MagicCard`, `PokemonCard`, `Set`, `Configuration`)
- `ports/`: seam interfaces (`IHttpClient`, `IFileSystem`, repositories, game seams)
- `services/`: use-case logic (`CollectionService`, `SetService`, `ConfigService`)
- `infra/`: concrete adapters (`Json*Repository`, `StdFileSystem`, `CprHttpClient`, `LocalImageStore`)
- `games/`: per-game modules (`magic`, `pokemon`)
### `ui_wx/`
`ui_wx/` contains all presentation code:
- `MainFrame`: top-level shell and menu/split-view orchestration
- `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`: reusable UI templates
- `Magic*` and `Pokemon*` classes: game-specific view/panel implementations
- `Theme.cpp`, `SvgIcons.cpp`, `IconListCtrl.cpp`: theming and visual behavior
### `app/`
`app/main.cpp` is the composition root:
- instantiate adapters, services, and modules
- register game modules and game views
- build `AppContext`
- create and show `MainFrame`
Keep this file focused on wiring, not business logic.
### `tests/`
Tests target non-UI behavior with deterministic fakes and in-memory adapters. When a domain JSON contract or filesystem naming rule changes, update the matching tests in the same change.
## Common Workflows
### Add A Small Feature
1. Identify the correct layer (`core`, `ui_wx`, or both).
2. Make the smallest coherent change in that layer.
3. Rebuild the affected target.
4. Run tests when core behavior changes.
### Add A New Game
Use [Adding a new game to Card Collection Manager](adding-a-new-game.md). Do not bypass the existing seams or invent parallel architecture for a new game.
### Release-Oriented Changes
Use [CI/CD Guide](ci-cd-guide.md) and [Versioning Guide](versioning.md) for workflow and release policy decisions.
## Avoid These Pitfalls
- adding wx headers in `core/`
- putting business logic in `app/main.cpp`
- accessing concrete adapters directly from UI code instead of `AppContext`
- changing JSON key spellings casually
- using unpinned dependency versions
## First-Day Checklist
1. Read repository `AGENTS.md` files (`core/`, `ui_wx/`, `app/`, `tests/`).
2. Build locally with [Build Locally Guide](dow-doc-build-locally.md).
3. Run the test suite once.
4. Make one small layer-contained change.
5. Rebuild and rerun relevant tests.
+116
View File
@@ -0,0 +1,116 @@
#documentation #testing #quality
# Testing Guide And Test Code Of Conduct
This guide defines how testing works in Card Collection Manager 3 and which standards test code must meet. For local build setup and toolchain prerequisites, see [Build Locally Guide](dow-doc-build-locally.md).
**Quick Setup:** run `ccm_core_tests` from a clean build, keep tests hermetic, and update contract tests in the same change when contracts move.
## Testing Focus
The project prioritizes deterministic, fast, behavior-oriented testing. Most automated coverage intentionally targets `core/` logic and infrastructure/service behavior, while UI validation remains manual.
## Test Setup
- framework: `doctest`
- primary target: `ccm_core_tests`
- location: `tests/`
- default behavior: tests enabled via `CCM_BUILD_TESTS=ON`
## Run Tests
From repository root:
```bash
cmake -S . -B build -DCCM_BUILD_TESTS=ON
cmake --build build --target ccm_core_tests
ctest --test-dir build --output-on-failure
```
Windows and Linux use the same logical flow; only generator and compiler setup differ.
## Coverage Surface
Current automated tests cover non-UI behavior, including:
- filesystem naming and parsing behavior
- domain JSON round-trip behavior
- service behavior (`CollectionService`, `ConfigService`, `SetService`)
- repository behavior with in-memory filesystem fakes
- game set-source parsing behavior
## Manual UI Validation
Because there is no UI automation, UI-affecting changes require manual checks:
- theme switching (dark and light)
- dialog and popup behavior
- add/edit/delete card flows
- set update flows
- preview and image interactions
For theming work, rebuild and run the final app target (`ccm`) instead of validating only static library targets.
## Test Code Of Conduct
### Keep Tests Hermetic
- do not call real network services
- do not depend on local machine files
- use fakes and in-memory adapters where possible
### Test Behavior, Not Internals
- assert externally visible outcomes
- avoid brittle assertions tied to incidental implementation details
- prefer domain-level expectations over call-level trivia
### Keep Tests Deterministic
- no unseeded randomness
- no timing-sensitive assertions that can flap
- no ordering assumptions unless ordering is part of the contract
### Keep Tests Readable
- one intent per test case
- descriptive test names
- clear arrange/act/assert flow
- minimal abstraction for small tests
### Update Tests With Contract Changes
When contracts change, update tests in the same change:
- domain JSON schema or aliases -> round-trip tests
- filename formatting or parsing -> filesystem naming tests
- service semantics -> matching service tests
Behavior changes without aligned tests are incomplete.
### Avoid Over-Mocking
- prefer realistic fakes over mock-heavy tests
- mock at external boundaries only when needed
- preserve confidence in integration-shaped behavior paths
### Keep Runtime Practical
- keep suite runtime fast enough for frequent local execution
- avoid repeated expensive setup when shared setup works
- justify any expensive new suite and keep scope narrow
## Review Checklist
Before merging test changes, verify:
- tests pass locally
- no new flakiness risk
- no external dependency introduced
- assertions reflect intended behavior
- failure messages are clear and actionable
## Related Docs
- [Build Locally Guide](dow-doc-build-locally.md)
- [Intro For New Developers](intro-to-new-developers.md)
- [Adding a new game to Card Collection Manager](adding-a-new-game.md)
+89
View File
@@ -0,0 +1,89 @@
#documentation #versioning #releases
# Versioning Guide
This guide defines how Card Collection Manager 3 assigns versions in CI and release flows. For workflow wiring and release execution details, see [CI/CD Guide](ci-cd-guide.md).
**Quick Setup:** choose a valid PR title prefix before opening a `master` PR because the prefix determines the release bump.
## Versioning Model
The project uses two versioning modes:
- feature-build versioning for non-`master` branches
- semantic versioning for merged PRs into `master`
## Feature-Build Versioning
Non-`master` pushes use `<branch-name>-<short-sha>` (for example `feature-dark-theme-a1b2c3d` or `fix-sort-order-f91d2ab`).
Rules:
- branch names are sanitized and lowercased
- commit SHA is shortened to 7 characters
- computation runs in `scripts/compute_feature_version.sh`
Usage:
- artifact names
- app embedded version (`CCM_APP_VERSION`, visible in `Help -> About`)
## Master Semantic Versioning
Merged PRs into `master` use semantic versions in `MAJOR.MINOR.PATCH` format (for example `1.4.2`).
CI computes the next version from the latest semver tag and the PR title prefix:
- `major...` -> bump `MAJOR`, reset `MINOR` and `PATCH` to `0`
- `minor...` -> bump `MINOR`, reset `PATCH` to `0`
- `fix...` -> bump `PATCH`
- `patch...` -> bump `PATCH`
- `path...` -> bump `PATCH` (accepted alias in current setup)
Computation runs in `scripts/compute_master_semver.sh`.
## Validation Rules
If the PR title prefix is not accepted, two controls fail by design:
- PR title guard check for `master` PRs
- semantic-version script validation
This enforcement keeps release bumps deterministic and reviewable.
## Tag Format
Master releases create git tags in this format:
- `v<semantic-version>`
Examples:
- `v1.0.0`
- `v2.3.7`
## Embedded App Version
The app embeds a build-time version string through CMake variable `CCM_APP_VERSION`.
CI behavior:
- feature workflows set it to `<branch>-<sha>`
- master release workflow sets it to semantic version
Local/manual behavior:
- default is `${PROJECT_VERSION} (localbuild)` unless overridden
This default makes local binaries easy to distinguish from CI and release outputs.
## PR Title Conventions
Use explicit prefixes in this shape:
- `major: <summary>`
- `minor: <summary>`
- `fix: <summary>`
- `patch: <summary>`
Example: `minor: add custom themed confirmation dialogs`.
+128
View File
@@ -0,0 +1,128 @@
#documentation #installer #nsis #windows
# Windows Installer Guide
This guide explains how the Windows installer for Card Collection Manager 3 is built, how it is configured, and how it cooperates with the rest of the build/release pipeline. For CI flow and artifact naming, see [CI/CD Guide](ci-cd-guide.md). For where the version string comes from, see [Versioning Guide](versioning.md).
**Quick Setup:** install NSIS (`makensis` on `PATH`), build the app into `build/bin/`, then run `makensis -DAPP_VERSION="<version>" scripts/installer.nsi` from the repository root.
## Installer Model
The installer is a single NSIS script: `scripts/installer.nsi`. It produces one self-contained executable that ships the entire `build/bin/` directory plus an embedded uninstaller.
Key facts:
- installer technology: NSIS (`makensis`) with the Modern UI 2 (MUI2) library
- output file: `ccm3-windows-installer.exe`, written to the repository root (resolved as `..\ccm3-windows-installer.exe` from `scripts/`)
- payload source: every file under `build/bin/` (resolved as `..\build\bin\*.*` from `scripts/`)
- icon: `scripts/installer_icon.ico` (resolved relative to `scripts/`)
- default install location: `%PROGRAMFILES64%\Card Collection Manager 3`
- elevation: `RequestExecutionLevel admin`
Because the installer pulls from `build/bin/` directly, it must run **after** a successful release build that has been bundled with all required runtime DLLs (see [Build Locally Guide](dow-doc-build-locally.md) for what ends up in `build/bin/`).
## Configuration Inputs
Almost everything the installer needs is hard-coded in `scripts/installer.nsi`. The only configurable input is the version string, supplied at `makensis` time:
- `APP_VERSION` — passed via `-DAPP_VERSION="<value>"`. Falls back to `"localbuild"` if not provided, so manual local runs still work.
This single value is reused in three visible places, so the installer, the uninstaller, and the OS Programs and Features entry all advertise the same version:
- installer/uninstaller window title (NSIS `Name`): `Card Collection Manager 3 <version>`
- footer / branding text on every wizard page (NSIS `BrandingText`): `Card Collection Manager 3 <version>`
- Add/Remove Programs `DisplayVersion` registry value, so Windows shows the version in its own column
The MUI welcome page and the uninstall confirm page reference `$(^Name)` internally, so embedding the version into `Name` is enough to make those pages say "Welcome to the Card Collection Manager 3 \<version\> Setup Wizard" and "Card Collection Manager 3 \<version\> will be uninstalled..." respectively, without any extra wiring.
## Installed Sections
The installer presents three sections on the Components page:
- **Core files (required)** — read-only (`SectionIn RO`). Copies the full `build/bin/` payload into `$INSTDIR`, writes the uninstaller, and registers the Add/Remove Programs entry plus an `App Paths` entry so `ccm3` resolves from `Win+R`.
- **Start Menu shortcuts** — creates a shortcut at the top level of the Start Menu plus a `Card Collection Manager 3` folder containing both the app shortcut and an "Uninstall" shortcut. Uses `SetShellVarContext all` so shortcuts go to the all-users Start Menu.
- **Desktop shortcut** — creates a desktop shortcut for all users.
Shortcut names intentionally do **not** include the version, so installing a newer version overwrites the existing shortcuts cleanly instead of leaving orphaned per-version entries behind.
## Registry Layout
The installer writes two registry roots, both under `HKLM` so an uninstall removes them cleanly regardless of which user launches it:
- `Software\Microsoft\Windows\CurrentVersion\Uninstall\Card Collection Manager 3` (the Add/Remove Programs entry):
- `DisplayName` — product name
- `DisplayVersion` — value of `APP_VERSION`
- `DisplayIcon` — path to `ccm3.exe`
- `UninstallString` / `QuietUninstallString` — interactive and silent uninstall commands
- `InstallLocation``$INSTDIR`
- `NoModify` / `NoRepair` — both `1` (we do not implement modify/repair flows)
- `Software\Microsoft\Windows\CurrentVersion\App Paths\ccm3.exe`:
- default value — full path to `ccm3.exe`
- `Path``$INSTDIR` so child processes inherit DLL search rights
The uninstall section (`Section "Uninstall"`) deletes both roots and removes the install directory and all created shortcuts. It uses `RMDir /r "$INSTDIR"` because the install dir is owned by the app.
## Build Commands
Run from the repository root.
### Local manual build
```powershell
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
makensis -DAPP_VERSION="0.1.0-localbuild" scripts/installer.nsi
```
The output is `ccm3-windows-installer.exe` at the repository root. With no `-DAPP_VERSION`, the installer self-labels as `localbuild` instead.
### CI build
Both Windows workflows install NSIS via MSYS2 (`mingw-w64-ucrt-x86_64-nsis`) and invoke the same script:
```yaml
- name: Build Windows installer
run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi
```
The `${VERSION}` value comes from:
- `scripts/compute_master_semver.sh` for merged `master` PRs (semantic version, e.g. `1.2.3`)
- `scripts/compute_feature_version.sh` for non-`master` branches (e.g. `feature-dark-mode-a1b2c3d`)
The exact same `${VERSION}` is also passed to `cmake -DCCM_APP_VERSION=...`, so the installer/uninstaller, the Programs and Features entry, and the running app's About dialog always agree.
## Artifact Names
The CI artifacts produced from a single installer build are documented in [CI/CD Guide](ci-cd-guide.md), but for reference:
- raw build output: `ccm3-windows-installer.exe` (in repo root, regardless of version)
- feature workflow artifact: `ccm3-windows-installer-<version>` (folder containing the exe)
- master release asset: `ccm3-windows-installer-<semver>.exe` (renamed at release-asset packaging time)
Renaming happens in the workflow's release-assets step, not in `installer.nsi`, so the script's `OutFile` is intentionally fixed.
## Editing Rules
When changing the installer:
- edit `scripts/installer.nsi` and keep both `.github/workflows/feature-windows.yml` and `.github/workflows/master-windows.yml` invoking it the same way
- pass the version through `-DAPP_VERSION="${VERSION}"` so the installer, uninstaller, and Programs and Features stay in sync with `CCM_APP_VERSION`
- keep installer assets that should not be generated at runtime (such as `installer_icon.ico`) committed in `scripts/`
- keep shortcut display names version-agnostic so upgrades do not orphan old shortcuts
- if you add a new registry value to the uninstall key, mirror it in the `Section "Uninstall"` cleanup if it lives outside that key
## Troubleshooting
- **`makensis: command not found`:** install NSIS and ensure `makensis` is on `PATH`. In CI this is provided by the MSYS2 package `mingw-w64-ucrt-x86_64-nsis`.
- **`File: ... \build\bin\*.*` failures:** the build payload is missing. Run `cmake --build build --parallel` first and confirm `build/bin/ccm3.exe` plus the runtime DLLs exist (see [Build Locally Guide](dow-doc-build-locally.md)).
- **Installer self-labels as `localbuild` in CI:** `-DAPP_VERSION="${VERSION}"` was not passed, or `${VERSION}` was empty. Check that the workflow's version-computation step ran before the installer step and exported `VERSION`.
- **Programs and Features does not show a version:** the `DisplayVersion` registry write was skipped because the installer was built without `APP_VERSION` (or with an empty value). Rebuild with the define set.
- **Old shortcuts left behind after upgrade:** shortcut display names were changed (or were made version-specific). Restore the version-agnostic names so upgrades overwrite cleanly.
- **Uninstaller appears to leave files:** `RMDir /r "$INSTDIR"` does not remove files outside `$INSTDIR`. Anything written by the app at runtime under user profile paths is intentionally kept; the uninstaller only manages what the installer placed.
## Related Docs
- [CI/CD Guide](ci-cd-guide.md)
- [Versioning Guide](versioning.md)
- [Build Locally Guide](dow-doc-build-locally.md)
+28
View File
@@ -0,0 +1,28 @@
# AGENTS.md
Utility scripts and CI helper assets live in this folder.
## Scope
- Keep CI-facing scripts and config here so workflows stay concise.
- Current contents:
- `installer.nsi` — NSIS installer definition used by the Windows CI job.
- `installer_icon.ico` — committed installer icon consumed by `installer.nsi`.
- `compute_feature_version.sh` — branch+sha version formatter for feature CI workflows.
- `compute_master_semver.sh` — semantic version bump logic for merged PRs to `master`.
## Editing rules
- Prefer referencing script files from workflows instead of embedding large inline scripts.
- Keep scripts deterministic and non-interactive so CI can run unattended.
- Keep versioning scripts strict and fail-fast (non-zero exit) when required title/version conventions are violated.
- When changing installer behavior, edit `installer.nsi` and ensure both `.github/workflows/feature-windows.yml` and `.github/workflows/master-windows.yml` still invoke it via `makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi`.
- Keep installer assets that should not be generated at runtime (such as icon files) committed in this folder.
- `installer.nsi` path semantics:
- `installer_icon.ico` is resolved relative to `scripts/`.
- build payload is loaded from `..\build\bin\*.*` (project root build output).
- installer output is written to `..\ccm3-windows-installer.exe` so CI upload paths can stay root-based.
- `installer.nsi` version semantics:
- `APP_VERSION` is supplied by CI (`makensis -DAPP_VERSION=...`) using the same `VERSION` value that is fed into `-DCCM_APP_VERSION` for the C++ build, so the installer, uninstaller, Add/Remove Programs entry, and the running app all advertise the same version.
- When `APP_VERSION` is not provided (manual local `makensis` runs), it defaults to `"localbuild"`.
- If a script depends on generated files, document those expectations in the script or workflow step that creates them.
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <branch-name> <commit-sha>" >&2
exit 1
fi
branch_name="$1"
commit_sha="$2"
branch_safe="$(echo "${branch_name}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+|-+$//g')"
short_sha="$(echo "${commit_sha}" | cut -c1-7)"
if [[ -z "${branch_safe}" ]]; then
branch_safe="branch"
fi
echo "${branch_safe}-${short_sha}"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <pr-title>" >&2
exit 1
fi
pr_title="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
if [[ "${pr_title}" == major* ]]; then
bump="major"
elif [[ "${pr_title}" == minor* ]]; then
bump="minor"
elif [[ "${pr_title}" == fix* || "${pr_title}" == patch* || "${pr_title}" == path* ]]; then
bump="patch"
else
echo "Invalid PR title prefix. Must start with: major, minor, fix, patch, or path." >&2
exit 1
fi
latest="$(git tag --list | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 || true)"
if [[ -z "${latest}" ]]; then
latest="0.0.0"
fi
IFS='.' read -r major minor patch <<< "${latest}"
case "${bump}" in
major)
major=$((major + 1))
minor=0
patch=0
;;
minor)
minor=$((minor + 1))
patch=0
;;
patch)
patch=$((patch + 1))
;;
esac
version="${major}.${minor}.${patch}"
release_tag="v${version}"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "version=${version}"
echo "release_tag=${release_tag}"
} >> "${GITHUB_OUTPUT}"
else
echo "${version}"
fi
+76
View File
@@ -0,0 +1,76 @@
Unicode True
!include "MUI2.nsh"
!define APP_NAME "Card Collection Manager 3"
!define APP_DIR "Card Collection Manager 3"
!define APP_EXE "ccm3.exe"
!define APP_ICON "installer_icon.ico"
!define REG_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Card Collection Manager 3"
!define APP_PATHS_KEY "Software\Microsoft\Windows\CurrentVersion\App Paths\ccm3.exe"
; APP_VERSION is supplied by CI via `makensis -DAPP_VERSION=...`. Falls back to
; "localbuild" so manual runs from a developer machine still work.
!ifndef APP_VERSION
!define APP_VERSION "localbuild"
!endif
Name "${APP_NAME} ${APP_VERSION}"
OutFile "..\ccm3-windows-installer.exe"
Icon "${APP_ICON}"
UninstallIcon "${APP_ICON}"
BrandingText "${APP_NAME} ${APP_VERSION}"
InstallDir "$PROGRAMFILES64\${APP_DIR}"
RequestExecutionLevel admin
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Section "!Core files (required)"
SectionIn RO
SetOutPath "$INSTDIR"
File /r "..\build\bin\*.*"
WriteUninstaller "$INSTDIR\Uninstall.exe"
WriteRegStr HKLM "${REG_KEY}" "DisplayName" "${APP_NAME}"
WriteRegStr HKLM "${REG_KEY}" "DisplayVersion" "${APP_VERSION}"
WriteRegStr HKLM "${REG_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
WriteRegStr HKLM "${REG_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
WriteRegStr HKLM "${REG_KEY}" "QuietUninstallString" "$\"$INSTDIR\Uninstall.exe$\" /S"
WriteRegStr HKLM "${REG_KEY}" "InstallLocation" "$INSTDIR"
WriteRegDWORD HKLM "${REG_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${REG_KEY}" "NoRepair" 1
WriteRegStr HKLM "${APP_PATHS_KEY}" "" "$INSTDIR\${APP_EXE}"
WriteRegStr HKLM "${APP_PATHS_KEY}" "Path" "$INSTDIR"
SectionEnd
Section "Start Menu shortcuts"
SetShellVarContext all
CreateDirectory "$SMPROGRAMS\${APP_DIR}"
CreateShortcut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
CreateShortcut "$SMPROGRAMS\${APP_DIR}\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
CreateShortcut "$SMPROGRAMS\${APP_DIR}\Uninstall ${APP_NAME}.lnk" "$INSTDIR\Uninstall.exe"
SectionEnd
Section "Desktop shortcut"
SetShellVarContext all
CreateShortcut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
SectionEnd
Section "Uninstall"
SetShellVarContext all
Delete "$DESKTOP\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\${APP_DIR}\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\${APP_DIR}\Uninstall ${APP_NAME}.lnk"
RMDir "$SMPROGRAMS\${APP_DIR}"
RMDir /r "$INSTDIR"
DeleteRegKey HKLM "${REG_KEY}"
DeleteRegKey HKLM "${APP_PATHS_KEY}"
SectionEnd
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+58
View File
@@ -0,0 +1,58 @@
# tests/AGENTS.md
`ccm_core_tests` — doctest unit tests for `ccm_core`. Hermetic, fast, no real network or disk. Read the root `AGENTS.md` first.
## File pointers
- `main.cpp` — doctest entry point with `DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN`. Do not put tests here.
- `fakes/InMemoryFileSystem.{hpp,cpp}``IFileSystem` implementation backed by `std::map`. Normalizes paths via `lexically_normal().generic_string()` (always `/` separators).
- `fs_names_tests.cpp``formatTextForFs` + `parseIndexFromFilename`. Both are compatibility ports of the original Rust path rules.
- `domain_json_tests.cpp` — JSON round-trip tests for every domain type. **Update this file whenever a domain type changes.**
- `image_service_tests.cpp``ImageService` (uses inline `RecordingImageStore` fake).
- `collection_service_tests.cpp``CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
- `config_service_tests.cpp``ConfigService` against `InMemoryFileSystem`.
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
- `set_service_tests.cpp``SetService` with `FakeSetSource` + `InMemSetRepo`.
- `magic_set_source_tests.cpp``MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
- `magic_card_preview_source_tests.cpp``MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`.
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `card_sorter_tests.cpp``sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key.
- `card_filter_tests.cpp``matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
- `CMakeLists.txt` — explicit list of every `.cpp` (no glob).
## Conventions
1. **Framework**: doctest. Each test file `#include <doctest/doctest.h>` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`.
2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`.
3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it.
4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators.
5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file".
6. **Add a `.cpp` to the `add_executable` call in `tests/CMakeLists.txt`.** No glob.
## Required follow-ups
- After modifying any domain type field or alias you **must** extend the matching test in `domain_json_tests.cpp`.
- After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code.
- After adding a new service in `core/` you **must** add a corresponding `<name>_service_tests.cpp` with at least the happy-path and one error-path test.
- After adding a new game's set source / card preview source you **must** add `tests/<name>_set_source_tests.cpp` and (if applicable) `tests/<name>_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`.
## Commands
- Configure with tests on:
`cmake -S . -B build -DCCM_BUILD_TESTS=ON`
- Build the suite:
`cmake --build build --target ccm_core_tests`
- Run all tests:
`ctest --test-dir build --output-on-failure`
- Run a single test by name pattern:
`./build/bin/ccm_core_tests --test-case="*nextImageIndex*"`
- Run a single suite:
`./build/bin/ccm_core_tests --test-suite="MagicSetSource::parseResponse"`
## Anti-patterns
- Don't depend on `ccm_ui_wx` from tests. UI is out of scope here.
- Don't rely on file paths existing on the host (no `/tmp`, no `C:\Users\...`). Use `InMemoryFileSystem`.
- Don't add tests that require network access. The Pokemon stub test checks the message, not a real API call.
+37
View File
@@ -0,0 +1,37 @@
# Unit tests for ccm_core. Wx and infra-bound tests are out of scope here -
# everything in this target operates against the ccm_core ports/services using
# in-memory fakes, so the suite is fast and hermetic.
add_executable(ccm_core_tests
fakes/InMemoryFileSystem.cpp
fs_names_tests.cpp
domain_json_tests.cpp
image_service_tests.cpp
collection_service_tests.cpp
config_service_tests.cpp
json_collection_repository_tests.cpp
json_set_repository_tests.cpp
set_service_tests.cpp
magic_set_source_tests.cpp
magic_card_preview_source_tests.cpp
card_preview_service_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
card_sorter_tests.cpp
card_filter_tests.cpp
main.cpp
)
target_include_directories(ccm_core_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(ccm_core_tests
PRIVATE
ccm_core
doctest::doctest
ccm_warnings
)
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
doctest_discover_tests(ccm_core_tests)
+160
View File
@@ -0,0 +1,160 @@
// CardFilter tests - exercises the per-row matcher ported from the table
// TableTemplate.tsx::applyFilter. Each TEST_CASE pins down a behavior the
// original UI relied on, so a regression here implies the C++ table no
// longer filters like the TypeScript reference.
#include <doctest/doctest.h>
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/services/CardFilter.hpp"
#include <string>
using namespace ccm;
namespace {
MagicCard mc(std::string name,
std::string setName,
std::uint8_t amount = 1,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "",
bool foil = false, bool sgnd = false, bool altered = false) {
MagicCard c;
c.id = 1;
c.name = std::move(name);
c.set.name = std::move(setName);
c.amount = amount;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
c.foil = foil;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
PokemonCard pc(std::string name,
std::string setName,
std::string setNo = "",
std::uint8_t amount = 1,
std::string note = "") {
PokemonCard c;
c.id = 1;
c.name = std::move(name);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
c.amount = amount;
c.note = std::move(note);
return c;
}
} // namespace
TEST_SUITE("CardFilter::matchesMagicFilter") {
TEST_CASE("empty filter matches every row (mirrors JS \"\".includes(\"\"))") {
CHECK(matchesMagicFilter(mc("Brainstorm", "Alpha"), ""));
}
TEST_CASE("matches by name (case-insensitive substring)") {
const MagicCard c = mc("Lightning Bolt", "Beta");
CHECK(matchesMagicFilter(c, "lightning"));
CHECK(matchesMagicFilter(c, "BOLT")); // both sides lowercased
CHECK(matchesMagicFilter(c, "ning Bo")); // mid-string substring
CHECK_FALSE(matchesMagicFilter(c, "fireball"));
}
TEST_CASE("matches by set.name (valueKey 'set.name', not 'set')") {
const MagicCard c = mc("Brainstorm", "Modern Horizons");
CHECK(matchesMagicFilter(c, "Modern"));
CHECK(matchesMagicFilter(c, "horizons"));
CHECK_FALSE(matchesMagicFilter(c, "Zendikar"));
}
TEST_CASE("matches by language label") {
const MagicCard c = mc("Brainstorm", "Alpha", 1, Language::Japanese);
CHECK(matchesMagicFilter(c, "japanese"));
CHECK_FALSE(matchesMagicFilter(c, "german"));
}
TEST_CASE("matches by condition label") {
const MagicCard c = mc("Brainstorm", "Alpha", 1,
Language::English, Condition::Played);
CHECK(matchesMagicFilter(c, "played"));
CHECK_FALSE(matchesMagicFilter(c, "mint"));
}
TEST_CASE("matches by amount as decimal string (val.toString())") {
// JS used `val.toString().toLowerCase().includes(filter)` so the
// *integer* amount becomes searchable as its decimal representation.
const MagicCard c = mc("Brainstorm", "Alpha", 17);
CHECK(matchesMagicFilter(c, "17"));
CHECK(matchesMagicFilter(c, "1")); // substring match: "1" in "17"
CHECK_FALSE(matchesMagicFilter(c, "99"));
}
TEST_CASE("matches by note (case-insensitive)") {
const MagicCard c = mc("Brainstorm", "Alpha", 1,
Language::English, Condition::NearMint,
"Birthday gift");
CHECK(matchesMagicFilter(c, "Birthday"));
CHECK(matchesMagicFilter(c, "GIFT"));
CHECK_FALSE(matchesMagicFilter(c, "trade"));
}
TEST_CASE("boolean flag columns (foil / signed / altered) are NOT matched") {
// Filtering checks only string- or number-typed cells (typeof check). The
// bool flag columns therefore must not match the literal "true" /
// "false" — even when the flag is set.
const MagicCard c = mc("Brainstorm", "Alpha", 1,
Language::English, Condition::NearMint, "",
/*foil=*/true, /*sgnd=*/true, /*altered=*/true);
CHECK_FALSE(matchesMagicFilter(c, "true"));
CHECK_FALSE(matchesMagicFilter(c, "false"));
// ...unless the literal happens to be a substring of an actual
// string-typed column. Sanity-check that the matcher still fires on
// the name field with the same haystack.
CHECK(matchesMagicFilter(c, "brain"));
}
TEST_CASE("filter is lowercased so uppercase input matches lowercased data") {
// The original path only lowercased the cell value, so "Brainstorm" filter against
// a card named "brainstorm" never matched. We lowercase both sides;
// pin that down here so a regression to the literal JS behavior gets
// caught.
const MagicCard c = mc("brainstorm", "alpha");
CHECK(matchesMagicFilter(c, "BRAIN"));
CHECK(matchesMagicFilter(c, "Alpha"));
}
}
TEST_SUITE("CardFilter::matchesPokemonFilter") {
TEST_CASE("matches by name and set.name") {
const PokemonCard c = pc("Charizard", "Base Set");
CHECK(matchesPokemonFilter(c, "char"));
CHECK(matchesPokemonFilter(c, "BASE"));
CHECK_FALSE(matchesPokemonFilter(c, "pikachu"));
}
TEST_CASE("Pokemon adds setNo to the searchable columns") {
// PokemonTable.tsx tableFields includes a "Set No" column whose
// valueKey is `setNo`. Pin that down — `setNo` is Pokemon-specific so
// the Magic matcher does not see it.
const PokemonCard c = pc("Charizard", "Base Set", "4/102");
CHECK(matchesPokemonFilter(c, "4/102"));
CHECK(matchesPokemonFilter(c, "/102"));
}
TEST_CASE("amount is searchable as decimal string for Pokemon too") {
const PokemonCard c = pc("Charizard", "Base Set", "4/102", 23);
CHECK(matchesPokemonFilter(c, "23"));
CHECK_FALSE(matchesPokemonFilter(c, "99"));
}
TEST_CASE("empty filter matches everything") {
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
}
}
+152
View File
@@ -0,0 +1,152 @@
#include <doctest/doctest.h>
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include <string>
using namespace ccm;
namespace {
class FakeSource final : public ICardPreviewSource {
public:
std::string url = "https://example.com/preview.jpg";
bool ok = true;
std::string err = "boom";
// Capture inputs so tests can assert routing.
std::string lastName;
std::string lastSetId;
std::string lastSetNo;
Result<std::string> fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override {
lastName = std::string(name);
lastSetId = std::string(setId);
lastSetNo = std::string(setNo);
return ok ? Result<std::string>::ok(url)
: Result<std::string>::err(err);
}
};
class FixedHttpClient final : public IHttpClient {
public:
std::string lastUrl;
std::string body;
bool ok = true;
std::string err = "offline";
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
return ok ? Result<std::string>::ok(body)
: Result<std::string>::err(err);
}
};
// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
Game gameId = Game::Magic;
ICardPreviewSource* preview = nullptr;
[[nodiscard]] Game id() const noexcept override { return gameId; }
[[nodiscard]] std::string dirName() const override { return "fake"; }
[[nodiscard]] std::string displayName() const override { return "Fake"; }
ISetSource& setSource() override {
// Tests in this file never call setSource(); a never-returned helper
// would clutter the fake. Throwing keeps misuse loud.
throw std::logic_error("FakeGameModule::setSource() not used in this test");
}
ICardPreviewSource* cardPreviewSource() noexcept override { return preview; }
};
} // namespace
TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
TEST_CASE("happy path: routes through registered module then http GET") {
FakeSource source;
source.url = "https://example.com/img.png";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.body = std::string("\x89PNG\r\n\x1a\n", 8); // arbitrary binary
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
REQUIRE(out.isOk());
CHECK(out.value() == http.body);
CHECK(http.lastUrl == "https://example.com/img.png");
CHECK(source.lastName == "Lightning Bolt");
CHECK(source.lastSetId == "lea");
CHECK(source.lastSetNo.empty());
}
TEST_CASE("module returning nullptr preview source is skipped silently") {
FakeGameModule module;
module.gameId = Game::Pokemon;
module.preview = nullptr;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module); // no-op
const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
CHECK(out.isErr());
CHECK(out.error().find("No preview source registered") != std::string::npos);
}
TEST_CASE("unregistered game returns an explicit error") {
FixedHttpClient http;
CardPreviewService svc{http};
const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
CHECK(out.isErr());
CHECK(out.error().find("No preview source registered") != std::string::npos);
}
TEST_CASE("source error propagates and http is not called") {
FakeSource source;
source.ok = false;
source.err = "scryfall 404";
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.lastUrl = "<unset>";
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
CHECK(out.isErr());
CHECK(out.error() == "scryfall 404");
CHECK(http.lastUrl == "<unset>");
}
TEST_CASE("http GET error propagates") {
FakeSource source;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
http.ok = false;
http.err = "net down";
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
CHECK(out.isErr());
CHECK(out.error() == "net down");
}
}
+248
View File
@@ -0,0 +1,248 @@
// CardSorter tests - exercises the per-column comparators ported from the
// TableTemplate.tsx::byField. Each TEST_CASE pins down a specific behavior
// the original UI relied on, so a regression here implies the C++ table no
// longer behaves like the TypeScript reference.
#include <doctest/doctest.h>
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/CardSorter.hpp"
#include <algorithm>
#include <string>
#include <vector>
using namespace ccm;
namespace {
MagicCard mc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::uint8_t amount = 1,
bool foil = false, bool sgnd = false, bool altered = false,
Language lang = Language::English,
Condition cond = Condition::NearMint,
std::string note = "") {
MagicCard c;
c.id = id;
c.name = std::move(name);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.amount = amount;
c.foil = foil;
c.signed_ = sgnd;
c.altered = altered;
c.language = lang;
c.condition = cond;
c.note = std::move(note);
return c;
}
PokemonCard pc(std::uint32_t id, std::string name,
std::string setName, std::string releaseDate,
std::uint8_t amount = 1,
bool holo = false, bool firstEdition = false,
bool sgnd = false, bool altered = false) {
PokemonCard c;
c.id = id;
c.name = std::move(name);
c.set.name = std::move(setName);
c.set.releaseDate = std::move(releaseDate);
c.amount = amount;
c.holo = holo;
c.firstEdition = firstEdition;
c.signed_ = sgnd;
c.altered = altered;
return c;
}
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
for (const auto& c : v) out.push_back(c.id);
return out;
}
std::vector<std::uint32_t> ids(const std::vector<PokemonCard>& v) {
std::vector<std::uint32_t> out;
out.reserve(v.size());
for (const auto& c : v) out.push_back(c.id);
return out;
}
} // namespace
TEST_SUITE("CardSorter - Magic columns") {
TEST_CASE("Name sorts case-insensitively (matches String.toLowerCase())") {
// Three cards whose names only differ in casing - if sort were a
// plain `<` this would put "ABC" before "abc" and wedge "Brainstorm"
// somewhere wrong. The JS path normalizes to lowercase first.
std::vector<MagicCard> v = {
mc(1, "brainstorm", "X", "2000/01/01"),
mc(2, "ABC", "X", "2000/01/01"),
mc(3, "abc", "X", "2000/01/01"),
};
sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/true);
// ABC and abc tie under case-insensitive compare; stable sort keeps
// the input order (id 2 before id 3).
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2, 3});
}
TEST_CASE("Set column sorts by release date (chronological), not by name") {
// The whole point of having distinct valueKey/sortKey: the
// table *displays* set.name, but ascending order is chronological.
std::vector<MagicCard> v = {
mc(1, "x", "Zendikar", "2009/10/02"),
mc(2, "y", "Alpha", "1993/08/05"),
mc(3, "z", "Modern Horizons","2019/06/14"),
};
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1, 3}); // 1993 < 2009 < 2019
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Amount sorts numerically (no string-compare 10 < 2 trap)") {
std::vector<MagicCard> v = {
mc(1, "a", "X", "2000/01/01", /*amount=*/10),
mc(2, "b", "X", "2000/01/01", /*amount=*/2),
mc(3, "c", "X", "2000/01/01", /*amount=*/4),
};
sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // 2 < 4 < 10
}
TEST_CASE("boolean flag column orders false < true (asc puts unset first)") {
std::vector<MagicCard> v = {
mc(1, "a", "X", "2000/01/01", 1, /*foil=*/true),
mc(2, "b", "X", "2000/01/01", 1, /*foil=*/false),
mc(3, "c", "X", "2000/01/01", 1, /*foil=*/true),
mc(4, "d", "X", "2000/01/01", 1, /*foil=*/false),
};
sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/true);
// Stable: relative order within each bucket preserved.
CHECK(ids(v) == std::vector<std::uint32_t>{2, 4, 1, 3});
sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/false);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2, 4});
}
TEST_CASE("Signed and Altered booleans sort independently") {
std::vector<MagicCard> v = {
mc(1, "a", "X", "2000/01/01", 1, false, /*sgnd=*/false, /*alt=*/true),
mc(2, "b", "X", "2000/01/01", 1, false, /*sgnd=*/true, /*alt=*/false),
};
sortMagicCards(v, MagicSortColumn::Signed, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{1, 2});
sortMagicCards(v, MagicSortColumn::Altered, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
}
TEST_CASE("Language and Condition sort by their string label, lowercased") {
std::vector<MagicCard> v = {
mc(1, "a", "X", "2000/01/01", 1, false, false, false,
Language::Japanese, Condition::Mint),
mc(2, "b", "X", "2000/01/01", 1, false, false, false,
Language::English, Condition::Played),
mc(3, "c", "X", "2000/01/01", 1, false, false, false,
Language::German, Condition::NearMint),
};
sortMagicCards(v, MagicSortColumn::Language, /*ascending=*/true);
// english < german < japanese (lowercased compare)
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortMagicCards(v, MagicSortColumn::Condition, /*ascending=*/true);
// mint < nearmint < played (lowercased compare)
CHECK(ids(v) == std::vector<std::uint32_t>{1, 3, 2});
}
TEST_CASE("Note sorts case-insensitively") {
std::vector<MagicCard> v = {
mc(1, "a", "X", "2000/01/01", 1, false, false, false,
Language::English, Condition::NearMint, "Zeta"),
mc(2, "b", "X", "2000/01/01", 1, false, false, false,
Language::English, Condition::NearMint, "alpha"),
mc(3, "c", "X", "2000/01/01", 1, false, false, false,
Language::English, Condition::NearMint, "Beta"),
};
sortMagicCards(v, MagicSortColumn::Note, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1}); // alpha, beta, zeta
}
TEST_CASE("stable: sort by name then by set keeps name order within each set") {
// Mirrors the UX expectation: a user clicks Name, then Set, and
// sees rows grouped by set with names alphabetical inside each group.
std::vector<MagicCard> v = {
mc(1, "Counterspell", "Beta", "1993/10/04"),
mc(2, "Brainstorm", "Alpha", "1993/08/05"),
mc(3, "Lightning Bolt","Beta", "1993/10/04"),
mc(4, "Ancestral Recall","Alpha","1993/08/05"),
};
sortMagicCards(v, MagicSortColumn::Name, /*asc=*/true);
sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*asc=*/true);
// Alpha (1993/08/05) first: ancestral, brainstorm
// Beta (1993/10/04) next: counterspell, lightning bolt
CHECK(ids(v) == std::vector<std::uint32_t>{4, 2, 1, 3});
}
}
TEST_SUITE("CardSorter - Pokemon-specific columns") {
TEST_CASE("Holo and FirstEdition each sort their own bool field") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*1st=*/false),
pc(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/true),
pc(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/false),
};
sortPokemonCards(v, PokemonSortColumn::Holo, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
sortPokemonCards(v, PokemonSortColumn::FirstEdition, /*ascending=*/true);
// After previous sort: 2,3,1 (false=2, false=1 actually... let me think
// -- with v in order {2,3,1} their firstEdition = {true,false,false}.
// Stable asc on firstEdition keeps 3 before 1 in the false bucket.)
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
TEST_CASE("Set column sorts by release date for Pokemon too") {
std::vector<PokemonCard> v = {
pc(1, "x", "Sun & Moon", "2017/02/03"),
pc(2, "y", "Base Set", "1999/01/09"),
pc(3, "z", "Sword & Shield","2020/02/07"),
};
sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1, 3});
}
TEST_CASE("Amount sorts numerically") {
std::vector<PokemonCard> v = {
pc(1, "a", "X", "2000/01/01", 9),
pc(2, "b", "X", "2000/01/01", 11),
pc(3, "c", "X", "2000/01/01", 1),
};
sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true);
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
}
}
TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") {
TEST_CASE("empty vector stays empty") {
std::vector<MagicCard> v;
sortMagicCards(v, MagicSortColumn::Name, true);
CHECK(v.empty());
}
TEST_CASE("single element preserved") {
std::vector<MagicCard> v = { mc(42, "Solo", "X", "2000/01/01") };
sortMagicCards(v, MagicSortColumn::Amount, false);
CHECK(v.size() == 1);
CHECK(v.front().id == 42);
}
}
+120
View File
@@ -0,0 +1,120 @@
#include <doctest/doctest.h>
#include "ccm/domain/MagicCard.hpp"
#include "ccm/ports/ICollectionRepository.hpp"
#include "ccm/ports/IImageStore.hpp"
#include "ccm/services/CollectionService.hpp"
#include <map>
#include <string>
#include <vector>
using namespace ccm;
namespace {
class InMemoryRepo final : public ICollectionRepository<MagicCard> {
public:
Map storage;
Result<Map> load(Game) override { return Result<Map>::ok(storage); }
Result<void> save(Game, const Map& m) override {
storage = m;
return Result<void>::ok();
}
};
class StubImageStore final : public IImageStore {
public:
std::vector<std::pair<Game, std::string>> removed;
Result<std::string> copyIn(Game, const std::filesystem::path&, const std::string& n) override {
return Result<std::string>::ok(n);
}
Result<void> remove(Game g, const std::string& n) override {
removed.emplace_back(g, n);
return Result<void>::ok();
}
std::filesystem::path resolvePath(Game, const std::string& n) const override {
return std::filesystem::path(n);
}
};
MagicCard makeCard(const std::string& name, std::vector<std::string> imgs = {}) {
MagicCard c;
c.name = name;
c.set = Set{"alp", "Alpha", "1993/08/05"};
c.images = std::move(imgs);
return c;
}
} // namespace
TEST_SUITE("CollectionService<MagicCard>") {
TEST_CASE("nextId on empty map is 0, then strictly increments") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto id0 = svc.add(Game::Magic, makeCard("A"));
REQUIRE(id0.isOk());
CHECK(id0.value() == 0);
const auto id1 = svc.add(Game::Magic, makeCard("B"));
REQUIRE(id1.isOk());
CHECK(id1.value() == 1);
const auto listed = svc.list(Game::Magic);
REQUIRE(listed.isOk());
CHECK(listed.value().size() == 2);
}
TEST_CASE("update modifies an existing card and is rejected for unknown ids") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(Game::Magic, makeCard("Initial")).value();
MagicCard updated = makeCard("Renamed");
updated.id = id;
CHECK(svc.update(Game::Magic, updated).isOk());
const auto found = svc.findById(Game::Magic, id);
REQUIRE(found.isOk());
REQUIRE(found.value().has_value());
CHECK(found.value()->name == "Renamed");
MagicCard ghost = makeCard("Ghost");
ghost.id = 999;
CHECK(svc.update(Game::Magic, ghost).isErr());
}
TEST_CASE("remove deletes images and the entry") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
const auto id = svc.add(
Game::Magic, makeCard("With Images", {"a.png", "b.png"})).value();
REQUIRE(svc.remove(Game::Magic, id).isOk());
// Both images should have been requested for deletion.
REQUIRE(store.removed.size() == 2);
CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png");
const auto listed = svc.list(Game::Magic);
REQUIRE(listed.isOk());
CHECK(listed.value().empty());
}
TEST_CASE("remove of unknown id returns an error") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
CHECK(svc.remove(Game::Magic, 12345).isErr());
}
}
+78
View File
@@ -0,0 +1,78 @@
#include <doctest/doctest.h>
#include "ccm/services/ConfigService.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
TEST_SUITE("ConfigService") {
TEST_CASE("missing file is created with defaults") {
InMemoryFileSystem fs;
ConfigService svc{fs, "/app/config.json", "/data"};
REQUIRE(svc.initialize().isOk());
CHECK(svc.current().dataStorage == "/data");
CHECK(svc.current().defaultGame == Game::Magic);
CHECK(svc.current().theme == Theme::Light);
const auto& written = fs.files();
REQUIRE(written.count("/app/config.json"));
const auto j = nlohmann::json::parse(written.at("/app/config.json"));
CHECK(j.at("dataStorage") == "/data");
CHECK(j.at("defaultGame") == "Magic");
CHECK(j.at("theme") == "Light");
}
TEST_CASE("existing file is loaded verbatim") {
InMemoryFileSystem fs;
REQUIRE(fs.writeText("/app/config.json",
R"({"dataStorage":"/somewhere","defaultGame":"Pokemon","theme":"Dark"})").isOk());
ConfigService svc{fs, "/app/config.json", "/default"};
REQUIRE(svc.initialize().isOk());
CHECK(svc.current().dataStorage == "/somewhere");
CHECK(svc.current().defaultGame == Game::Pokemon);
CHECK(svc.current().theme == Theme::Dark);
}
TEST_CASE("store updates both the live config and the file") {
InMemoryFileSystem fs;
ConfigService svc{fs, "/app/config.json", "/data"};
REQUIRE(svc.initialize().isOk());
Configuration next;
next.dataStorage = "/new/place";
next.defaultGame = Game::Pokemon;
next.theme = Theme::Dark;
REQUIRE(svc.store(next).isOk());
CHECK(svc.current() == next);
const auto j = nlohmann::json::parse(fs.files().at("/app/config.json"));
CHECK(j.at("dataStorage") == "/new/place");
CHECK(j.at("defaultGame") == "Pokemon");
CHECK(j.at("theme") == "Dark");
}
TEST_CASE("missing theme field defaults to light for compatibility") {
InMemoryFileSystem fs;
REQUIRE(fs.writeText("/app/config.json",
R"({"dataStorage":"/somewhere","defaultGame":"Pokemon"})").isOk());
ConfigService svc{fs, "/app/config.json", "/default"};
REQUIRE(svc.initialize().isOk());
CHECK(svc.current().theme == Theme::Light);
}
TEST_CASE("malformed JSON surfaces a clear error") {
InMemoryFileSystem fs;
REQUIRE(fs.writeText("/app/config.json", "not-json").isOk());
ConfigService svc{fs, "/app/config.json", "/default"};
const auto r = svc.initialize();
REQUIRE(r.isErr());
CHECK(r.error().find("config.json parse error") != std::string::npos);
}
}
+123
View File
@@ -0,0 +1,123 @@
#include <doctest/doctest.h>
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
TEST_SUITE("domain enums round-trip JSON as strings") {
TEST_CASE("Game") {
nlohmann::json j = Game::Magic;
CHECK(j.get<std::string>() == "Magic");
CHECK(j.get<Game>() == Game::Magic);
nlohmann::json j2 = "Pokemon";
CHECK(j2.get<Game>() == Game::Pokemon);
nlohmann::json j3 = Theme::Dark;
CHECK(j3.get<std::string>() == "Dark");
CHECK(j3.get<Theme>() == Theme::Dark);
}
TEST_CASE("Language and Condition") {
nlohmann::json l = Language::Japanese;
CHECK(l.get<std::string>() == "Japanese");
CHECK(l.get<Language>() == Language::Japanese);
nlohmann::json c = Condition::LightPlayed;
CHECK(c.get<std::string>() == "LightPlayed");
CHECK(c.get<Condition>() == Condition::LightPlayed);
}
TEST_CASE("invalid enum string throws") {
nlohmann::json bad = "Spanglish";
CHECK_THROWS(bad.get<Language>());
}
}
TEST_SUITE("Set JSON shape stays stable") {
TEST_CASE("uses 'releaseDate' alias") {
Set s{"abc", "Test Set", "2024/05/01"};
const nlohmann::json j = s;
CHECK(j.contains("releaseDate"));
CHECK_FALSE(j.contains("release_date"));
CHECK(j.at("releaseDate") == "2024/05/01");
const Set back = j.get<Set>();
CHECK(back == s);
}
}
TEST_SUITE("MagicCard JSON") {
TEST_CASE("round-trips with all original field names") {
MagicCard c;
c.id = 42;
c.amount = 3;
c.name = "Lightning Bolt";
c.set = Set{"lea", "Limited Edition Alpha", "1993/08/05"};
c.note = "rare promo";
c.images = {"foo+bar+0.png"};
c.language = Language::English;
c.condition = Condition::NearMint;
c.foil = true;
c.signed_ = false;
c.altered = false;
nlohmann::json j = c;
CHECK(j.contains("signed"));
CHECK(j.at("signed") == false);
CHECK(j.at("foil") == true);
const MagicCard back = j.get<MagicCard>();
CHECK(back == c);
}
}
TEST_SUITE("PokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
PokemonCard c;
c.id = 7;
c.amount = 1;
c.name = "Charizard";
c.set = Set{"base1", "Base Set", "1999/01/09"};
c.setNo = "4/102";
c.note = "";
c.images = {};
c.language = Language::English;
c.condition = Condition::Excellent;
c.firstEdition = true;
c.holo = true;
c.signed_ = false;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "4/102");
CHECK(j.at("firstEdition") == true);
CHECK(j.at("signed") == false);
const PokemonCard back = j.get<PokemonCard>();
CHECK(back == c);
}
}
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
TEST_CASE("dataStorage / defaultGame / theme keys are present") {
Configuration cfg;
cfg.dataStorage = "/some/path";
cfg.defaultGame = Game::Pokemon;
cfg.theme = Theme::Dark;
nlohmann::json j = cfg;
CHECK(j.at("dataStorage") == "/some/path");
CHECK(j.at("defaultGame") == "Pokemon");
CHECK(j.at("theme") == "Dark");
const auto back = j.get<Configuration>();
CHECK(back == cfg);
}
}

Some files were not shown because too many files have changed in this diff Show More