Changes
diff --git a/src/app.cpp b/src/app.cpp
index 3c7b3e7..27cb90b 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -265,6 +265,27 @@ mw::E<std::int64_t> parseRarity(
return rarity;
}
+mw::E<std::int64_t> parseRevision(
+ const std::unordered_map<std::string, std::string>& fields)
+{
+ const auto position = fields.find("revision");
+ if(position == fields.end())
+ {
+ return std::unexpected(mw::runtimeError("Revision is required"));
+ }
+ std::int64_t revision = 0;
+ const std::string& text = position->second;
+ const auto result = std::from_chars(
+ text.data(), text.data() + text.size(), revision);
+ if(text.empty() || result.ec != std::errc{} ||
+ result.ptr != text.data() + text.size() || revision < 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Revision must be a positive integer"));
+ }
+ return revision;
+}
+
bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
{
std::error_code filesystem_error;
@@ -344,9 +365,16 @@ void App::handleCardNew(
const inja::json template_data = {
{"action_url", urlFor("cards")},
{"back_url", urlFor("card-index")},
+ {"display_id", "New addition"},
+ {"foil_action", "keep"},
{"foil_url", ""},
{"front_url", urlFor("static", {"card_placeholder.svg"})},
+ {"has_foil", false},
+ {"heading", "Create card"},
+ {"long_description", ""},
+ {"mode", "create"},
{"model_url", urlFor("static", {"foil/model/card.obj"})},
+ {"name", ""},
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
{"shader_fragment_url",
@@ -355,6 +383,10 @@ void App::handleCardNew(
urlFor("static", {"foil/vert-shader.glsl"})},
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
+ {"rarity", 0},
+ {"revision", 0},
+ {"short_description", ""},
+ {"submit_label", "Create card"},
{"thumbnail_long_side", config_.thumbnail_long_side},
{"title", "Create card · Card Collection"},
};
@@ -375,6 +407,113 @@ void App::handleCardNew(
}
}
+void App::handleCardEdit(
+ const Request& request,
+ Response& response)
+{
+ const auto id_parameter = request.path_params.find("id");
+ if(id_parameter == request.path_params.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto identity = parsePublicId(id_parameter->second);
+ if(!identity)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto card_result = data_source_->getCard(*identity);
+ if(!card_result)
+ {
+ spdlog::error(
+ "Failed to load card {} for editing: {}",
+ id_parameter->second,
+ card_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ if(!*card_result)
+ {
+ respondNotFound(response);
+ return;
+ }
+ const Card& card = **card_result;
+ if(card.identity.game_short_name)
+ {
+ respondBadRequest(
+ response, "Game-specific cards are not implemented yet");
+ return;
+ }
+
+ auto public_id_result = formatPublicId(card.identity);
+ if(!public_id_result)
+ {
+ respondInternalError(response);
+ return;
+ }
+ const std::string& public_id = *public_id_result;
+ const std::string front_name =
+ "front-art." + card.front_extension;
+ const std::string front_url = urlFor(
+ "card-asset",
+ {public_id + "/" + front_name},
+ {{"v", std::to_string(card.revision)}});
+ std::string foil_url;
+ if(card.foil_extension)
+ {
+ foil_url = urlFor(
+ "card-asset",
+ {public_id + "/foil." + *card.foil_extension},
+ {{"v", std::to_string(card.revision)}});
+ }
+
+ const inja::json template_data = {
+ {"action_url", urlFor("card", {public_id})},
+ {"back_url", urlFor("card", {public_id})},
+ {"display_id", uppercaseAscii(public_id)},
+ {"foil_action", "keep"},
+ {"foil_url", foil_url},
+ {"front_url", front_url},
+ {"has_foil", card.foil_extension.has_value()},
+ {"heading", "Edit card"},
+ {"long_description", card.long_description.value_or("")},
+ {"mode", "edit"},
+ {"model_url", urlFor("static", {"foil/model/card.obj"})},
+ {"name", card.name},
+ {"preview_script_url",
+ urlFor("static", {"foil/card_preview.js"})},
+ {"rarity", card.rarity},
+ {"revision", card.revision},
+ {"shader_fragment_url",
+ urlFor("static", {"foil/frag-shader.glsl"})},
+ {"shader_vertex_url",
+ urlFor("static", {"foil/vert-shader.glsl"})},
+ {"short_description", card.short_description.value_or("")},
+ {"spectral_lut_url",
+ urlFor("static", {"foil/spectral_xyz.bin"})},
+ {"submit_label", "Save changes"},
+ {"thumbnail_long_side", config_.thumbnail_long_side},
+ {"title", "Edit " + card.name + " · Card Collection"},
+ };
+
+ try
+ {
+ response.status = 200;
+ response.set_content(
+ templates_.render(card_form_template_, template_data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error(
+ "Failed to render the edit form for card {}: {}",
+ card.id,
+ error.what());
+ respondInternalError(response);
+ }
+}
+
void App::handleCardCreate(
const Request& request,
Response& response,
@@ -464,6 +603,176 @@ void App::handleCardCreate(
response.set_header("Location", urlFor("card", {*created}));
}
+void App::handleCardUpdate(
+ const Request& request,
+ Response& response,
+ const ContentReader& content_reader)
+{
+ const auto id_parameter = request.path_params.find("id");
+ if(id_parameter == request.path_params.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto identity = parsePublicId(id_parameter->second);
+ if(!identity)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto card_result = data_source_->getCard(*identity);
+ if(!card_result)
+ {
+ spdlog::error(
+ "Failed to load card {} for update: {}",
+ id_parameter->second,
+ card_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ if(!*card_result)
+ {
+ respondNotFound(response);
+ return;
+ }
+ if((**card_result).identity.game_short_name)
+ {
+ respondBadRequest(
+ response, "Game-specific cards are not implemented yet");
+ return;
+ }
+ if(!request.is_multipart_form_data())
+ {
+ respondBadRequest(response, "Expected a multipart form upload");
+ return;
+ }
+
+ MultipartReader multipart_reader(config_.card_storage_root);
+ auto upload = multipart_reader.read(content_reader);
+ if(!upload)
+ {
+ respondOperationError(
+ response, upload.error(), "Failed to receive a card edit");
+ return;
+ }
+ if(upload->fields.contains("game"))
+ {
+ respondBadRequest(response, "Card identity cannot be edited");
+ return;
+ }
+ const auto source_mode = upload->fields.find("source_mode");
+ if(source_mode != upload->fields.end() &&
+ source_mode->second != "files" &&
+ source_mode->second != "urls")
+ {
+ respondBadRequest(response, "Unknown image source mode");
+ return;
+ }
+ if(!upload->series_ids.empty())
+ {
+ respondBadRequest(
+ response, "Loose cards cannot belong to a series");
+ return;
+ }
+
+ const auto name_position = upload->fields.find("name");
+ const std::string name = name_position == upload->fields.end()
+ ? std::string()
+ : trimAscii(name_position->second);
+ if(name.empty())
+ {
+ respondBadRequest(response, "Card name is required");
+ return;
+ }
+ if(name.size() > 200)
+ {
+ respondBadRequest(response, "Card name is too long");
+ return;
+ }
+ auto rarity = parseRarity(upload->fields);
+ if(!rarity)
+ {
+ respondBadRequest(response, rarity.error().msg());
+ return;
+ }
+ auto revision = parseRevision(upload->fields);
+ if(!revision)
+ {
+ respondBadRequest(response, revision.error().msg());
+ return;
+ }
+
+ const auto front_position = upload->fields.find("front_action");
+ FrontAssetAction front_action;
+ if(front_position == upload->fields.end())
+ {
+ respondBadRequest(response, "Front artwork action is required");
+ return;
+ }
+ if(front_position->second == "keep")
+ {
+ front_action = FrontAssetAction::KEEP;
+ }
+ else if(front_position->second == "replace")
+ {
+ front_action = FrontAssetAction::REPLACE;
+ }
+ else
+ {
+ respondBadRequest(response, "Unknown front artwork action");
+ return;
+ }
+
+ const auto foil_position = upload->fields.find("foil_action");
+ FoilAssetAction foil_action;
+ if(foil_position == upload->fields.end())
+ {
+ respondBadRequest(response, "Foil control action is required");
+ return;
+ }
+ if(foil_position->second == "keep")
+ {
+ foil_action = FoilAssetAction::KEEP;
+ }
+ else if(foil_position->second == "replace")
+ {
+ foil_action = FoilAssetAction::REPLACE;
+ }
+ else if(foil_position->second == "remove")
+ {
+ foil_action = FoilAssetAction::REMOVE;
+ }
+ else
+ {
+ respondBadRequest(response, "Unknown foil control action");
+ return;
+ }
+
+ auto updated = card_service_->updateLooseCard({
+ std::move(**card_result),
+ *revision,
+ name,
+ optionalText(upload->fields, "short_description"),
+ optionalText(upload->fields, "long_description"),
+ *rarity,
+ upload->staging_directory,
+ front_action,
+ foil_action,
+ upload->front,
+ upload->foil,
+ upload->thumbnail,
+ });
+ if(!updated)
+ {
+ respondOperationError(
+ response, updated.error(), "Failed to update a card");
+ return;
+ }
+
+ response.status = 303;
+ response.set_header("Location", urlFor("card", {*updated}));
+}
+
void App::handleCardView(
const Request& request,
Response& response)
@@ -580,6 +889,7 @@ void App::handleCardView(
{"asset_warning", !missing_assets.empty()},
{"back_url", urlFor("card-index")},
{"display_id", uppercaseAscii(public_id)},
+ {"edit_url", urlFor("card-edit", {public_id})},
{"foil_url", foil_url.value_or("")},
{"front_url", front_url},
{"game", card.identity.game_short_name
@@ -765,12 +1075,18 @@ void App::setup()
server.Get(
getPath("card-new"),
std::bind_front(&App::handleCardNew, this));
+ server.Get(
+ getPath("card-edit", {"id"}),
+ std::bind_front(&App::handleCardEdit, this));
server.Get(
getPath("card", {"id"}),
std::bind_front(&App::handleCardView, this));
server.Post(
getPath("cards"),
std::bind_front(&App::handleCardCreate, this));
+ server.Post(
+ getPath("card", {"id"}),
+ std::bind_front(&App::handleCardUpdate, this));
}
std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index 684cc25..9bed86e 100644
--- a/src/app.h
+++ b/src/app.h
@@ -54,6 +54,15 @@ public:
/// Render one read-only card page.
void handleCardView(const Request& request, Response& response);
+ /// Render the edit form for one card.
+ void handleCardEdit(const Request& request, Response& response);
+
+ /// Accept and persist one card edit.
+ void handleCardUpdate(
+ const Request& request,
+ Response& response,
+ const ContentReader& content_reader);
+
private:
/// Register implemented handlers and static mounts.
void setup() override;
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
index c560e8d..a059aae 100644
--- a/src/asset_store.cpp
+++ b/src/asset_store.cpp
@@ -12,6 +12,12 @@ AssetStore::AssetStore(std::filesystem::path card_storage_root)
std::move(card_storage_root) / "published")
{}
+std::filesystem::path AssetStore::directory(
+ const std::string& public_id) const
+{
+ return published_root_ / public_id;
+}
+
mw::E<void> AssetStore::publish(
const std::filesystem::path& staging_directory,
const std::string& public_id) const
@@ -50,6 +56,83 @@ mw::E<void> AssetStore::publish(
return {};
}
+mw::E<AssetReplacement> AssetStore::replace(
+ const std::filesystem::path& staging_directory,
+ const std::string& public_id) const
+{
+ const std::filesystem::path destination = published_root_ / public_id;
+ const std::filesystem::path previous =
+ staging_directory.parent_path() /
+ (staging_directory.filename().string() + ".previous");
+ std::error_code filesystem_error;
+ if(!std::filesystem::is_directory(destination, filesystem_error))
+ {
+ return std::unexpected(mw::runtimeError(
+ "The existing card asset directory is missing"));
+ }
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to inspect the existing card assets: " +
+ filesystem_error.message()));
+ }
+
+ std::filesystem::rename(destination, previous, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to retain the existing card assets: " +
+ filesystem_error.message()));
+ }
+ std::filesystem::rename(
+ staging_directory, destination, filesystem_error);
+ if(filesystem_error)
+ {
+ std::error_code restore_error;
+ std::filesystem::rename(previous, destination, restore_error);
+ return std::unexpected(mw::runtimeError(
+ "Failed to publish the edited card assets: " +
+ filesystem_error.message()));
+ }
+ return AssetReplacement{public_id, previous};
+}
+
+void AssetStore::restore(const AssetReplacement& replacement) const
+{
+ const std::filesystem::path destination =
+ published_root_ / replacement.public_id;
+ std::error_code filesystem_error;
+ std::filesystem::remove_all(destination, filesystem_error);
+ if(!filesystem_error)
+ {
+ std::filesystem::rename(
+ replacement.previous_directory,
+ destination,
+ filesystem_error);
+ }
+ if(filesystem_error)
+ {
+ spdlog::critical(
+ "Failed to restore rolled-back assets for card {}: {}",
+ replacement.public_id,
+ filesystem_error.message());
+ }
+}
+
+void AssetStore::finish(const AssetReplacement& replacement) const
+{
+ std::error_code filesystem_error;
+ std::filesystem::remove_all(
+ replacement.previous_directory, filesystem_error);
+ if(filesystem_error)
+ {
+ spdlog::warn(
+ "Failed to remove previous assets for card {}: {}",
+ replacement.public_id,
+ filesystem_error.message());
+ }
+}
+
void AssetStore::removePublished(const std::string& public_id) const
{
std::error_code filesystem_error;
diff --git a/src/asset_store.h b/src/asset_store.h
index ea528a0..7a97840 100644
--- a/src/asset_store.h
+++ b/src/asset_store.h
@@ -5,6 +5,16 @@
#include <mw/error.hpp>
+/// Published asset directory retained until an edit transaction commits.
+struct AssetReplacement
+{
+ /// Public ID whose directory was replaced.
+ std::string public_id;
+
+ /// Private path containing the previous published directory.
+ std::filesystem::path previous_directory;
+};
+
/// Atomically publish and remove complete card asset directories.
class AssetStore
{
@@ -12,11 +22,25 @@ public:
/// Construct an asset store below the configured private root.
explicit AssetStore(std::filesystem::path card_storage_root);
+ /// Return the canonical published directory for one public ID.
+ std::filesystem::path directory(const std::string& public_id) const;
+
/// Rename a complete staging directory into its public location.
mw::E<void> publish(
const std::filesystem::path& staging_directory,
const std::string& public_id) const;
+ /// Swap a complete staging directory with existing published assets.
+ mw::E<AssetReplacement> replace(
+ const std::filesystem::path& staging_directory,
+ const std::string& public_id) const;
+
+ /// Restore the previous assets after a database rollback.
+ void restore(const AssetReplacement& replacement) const;
+
+ /// Remove previous assets after the database update commits.
+ void finish(const AssetReplacement& replacement) const;
+
/// Remove one precisely identified published directory after rollback.
void removePublished(const std::string& public_id) const;
diff --git a/src/card_service.cpp b/src/card_service.cpp
index aa648bd..c7c4c87 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -6,6 +6,7 @@
#include <optional>
#include <random>
#include <string>
+#include <system_error>
#include <utility>
#include "public_id.h"
@@ -23,6 +24,25 @@ std::uint32_t randomLooseNumber()
return distribution(generator);
}
+mw::E<void> copyAsset(
+ const std::filesystem::path& source,
+ const std::filesystem::path& destination)
+{
+ std::error_code filesystem_error;
+ std::filesystem::copy_file(
+ source,
+ destination,
+ std::filesystem::copy_options::none,
+ filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to stage an existing card asset: " +
+ filesystem_error.message()));
+ }
+ return {};
+}
+
} // namespace
CardService::CardService(
@@ -157,3 +177,208 @@ mw::E<std::string> CardService::createLooseCard(
}
return *public_id;
}
+
+mw::E<std::string> CardService::updateLooseCard(
+ UpdateLooseCardInput input)
+{
+ Card& card = input.current_card;
+ if(card.id <= 0 || card.identity.game_short_name)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "Only existing loose cards can be edited"));
+ }
+ if(input.expected_revision != card.revision)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "The card was changed in another request"));
+ }
+
+ const bool rendering_changed =
+ input.front_action == FrontAssetAction::REPLACE ||
+ input.foil_action != FoilAssetAction::KEEP;
+ if((input.front_action == FrontAssetAction::REPLACE) !=
+ input.front.has_value())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Front artwork does not match the requested action"));
+ }
+ if((input.foil_action == FoilAssetAction::REPLACE) !=
+ input.foil.has_value())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Foil control does not match the requested action"));
+ }
+ if(!rendering_changed && input.thumbnail)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "A metadata-only edit must not include a thumbnail"));
+ }
+
+ auto public_id = formatPublicId(card.identity);
+ if(!public_id)
+ {
+ return std::unexpected(std::move(public_id.error()));
+ }
+
+ std::optional<AssetReplacement> replacement;
+ if(rendering_changed)
+ {
+ const std::filesystem::path published =
+ asset_store_.directory(*public_id);
+ ProcessedImage front;
+ if(input.front_action == FrontAssetAction::REPLACE)
+ {
+ auto processed = image_processor_.process(
+ *input.front, CardAssetType::FRONT_ART);
+ if(!processed)
+ {
+ return std::unexpected(std::move(processed.error()));
+ }
+ front = std::move(*processed);
+ }
+ else
+ {
+ const std::string name =
+ "front-art." + card.front_extension;
+ auto copied = copyAsset(
+ published / name, input.staging_directory / name);
+ if(!copied)
+ {
+ return std::unexpected(std::move(copied.error()));
+ }
+ front = {
+ input.staging_directory / name,
+ card.front_extension,
+ 0,
+ 0,
+ false,
+ };
+ }
+
+ std::optional<ProcessedImage> foil;
+ if(input.foil_action == FoilAssetAction::REPLACE)
+ {
+ auto processed = image_processor_.process(
+ *input.foil, CardAssetType::FOIL_CONTROL);
+ if(!processed)
+ {
+ return std::unexpected(std::move(processed.error()));
+ }
+ foil = std::move(*processed);
+ }
+ else if(input.foil_action == FoilAssetAction::KEEP &&
+ card.foil_extension)
+ {
+ const std::string name = "foil." + *card.foil_extension;
+ auto copied = copyAsset(
+ published / name, input.staging_directory / name);
+ if(!copied)
+ {
+ return std::unexpected(std::move(copied.error()));
+ }
+ foil = ProcessedImage{
+ input.staging_directory / name,
+ *card.foil_extension,
+ 0,
+ 0,
+ false,
+ };
+ }
+
+ ProcessedImage thumbnail;
+ if(foil)
+ {
+ if(!input.thumbnail)
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "A rendered thumbnail is required for this edit"));
+ }
+ auto processed = image_processor_.process(
+ *input.thumbnail, CardAssetType::THUMBNAIL);
+ if(!processed)
+ {
+ return std::unexpected(std::move(processed.error()));
+ }
+ thumbnail = std::move(*processed);
+ }
+ else
+ {
+ if(input.thumbnail)
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "A plain card must not include an uploaded thumbnail"));
+ }
+ auto generated = image_processor_.generatePlainThumbnail(
+ front, input.staging_directory / "thumb.avif");
+ if(!generated)
+ {
+ return std::unexpected(std::move(generated.error()));
+ }
+ thumbnail = std::move(*generated);
+ }
+ card.front_extension = std::move(front.extension);
+ card.foil_extension = foil
+ ? std::optional<std::string>(std::move(foil->extension))
+ : std::nullopt;
+ card.thumbnail_extension = std::move(thumbnail.extension);
+ }
+
+ card.name = std::move(input.name);
+ card.short_description = std::move(input.short_description);
+ card.long_description = std::move(input.long_description);
+ card.rarity = input.rarity;
+ ++card.revision;
+
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto current = (*transaction)->getCardForUpdate(card.id);
+ if(!current)
+ {
+ return std::unexpected(std::move(current.error()));
+ }
+ if(!*current)
+ {
+ return std::unexpected(mw::httpError(404, "Card not found"));
+ }
+ if((**current).revision != input.expected_revision)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "The card was changed in another request"));
+ }
+
+ auto updated = (*transaction)->updateCard(card, nullptr, nullptr, {});
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ if(rendering_changed)
+ {
+ auto replaced = asset_store_.replace(
+ input.staging_directory, *public_id);
+ if(!replaced)
+ {
+ return std::unexpected(std::move(replaced.error()));
+ }
+ replacement = std::move(*replaced);
+ }
+
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ if(replacement)
+ {
+ asset_store_.restore(*replacement);
+ }
+ return std::unexpected(std::move(committed.error()));
+ }
+ if(replacement)
+ {
+ asset_store_.finish(*replacement);
+ }
+ return *public_id;
+}
diff --git a/src/card_service.h b/src/card_service.h
index 443fdfd..f91ae6b 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -40,6 +40,61 @@ struct CreateLooseCardInput
std::optional<std::filesystem::path> thumbnail;
};
+/// Requested handling for an existing required artwork asset.
+enum class FrontAssetAction
+{
+ KEEP,
+ REPLACE
+};
+
+/// Requested handling for an existing optional foil-control asset.
+enum class FoilAssetAction
+{
+ KEEP,
+ REPLACE,
+ REMOVE
+};
+
+/// Validated common fields and staged files for editing a loose card.
+struct UpdateLooseCardInput
+{
+ /// Card state observed while rendering the edit form or accepting it.
+ Card current_card;
+
+ /// Revision submitted by the browser for optimistic concurrency.
+ std::int64_t expected_revision;
+
+ /// Replacement human-readable card name.
+ std::string name;
+
+ /// Replacement optional short Markdown description.
+ std::optional<std::string> short_description;
+
+ /// Replacement optional long Markdown description.
+ std::optional<std::string> long_description;
+
+ /// Replacement nonnegative rarity value.
+ std::int64_t rarity;
+
+ /// Complete private staging directory for this request.
+ std::filesystem::path staging_directory;
+
+ /// Whether to retain or replace the front artwork.
+ FrontAssetAction front_action;
+
+ /// Whether to retain, replace, or remove the foil control.
+ FoilAssetAction foil_action;
+
+ /// Staged replacement front artwork, when requested.
+ std::optional<std::filesystem::path> front;
+
+ /// Staged replacement foil control, when requested.
+ std::optional<std::filesystem::path> foil;
+
+ /// Browser-rendered thumbnail for a changed resulting foil card.
+ std::optional<std::filesystem::path> thumbnail;
+};
+
/// Coordinate image processing, persistence, and asset publication.
class CardService
{
@@ -53,6 +108,9 @@ public:
/// Create a loose card and return its canonical public ID.
mw::E<std::string> createLooseCard(CreateLooseCardInput input);
+ /// Update a loose card and return its unchanged canonical public ID.
+ mw::E<std::string> updateLooseCard(UpdateLooseCardInput input);
+
private:
DataSourceInterface& data_source_;
ImageProcessor image_processor_;
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index f1ca818..097f924 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -235,9 +235,69 @@ public:
/// Re-read a card while the transaction lock is held.
mw::E<std::optional<Card>> getCardForUpdate(
- [[maybe_unused]] std::int64_t card_id) override
+ std::int64_t card_id) override
{
- return std::unexpected(notImplemented("transactional card reads"));
+ auto statement = connection_.statementFromStr(
+ "SELECT id, game_short_name, card_number, name, "
+ "short_description, long_description, rarity, "
+ "front_extension, foil_extension, thumbnail_extension, "
+ "revision FROM cards WHERE id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_.eval<
+ std::int64_t,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::string,
+ std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<Card>{};
+ }
+
+ auto& [
+ id,
+ game_short_name,
+ card_number,
+ name,
+ short_description,
+ long_description,
+ rarity,
+ front_extension,
+ foil_extension,
+ thumbnail_extension,
+ revision] = rows->front();
+ Card card = {
+ id,
+ {std::move(game_short_name),
+ static_cast<std::uint64_t>(card_number)},
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision,
+ };
+ return std::optional<Card>(std::move(card));
}
/// Insert common and series-membership rows for a loose card.
@@ -341,13 +401,66 @@ public:
/// Replace card rows after validation by the service layer.
mw::E<void> updateCard(
- [[maybe_unused]] const Card& card,
- [[maybe_unused]] const GameDefinition* game,
- [[maybe_unused]] const GameCardMetadata* metadata,
- [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+ const Card& card,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids)
override
{
- return std::unexpected(notImplemented("card updates"));
+ if(card.id <= 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "An updated card requires an internal ID"));
+ }
+ if(card.identity.game_short_name || game != nullptr ||
+ metadata != nullptr || !series_ids.empty())
+ {
+ return std::unexpected(notImplemented(
+ "game-specific card updates"));
+ }
+
+ auto statement = connection_.statementFromStr(
+ "UPDATE cards SET name = ?, short_description = ?, "
+ "long_description = ?, rarity = ?, front_extension = ?, "
+ "foil_extension = ?, thumbnail_extension = ?, revision = ? "
+ "WHERE id = ? AND game_short_name IS NULL "
+ "AND card_number = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ card.name,
+ card.short_description,
+ card.long_description,
+ card.rarity,
+ card.front_extension,
+ card.foil_extension,
+ card.thumbnail_extension,
+ card.revision,
+ card.id,
+ static_cast<std::int64_t>(card.identity.card_number));
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto update = connection_.execute(std::move(*statement));
+ if(!update)
+ {
+ return std::unexpected(std::move(update.error()));
+ }
+ auto changes = connection_.evalToValue<std::int64_t>(
+ "SELECT changes();");
+ if(!changes)
+ {
+ return std::unexpected(std::move(changes.error()));
+ }
+ if(*changes != 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "The card disappeared while it was being updated"));
+ }
+ return {};
}
/// Delete a card and its dependent database rows.
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index 0a37ec9..ab2af04 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -33,6 +33,9 @@ bool isTextField(std::string_view name)
"source_mode",
"front_url",
"foil_url",
+ "front_action",
+ "foil_action",
+ "revision",
"series_id",
};
return names.contains(std::string(name));
diff --git a/static/card_form.js b/static/card_form.js
index bced84a..a73aa1e 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -1,4 +1,36 @@
-/** Show only the image-source fields selected for the create-card form. */
+/** Return the non-executable form configuration emitted by the server. */
+function getCardPageData()
+{
+ return JSON.parse(document.getElementById("CardPageData").textContent);
+}
+
+/** Return whether the current form is editing an existing card. */
+function isEditForm()
+{
+ return getCardPageData().mode == "edit";
+}
+
+/** Return whether the form requests replacement front artwork. */
+function replacesFront()
+{
+ if(!isEditForm())
+ {
+ return true;
+ }
+ return document.getElementById("CardFrontAction").value == "replace";
+}
+
+/** Return whether the form requests replacement foil control. */
+function replacesFoil()
+{
+ if(!isEditForm())
+ {
+ return true;
+ }
+ return document.getElementById("CardFoilAction").value == "replace";
+}
+
+/** Show and enable image-source fields required by the current actions. */
function updateSourceMode()
{
const selected_mode = document.querySelector(
@@ -9,16 +41,18 @@ function updateSourceMode()
file_fields.hidden = !uses_files;
url_fields.hidden = uses_files;
- for(const input of file_fields.querySelectorAll("input"))
- {
- input.disabled = !uses_files;
- }
- for(const input of url_fields.querySelectorAll("input"))
- {
- input.disabled = uses_files;
- }
- document.getElementById("CardFrontFile").required = uses_files;
- document.getElementById("CardFrontUrl").required = !uses_files;
+ const front_file = document.getElementById("CardFrontFile");
+ const foil_file = document.getElementById("CardFoilFile");
+ const front_url = document.getElementById("CardFrontUrl");
+ const foil_url = document.getElementById("CardFoilUrl");
+ front_file.disabled = !uses_files || !replacesFront();
+ foil_file.disabled = !uses_files || !replacesFoil();
+ front_url.disabled = uses_files || !replacesFront();
+ foil_url.disabled = uses_files || !replacesFoil();
+ front_file.required = uses_files && replacesFront();
+ front_url.required = !uses_files && replacesFront();
+ foil_file.required = uses_files && isEditForm() && replacesFoil();
+ foil_url.required = !uses_files && isEditForm() && replacesFoil();
}
/** Display the selected image filename beside one file input. */
@@ -36,17 +70,66 @@ function setInputFile(input, blob, filename)
input.files = transfer.files;
}
-/** Refresh the WebGL preview from the currently selected local files. */
-function updateLocalPreview()
+/** Fetch an existing same-origin image for a mixed edit preview. */
+async function fetchExistingImage(url, filename)
+{
+ const response = await fetch(url, {credentials: "same-origin"});
+ if(!response.ok)
+ {
+ throw(new Error(
+ `Failed to load the current card image: HTTP ${response.status}`));
+ }
+ const blob = await response.blob();
+ return new File([blob], filename, {type: blob.type});
+}
+
+/** Resolve retained and replacement files for the current preview. */
+async function getPreviewFiles()
+{
+ const page_data = getCardPageData();
+ const selected_front = document.getElementById(
+ "CardFrontFile").files[0];
+ const selected_foil = document.getElementById(
+ "CardFoilFile").files[0];
+ if(page_data.mode == "create")
+ {
+ return {front: selected_front, foil: selected_foil};
+ }
+
+ let front = selected_front;
+ if(!replacesFront() || front == null)
+ {
+ front = await fetchExistingImage(
+ page_data.front_url, "current-front");
+ }
+ const foil_action = document.getElementById("CardFoilAction").value;
+ let foil = selected_foil;
+ if(foil_action == "remove")
+ {
+ foil = null;
+ }
+ else if((foil_action == "keep" || foil == null) &&
+ page_data.foil_url != null)
+ {
+ foil = await fetchExistingImage(
+ page_data.foil_url, "current-foil");
+ }
+ return {front, foil};
+}
+
+/** Refresh the WebGL preview from retained and selected image files. */
+async function updateLocalPreview()
{
const preview = window.cardPreview;
if(preview == null)
{
- return Promise.resolve();
+ return;
+ }
+ const files = await getPreviewFiles();
+ if(files.front != null)
+ {
+ await preview.setFiles(files.front, files.foil);
}
- const front = document.getElementById("CardFrontFile").files[0];
- const foil = document.getElementById("CardFoilFile").files[0];
- return preview.setFiles(front, foil);
}
/** Fetch one CORS-enabled URL into its corresponding file input. */
@@ -91,28 +174,48 @@ async function prepareCardSubmission(event)
try
{
+ const page_data = getCardPageData();
+ const foil_action = page_data.mode == "edit"
+ ? document.getElementById("CardFoilAction").value
+ : "replace";
+ const rendering_changed = page_data.mode == "create" ||
+ replacesFront() || foil_action != "keep";
const mode = document.querySelector(
'input[name="source_mode"]:checked').value;
const front_input = document.getElementById("CardFrontFile");
const foil_input = document.getElementById("CardFoilFile");
if(mode == "urls")
{
- foil_input.value = "";
- await fetchImageInput(
- document.getElementById("CardFrontUrl"),
- front_input,
- "front-upload");
- const foil_url = document.getElementById("CardFoilUrl");
- if(foil_url.value != "")
+ if(replacesFront())
+ {
+ await fetchImageInput(
+ document.getElementById("CardFrontUrl"),
+ front_input,
+ "front-upload");
+ front_input.disabled = false;
+ }
+ if(replacesFoil())
{
- await fetchImageInput(foil_url, foil_input, "foil-upload");
+ foil_input.value = "";
+ const foil_url = document.getElementById("CardFoilUrl");
+ if(foil_url.value != "")
+ {
+ await fetchImageInput(
+ foil_url, foil_input, "foil-upload");
+ }
+ foil_input.disabled = false;
}
- front_input.disabled = false;
- foil_input.disabled = false;
}
- await updateLocalPreview();
- if(foil_input.files.length != 0)
+ if(rendering_changed)
+ {
+ await updateLocalPreview();
+ }
+ const resulting_foil = foil_action != "remove" &&
+ (foil_action == "replace"
+ ? foil_input.files.length != 0
+ : page_data.foil_url != null);
+ if(rendering_changed && resulting_foil)
{
if(window.cardPreview == null)
{
@@ -170,6 +273,21 @@ function initializeCardForm()
document.getElementById("CardFoilFileMeta"));
updateLocalPreview().catch(console.error);
});
+ if(isEditForm())
+ {
+ document.getElementById("CardFrontAction").addEventListener(
+ "change", function updateFrontAction()
+ {
+ updateSourceMode();
+ updateLocalPreview().catch(console.error);
+ });
+ document.getElementById("CardFoilAction").addEventListener(
+ "change", function updateFoilAction()
+ {
+ updateSourceMode();
+ updateLocalPreview().catch(console.error);
+ });
+ }
document.getElementById("CardForm").addEventListener(
"submit", prepareCardSubmission);
updateSourceMode();
diff --git a/static/css/styles.css b/static/css/styles.css
index 0f2727e..5861370 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -520,6 +520,16 @@ h1 {
transform: translateX(-0.2rem);
}
+.card-actions {
+ display: flex;
+ justify-content: space-between;
+ margin: -0.5rem 0 1.5rem -0.65rem;
+}
+
+.card-actions .back-link {
+ margin: 0;
+}
+
.card-view-id {
margin: 0 0 0.4rem;
color: var(--violet);
diff --git a/templates/card_form.html b/templates/card_form.html
index 9065b24..f07ffcd 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -15,33 +15,69 @@
<aside class="card-info-panel card-form-panel"
aria-labelledby="CardFormHeading">
<a class="back-link" href="{{ back_url }}">← All cards</a>
- <p class="card-view-id">New addition</p>
- <h1 id="CardFormHeading">Create card</h1>
+ <p class="card-view-id">{{ display_id }}</p>
+ <h1 id="CardFormHeading">{{ heading }}</h1>
<form id="CardForm" class="card-form" method="post"
action="{{ action_url }}" enctype="multipart/form-data">
<section class="form-section" aria-labelledby="IdentityHeading">
<h2 id="IdentityHeading">Identity</h2>
+ {% if mode == "edit" %}
+ <input name="revision" type="hidden" value="{{ revision }}">
+ {% endif %}
<label class="form-field" for="CardGame">
<span>Game</span>
- <select id="CardGame" name="game">
+ <select id="CardGame" name="game"
+ {% if mode == "edit" %}disabled{% endif %}>
<option value="">Loose card</option>
</select>
</label>
<label class="form-field" for="CardName">
<span>Name</span>
<input id="CardName" name="name" type="text"
- maxlength="200" autocomplete="off" required>
+ maxlength="200" autocomplete="off"
+ value="{{ name }}" required>
</label>
<label class="form-field" for="CardRarity">
<span>Rarity</span>
<input id="CardRarity" name="rarity" type="number"
- min="0" step="1" value="0" required>
+ min="0" step="1" value="{{ rarity }}" required>
</label>
</section>
<fieldset class="form-section image-source-section">
<legend>Card images</legend>
+ {% if mode == "edit" %}
+ <label class="form-field" for="CardFrontAction">
+ <span>Front artwork</span>
+ <select id="CardFrontAction" name="front_action">
+ <option value="keep">Keep current</option>
+ <option value="replace">Replace</option>
+ </select>
+ </label>
+ <label class="form-field" for="CardFoilAction">
+ <span>Foil control</span>
+ <select id="CardFoilAction" name="foil_action">
+ <option value="keep">
+ {% if has_foil %}
+ Keep current
+ {% else %}
+ No foil
+ {% endif %}
+ </option>
+ <option value="replace">
+ {% if has_foil %}
+ Replace
+ {% else %}
+ Add foil
+ {% endif %}
+ </option>
+ {% if has_foil %}
+ <option value="remove">Remove</option>
+ {% endif %}
+ </select>
+ </label>
+ {% endif %}
<div class="source-toggle">
<label>
<input type="radio" name="source_mode"
@@ -63,7 +99,7 @@
<span>Front artwork</span>
<input id="CardFrontFile" name="front" type="file"
accept="image/png,image/jpeg,image/webp,.avif"
- required>
+ {% if mode == "create" %}required{% endif %}>
<small id="CardFrontFileMeta">No file selected</small>
</label>
<label class="form-field image-field"
@@ -99,17 +135,21 @@
<label class="form-field" for="CardShortDescription">
<span>Short description <em>Optional</em></span>
<textarea id="CardShortDescription"
- name="short_description" rows="3"></textarea>
+ name="short_description"
+ rows="3">{{ short_description }}</textarea>
</label>
<label class="form-field" for="CardLongDescription">
<span>Long description <em>Optional</em></span>
<textarea id="CardLongDescription"
- name="long_description" rows="7"></textarea>
+ name="long_description"
+ rows="7">{{ long_description }}</textarea>
</label>
<p class="form-hint">Descriptions support Markdown.</p>
</section>
- <button class="form-submit" type="submit">Create card</button>
+ <button class="form-submit" type="submit">
+ {{ submit_label }}
+ </button>
<p id="CardFormStatus" class="form-status" role="status"
aria-live="polite"></p>
</form>
@@ -118,9 +158,9 @@
<script id="CardPageData" type="application/json">
{
- "mode": "create",
+ "mode": "{{ mode }}",
"front_url": "{{ front_url }}",
- "foil_url": null,
+ "foil_url": {% if has_foil %}"{{ foil_url }}"{% else %}null{% endif %},
"model_url": "{{ model_url }}",
"vertex_shader_url": "{{ shader_vertex_url }}",
"fragment_shader_url": "{{ shader_fragment_url }}",
diff --git a/templates/card_view.html b/templates/card_view.html
index 9088fde..64904a5 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -14,7 +14,10 @@
</section>
<aside class="card-info-panel" aria-labelledby="CardHeading">
- <a class="back-link" href="{{ back_url }}">← All cards</a>
+ <nav class="card-actions" aria-label="Card actions">
+ <a class="back-link" href="{{ back_url }}">← All cards</a>
+ <a class="back-link" href="{{ edit_url }}">Edit card</a>
+ </nav>
<p class="card-view-id">{{ display_id }}</p>
<h1 id="CardHeading">{{ name }}</h1>
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 425fe37..034f9e7 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -168,6 +168,40 @@ TEST(AppTest, RendersCardCreationForm)
std::string::npos);
}
+/// Verify the edit page reuses the card form with current values and actions.
+TEST(AppTest, RendersCardEditForm)
+{
+ Card card = makeCard(2, 35, "Moon card");
+ card.identity.game_short_name = std::nullopt;
+ card.short_description = "A quiet night.";
+ card.rarity = 4;
+ auto data_source = std::make_unique<DataSourceFake>(
+ std::vector<Card>{card});
+ App app(
+ makeConfig("https://example.test/collection/"),
+ std::move(data_source));
+ auto public_id = formatPublicId(card.identity);
+ ASSERT_TRUE(public_id);
+ App::Request request;
+ request.path_params.emplace("id", *public_id);
+ App::Response response;
+
+ app.handleCardEdit(request, response);
+
+ EXPECT_EQ(response.status, 200);
+ EXPECT_NE(response.body.find("Edit card"), std::string::npos);
+ EXPECT_NE(response.body.find("value=\"Moon card\""),
+ std::string::npos);
+ EXPECT_NE(response.body.find("name=\"revision\""),
+ std::string::npos);
+ EXPECT_NE(response.body.find("name=\"front_action\""),
+ std::string::npos);
+ EXPECT_NE(response.body.find("name=\"foil_action\""),
+ std::string::npos);
+ EXPECT_NE(response.body.find("\"mode\": \"edit\""),
+ std::string::npos);
+}
+
/// Verify a card page renders fake metadata and read-only preview data.
TEST(AppTest, RendersCardView)
{
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 0b3eb51..1b1c943 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -220,6 +220,127 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
}
+/// Verify metadata and artwork edits increment revisions and replace assets.
+TEST(CardServiceTest, UpdatesLooseCard)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+ GameRegistry games;
+ auto data_source = prepareDataSource(database, games);
+ ASSERT_TRUE(data_source);
+
+ const std::filesystem::path create_staging =
+ temporary.path() / ".staging/upload";
+ const std::filesystem::path original_front =
+ create_staging / "upload_front";
+ writePng(original_front);
+ CardService service(
+ **data_source,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ auto public_id = service.createLooseCard({
+ "Original",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ create_staging,
+ original_front,
+ std::nullopt,
+ std::nullopt,
+ });
+ ASSERT_TRUE(public_id);
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ const Card original_card = cards->front();
+
+ const std::filesystem::path metadata_staging =
+ temporary.path() / ".staging/metadata";
+ std::filesystem::create_directory(metadata_staging);
+ auto metadata_update = service.updateLooseCard({
+ original_card,
+ 1,
+ "Edited metadata",
+ std::optional<std::string>("Summary"),
+ std::nullopt,
+ 3,
+ metadata_staging,
+ FrontAssetAction::KEEP,
+ FoilAssetAction::KEEP,
+ std::nullopt,
+ std::nullopt,
+ std::nullopt,
+ });
+ ASSERT_TRUE(metadata_update) << metadata_update.error().msg();
+ EXPECT_EQ(*metadata_update, *public_id);
+ cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ EXPECT_EQ(cards->front().name, "Edited metadata");
+ EXPECT_EQ(cards->front().revision, 2);
+ const std::filesystem::path published =
+ temporary.path() / "published" / *public_id;
+ EXPECT_TRUE(
+ std::filesystem::is_regular_file(published / "front-art.avif"));
+
+ const std::filesystem::path stale_staging =
+ temporary.path() / ".staging/stale";
+ std::filesystem::create_directory(stale_staging);
+ auto stale_update = service.updateLooseCard({
+ original_card,
+ 1,
+ "Stale edit",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ stale_staging,
+ FrontAssetAction::KEEP,
+ FoilAssetAction::KEEP,
+ std::nullopt,
+ std::nullopt,
+ std::nullopt,
+ });
+ ASSERT_FALSE(stale_update);
+ const mw::HTTPError* stale_error =
+ stale_update.error().as<mw::HTTPError>();
+ ASSERT_NE(stale_error, nullptr);
+ EXPECT_EQ(stale_error->code, 409);
+
+ const std::filesystem::path artwork_staging =
+ temporary.path() / ".staging/artwork";
+ std::filesystem::create_directory(artwork_staging);
+ const std::filesystem::path replacement_front =
+ artwork_staging / "upload_front";
+ writeJpeg(replacement_front);
+ auto artwork_update = service.updateLooseCard({
+ cards->front(),
+ 2,
+ "Edited artwork",
+ std::nullopt,
+ std::nullopt,
+ 1,
+ artwork_staging,
+ FrontAssetAction::REPLACE,
+ FoilAssetAction::KEEP,
+ replacement_front,
+ std::nullopt,
+ std::nullopt,
+ });
+ ASSERT_TRUE(artwork_update) << artwork_update.error().msg();
+ cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ EXPECT_EQ(cards->front().front_extension, "jpg");
+ EXPECT_EQ(cards->front().thumbnail_extension, "avif");
+ EXPECT_EQ(cards->front().revision, 3);
+ EXPECT_TRUE(
+ std::filesystem::is_regular_file(published / "front-art.jpg"));
+ EXPECT_FALSE(
+ std::filesystem::exists(published / "front-art.avif"));
+ EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
+}
+
/// Verify multipart binaries use server paths and empty file controls vanish.
TEST(MultipartReaderTest, StreamsExpectedFields)
{
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 00a9c9b..56fdde3 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -218,6 +218,44 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
EXPECT_EQ(cards->front().name, "New card");
}
+/// Verify transactional reads and updates preserve a loose-card identity.
+TEST(DataSourceSQLiteTest, UpdatesLooseCard)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto card_id = (*transaction)->insertCard(
+ makeLooseCard(42, "Original"), nullptr, nullptr, {});
+ ASSERT_TRUE(card_id);
+ ASSERT_TRUE((*transaction)->commit());
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto card = (*transaction)->getCardForUpdate(*card_id);
+ ASSERT_TRUE(card);
+ ASSERT_TRUE(*card);
+ (**card).name = "Edited";
+ (**card).rarity = 5;
+ (**card).foil_extension = "webp";
+ (**card).revision = 2;
+ ASSERT_TRUE((*transaction)->updateCard(
+ **card, nullptr, nullptr, {}));
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto stored = (*data_source)->getCard({std::nullopt, 42});
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ EXPECT_EQ((**stored).id, *card_id);
+ EXPECT_EQ((**stored).identity.card_number, 42);
+ EXPECT_EQ((**stored).name, "Edited");
+ EXPECT_EQ((**stored).rarity, 5);
+ EXPECT_EQ((**stored).foil_extension, "webp");
+ EXPECT_EQ((**stored).revision, 2);
+}
+
/// Verify destroying an uncommitted transaction rolls card insertion back.
TEST(DataSourceSQLiteTest, RollsBackLooseCard)
{