[Client] DeckSyncProfile + sidecar + DeckDiff with tests

This commit is contained in:
Lukas Brübach 2026-10-11 14:26:12 +02:00
parent 11fdfb4610
commit be072ffd68
12 changed files with 742 additions and 0 deletions

View file

@ -26,6 +26,8 @@ set(cockatrice_SOURCES
src/client/settings/shortcuts_settings.cpp
src/interface/deck_loader/card_node_function.cpp
src/interface/deck_loader/deck_loader.cpp
src/interface/deck_sync/deck_sync_profile.cpp
src/interface/deck_sync/deck_sync_sidecar.cpp
src/interface/widgets/dialogs/dlg_connect.cpp
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp

View file

@ -0,0 +1,31 @@
#include "deck_sync_profile.h"
#include <QCryptographicHash>
DeckSyncProfile::DeckSyncProfile(const QString &_host, int _port, const QString &_username)
: host(_host), port(_port), username(_username)
{
}
bool DeckSyncProfile::isValid() const
{
return !host.isEmpty() && port > 0 && !username.isEmpty();
}
QString DeckSyncProfile::key() const
{
if (!isValid()) {
return {};
}
// NUL separators keep the triple injective: neither IPv6 hosts nor
// usernames containing ':' can collide two profiles into one file.
// Lowercasing is safe on both parts: DNS names are case-insensitive, and
// Servatrice usernames are unique under a case-insensitive collation
// (cockatrice_users uses utf8mb4_unicode_ci), so both spellings are one account.
QByteArray blob = host.trimmed().toLower().toUtf8();
blob.append('\0');
blob.append(QByteArray::number(port));
blob.append('\0');
blob.append(username.trimmed().toLower().toUtf8());
return QCryptographicHash::hash(blob, QCryptographicHash::Sha1).toHex();
}

View file

@ -0,0 +1,43 @@
/**
* @file deck_sync_profile.h
* @brief Identity of one sync target (server + user) for deck collection sync.
*/
#ifndef DECK_SYNC_PROFILE_H
#define DECK_SYNC_PROFILE_H
#include <QString>
/**
* @class DeckSyncProfile
* @brief Identifies which server and login a sync sidecar belongs to.
*
* A local deck folder can sync against several servers. Every server login
* gets its own profile, and every profile gets its own sidecar file, so
* switching servers never mixes up remote deck ids.
*/
class DeckSyncProfile
{
public:
QString host; ///< Server hostname as entered by the user.
int port = 0; ///< Server port.
QString username; ///< Login name on that server.
DeckSyncProfile() = default;
DeckSyncProfile(const QString &_host, int _port, const QString &_username);
/** @brief False when any of host, port or username is missing. */
[[nodiscard]] bool isValid() const;
/**
* @brief Stable file-system-safe key for this profile.
*
* SHA1 hex over the NUL-separated lowercased "host", port and "username"
* triple, so the same server reached with different hostname
* capitalisation maps to one file, while IPv6 hosts and usernames
* containing ':' can never collide two profiles. Empty for invalid
* profiles, so no two half-configured profiles share a file.
*/
[[nodiscard]] QString key() const;
};
#endif // DECK_SYNC_PROFILE_H

View file

@ -0,0 +1,98 @@
#include "deck_sync_sidecar.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
namespace
{
constexpr int SIDECAR_FORMAT_VERSION = 1;
} // namespace
DeckSyncSidecar::DeckSyncSidecar(const QString &_filePath) : filePath(_filePath)
{
}
bool DeckSyncSidecar::load()
{
recordMap.clear();
QFile file(filePath);
if (!file.exists()) {
return true;
}
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject() || !document.object().value("records").isObject()) {
return false;
}
const QJsonObject records = document.object().value("records").toObject();
for (auto it = records.begin(); it != records.end(); ++it) {
if (!it.value().isObject()) {
continue;
}
const QJsonObject entry = it.value().toObject();
DeckSyncRecord record;
record.guid = entry.value("guid").toString();
record.remoteId = entry.value("remoteId").toInt(-1);
record.baseHash = entry.value("baseHash").toString();
record.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble());
record.deleted = entry.value("deleted").toBool(false);
recordMap.insert(it.key(), record);
}
return true;
}
bool DeckSyncSidecar::save() const
{
QJsonObject records;
for (auto it = recordMap.begin(); it != recordMap.end(); ++it) {
QJsonObject entry;
entry.insert("guid", it.value().guid);
entry.insert("remoteId", it.value().remoteId);
entry.insert("baseHash", it.value().baseHash);
entry.insert("lastSync", static_cast<double>(it.value().lastSyncSecs));
entry.insert("deleted", it.value().deleted);
records.insert(it.key(), entry);
}
QJsonObject root;
root.insert("version", SIDECAR_FORMAT_VERSION);
root.insert("records", records);
QDir().mkpath(QFileInfo(filePath).absolutePath());
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
return true;
}
bool DeckSyncSidecar::contains(const QString &relativePath) const
{
return recordMap.contains(relativePath);
}
DeckSyncRecord DeckSyncSidecar::get(const QString &relativePath) const
{
return recordMap.value(relativePath, DeckSyncRecord());
}
void DeckSyncSidecar::set(const QString &relativePath, const DeckSyncRecord &record)
{
recordMap.insert(relativePath, record);
}
void DeckSyncSidecar::remove(const QString &relativePath)
{
recordMap.remove(relativePath);
}
QMap<QString, DeckSyncRecord> DeckSyncSidecar::records() const
{
return recordMap;
}

View file

@ -0,0 +1,52 @@
/**
* @file deck_sync_sidecar.h
* @brief Per-profile local bookkeeping for deck collection sync.
*/
#ifndef DECK_SYNC_SIDECAR_H
#define DECK_SYNC_SIDECAR_H
#include <QMap>
#include <QString>
/** @brief Sync bookkeeping for one local deck file. */
struct DeckSyncRecord
{
QString guid; ///< Stable deck identity shared with the server.
int remoteId = -1; ///< Server row id, -1 when never uploaded.
QString baseHash; ///< Deck hash at the last successful sync.
qint64 lastSyncSecs = 0; ///< Last sync time, seconds since Unix epoch.
bool deleted = false; ///< Local tombstone waiting to be pushed.
};
/**
* @class DeckSyncSidecar
* @brief Loads and stores sync records as JSON next to the deck folder.
*
* One sidecar file per DeckSyncProfile (named by profile key), mapping paths
* relative to the deck folder to their last-synced state. Comparing the live
* file hash against baseHash tells clean apart from locally modified.
*/
class DeckSyncSidecar
{
public:
QString filePath; ///< Full path of the sidecar JSON file.
explicit DeckSyncSidecar(const QString &_filePath);
/** @brief Loads records; a missing file loads as empty and returns true. */
bool load();
/** @brief Stores records, creating parent folders as needed. */
bool save() const;
[[nodiscard]] bool contains(const QString &relativePath) const;
/** @brief Returns the record, or a default record when unknown. */
[[nodiscard]] DeckSyncRecord get(const QString &relativePath) const;
void set(const QString &relativePath, const DeckSyncRecord &record);
void remove(const QString &relativePath);
[[nodiscard]] QMap<QString, DeckSyncRecord> records() const;
private:
QMap<QString, DeckSyncRecord> recordMap;
};
#endif // DECK_SYNC_SIDECAR_H