Compare commits

...

16 commits

Author SHA1 Message Date
Lukas Brübach
de3468cc33 [DeckSync] Address tombstone review: remote deletes, revival and purge clock
- Treat a record whose server row is gone as a remote delete only when the
  local file still matches the sidecar's base hash. An edit made after the
  remote delete is newer than the delete, so it uploads and revives the row
  instead of being removed.
- Revive a tombstone with the same user and guid in cmdDeckUpload instead of
  inserting a second row next to it, so the deck keeps its id and version
  history and the promise in servatrice.ini.example holds.
- Run tombstone purging on its own clock, driven by the new
  [deck_sync] purge_interval option, so disabling share cleanup no longer
  disables purging silently.
- Write the base snapshot through QSaveFile and log a failed commit, so a
  crash or a full disk cannot leave a truncated base for DeckDiff::merge().
2026-11-01 22:25:56 +01:00
Lukas Brübach
157e63b179 Fix ZeldaZach review for PR 7494: match by remoteId/guid before remote-delete, only tombstone when file truly missing 2026-11-01 22:25:51 +01:00
Lukas Brübach
e43862c3bc [Server+Client] Tombstone deck deletes with purge and base snapshots 2026-11-01 22:25:51 +01:00
Lukas Brübach
2916fa45af [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-01 22:23:25 +01:00
Lukas Brübach
ad0da98a86 [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-01 22:23:25 +01:00
BruebachL
c0a62a2486 [Format] Fix include order in tab_deck_storage.h 2026-11-01 22:23:25 +01:00
BruebachL
0757d3d867 [DeckSync] Use designated initializer for ServerFile per review 2026-11-01 22:23:25 +01:00
BruebachL
8a541531da [DeckSync] Prefer ctor params for set-once client/provider per review 2026-11-01 22:23:25 +01:00
BruebachL
e1a9f7766d [DeckSync] Move SyncSummary into DeckSyncManager per review 2026-11-01 22:23:25 +01:00
Lukas Brübach
ad1cb8f133 Fix ZeldaZach review for PR 7493: match uploads by sidecar remoteId/guid, validate server paths 2026-11-01 22:23:25 +01:00
Lukas Brübach
e63b72fe11 [Client] Manual deck collection sync with Sync Now action 2026-11-01 22:23:25 +01:00
Lukas Brübach
fa4c5766eb 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-01 22:11:40 +01:00
BruebachL
15673dc481 [DeckDiff] Use namespace instead of static-only class per review 2026-11-01 22:11:40 +01:00
Lukas Brübach
da106fdef7 Fixup: clang-format alignment in deck_diff.h 2026-11-01 22:11:40 +01:00
Lukas Brübach
f661468069 Fix ZeldaZach review for PR 7492: preserve printing in DeckDiff::merge, atomic sidecar save with QSaveFile 2026-11-01 22:11:40 +01:00
Lukas Brübach
be072ffd68 [Client] DeckSyncProfile + sidecar + DeckDiff with tests 2026-11-01 22:11:40 +01:00
25 changed files with 2061 additions and 44 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,726 @@
#include "deck_sync_manager.h"
#include "../deck_loader/deck_loader.h"
#include "deck_sync_sidecar.h"
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#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_del.pb.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();
pendingDeletes.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) {
// The sidecar knows a server row that the listing no longer has: it was
// deleted elsewhere. Only mirror that delete when the local file is
// still exactly what was synced - an edit made after the remote delete
// is newer than the delete and gets uploaded again.
const bool knownToServer = sidecarRecord.remoteId != -1 || !sidecarRecord.guid.isEmpty();
const bool unchangedSinceSync =
!sidecarRecord.baseHash.isEmpty() && it.value().hash == sidecarRecord.baseHash;
if (knownToServer && unchangedSinceSync) {
const QString absolutePath = deckPath + "/" + it.key();
const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
if (!QFile::copy(absolutePath, backupPath) || !QFile::remove(absolutePath)) {
++skippedCount;
continue;
}
sidecar.remove(it.key());
++downloadedCount;
continue;
}
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);
snapshotLocalFile(it.key(), record.guid);
++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});
}
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
if (localFiles.contains(it.key())) {
continue;
}
if (QFileInfo::exists(deckPath + "/" + it.key())) {
continue;
}
if (it.value().remoteId == -1) {
sidecar.remove(it.key());
continue;
}
pendingDeletes.append(it.key());
}
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 (!pendingDeletes.isEmpty()) {
const QString relativePath = pendingDeletes.first();
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
sendDelete(relativePath, sidecar.get(relativePath).remoteId);
} else 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);
QVariantMap context;
context.insert("path", op.relativePath);
context.insert("serverPath", op.serverPath);
context.insert("deckId", op.deckId);
context.insert("guid", op.guid);
pend->setExtraData(context);
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 QVariantMap context = extraData.toMap();
const QString relativePath = context.value("path").toString();
const int deckId = context.value("deckId", -1).toInt();
if (response.response_code() != Response::RespOk) {
if (response.response_code() == Response::RespNameNotFound && deckId != -1) {
// The server row is gone (purged tombstone or removed elsewhere):
// upload the deck as new instead of updating a dead id.
UploadOp retry;
retry.relativePath = relativePath;
retry.serverPath = context.value("serverPath").toString();
retry.deckId = -1;
retry.guid = context.value("guid").toString();
pendingUploads.prepend(retry);
processNext();
return;
}
++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();
snapshotLocalFile(relativePath, record.guid);
++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();
snapshotLocalFile(relativePath, record.guid);
++downloadedCount;
processNext();
}
void DeckSyncManager::sendDelete(const QString &relativePath, int deckId)
{
emit statusMessage(tr("Deleting %1 from server...").arg(relativePath));
Command_DeckDel cmd;
cmd.set_deck_id(deckId);
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
pend->setExtraData(relativePath);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onDeleteFinished);
client->sendCommand(pend);
}
void DeckSyncManager::onDeleteFinished(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (!syncing) {
return;
}
const QString relativePath = extraData.toString();
for (int i = 0; i < pendingDeletes.size(); ++i) {
if (pendingDeletes.at(i) == relativePath) {
pendingDeletes.removeAt(i);
break;
}
}
// Both outcomes converge the record: pushed deletes and server-side
// deletes (already tombstoned, answering NameNotFound) are done.
if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) {
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
sidecar.remove(relativePath);
sidecar.save();
} else {
++skippedCount;
}
processNext();
}
void DeckSyncManager::snapshotLocalFile(const QString &relativePath, const QString &guid)
{
if (guid.isEmpty()) {
return;
}
QFile source(deckPath + "/" + relativePath);
if (!source.open(QIODevice::ReadOnly)) {
return;
}
QDir().mkpath(deckPath + "/" + SYNC_META_DIR + "/bases");
// QSaveFile: a crash or a full disk must not leave a truncated base behind,
// because DeckDiff::merge() against a partial base re-adds or drops cards.
QSaveFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod");
if (!snapshot.open(QIODevice::WriteOnly)) {
return;
}
snapshot.write(source.readAll());
if (!snapshot.commit()) {
qWarning() << "DeckSync: could not write the base snapshot for" << relativePath;
}
}
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,157 @@
/**
* @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.
*
* Deletes are tombstones, never silent: a locally deleted deck is pushed with
* Command_DeckDel, a server-side delete is applied locally only after copying
* the file to a .sync-backup, and resurrection reuses the stored deck id.
*/
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);
void
onDeleteFinished(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);
void sendDelete(const QString &relativePath, int deckId);
void snapshotLocalFile(const QString &relativePath, const QString &guid);
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;
QList<QString> pendingDeletes;
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,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,99 @@
#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();
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());
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,143 @@
#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) {
DeckCardChange change;
change.zone = key.first;
change.cardName = key.second;
change.oldCount = oldCount;
change.newCount = newInfo.count;
change.cardSetShortName = newInfo.cardSetShortName;
change.cardSetNumber = newInfo.cardSetNumber;
change.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

