Compare commits

...

13 commits

Author SHA1 Message Date
Lukas Brübach
34e22df5ba [DeckSync] Match server rows by identity and hash the whole deck
Sync keyed local files to server rows by path, but the server names a row
after the deck, not after the file, so any deck whose file name and deck name
differ uploaded again on every run. A second pass then brought the server copy
back as an extra local file.

- Match by the sidecar's row id, then guid, then content, and only then by the
  server path, so renaming either side no longer duplicates a deck.
- Key the server tree by row id: the server allows several rows with the same
  name in one folder, and keying by path silently dropped all but one.
- Stop discarding rows whose name contains '/' ("U/W Control"). They stay
  matchable, and only the download file name is sanitised.
- Build the download path from the folder plus a sanitised row name, including
  every folder segment, so a hostile server name cannot escape the deck folder.
- Download a colliding server row under a unique name and count it, instead of
  skipping it without a trace.
- Hash the whole deck instead of the card hash: SHA-1 over the native deck
  string covers name, printings, banner, tokens and sideboard plans. The client
  and the server hash the exact bytes they exchange, so both agree.

Adds deck_content_hash_test for the client/server hashing invariant and for the
name-only change the card hash cannot see.
2026-11-05 12:51:01 +01:00
Lukas Brübach
aa0ab333f6 [DeckSync] Pass the sync summary through the signal and tidy the manager API
- Emit SyncSummary from syncFinished() instead of four positional ints, and
  build it from a single currentSummary() helper, so the counts cannot drift
  between the status message and the signal.
- Use a designated initializer for LocalFile, like the other records.
- Drop setClient() and setColorIdentityProvider(): both fields are already
  constructor parameters and the setters had no callers. setProfile() and
  setDeckPath() stay, since the tab re-reads them before every run.
- Fix the include paths in tab_deck_storage.h and the narrowing conversion of
  the tree item id, so the branch builds again.
2026-11-05 12:51:01 +01:00
BruebachL
8367deefaf [Format] Fix include order in tab_deck_storage.h 2026-11-05 12:51:01 +01:00
BruebachL
8846a6f5ca [DeckSync] Use designated initializer for ServerFile per review 2026-11-05 12:51:01 +01:00
BruebachL
5b51fc8810 [DeckSync] Prefer ctor params for set-once client/provider per review 2026-11-05 12:51:01 +01:00
BruebachL
aa5cadd4eb [DeckSync] Move SyncSummary into DeckSyncManager per review 2026-11-05 12:51:01 +01:00
Lukas Brübach
c0aa30f75f Fix ZeldaZach review for PR 7493: match uploads by sidecar remoteId/guid, validate server paths 2026-11-05 12:51:01 +01:00
Lukas Brübach
9c5ddc889c [Client] Manual deck collection sync with Sync Now action 2026-11-05 12:51:01 +01:00
Lukas Brübach
0c6d64c565 Cover merge printing preservation with a test
Base deck with 4 Mountain (DOM 250), local adds 2 Shock (M19 156), remote
adds an unrelated card. Asserts the merge keeps one Mountain node with its
printing and adds a single Shock node carrying M19/156.
2026-11-05 12:50:48 +01:00
BruebachL
507a80f0b2 [DeckDiff] Use namespace instead of static-only class per review 2026-11-05 12:50:48 +01:00
Lukas Brübach
02ccad2374 Fixup: clang-format alignment in deck_diff.h 2026-11-05 12:50:48 +01:00
Lukas Brübach
473b255959 Fix ZeldaZach review for PR 7492: preserve printing in DeckDiff::merge, atomic sidecar save with QSaveFile 2026-11-05 12:50:48 +01:00
Lukas Brübach
1e97ab086b [Client] DeckSyncProfile + sidecar + DeckDiff with tests 2026-11-05 12:50:48 +01:00
20 changed files with 1776 additions and 2 deletions

View file

@ -26,6 +26,9 @@ 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/deck_sync/deck_sync_manager.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,602 @@
#include "deck_sync_manager.h"
#include "../deck_loader/deck_loader.h"
#include "deck_sync_sidecar.h"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QStringList>
#include <QUuid>
#include <libcockatrice/deck_list/deck_file_format.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/string_limits.h>
namespace
{
constexpr const char *SYNC_META_DIR = ".sync";
constexpr const char *BACKUP_SUFFIX = ".sync-backup-";
QString serverPathFor(const QString &relativePath)
{
QString path = relativePath;
if (path.endsWith(".cod", Qt::CaseInsensitive)) {
path.chop(4);
}
return path;
}
// Rows are named after the deck, not after the file, so a row name may contain
// characters a file name cannot ("U/W Control"). Downloads therefore rebuild the
// local path from the folder plus a sanitised row name, and never from the raw
// server-supplied path.
QString sanitizedSegment(const QString &segment)
{
QString safe = segment;
safe.replace('/', '-');
safe.replace('\\', '-');
safe = safe.trimmed();
if (safe.isEmpty() || safe == QStringLiteral(".") || safe == QStringLiteral("..")) {
return QStringLiteral("deck");
}
return safe;
}
QString localPathFor(const QString &dir, const QString &name)
{
// Every segment is server-supplied, so each one is sanitised before it can
// become part of a local path.
QStringList segments;
for (const QString &segment : dir.split('/', Qt::SkipEmptyParts)) {
segments << sanitizedSegment(segment);
}
segments << sanitizedSegment(name);
const QString path = segments.join('/');
return path.endsWith(".cod", Qt::CaseInsensitive) ? path : path + QStringLiteral(".cod");
}
} // namespace
DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent)
{
}
DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider provider, QObject *parent)
: QObject(parent), client(_client), colorIdentityProvider(std::move(provider))
{
}
void DeckSyncManager::setProfile(const DeckSyncProfile &_profile)
{
profile = _profile;
}
void DeckSyncManager::setDeckPath(const QString &path)
{
deckPath = path;
}
bool DeckSyncManager::isSyncing() const
{
return syncing;
}
void DeckSyncManager::syncNow()
{
if (syncing) {
return;
}
if (client == nullptr || !profile.isValid() || deckPath.isEmpty()) {
emit syncFinished(SyncSummary{}, tr("Not connected to a server."));
return;
}
QDir deckDir(deckPath);
if (!deckDir.exists()) {
emit syncFinished(SyncSummary{}, tr("Deck folder not found."));
return;
}
syncing = true;
uploadedCount = 0;
downloadedCount = 0;
unchangedCount = 0;
skippedCount = 0;
pendingUploads.clear();
pendingDownloads.clear();
ensuredDirs.clear();
ensuredDirs.insert(QString());
localFiles.clear();
emit syncStarted();
emit statusMessage(tr("Scanning local decks..."));
sidecarPath = deckDir.filePath(SYNC_META_DIR + QString("/") + profile.key() + ".json");
DeckSyncSidecar sidecar(sidecarPath);
if (!sidecar.load()) {
failSync(tr("Could not read sync state."));
return;
}
QDirIterator it(deckPath, QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories);
while (it.hasNext()) {
const QString absolutePath = it.next();
const QString relativePath = deckDir.relativeFilePath(absolutePath);
if (relativePath.startsWith(SYNC_META_DIR + QString("/")) ||
!absolutePath.endsWith(".cod", Qt::CaseInsensitive)) {
continue;
}
std::optional<LoadedDeck> loaded = DeckLoader::loadFromFile(absolutePath, DeckFileFormat::Cockatrice);
if (!loaded) {
++skippedCount;
continue;
}
localFiles.insert(relativePath, LocalFile{.absolutePath = absolutePath,
.hash = loaded->deckList.getContentHash(),
.modified = QFileInfo(absolutePath).lastModified()});
}
Command_DeckList cmd;
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onListFinished);
client->sendCommand(pend);
}
void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<int, ServerFile> &files,
QSet<QString> &dirs)
{
for (const auto &item : folder.items()) {
const QString name = QString::fromStdString(item.name());
const QString path = prefix.isEmpty() ? name : prefix + "/" + name;
if (item.has_folder()) {
dirs.insert(path);
collectServerFiles(item.folder(), path, files, dirs);
} else if (item.has_file()) {
const ServerInfo_DeckStorage_File &file = item.file();
const int id = static_cast<int>(item.id());
ServerFile entry{
.id = id,
.path = path,
.dir = prefix,
.name = name,
.guid = QString::fromStdString(file.guid()),
.hash = QString::fromStdString(file.hash()),
.lastModifiedSecs = file.has_last_modified() ? static_cast<qint64>(file.last_modified()) : 0,
};
// Keyed by id: the server allows several rows with the same name in a
// folder, and keying by path silently dropped all but one of them.
files.insert(id, entry);
}
}
}
void DeckSyncManager::onListFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (!syncing) {
return;
}
if (response.response_code() != Response::RespOk) {
failSync(tr("Could not read server decks."));
return;
}
QMap<int, ServerFile> serverFiles;
QSet<QString> serverDirs;
const Response_DeckList &list = response.GetExtension(Response_DeckList::ext);
collectServerFiles(list.root(), QString(), serverFiles, serverDirs);
ensuredDirs.unite(serverDirs);
emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size()));
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
const qint64 nowSecs = QDateTime::currentDateTime().toSecsSinceEpoch();
// Reuse the sidecar's guid so the server keeps recognizing the row, and mint
// one only for a deck that has never been synced.
auto guidFor = [&sidecar](const QString &relativePath) {
const DeckSyncRecord record = sidecar.get(relativePath);
return record.guid.isEmpty() ? QUuid::createUuid().toString(QUuid::WithoutBraces) : record.guid;
};
QMap<QString, int> serverIdByPath;
QMap<QString, int> serverIdByGuid;
for (auto sit = serverFiles.begin(); sit != serverFiles.end(); ++sit) {
serverIdByPath.insert(sit.value().path, sit.key());
if (!sit.value().guid.isEmpty()) {
serverIdByGuid.insert(sit.value().guid, sit.key());
}
}
QSet<int> matchedServerIds;
// A local file without a usable sidecar record still usually has a server
// counterpart: rows are named after the deck, so compare content rather than
// file names, and only upload when no unmatched row has the same content.
auto unmatchedByHash = [&serverFiles, &matchedServerIds](const QString &hash) {
if (hash.isEmpty()) {
return -1;
}
for (auto sit = serverFiles.begin(); sit != serverFiles.end(); ++sit) {
if (!matchedServerIds.contains(sit.key()) && sit.value().hash == hash) {
return sit.key();
}
}
return -1;
};
for (auto it = localFiles.begin(); it != localFiles.end(); ++it) {
const DeckSyncRecord sidecarRecord = sidecar.get(it.key());
int serverId = -1;
if (sidecarRecord.remoteId != -1 && serverFiles.contains(sidecarRecord.remoteId)) {
serverId = sidecarRecord.remoteId;
} else if (!sidecarRecord.guid.isEmpty() && serverIdByGuid.contains(sidecarRecord.guid)) {
serverId = serverIdByGuid.value(sidecarRecord.guid);
} else {
// Content first: a row with the same deck is unambiguous, while a
// matching file name may belong to a different deck entirely.
serverId = unmatchedByHash(it.value().hash);
if (serverId == -1) {
const QString wantedPath = serverPathFor(it.key());
if (serverIdByPath.contains(wantedPath)) {
const int pathId = serverIdByPath.value(wantedPath);
if (!matchedServerIds.contains(pathId)) {
serverId = pathId;
}
}
}
}
if (serverId == -1) {
const QString serverPath = serverPathFor(it.key());
pendingUploads.append(UploadOp{.relativePath = it.key(),
.serverPath = serverPath,
.serverDir = parentDir(serverPath),
.deckId = -1,
.guid = guidFor(it.key())});
continue;
}
const ServerFile &server = serverFiles.value(serverId);
matchedServerIds.insert(serverId);
if (it.value().hash == server.hash) {
DeckSyncRecord record = sidecar.get(it.key());
if (record.guid.isEmpty()) {
record.guid = server.guid;
}
record.remoteId = server.id;
record.baseHash = it.value().hash;
record.lastSyncSecs = nowSecs;
record.deleted = false;
sidecar.set(it.key(), record);
++unchangedCount;
continue;
}
const qint64 localSecs = it.value().modified.toSecsSinceEpoch();
// A server row without last_modified predates sync: the local file wins.
// Otherwise strictly newer wins; ties go to the server.
if (server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs) {
pendingUploads.append(UploadOp{.relativePath = it.key(),
.serverPath = server.path,
.serverDir = server.dir,
.deckId = server.id,
.guid = server.guid});
} else {
pendingDownloads.append(DownloadOp{.relativePath = it.key(),
.deckId = server.id,
.guid = server.guid,
.hash = server.hash,
.lastModifiedSecs = server.lastModifiedSecs});
}
}
for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) {
if (matchedServerIds.contains(it.key())) {
continue;
}
const QString wantedPath = localPathFor(it.value().dir, it.value().name);
const QString relativePath = uniqueLocalPath(wantedPath);
if (relativePath.isEmpty()) {
++skippedCount;
continue;
}
pendingDownloads.append(DownloadOp{.relativePath = relativePath,
.deckId = it.value().id,
.guid = it.value().guid,
.hash = it.value().hash,
.lastModifiedSecs = it.value().lastModifiedSecs});
}
sidecar.save();
processNext();
}
QString DeckSyncManager::parentDir(const QString &path)
{
const int slash = path.lastIndexOf('/');
return slash == -1 ? QString() : path.left(slash);
}
void DeckSyncManager::processNext()
{
if (!syncing) {
return;
}
if (!pendingUploads.isEmpty()) {
processNextUpload();
} else if (!pendingDownloads.isEmpty()) {
sendDownload(pendingDownloads.first());
} else {
finishSync();
}
}
void DeckSyncManager::processNextUpload()
{
const UploadOp op = pendingUploads.first();
const QString dir = op.serverDir;
if (!ensuredDirs.contains(dir)) {
sendMkDirFor(dir);
return;
}
pendingUploads.removeFirst();
sendUpload(op);
}
void DeckSyncManager::sendMkDirFor(const QString &dir)
{
QStringList parts = dir.split('/');
QString made;
for (const QString &part : parts) {
const QString candidate = made.isEmpty() ? part : made + "/" + part;
if (!ensuredDirs.contains(candidate)) {
Command_DeckNewDir cmd;
cmd.set_path(made.toStdString());
cmd.set_dir_name(part.toStdString());
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
pend->setExtraData(dir);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onEnsureDirFinished);
client->sendCommand(pend);
return;
}
made = candidate;
}
processNextUpload();
}
void DeckSyncManager::onEnsureDirFinished(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (!syncing) {
return;
}
if (response.response_code() != Response::RespOk) {
failSync(tr("Could not create server folder."));
return;
}
const QString dir = extraData.toString();
QStringList parts = dir.split('/');
QString made;
for (const QString &part : parts) {
made = made.isEmpty() ? part : made + "/" + part;
if (!ensuredDirs.contains(made)) {
ensuredDirs.insert(made);
break;
}
}
processNextUpload();
}
void DeckSyncManager::sendUpload(const UploadOp &op)
{
emit statusMessage(tr("Uploading %1...").arg(op.relativePath));
std::optional<LoadedDeck> loaded =
DeckLoader::loadFromFile(localFiles.value(op.relativePath).absolutePath, DeckFileFormat::Cockatrice);
if (!loaded) {
++skippedCount;
processNext();
return;
}
const QString deckString = loaded->deckList.writeToString_Native();
if (deckString.length() > MAX_FILE_LENGTH) {
++skippedCount;
processNext();
return;
}
Command_DeckUpload cmd;
if (op.deckId == -1) {
cmd.set_path(op.serverDir.toStdString());
} else {
cmd.set_deck_id(op.deckId);
}
cmd.set_deck_list(deckString.toStdString());
cmd.set_guid(op.guid.toStdString());
if (colorIdentityProvider) {
cmd.set_color_identity(colorIdentityProvider(loaded->deckList).toStdString());
}
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
pend->setExtraData(op.relativePath);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onUploadFinished);
client->sendCommand(pend);
}
void DeckSyncManager::onUploadFinished(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (!syncing) {
return;
}
const QString relativePath = extraData.toString();
if (response.response_code() != Response::RespOk) {
++skippedCount;
processNext();
return;
}
const Response_DeckUpload &upload = response.GetExtension(Response_DeckUpload::ext);
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
DeckSyncRecord record = sidecar.get(relativePath);
if (upload.has_new_file()) {
const auto &file = upload.new_file();
if (!file.file().guid().empty()) {
record.guid = QString::fromStdString(file.file().guid());
}
if (!file.file().hash().empty()) {
record.baseHash = QString::fromStdString(file.file().hash());
}
if (file.file().has_last_modified()) {
record.lastSyncSecs = static_cast<qint64>(file.file().last_modified());
} else {
record.lastSyncSecs = QDateTime::currentDateTime().toSecsSinceEpoch();
}
record.remoteId = static_cast<int>(file.id());
} else if (localFiles.contains(relativePath)) {
record.baseHash = localFiles.value(relativePath).hash;
record.lastSyncSecs = QDateTime::currentDateTime().toSecsSinceEpoch();
}
record.deleted = false;
sidecar.set(relativePath, record);
sidecar.save();
++uploadedCount;
processNext();
}
QString DeckSyncManager::uniqueLocalPath(const QString &relativePath) const
{
if (!QFileInfo(deckPath + "/" + relativePath).exists()) {
return relativePath;
}
QString base = relativePath;
if (base.endsWith(".cod", Qt::CaseInsensitive)) {
base.chop(4);
}
for (int i = 2; i < 1000; ++i) {
const QString candidate = base + " (" + QString::number(i) + ").cod";
if (!QFileInfo(deckPath + "/" + candidate).exists()) {
return candidate;
}
}
return QString();
}
void DeckSyncManager::sendDownload(const DownloadOp &op)
{
emit statusMessage(tr("Downloading %1...").arg(op.relativePath));
Command_DeckDownload cmd;
cmd.set_deck_id(op.deckId);
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
pend->setExtraData(op.relativePath);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onDownloadFinished);
client->sendCommand(pend);
}
void DeckSyncManager::onDownloadFinished(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (!syncing) {
return;
}
const QString relativePath = extraData.toString();
for (int i = 0; i < pendingDownloads.size(); ++i) {
if (pendingDownloads.at(i).relativePath == relativePath) {
pendingDownloads.removeAt(i);
break;
}
}
if (response.response_code() != Response::RespOk) {
++skippedCount;
processNext();
return;
}
const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext);
DeckList deck;
if (!deck.loadFromString_Native(QString::fromStdString(download.deck()))) {
++skippedCount;
processNext();
return;
}
const QString cleanDeckPath = QDir::cleanPath(deckPath);
const QString absolutePath = QDir::cleanPath(deckPath + "/" + relativePath);
if (absolutePath != cleanDeckPath && !absolutePath.startsWith(cleanDeckPath + "/")) {
++skippedCount;
processNext();
return;
}
if (localFiles.contains(relativePath)) {
const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
if (!QFile::copy(absolutePath, backupPath)) {
++skippedCount;
processNext();
return;
}
}
QDir().mkpath(QFileInfo(absolutePath).absolutePath());
if (!DeckLoader::saveToFile(deck, absolutePath, DeckFileFormat::Cockatrice)) {
++skippedCount;
processNext();
return;
}
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
DeckSyncRecord record = sidecar.get(relativePath);
if (!download.guid().empty()) {
record.guid = QString::fromStdString(download.guid());
}
if (!download.hash().empty()) {
record.baseHash = QString::fromStdString(download.hash());
} else {
record.baseHash = deck.getContentHash();
}
record.lastSyncSecs = download.has_last_modified() ? static_cast<qint64>(download.last_modified())
: QDateTime::currentDateTime().toSecsSinceEpoch();
record.deleted = false;
sidecar.set(relativePath, record);
sidecar.save();
++downloadedCount;
processNext();
}
void DeckSyncManager::failSync(const QString &error)
{
syncing = false;
emit syncFinished(currentSummary(), error);
}
void DeckSyncManager::finishSync()
{
syncing = false;
const SyncSummary summary = currentSummary();
emit statusMessage(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(summary.uploaded)
.arg(summary.downloaded)
.arg(summary.unchanged)
.arg(summary.skipped));
emit syncFinished(summary, QString());
}
DeckSyncManager::SyncSummary DeckSyncManager::currentSummary() const
{
return SyncSummary{
.uploaded = uploadedCount,
.downloaded = downloadedCount,
.unchanged = unchangedCount,
.skipped = skippedCount,
};
}