@ -598,3 +598,16 @@ max_shares_per_day=50
; Minimum enforced: 1.
; Default: 20
max_versions=20
; How many days a deleted deck stays restorable as a tombstone before the
; purge clock removes it (with its versions) for good. Re-uploading the
; same deck within this window revives the tombstone instead of creating a copy.
; A value of 0 purges tombstones on the next purge run.
; Default: 30
tombstone_days=30
; How often, in minutes, the tombstone purge runs. This is independent of
; [deck_share] cleanup_interval, so disabling share cleanup does not disable
; purging. A value of 0 disables purging entirely.
; Default: 60
purge_interval=60

View file

@ -632,6 +632,16 @@ bool Servatrice::initServer()
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
// Tombstone purging runs on its own clock: turning share cleanup off must not
// silently disable it as well.
deckTombstoneClock = new QTimer(this);
connect(deckTombstoneClock, SIGNAL(timeout()), this, SLOT(purgeDeckTombstones()));
const int deckSyncPurgeInterval = getDeckSyncPurgeInterval();
if (deckSyncPurgeInterval > 0) {
qDebug() << "Starting deck tombstone purge clock, interval" << deckSyncPurgeInterval << "ms";
deckTombstoneClock->start(deckSyncPurgeInterval);
}
// SOCKET SERVER
if (getNumberOfTCPPools() > 0) {
gameServer =
@ -856,6 +866,11 @@ void Servatrice::cleanupExpiredDeckShares()
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::purgeDeckTombstones()
{
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1401,6 +1416,17 @@ int Servatrice::getDeckSyncMaxVersions() const
return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt());
}
int Servatrice::getDeckSyncTombstoneDays() const
{
return qMax(0, settingsCache->value("deck_sync/tombstone_days", 30).toInt());
}
int Servatrice::getDeckSyncPurgeInterval() const
{
// default: every 60 minutes, independent of deck_share/cleanup_interval
return qMax(0, settingsCache->value("deck_sync/purge_interval", 60).toInt()) * 60000;
}
int Servatrice::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -166,6 +166,7 @@ private slots:
void sweepLegacyBackups();
void shutdownTimeout();
void cleanupExpiredDeckShares();
void purgeDeckTombstones();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -181,6 +182,7 @@ private:
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
QTimer *legacyBackupSweepClock;
QTimer *deckTombstoneClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -321,6 +323,8 @@ public:
int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const;
int getDeckSyncMaxVersions() const;
int getDeckSyncTombstoneDays() const;
int getDeckSyncPurgeInterval() const;
int getMaxUsersPerAddress() const;
/// Raw security/trusted_sources list; match addresses against it with isTrustedSource().
QString getTrustedSources() const;