View file

@ -0,0 +1,148 @@
/**
* @file deck_sync_manager.h
* @brief One-shot two-way deck collection sync between local files and the server.
*/
#ifndef DECK_SYNC_MANAGER_H
#define DECK_SYNC_MANAGER_H
#include "deck_sync_profile.h"
#include <QDateTime>
#include <QList>
#include <QMap>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariant>
#include <functional>
class AbstractClient;
class CommandContainer;
class DeckList;
class Response;
class ServerInfo_DeckStorage_Folder;
/**
* @class DeckSyncManager
* @brief Runs a manual whole-collection sync: local *.cod files vs server deck storage.
*
* Each syncNow() run loads the profile sidecar, scans the deck folder, fetches
* the server tree and mirrors both sides: unknown local decks upload, unknown
* server decks download, and decks changed on both sides resolve newer-wins
* (ties go to the server). A local file is matched to its server row by the
* sidecar's row id, then guid, then the server path, and finally by content, so
* renaming either side does not duplicate a deck. Before a download overwrites a
* locally modified file, the local content is copied to a .sync-backup file next
* to it.
*
* Ownership: fire-and-forget per run, driven by PendingCommand round trips.
* No watcher, no auto triggers, no conflict UI — those arrive in later layers.
*/
class DeckSyncManager : public QObject
{
Q_OBJECT
public:
/** @brief Computes the color identity string uploaded alongside a deck. May be empty. */
using ColorIdentityProvider = std::function<QString(const DeckList &)>;
/** @brief Outcome counts for a finished sync run, carried by syncFinished(). */
struct SyncSummary
{
int uploaded = 0;
int downloaded = 0;
int unchanged = 0;
int skipped = 0;
};
explicit DeckSyncManager(QObject *parent = nullptr);
DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr);
void setProfile(const DeckSyncProfile &_profile);
void setDeckPath(const QString &path);
[[nodiscard]] bool isSyncing() const;
public slots:
void syncNow();
signals:
void syncStarted();
void statusMessage(const QString &message);
void syncFinished(const SyncSummary &summary, const QString &error);
private slots:
void onListFinished(const Response &response, const CommandContainer &commandContainer);
void
onEnsureDirFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onUploadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onDownloadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
private:
struct ServerFile
{
int id = -1;
QString path; ///< Server-side address: folder prefix plus row name.
QString dir; ///< Folder prefix, empty in the root.
QString name; ///< Row name exactly as the server stores it.
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct LocalFile
{
QString absolutePath;
QString hash;
QDateTime modified;
};
struct UploadOp
{
QString relativePath;
QString serverPath; ///< Target address of a brand-new row.
QString serverDir; ///< Folder that must exist before the upload.
int deckId = -1;
QString guid;
};
struct DownloadOp
{
QString relativePath;
int deckId = -1;
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
void failSync(const QString &error);
void finishSync();
[[nodiscard]] SyncSummary currentSummary() const;
void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<int, ServerFile> &files,
QSet<QString> &dirs);
void processNext();
void processNextUpload();
void sendMkDirFor(const QString &dir);
void sendUpload(const UploadOp &op);
void sendDownload(const DownloadOp &op);
static QString parentDir(const QString &path);
QString uniqueLocalPath(const QString &relativePath) const;
AbstractClient *client = nullptr;
DeckSyncProfile profile;
QString deckPath;
ColorIdentityProvider colorIdentityProvider;
bool syncing = false;
QString sidecarPath;
QMap<QString, LocalFile> localFiles;
QSet<QString> ensuredDirs;
QList<UploadOp> pendingUploads;
QList<DownloadOp> pendingDownloads;
int uploadedCount = 0;
int downloadedCount = 0;
int unchangedCount = 0;
int skippedCount = 0;
};
#endif // DECK_SYNC_MANAGER_H

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,42 @@
/**
* @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>
/**
* @struct 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.
*/
struct DeckSyncProfile
{
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>
#include <QSaveFile>
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();
const DeckSyncRecord record{.guid = entry.value("guid").toString(),
.remoteId = entry.value("remoteId").toInt(-1),
.baseHash = entry.value("baseHash").toString(),
.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble()),
.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());
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
return file.commit();
}
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

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h"
@ -29,6 +30,7 @@
#include <QInputDialog>
#include <QItemSelection>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QModelIndex>
@ -177,6 +179,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout);
syncStatusLabel = new QLabel(this);
syncStatusLabel->setWordWrap(true);
rightVbox->addWidget(syncStatusLabel);
rightGroupBox = new QGroupBox;
rightGroupBox->setLayout(rightVbox);
@ -228,6 +233,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
aSyncNow = new QAction(this);
aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload));
connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow);
// Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal);
@ -239,11 +248,20 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aSyncNow);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
syncManager = new DeckSyncManager(
client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this);
connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage);
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
if (currentUserInfo != nullptr) {
syncUsername = QString::fromStdString(currentUserInfo->name());
}
retranslateUi();
QWidget *mainWidget = new QWidget(this);
@ -272,6 +290,8 @@ void TabDeckStorage::retranslateUi()
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder"));
aSyncNow->setText(tr("Sync now"));
renderSyncStatus();
shareBar->retranslateUi();
if (shareBar->isVisible()) {
onServerSelectionChanged();
@ -299,6 +319,7 @@ QString TabDeckStorage::getTargetPath() const
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{
syncUsername = QString::fromStdString(userInfo.name());
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
}
@ -316,6 +337,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
void TabDeckStorage::setRemoteEnabled(bool enabled)
{
remoteEnabled = enabled;
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
@ -323,6 +345,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
if (enabled) {
serverDirView->refreshTree();
@ -526,6 +549,61 @@ void TabDeckStorage::actOpenDecksFolder()
QtUtils::openPathInFileManager(dir);
}
void TabDeckStorage::actSyncNow()
{
DeckSyncProfile profile(client->serverName(), client->serverPort(), syncUsername);
syncManager->setProfile(profile);
syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath());
aSyncNow->setEnabled(false);
syncManager->syncNow();
}
void TabDeckStorage::onSyncStatusMessage(const QString &message)
{
syncStatusType = SyncStatusType::Working;
syncStatusDetail = message;
renderSyncStatus();
}
void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &error)
{
aSyncNow->setEnabled(remoteEnabled);
if (!error.isEmpty()) {
syncStatusType = SyncStatusType::Failed;
syncStatusDetail = error;
} else {
syncStatusType = SyncStatusType::Finished;
syncStatusDetail.clear();
lastSyncSummary = summary;
}
renderSyncStatus();
if (error.isEmpty()) {
serverDirView->refreshTree();
}
}
void TabDeckStorage::renderSyncStatus()
{
switch (syncStatusType) {
case SyncStatusType::Working:
syncStatusLabel->setText(syncStatusDetail);
break;
case SyncStatusType::Failed:
syncStatusLabel->setText(tr("Sync failed: %1").arg(syncStatusDetail));
break;
case SyncStatusType::Finished:
syncStatusLabel->setText(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(lastSyncSummary.uploaded)
.arg(lastSyncSummary.downloaded)
.arg(lastSyncSummary.unchanged)
.arg(lastSyncSummary.skipped));
break;
case SyncStatusType::Idle:
syncStatusLabel->setText(tr("Not synced yet"));
break;
}
}
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
{
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {

View file

@ -8,7 +8,8 @@
#ifndef TAB_DECK_STORAGE_H
#define TAB_DECK_STORAGE_H
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../server/remote/remote_decklist_tree_widget.h"
#include "tab.h"
#include <QList>
@ -25,6 +26,7 @@ class QFileSystemModel;
class QToolBar;
class QGroupBox;
class QTimer;
class QLabel;
class CommandContainer;
class Response;
class ShareBarWidget;
@ -50,6 +52,23 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
QAction *aSyncNow;
QLabel *syncStatusLabel;
DeckSyncManager *syncManager;
QString syncUsername;
bool remoteEnabled = false;
enum class SyncStatusType
{
Idle,
Working,
Finished,
Failed
};
using SyncSummary = DeckSyncManager::SyncSummary;
SyncStatusType syncStatusType = SyncStatusType::Idle;
QString syncStatusDetail;
SyncSummary lastSyncSummary;
void renderSyncStatus();
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
@ -108,6 +127,10 @@ private slots:
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
void actSyncNow();
void onSyncStatusMessage(const QString &message);
void onSyncFinished(const SyncSummary &summary, const QString &error);
public:
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
void retranslateUi() override;

View file

@ -7,6 +7,7 @@ set(HEADERS
libcockatrice/deck_list/tree/abstract_deck_list_node.h
libcockatrice/deck_list/tree/deck_list_card_node.h
libcockatrice/deck_list/tree/inner_deck_list_node.h
libcockatrice/deck_list/deck_diff.h
libcockatrice/deck_list/deck_file_format.h
libcockatrice/deck_list/deck_list.h
libcockatrice/deck_list/deck_list_history_manager.h
@ -25,6 +26,7 @@ add_library(
libcockatrice/deck_list/tree/abstract_deck_list_node.cpp
libcockatrice/deck_list/tree/deck_list_card_node.cpp
libcockatrice/deck_list/tree/inner_deck_list_node.cpp
libcockatrice/deck_list/deck_diff.cpp
libcockatrice/deck_list/deck_file_format.cpp
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp

View file

@ -0,0 +1,142 @@
#include "deck_diff.h"
#include "deck_list.h"
#include "tree/deck_list_card_node.h"
#include "tree/inner_deck_list_node.h"
#include <QMap>
#include <QPair>
#include <QSet>
#include <algorithm>
namespace
{
using ZoneCardKey = QPair<QString, QString>;
struct ZoneCardInfo
{
int count = 0;
QString cardSetShortName;
QString cardSetNumber;
QString cardProviderId;
};
QMap<ZoneCardKey, ZoneCardInfo> zoneCardInfos(const DeckList &deck)
{
QMap<ZoneCardKey, ZoneCardInfo> infos;
deck.forEachCard([&infos](InnerDecklistNode *zone, DecklistCardNode *card) {
ZoneCardInfo &info = infos[{zone->getName(), card->getName()}];
if (info.count == 0) {
info.cardSetShortName = card->getCardSetShortName();
info.cardSetNumber = card->getCardCollectorNumber();
info.cardProviderId = card->getCardProviderId();
}
info.count += card->getNumber();
});
return infos;
}
QMap<ZoneCardKey, int> zoneCardCounts(const DeckList &deck)
{
QMap<ZoneCardKey, int> counts;
const QMap<ZoneCardKey, ZoneCardInfo> infos = zoneCardInfos(deck);
for (auto it = infos.begin(); it != infos.end(); ++it) {
counts.insert(it.key(), it.value().count);
}
return counts;
}
bool changeLessThan(const DeckCardChange &a, const DeckCardChange &b)
{
if (a.zone != b.zone) {
return a.zone < b.zone;
}
return a.cardName < b.cardName;
}
} // namespace
namespace DeckDiff
{
DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck)
{
const QMap<ZoneCardKey, int> oldCounts = zoneCardCounts(oldDeck);
const QMap<ZoneCardKey, ZoneCardInfo> newInfos = zoneCardInfos(newDeck);
QSet<ZoneCardKey> keys(oldCounts.keyBegin(), oldCounts.keyEnd());
keys.unite(QSet<ZoneCardKey>(newInfos.keyBegin(), newInfos.keyEnd()));
DeckDiffResult result;
for (const ZoneCardKey &key : keys) {
const int oldCount = oldCounts.value(key, 0);
const ZoneCardInfo newInfo = newInfos.value(key, ZoneCardInfo());
if (oldCount != newInfo.count) {
const DeckCardChange change{.zone = key.first,
.cardName = key.second,
.oldCount = oldCount,
.newCount = newInfo.count,
.cardSetShortName = newInfo.cardSetShortName,
.cardSetNumber = newInfo.cardSetNumber,
.cardProviderId = newInfo.cardProviderId};
result.changes.append(change);
}
}
std::sort(result.changes.begin(), result.changes.end(), changeLessThan);
return result;
}
bool canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
{
QSet<ZoneCardKey> localKeys;
for (const DeckCardChange &change : localChanges.changes) {
localKeys.insert({change.zone, change.cardName});
}
for (const DeckCardChange &change : remoteChanges.changes) {
if (localKeys.contains({change.zone, change.cardName})) {
return false;
}
}
return true;
}
DeckList merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
{
QMap<ZoneCardKey, int> counts = zoneCardCounts(baseDeck);
QMap<ZoneCardKey, DeckCardChange> printings;
for (const DeckCardChange &change : localChanges.changes) {
counts[{change.zone, change.cardName}] = change.newCount;
printings[{change.zone, change.cardName}] = change;
}
for (const DeckCardChange &change : remoteChanges.changes) {
counts[{change.zone, change.cardName}] = change.newCount;
printings[{change.zone, change.cardName}] = change;
}
DeckList merged = baseDeck;
QMap<ZoneCardKey, QList<DecklistCardNode *>> nodesByKey;
merged.forEachCard([&nodesByKey](InnerDecklistNode *zone, DecklistCardNode *card) {
nodesByKey[{zone->getName(), card->getName()}].append(card);
});
for (auto it = counts.begin(); it != counts.end(); ++it) {
const ZoneCardKey &key = it.key();
const int target = qMax(0, it.value());
QList<DecklistCardNode *> nodes = nodesByKey.value(key);
if (target == 0) {
for (DecklistCardNode *node : nodes) {
merged.getTree()->deleteNode(node);
}
} else if (nodes.isEmpty()) {
const DeckCardChange printing = printings.value(key);
merged.getTree()->addCard(printing.cardName, target, printing.zone, -1, printing.cardSetShortName,
printing.cardSetNumber, printing.cardProviderId, true);
} else {
nodes.first()->setNumber(target);
for (int i = 1; i < nodes.size(); ++i) {
merged.getTree()->deleteNode(nodes.at(i));
}
}
}
merged.refreshDeckHash();
return merged;
}
} // namespace DeckDiff

View file

@ -0,0 +1,72 @@
/**
* @file deck_diff.h
* @brief Card-level diff and three-way merge for deck collection sync.
*/
#ifndef DECK_DIFF_H
#define DECK_DIFF_H
#include <QList>
#include <QString>
class DeckList;
/** @brief A single card-count change within one zone. */
struct DeckCardChange
{
QString zone; ///< Zone name, e.g. "main".
QString cardName; ///< Card name.
int oldCount = 0; ///< Copies in the old deck.
int newCount = 0; ///< Copies in the new deck.
QString cardSetShortName; ///< Printing set carried from the new side, empty when unchanged.
QString cardSetNumber; ///< Printing collector number carried from the new side.
QString cardProviderId; ///< Printing provider id carried from the new side.
/** @brief Copies added (positive) or removed (negative). */
[[nodiscard]] int delta() const
{
return newCount - oldCount;
}
};
/** @brief Card-level difference between two decks, sorted by zone then card name. */
struct DeckDiffResult
{
QList<DeckCardChange> changes;
/** @brief True when both decks hold the same cards in the same zones. */
[[nodiscard]] bool isEmpty() const
{
return changes.isEmpty();
}
};
/**
* @namespace DeckDiff
* @brief Pure card-count diff and three-way merge for deck sync.
*
* Compares card names and copy counts per zone only; a merged deck keeps
* base printings for existing cards and carries the changed side's printing
* for newly added cards.
*/
namespace DeckDiff
{
/** @brief Diffs oldDeck against newDeck. */
DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck);
/**
* @brief True when both change sets touch disjoint (zone, card) keys and a merge needs no arbitration.
*/
bool canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
/**
* @brief Applies both change sets onto a copy of baseDeck.
*
* @precondition localChanges and remoteChanges were both computed with
* diff() against baseDeck. Stale or foreign change sets silently
* resurrect or delete cards.
*
* Counts are clamped at zero. On overlapping keys the remote change wins;
* callers offer this result as "Use Merge" only when canAutoMerge is true.
* Metadata and sideboard plans come from baseDeck.
*/
DeckList merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
} // namespace DeckDiff
#endif // DECK_DIFF_H

View file

@ -9,6 +9,7 @@
#include "tree/inner_deck_list_node.h"
#include <QAnyStringView>
#include <QCryptographicHash>
#include <QDebug>
#include <QSet>
#include <QSharedPointer>
@ -453,6 +454,16 @@ void DeckList::refreshDeckHash()
cachedDeckHash = QString();
}
QString DeckList::contentHash(const QString &nativeDeck)
{
return QString::fromLatin1(QCryptographicHash::hash(nativeDeck.toUtf8(), QCryptographicHash::Sha1).toHex());
}
QString DeckList::getContentHash() const
{
return contentHash(writeToString_Native());
}
/**
* Calls a given function on each card in the deck.
*/

View file

@ -289,6 +289,16 @@ public:
/** @name Deck identity */
///@{
QString getDeckHash() const;
/**
* @brief SHA-1 of a native deck string, covering everything the card hash
* ignores (name, printings, banner, tokens, comments, sideboard plans).
*
* Deck-collection sync hashes the exact string it sends or receives, so both
* peers compare the same bytes.
*/
static QString contentHash(const QString &nativeDeck);
/** @brief contentHash() of this deck's native serialisation. */
QString getContentHash() const;
void refreshDeckHash();
///@}

View file

@ -1114,7 +1114,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
const QString tagsJson = serializeDeckTags(deck.getTags());
const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity()));
const QString deckHash = deck.getDeckHash();
// Hash the exact bytes the client sent: both peers must compare the same
// string, not each side's re-serialisation of it.
const QString deckHash = DeckList::contentHash(deckStr);
QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString();
if (QUuid::fromString(guid).isNull()) {
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);

View file

@ -407,6 +407,43 @@ cockatrice_add_test(
bench
)
cockatrice_add_test(
NAME
deck_diff_test
SOURCES
deck_diff_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
deck_content_hash_test
SOURCES
deck_content_hash_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
deck_sync_profile_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
deck_sync_profile_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync
)
cockatrice_add_test(
NAME
deck_sync_sidecar_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
deck_sync_sidecar_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync
)
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
if(TARGET Qt6::lrelease)
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")

View file

@ -0,0 +1,51 @@
#include "gtest/gtest.h"
#include <QString>
#include <libcockatrice/deck_list/deck_list.h>
namespace
{
QString deckWithName(const QString &name)
{
return QStringLiteral(R"(<?xml version="1.0"?><cockatrice_deck version="1">)"
R"(<deckname>%1</deckname><comments></comments>)"
R"(<zone name="main"><card number="4" name="Lightning Bolt"/></zone>)"
R"(<zone name="side"><card number="2" name="Pyroblast"/></zone>)"
R"(</cockatrice_deck>)")
.arg(name);
}
} // namespace
TEST(DeckContentHashTest, MatchesNativeSerialisation)
{
DeckList deck;
ASSERT_TRUE(deck.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
EXPECT_EQ(deck.getContentHash(), DeckList::contentHash(deck.writeToString_Native()));
}
TEST(DeckContentHashTest, IsStableAcrossLoads)
{
DeckList first;
DeckList second;
ASSERT_TRUE(first.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
ASSERT_TRUE(second.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
EXPECT_EQ(first.getContentHash(), second.getContentHash());
}
TEST(DeckContentHashTest, CoversDeckName)
{
DeckList burn;
DeckList renamed;
ASSERT_TRUE(burn.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
ASSERT_TRUE(renamed.loadFromString_Native(deckWithName(QStringLiteral("Burn v2"))));
// The card hash only looks at card names and counts, so it cannot tell the
// two decks apart; the content hash must.
EXPECT_EQ(burn.getDeckHash(), renamed.getDeckHash());
EXPECT_NE(burn.getContentHash(), renamed.getContentHash());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

181
tests/deck_diff_test.cpp Normal file
View file

@ -0,0 +1,181 @@
#include "gtest/gtest.h"
#include <QList>
#include <QString>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
namespace
{
DeckList makeDeck(std::initializer_list<std::pair<const char *, int>> mainCards,
std::initializer_list<std::pair<const char *, int>> sideCards = {})
{
DeckList deck;
for (const auto &[name, count] : mainCards) {
for (int i = 0; i < count; ++i) {
deck.addCard(name, "main");
}
}
for (const auto &[name, count] : sideCards) {
for (int i = 0; i < count; ++i) {
deck.addCard(name, "side");
}
}
return deck;
}
int countOf(const DeckList &deck, const char *zone, const char *name)
{
int count = 0;
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
if (zoneNode->getName() == zone && card->getName() == name) {
count += card->getNumber();
}
});
return count;
}
QList<DecklistCardNode *> nodesOf(const DeckList &deck, const char *zone, const char *name)
{
QList<DecklistCardNode *> nodes;
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
if (zoneNode->getName() == zone && card->getName() == name) {
nodes.append(card);
}
});
return nodes;
}
} // namespace
TEST(DeckDiffTest, IdenticalDecksHaveEmptyDiff)
{
DeckList first = makeDeck({{"Lightning Bolt", 4}});
DeckList second = makeDeck({{"Lightning Bolt", 4}});
EXPECT_TRUE(DeckDiff::diff(first, second).isEmpty());
}
TEST(DeckDiffTest, ChangesAreSortedByZoneThenName)
{
DeckList oldDeck = makeDeck({{"Mountain", 20}, {"Lightning Bolt", 4}}, {{"Shock", 2}});
DeckList newDeck = makeDeck({{"Mountain", 19}, {"Shock", 5}}, {{"Shock", 1}, {"Teferi", 1}});
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
ASSERT_EQ(result.changes.size(), 5);
EXPECT_EQ(result.changes.at(0).zone, "main");
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
EXPECT_EQ(result.changes.at(1).cardName, "Mountain");
EXPECT_EQ(result.changes.at(2).cardName, "Shock");
EXPECT_EQ(result.changes.at(3).zone, "side");
EXPECT_EQ(result.changes.at(3).cardName, "Shock");
EXPECT_EQ(result.changes.at(4).cardName, "Teferi");
}
TEST(DeckDiffTest, ReportsAddedAndRemovedCopies)
{
DeckList oldDeck = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
DeckList newDeck = makeDeck({{"Lightning Bolt", 2}, {"Shock", 4}, {"Mountain", 20}});
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
ASSERT_EQ(result.changes.size(), 2);
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
EXPECT_EQ(result.changes.at(0).oldCount, 4);
EXPECT_EQ(result.changes.at(0).newCount, 2);
EXPECT_EQ(result.changes.at(0).delta(), -2);
EXPECT_EQ(result.changes.at(1).cardName, "Shock");
EXPECT_EQ(result.changes.at(1).oldCount, 0);
EXPECT_EQ(result.changes.at(1).newCount, 4);
}
TEST(DeckDiffTest, ZoneMoveIsTwoChanges)
{
DeckList oldDeck = makeDeck({{"Teferi", 1}});
DeckList newDeck = makeDeck({}, {{"Teferi", 1}});
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
ASSERT_EQ(result.changes.size(), 2);
EXPECT_EQ(result.changes.at(0).zone, "main");
EXPECT_EQ(result.changes.at(0).newCount, 0);
EXPECT_EQ(result.changes.at(1).zone, "side");
EXPECT_EQ(result.changes.at(1).oldCount, 0);
}
TEST(DeckDiffTest, DisjointChangesCanAutoMerge)
{
DeckList base = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
DeckList local = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 18}});
DeckList remote = makeDeck({{"Shock", 2}, {"Lightning Bolt", 4}, {"Mountain", 20}});
DeckDiffResult localChanges = DeckDiff::diff(base, local);
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
EXPECT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
EXPECT_EQ(countOf(merged, "main", "Mountain"), 18);
EXPECT_EQ(countOf(merged, "main", "Shock"), 2);
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 4);
}
TEST(DeckDiffTest, OverlappingChangesCannotAutoMergeAndRemoteWins)
{
DeckList base = makeDeck({{"Lightning Bolt", 4}});
DeckList local = makeDeck({{"Lightning Bolt", 3}});
DeckList remote = makeDeck({{"Lightning Bolt", 1}});
DeckDiffResult localChanges = DeckDiff::diff(base, local);
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
EXPECT_FALSE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 1);
}
TEST(DeckDiffTest, MergeKeepsBaseMetadata)
{
DeckList base = makeDeck({{"Lightning Bolt", 4}});
base.setName("Affinity v3");
DeckList remote = makeDeck({{"Lightning Bolt", 4}, {"Shock", 1}});
DeckList merged = DeckDiff::merge(base, DeckDiff::diff(base, base), DeckDiff::diff(base, remote));
EXPECT_EQ(merged.getName(), "Affinity v3");
EXPECT_EQ(countOf(merged, "main", "Shock"), 1);
}
TEST(DeckDiffTest, MergePreservesBasePrintingAndCollapsesNodes)
{
DeckList base;
base.addCard("Mountain", "main", -1, "DOM", "250")->setNumber(4);
base.refreshDeckHash();
DeckList local = base;
local.addCard("Shock", "main", -1, "M19", "156")->setNumber(2);
local.refreshDeckHash();
DeckList remote = base;
remote.addCard("Lightning Bolt", "main", -1, "M10", "146")->setNumber(4);
remote.refreshDeckHash();
const DeckDiffResult localChanges = DeckDiff::diff(base, local);
const DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
ASSERT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
const DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
const QList<DecklistCardNode *> mountains = nodesOf(merged, "main", "Mountain");
ASSERT_EQ(mountains.size(), 1);
EXPECT_EQ(mountains.first()->getNumber(), 4);
EXPECT_EQ(mountains.first()->getCardSetShortName(), "DOM");
EXPECT_EQ(mountains.first()->getCardCollectorNumber(), "250");
const QList<DecklistCardNode *> shocks = nodesOf(merged, "main", "Shock");
ASSERT_EQ(shocks.size(), 1);
EXPECT_EQ(shocks.first()->getNumber(), 2);
EXPECT_EQ(shocks.first()->getCardSetShortName(), "M19");
EXPECT_EQ(shocks.first()->getCardCollectorNumber(), "156");
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,63 @@
#include "gtest/gtest.h"
#include <QString>
#include <deck_sync_profile.h>
TEST(DeckSyncProfileTest, KeyIsStable)
{
DeckSyncProfile first("woogerworks.com", 4747, "Skog7");
DeckSyncProfile second("woogerworks.com", 4747, "Skog7");
EXPECT_EQ(first.key(), second.key());
EXPECT_FALSE(first.key().isEmpty());
}
TEST(DeckSyncProfileTest, KeyIgnoresCase)
{
DeckSyncProfile lower("woogerworks.com", 4747, "skog7");
DeckSyncProfile upper("WOOGERWORKS.COM", 4747, "SKOG7");
EXPECT_EQ(lower.key(), upper.key());
}
TEST(DeckSyncProfileTest, KeyDiffersPerServerAndUser)
{
DeckSyncProfile base("woogerworks.com", 4747, "skog7");
EXPECT_NE(base.key(), DeckSyncProfile("other.server", 4747, "skog7").key());
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4748, "skog7").key());
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4747, "other").key());
}
TEST(DeckSyncProfileTest, KeyIsUnambiguousAcrossSeparators)
{
DeckSyncProfile left("a:1", 2, "b");
DeckSyncProfile right("a", 1, "2:b");
EXPECT_NE(left.key(), right.key());
EXPECT_NE(DeckSyncProfile("::1", 4747, "skog7").key(), DeckSyncProfile("::2", 4747, "skog7").key());
}
TEST(DeckSyncProfileTest, InvalidProfilesHaveNoKey)
{
EXPECT_TRUE(DeckSyncProfile().key().isEmpty());
EXPECT_TRUE(DeckSyncProfile("", 4747, "skog7").key().isEmpty());
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 0, "skog7").key().isEmpty());
}
TEST(DeckSyncProfileTest, KeyIgnoresSurroundingWhitespace)
{
DeckSyncProfile plain("woogerworks.com", 4747, "skog7");
DeckSyncProfile padded(" woogerworks.com ", 4747, " skog7 ");
EXPECT_EQ(plain.key(), padded.key());
}
TEST(DeckSyncProfileTest, ValidityRequiresAllFields)
{
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 4747, "skog7").isValid());
EXPECT_FALSE(DeckSyncProfile("", 4747, "skog7").isValid());
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 0, "skog7").isValid());
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 4747, "").isValid());
EXPECT_FALSE(DeckSyncProfile().isValid());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,126 @@
#include "gtest/gtest.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QString>
#include <QTemporaryDir>
#include <deck_sync_sidecar.h>
namespace
{
QString sidecarPath(QTemporaryDir &dir)
{
return dir.filePath(".sync/profilekey.json");
}
} // namespace
TEST(DeckSyncSidecarTest, MissingFileLoadsEmpty)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_TRUE(sidecar.load());
EXPECT_TRUE(sidecar.records().isEmpty());
EXPECT_FALSE(sidecar.contains("affinity.cod"));
}
TEST(DeckSyncSidecarTest, RoundTripsRecords)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
DeckSyncRecord record;
record.guid = "123e4567-e89b-12d3-a456-426614174000";
record.remoteId = 42;
record.baseHash = "5cac19qm";
record.lastSyncSecs = 1791739200;
record.deleted = true;
sidecar.set("decks/affinity.cod", record);
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
EXPECT_TRUE(reloaded.contains("decks/affinity.cod"));
DeckSyncRecord back = reloaded.get("decks/affinity.cod");
EXPECT_EQ(back.guid, record.guid);
EXPECT_EQ(back.remoteId, 42);
EXPECT_EQ(back.baseHash, "5cac19qm");
EXPECT_EQ(back.lastSyncSecs, 1791739200);
EXPECT_TRUE(back.deleted);
}
TEST(DeckSyncSidecarTest, UnknownPathReturnsDefaultRecord)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
DeckSyncRecord back = sidecar.get("missing.cod");
EXPECT_EQ(back.remoteId, -1);
EXPECT_TRUE(back.guid.isEmpty());
}
namespace
{
bool writeRaw(const QString &path, const QByteArray &bytes)
{
QDir().mkpath(QFileInfo(path).absolutePath());
QFile file(path);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size();
}
} // namespace
TEST(DeckSyncSidecarTest, GarbageFileFailsToLoad)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(writeRaw(sidecarPath(dir), "not json at all"));
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_FALSE(sidecar.load());
}
TEST(DeckSyncSidecarTest, WrongRecordsShapeFailsToLoad)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":["a.cod"]})"));
DeckSyncSidecar missing(sidecarPath(dir));
EXPECT_FALSE(missing.load());
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"foo":1})"));
DeckSyncSidecar other(sidecarPath(dir));
EXPECT_FALSE(other.load());
}
TEST(DeckSyncSidecarTest, NonObjectEntriesAreSkipped)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":{"a.cod":42,"b.cod":{"guid":"g"}}})"));
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_TRUE(sidecar.load());
EXPECT_FALSE(sidecar.contains("a.cod"));
EXPECT_TRUE(sidecar.contains("b.cod"));
EXPECT_EQ(sidecar.get("b.cod").guid, "g");
}
TEST(DeckSyncSidecarTest, RemoveDropsRecord)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
sidecar.set("a.cod", DeckSyncRecord());
sidecar.remove("a.cod");
EXPECT_FALSE(sidecar.contains("a.cod"));
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
EXPECT_FALSE(reloaded.contains("a.cod"));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}