View file

@ -1370,8 +1370,8 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
{
checkSql();
QSqlQuery *query =
prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = prepareQuery(
"select content from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", deckId);
query->bindValue(":id_user", userId);
execSqlQuery(query);
@ -1522,6 +1522,19 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::purgeDeckTombstones(int olderThanDays)
{
checkSql();
// DB-native clock: tombstones are stamped with NOW() on this same
// database, so the grace window is immune to app/db clock skew.
// The null arm only catches manually flagged rows.
QSqlQuery *query = prepareQuery("delete from {prefix}_decklist_files where deleted = 1 and (last_modified < "
"DATE_SUB(NOW(), INTERVAL :days DAY) or last_modified is null)");
query->bindValue(":days", qMax(0, olderThanDays));
execSqlQuery(query);
}
bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares)
{
checkSql();

View file

@ -152,6 +152,11 @@ public:
bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares();
/**
* @brief Hard-deletes tombstoned decks older than the grace period (cascades to versions).
* @param olderThanDays Tombstones older than this many days are purged; 0 purges all tombstones.
*/
void purgeDeckTombstones(int olderThanDays);
int getNextGameId() override;
int getNextReplayId() override;

View file

@ -709,7 +709,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user");
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user "
"and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
@ -854,8 +855,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
sqlInterface->checkSql();
if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery(
"select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
@ -965,7 +966,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
deckDelDirHelper(query->value(0).toInt());
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id_folder = :id_folder");
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() "
"where id_folder = :id_folder and deleted = 0");
query->bindValue(":id_folder", basePathId);
sqlInterface->execSqlQuery(query);
@ -1016,18 +1018,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
sqlInterface->checkSql();
QSqlQuery *query =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id = :id and id_user = :id_user");
sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() where id = "
":id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
query->bindValue(":id", cmd.deck_id());
sqlInterface->execSqlQuery(query);
return Response::RespOk;
}
@ -1114,7 +1115,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);
@ -1126,31 +1129,71 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
return Response::RespNameNotFound;
}
QSqlQuery *query =
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
// A deck that was deleted elsewhere leaves a tombstone behind. Revive it
// instead of inserting a second row with the same guid, which would split
// the deck's version history and leave the tombstone to be purged.
int deckId = -1;
bool inserted = false;
QSqlQuery *tombstoneQuery =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_user = :id_user and guid = "
":guid and deleted = 1 order by last_modified desc limit 1");
tombstoneQuery->bindValue(":id_user", userInfo->id());
tombstoneQuery->bindValue(":guid", guid);
if (!sqlInterface->execSqlQuery(tombstoneQuery)) {
return Response::RespContextError;
}
const int deckId = query->lastInsertId().toInt();
if (tombstoneQuery->next()) {
deckId = tombstoneQuery->value(0).toInt();
QSqlQuery *reviveQuery = sqlInterface->prepareQuery(
"update {prefix}_decklist_files set id_folder=:id_folder, name=:name, upload_time=NOW(), "
"content=:content, is_public=:is_public, banner_card_name=:banner_card_name, "
"banner_card_provider=:banner_card_provider, color_identity=:color_identity, tags=:tags, "
"hash=:hash, last_modified=NOW(), deleted=0 where id=:id and id_user=:id_user");
reviveQuery->bindValue(":id_folder", folderId);
reviveQuery->bindValue(":id_user", userInfo->id());
reviveQuery->bindValue(":id", deckId);
reviveQuery->bindValue(":name", deckName);
reviveQuery->bindValue(":content", deckStr);
reviveQuery->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
reviveQuery->bindValue(":banner_card_name", bannerCardName);
reviveQuery->bindValue(":banner_card_provider", bannerCardProvider);
reviveQuery->bindValue(":color_identity", colorIdentity);
reviveQuery->bindValue(":tags", tagsJson);
reviveQuery->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(reviveQuery)) {
return Response::RespContextError;
}
} else {
QSqlQuery *query = sqlInterface->prepareQuery(
"insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
deckId = query->lastInsertId().toInt();
inserted = true;
}
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1160,9 +1203,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
stampQuery->bindValue(":id", deckId);
stampQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1195,9 +1241,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
}
const QString oldHash = currentQuery->value(1).toString();
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
QString updateQuery =
"update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW(), "
"deleted=0";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
@ -1399,7 +1447,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C
// issues its own query on the same cached statement set.
QSqlQuery *query =
sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
":id_folder and id_user = :id_user and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(query)) {

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();
}