Compare commits

...

16 commits

Author SHA1 Message Date
Lukas Brübach
428bc32b8b [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 13:21:05 +01:00
Lukas Brübach
884501d416 Fix ZeldaZach review for PR 7494: match by remoteId/guid before remote-delete, only tombstone when file truly missing 2026-11-01 12:51:19 +01:00
Lukas Brübach
07782462f8 [Server+Client] Tombstone deck deletes with purge and base snapshots 2026-11-01 12:51:19 +01:00
Lukas Brübach
543cc16d83 [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 12:43:02 +01:00
Lukas Brübach
e220bb3aa3 [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 12:41:00 +01:00
BruebachL
d3b9ac2b43 [Format] Fix include order in tab_deck_storage.h 2026-10-13 11:50:40 +02:00
BruebachL
317b37db01 [DeckSync] Use designated initializer for ServerFile per review 2026-10-13 11:45:54 +02:00
BruebachL
14b3de0826 [DeckSync] Prefer ctor params for set-once client/provider per review 2026-10-13 11:45:44 +02:00
BruebachL
b4a2869579 [DeckSync] Move SyncSummary into DeckSyncManager per review 2026-10-13 11:45:29 +02:00
Lukas Brübach
bdb9de0e6e Fix ZeldaZach review for PR 7493: match uploads by sidecar remoteId/guid, validate server paths 2026-10-13 07:23:30 +02:00
Lukas Brübach
be3cb65af5 [Client] Manual deck collection sync with Sync Now action 2026-10-13 07:23:30 +02:00
Lukas Brübach
4721cde443 Fixup: clang-format alignment in deck_diff.h 2026-10-13 07:22:47 +02:00
Lukas Brübach
1a70940fc7 Fix ZeldaZach review for PR 7492: preserve printing in DeckDiff::merge, atomic sidecar save with QSaveFile 2026-10-13 07:17:02 +02:00
Lukas Brübach
8f09048bce [Client] DeckSyncProfile + sidecar + DeckDiff with tests 2026-10-11 14:26:12 +02:00
Lukas Brübach
a03f4c2fa4 [Server] Persist and serve deck sync fields + version rows 2026-10-11 14:16:30 +02:00
Lukas Brübach
10d46bafbd [Server] Deck sync identity: schema v38 + proto guid/hash fields 2026-10-11 14:09:05 +02:00
31 changed files with 2225 additions and 47 deletions

View file

@ -27,6 +27,9 @@ set(cockatrice_SOURCES
src/interface/deck_loader/deck_file_format.cpp src/interface/deck_loader/deck_file_format.cpp
src/interface/deck_loader/deck_loader.cpp src/interface/deck_loader/deck_loader.cpp
src/interface/deck_loader/loaded_deck.cpp src/interface/deck_loader/loaded_deck.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_connect.cpp
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp src/interface/widgets/dialogs/dlg_create_game.cpp

View file

@ -0,0 +1,726 @@
#include "deck_sync_manager.h"
#include "../deck_loader/deck_file_format.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_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

@ -4,6 +4,7 @@
#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_file_format.h"
#include "../../deck_loader/deck_loader.h" #include "../../deck_loader/deck_loader.h"
#include "../../deck_loader/loaded_deck.h" #include "../../deck_loader/loaded_deck.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../../pixel_map_generator.h" #include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h" #include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h" #include "../deck_share/deck_share_utils.h"
@ -32,6 +33,7 @@
#include <QInputDialog> #include <QInputDialog>
#include <QItemSelection> #include <QItemSelection>
#include <QItemSelectionModel> #include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QModelIndex> #include <QModelIndex>
@ -178,6 +180,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightVbox->addWidget(shareBar); rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView); rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout); rightVbox->addLayout(rightToolBarLayout);
syncStatusLabel = new QLabel(this);
syncStatusLabel->setWordWrap(true);
rightVbox->addWidget(syncStatusLabel);
rightGroupBox = new QGroupBox; rightGroupBox = new QGroupBox;
rightGroupBox->setLayout(rightVbox); rightGroupBox->setLayout(rightVbox);
@ -229,6 +234,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aPublishDeck->setIcon(QPixmap("theme:icons/lock")); aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); 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 // Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal); leftToolBar->addAction(aRenameLocal);
@ -240,11 +249,20 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload); rightToolBar->addAction(aDownload);
rightToolBar->addAction(aSyncNow);
rightToolBar->addAction(aShareDecks); rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck); 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(); retranslateUi();
QWidget *mainWidget = new QWidget(this); QWidget *mainWidget = new QWidget(this);
@ -273,6 +291,8 @@ void TabDeckStorage::retranslateUi()
aShareDecks->setText(tr("Share decks")); aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck")); aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder")); aOpenDecksFolder->setText(tr("Open decks folder"));
aSyncNow->setText(tr("Sync now"));
renderSyncStatus();
shareBar->retranslateUi(); shareBar->retranslateUi();
if (shareBar->isVisible()) { if (shareBar->isVisible()) {
onServerSelectionChanged(); onServerSelectionChanged();
@ -300,6 +320,7 @@ QString TabDeckStorage::getTargetPath() const
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo) void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{ {
syncUsername = QString::fromStdString(userInfo.name());
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered); setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
} }
@ -317,6 +338,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
void TabDeckStorage::setRemoteEnabled(bool enabled) void TabDeckStorage::setRemoteEnabled(bool enabled)
{ {
remoteEnabled = enabled;
aUpload->setEnabled(enabled); aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled); aDownload->setEnabled(enabled);
@ -324,6 +346,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aPublishDeck->setEnabled(enabled); aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled); aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled);
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
if (enabled) { if (enabled) {
serverDirView->refreshTree(); serverDirView->refreshTree();
@ -527,6 +550,61 @@ void TabDeckStorage::actOpenDecksFolder()
QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); QDesktopServices::openUrl(QUrl::fromLocalFile(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) void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
{ {
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) { if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {

View file

@ -8,7 +8,8 @@
#ifndef TAB_DECK_STORAGE_H #ifndef TAB_DECK_STORAGE_H
#define 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 "tab.h"
#include <QList> #include <QList>
@ -25,6 +26,7 @@ class QFileSystemModel;
class QToolBar; class QToolBar;
class QGroupBox; class QGroupBox;
class QTimer; class QTimer;
class QLabel;
class CommandContainer; class CommandContainer;
class Response; class Response;
class ShareBarWidget; class ShareBarWidget;
@ -50,6 +52,23 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder; QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; 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; bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer; QTimer *visibilityRefreshTimer;
QStringList visibilityFailures; QStringList visibilityFailures;
@ -108,6 +127,10 @@ private slots:
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(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: public:
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo); TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
void retranslateUi() override; void retranslateUi() override;

View file

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

View file

@ -0,0 +1,141 @@
#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
DeckDiffResult DeckDiff::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 DeckDiff::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
DeckDiff::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;
}

View file

@ -0,0 +1,74 @@
/**
* @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();
}
};
/**
* @class 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.
*/
class DeckDiff
{
public:
/** @brief Diffs oldDeck against newDeck. */
static 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.
*/
static 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.
*/
static DeckList
merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
};
#endif // DECK_DIFF_H

View file

@ -9,6 +9,7 @@
#include "tree/inner_deck_list_node.h" #include "tree/inner_deck_list_node.h"
#include <QAnyStringView> #include <QAnyStringView>
#include <QCryptographicHash>
#include <QDebug> #include <QDebug>
#include <QSet> #include <QSet>
#include <QSharedPointer> #include <QSharedPointer>
@ -453,6 +454,16 @@ void DeckList::refreshDeckHash()
cachedDeckHash = QString(); 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. * Calls a given function on each card in the deck.
*/ */

View file

@ -289,6 +289,16 @@ public:
/** @name Deck identity */ /** @name Deck identity */
///@{ ///@{
QString getDeckHash() const; 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(); void refreshDeckHash();
///@} ///@}

View file

@ -14,4 +14,7 @@ message Command_DeckUpload {
reserved 5, 6, 8; reserved 5, 6, 8;
reserved "banner_card_name", "banner_card_provider", "tags"; reserved "banner_card_name", "banner_card_provider", "tags";
optional string color_identity = 7; optional string color_identity = 7;
// Stable per-deck identity for two-way collection sync. Sent on first
// synced upload, echoed back in ServerInfo_DeckStorage_File afterwards.
optional string guid = 9;
} }

View file

@ -6,4 +6,9 @@ message Response_DeckDownload {
optional Response_DeckDownload ext = 1007; optional Response_DeckDownload ext = 1007;
} }
optional string deck = 1; optional string deck = 1;
// Sync identity echoed so the client can update its sidecar without an
// extra list fetch.
optional string guid = 2;
optional string hash = 3;
optional uint64 last_modified = 4;
} }

View file

@ -11,6 +11,15 @@ message ServerInfo_DeckStorage_File {
// Tag names associated with the deck. Empty for decks uploaded before the // Tag names associated with the deck. Empty for decks uploaded before the
// tags column existed. // tags column existed.
repeated string tags = 6; repeated string tags = 6;
// Stable per-deck identity for two-way collection sync. Empty for decks
// uploaded before the sync migration until they are re-uploaded.
optional string guid = 7;
// Content hash (DeckList::getDeckHash) for change detection without
// downloading the full list.
optional string hash = 8;
// Last modification time, seconds since Unix epoch. Drives newest-wins
// default and conflict-dialog recency highlight.
optional uint64 last_modified = 9;
} }
message ServerInfo_DeckStorage_Folder { message ServerInfo_DeckStorage_Folder {

View file

@ -0,0 +1,32 @@
-- Servatrice db migration from version 37 to version 38
-- Deck collection sync + versioning.
--
-- 1. Stable per-deck identity and change detection for two-way sync:
-- guid links a local file to its server row across renames, hash detects
-- content changes without downloading, last_modified drives newest-wins
-- default and conflict highlighting, deleted implements tombstones so a
-- delete on one device does not resurrect from the other.
-- 2. Limited per-deck version history: every hash-changing upload inserts a
-- row; the server prunes beyond the configured retention (default 20).
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `guid` char(36) NULL AFTER `tags`,
ADD COLUMN `hash` char(40) NULL AFTER `guid`,
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`;
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`deck_id` int(7) unsigned zerofill NOT NULL,
`version_no` int(7) unsigned NOT NULL,
`content` text NOT NULL,
`hash` char(40) NULL,
`created_at` datetime NOT NULL,
`created_by_clientid` varchar(15) NULL,
PRIMARY KEY (`id`),
KEY `deck_id` (`deck_id`),
FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
UPDATE cockatrice_schema_version SET version=38 WHERE version=37;

View file

@ -479,3 +479,25 @@ max_decks_per_share=50
; A value of 0 disables the limit. ; A value of 0 disables the limit.
; Default: 50 ; Default: 50
max_shares_per_day=50 max_shares_per_day=50
[deck_sync]
; Maximum number of stored versions kept per deck. Each upload with changed
; content adds a version; older versions beyond this limit are pruned.
; 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

@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
PRIMARY KEY (`version`) PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
INSERT INTO cockatrice_schema_version VALUES(37); INSERT INTO cockatrice_schema_version VALUES(38);
-- users and user data tables -- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` ( CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -71,11 +71,30 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` (
`banner_card_provider` varchar(32) NULL, `banner_card_provider` varchar(32) NULL,
`color_identity` varchar(5) NULL, `color_identity` varchar(5) NULL,
`tags` text NULL, `tags` text NULL,
`guid` char(36) NULL,
`hash` char(40) NULL,
`last_modified` datetime NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `FolderPlusUser` (`id_folder`,`id_user`), KEY `FolderPlusUser` (`id_folder`,`id_user`),
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Per-deck version history for collection sync.
-- One row per hash-changing upload; pruned beyond retention (default 20).
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`deck_id` int(7) unsigned zerofill NOT NULL,
`version_no` int(7) unsigned NOT NULL,
`content` text NOT NULL,
`hash` char(40) NULL,
`created_at` datetime NOT NULL,
`created_by_clientid` varchar(15) NULL,
PRIMARY KEY (`id`),
KEY `deck_id` (`deck_id`),
FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Temporary deck shares: a named bundle of decks that can be fetched by -- Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires. -- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (

View file

@ -466,6 +466,16 @@ bool Servatrice::initServer()
deckShareCleanupClock->start(deckShareCleanupInterval); deckShareCleanupClock->start(deckShareCleanupInterval);
} }
// 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 // SOCKET SERVER
if (getNumberOfTCPPools() > 0) { if (getNumberOfTCPPools() > 0) {
gameServer = gameServer =
@ -689,6 +699,11 @@ void Servatrice::cleanupExpiredDeckShares()
servatriceDatabaseInterface->cleanupExpiredDeckShares(); servatriceDatabaseInterface->cleanupExpiredDeckShares();
} }
void Servatrice::purgeDeckTombstones()
{
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
}
void Servatrice::statusUpdate() void Servatrice::statusUpdate()
{ {
if (!servatriceDatabaseInterface->checkSql()) { if (!servatriceDatabaseInterface->checkSql()) {
@ -1122,6 +1137,22 @@ int Servatrice::getDeckShareMaxSharesPerDay() const
return settingsCache->value("deck_share/max_shares_per_day", 50).toInt(); return settingsCache->value("deck_share/max_shares_per_day", 50).toInt();
} }
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 int Servatrice::getNumberOfTCPPools() const
{ {
return settingsCache->value("server/number_pools", 1).toInt(); return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -157,6 +157,7 @@ private slots:
void statusUpdate(); void statusUpdate();
void shutdownTimeout(); void shutdownTimeout();
void cleanupExpiredDeckShares(); void cleanupExpiredDeckShares();
void purgeDeckTombstones();
protected: protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override; void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -171,6 +172,7 @@ private:
DatabaseType databaseType; DatabaseType databaseType;
QTimer *pingClock, *statusUpdateClock; QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock; QTimer *deckShareCleanupClock;
QTimer *deckTombstoneClock;
Servatrice_GameServer *gameServer; Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer; Servatrice_IslServer *islServer;
@ -292,6 +294,9 @@ public:
int getDeckShareCleanupInterval() const; int getDeckShareCleanupInterval() const;
int getDeckShareMaxDecksPerShare() const; int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const; int getDeckShareMaxSharesPerDay() const;
int getDeckSyncMaxVersions() const;
int getDeckSyncTombstoneDays() const;
int getDeckSyncPurgeInterval() const;
int getMaxUsersPerAddress() const; int getMaxUsersPerAddress() const;
int getMessageCountingInterval() const override; int getMessageCountingInterval() const override;
int getMaxMessageCountPerInterval() const override; int getMaxMessageCountPerInterval() const override;

View file

@ -1088,8 +1088,8 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
{ {
checkSql(); checkSql();
QSqlQuery *query = QSqlQuery *query = prepareQuery(
prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user"); "select content from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", deckId); query->bindValue(":id", deckId);
query->bindValue(":id_user", userId); query->bindValue(":id_user", userId);
execSqlQuery(query); execSqlQuery(query);
@ -1240,6 +1240,19 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
execSqlQuery(query); 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) bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares)
{ {
checkSql(); checkSql();

View file

@ -21,7 +21,7 @@
class QSqlQuery; class QSqlQuery;
#define DATABASE_SCHEMA_VERSION 37 #define DATABASE_SCHEMA_VERSION 38
class Servatrice; class Servatrice;
@ -140,6 +140,11 @@ public:
bool getDeckShareItem(const QString &token, int itemId, QString &content); bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */ /** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares(); 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 getNextGameId() override;
int getNextReplayId() override; int getNextReplayId() override;

View file

@ -70,6 +70,7 @@
#include <QString> #include <QString>
#include <QStringLiteral> #include <QStringLiteral>
#include <QTextStream> #include <QTextStream>
#include <QUuid>
#include <QVariant> #include <QVariant>
#include <QtGlobal> #include <QtGlobal>
#include <algorithm> #include <algorithm>
@ -681,8 +682,9 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
} }
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, " query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags from {prefix}_decklist_files where " "banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"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_folder", folderId);
query->bindValue(":id_user", userId); query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) { if (!sqlInterface->execSqlQuery(query)) {
@ -708,6 +710,12 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { for (const QString &tag : deserializeDeckTags(query->value(7).toString())) {
newFile->add_tags(tag.toStdString()); newFile->add_tags(tag.toStdString());
} }
newFile->set_guid(query->value(8).toString().toStdString());
newFile->set_hash(query->value(9).toString().toStdString());
const QDateTime lastModified = query->value(10).toDateTime();
if (lastModified.isValid()) {
newFile->set_last_modified(lastModified.toSecsSinceEpoch());
}
} }
return true; return true;
@ -821,8 +829,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
sqlInterface->checkSql(); sqlInterface->checkSql();
if (cmd.has_deck_id()) { if (cmd.has_deck_id()) {
QSqlQuery *query = QSqlQuery *query = sqlInterface->prepareQuery(
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user"); "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", cmd.deck_id());
query->bindValue(":id_user", userInfo->id()); query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query); sqlInterface->execSqlQuery(query);
@ -932,7 +940,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
deckDelDirHelper(query->value(0).toInt()); 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); query->bindValue(":id_folder", basePathId);
sqlInterface->execSqlQuery(query); sqlInterface->execSqlQuery(query);
@ -983,18 +992,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
sqlInterface->checkSql(); sqlInterface->checkSql();
QSqlQuery *query = 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", cmd.deck_id());
query->bindValue(":id_user", userInfo->id()); query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query); if (!sqlInterface->execSqlQuery(query)) {
if (!query->next()) { return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound; 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; return Response::RespOk;
} }
@ -1014,6 +1022,38 @@ QString sanitizeColorIdentity(const QString &colorIdentity)
} }
} // namespace } // namespace
bool AbstractServerSocketInterface::storeDeckVersion(int deckId, const QString &content, const QString &hash)
{
QSqlQuery *maxQuery =
sqlInterface->prepareQuery("select coalesce(max(version_no), 0) from {prefix}_deck_versions where deck_id = "
":deck_id");
maxQuery->bindValue(":deck_id", deckId);
if (!sqlInterface->execSqlQuery(maxQuery) || !maxQuery->next()) {
return false;
}
const int nextVersion = maxQuery->value(0).toInt() + 1;
QSqlQuery *insertQuery =
sqlInterface->prepareQuery("insert into {prefix}_deck_versions (deck_id, version_no, content, hash, "
"created_at, created_by_clientid) values(:deck_id, :version_no, :content, :hash, "
"NOW(), :created_by_clientid)");
insertQuery->bindValue(":deck_id", deckId);
insertQuery->bindValue(":version_no", nextVersion);
insertQuery->bindValue(":content", content);
insertQuery->bindValue(":hash", hash);
insertQuery->bindValue(":created_by_clientid", QString::fromStdString(userInfo->clientid()));
if (!sqlInterface->execSqlQuery(insertQuery)) {
return false;
}
QSqlQuery *pruneQuery = sqlInterface->prepareQuery("delete from {prefix}_deck_versions where deck_id = :deck_id "
"and version_no <= :min_kept_version");
pruneQuery->bindValue(":deck_id", deckId);
pruneQuery->bindValue(":min_kept_version", nextVersion - servatrice->getDeckSyncMaxVersions());
sqlInterface->execSqlQuery(pruneQuery);
return true;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd,
ResponseContainer &rc) ResponseContainer &rc)
{ {
@ -1049,6 +1089,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
const QString tagsJson = serializeDeckTags(deck.getTags()); const QString tagsJson = serializeDeckTags(deck.getTags());
const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity()));
// 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);
}
if (cmd.has_path()) { if (cmd.has_path()) {
int folderId = getDeckPathId(nameFromStdString(cmd.path())); int folderId = getDeckPathId(nameFromStdString(cmd.path()));
@ -1056,35 +1103,123 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
return Response::RespNameNotFound; return Response::RespNameNotFound;
} }
QSqlQuery *query = // A deck that was deleted elsewhere leaves a tombstone behind. Revive it
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, " // instead of inserting a second row with the same guid, which would split
"content, is_public, banner_card_name, banner_card_provider, color_identity, " // the deck's version history and leave the tombstone to be purged.
"tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, " int deckId = -1;
":banner_card_name, :banner_card_provider, :color_identity, :tags)"); bool inserted = false;
query->bindValue(":id_folder", folderId); QSqlQuery *tombstoneQuery =
query->bindValue(":id_user", userInfo->id()); sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_user = :id_user and guid = "
query->bindValue(":name", deckName); ":guid and deleted = 1 order by last_modified desc limit 1");
query->bindValue(":content", deckStr); tombstoneQuery->bindValue(":id_user", userInfo->id());
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0); tombstoneQuery->bindValue(":guid", guid);
query->bindValue(":banner_card_name", bannerCardName); if (!sqlInterface->execSqlQuery(tombstoneQuery)) {
query->bindValue(":banner_card_provider", bannerCardProvider); return Response::RespContextError;
query->bindValue(":color_identity", colorIdentity); }
query->bindValue(":tags", tagsJson); if (tombstoneQuery->next()) {
if (!sqlInterface->execSqlQuery(query)) { 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)) {
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
QSqlQuery *stampQuery =
sqlInterface->prepareQuery("select unix_timestamp(upload_time), unix_timestamp(last_modified) from "
"{prefix}_decklist_files where id = :id and id_user = :id_user");
stampQuery->bindValue(":id", deckId);
stampQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError; return Response::RespContextError;
} }
Response_DeckUpload *re = new Response_DeckUpload; Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(query->lastInsertId().toInt()); fileInfo->set_id(deckId);
fileInfo->set_name(deckName.toStdString()); fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); fileInfo->mutable_file()->set_creation_time(stampQuery->value(0).toUInt());
fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public()); fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public());
fileInfo->mutable_file()->set_guid(guid.toStdString());
fileInfo->mutable_file()->set_hash(deckHash.toStdString());
fileInfo->mutable_file()->set_last_modified(stampQuery->value(1).toULongLong());
rc.setResponseExtension(re); rc.setResponseExtension(re);
} else if (cmd.has_deck_id()) { } else if (cmd.has_deck_id()) {
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, " QSqlQuery *currentQuery =
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, " sqlInterface->prepareQuery("select guid, hash from {prefix}_decklist_files where id = :id and id_user = "
"color_identity=:color_identity, tags=:tags"; ":id_user");
currentQuery->bindValue(":id", cmd.deck_id());
currentQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(currentQuery)) {
return Response::RespContextError;
}
if (!currentQuery->next()) {
return Response::RespNameNotFound;
}
if (!QUuid::fromString(currentQuery->value(0).toString()).isNull()) {
guid = currentQuery->value(0).toString();
} else if (cmd.has_guid() && !QUuid::fromString(nameFromStdString(cmd.guid()).left(36)).isNull()) {
guid = nameFromStdString(cmd.guid()).left(36);
}
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(), "
"deleted=0";
if (cmd.has_is_public()) { if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public"; updateQuery += ", is_public=:is_public";
} }
@ -1099,33 +1234,56 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
query->bindValue(":banner_card_provider", bannerCardProvider); query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity); query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson); query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (cmd.has_is_public()) { if (cmd.has_is_public()) {
query->bindValue(":is_public", cmd.is_public() ? 1 : 0); query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
} }
if (!sqlInterface->execSqlQuery(query)) { if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError; return Response::RespContextError;
} }
if (query->numRowsAffected() == 0) { if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound; return Response::RespNameNotFound;
} }
if (oldHash != deckHash) {
if (!storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) {
return Response::RespContextError;
}
} else {
// Self-heal a version gap left by an earlier failed version write:
// a same-content re-upload must still leave a version behind.
QSqlQuery *versionQuery =
sqlInterface->prepareQuery("select count(*) from {prefix}_deck_versions where deck_id = :deck_id and "
"hash = :hash");
versionQuery->bindValue(":deck_id", cmd.deck_id());
versionQuery->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(versionQuery) || !versionQuery->next()) {
return Response::RespContextError;
}
if (versionQuery->value(0).toInt() == 0 && !storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) {
return Response::RespContextError;
}
}
QSqlQuery *visibilityQuery = QSqlQuery *visibilityQuery =
sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and " sqlInterface->prepareQuery("select is_public, unix_timestamp(upload_time), unix_timestamp(last_modified) "
"id_user = :id_user"); "from {prefix}_decklist_files where id = :id and id_user = :id_user");
visibilityQuery->bindValue(":id", cmd.deck_id()); visibilityQuery->bindValue(":id", cmd.deck_id());
visibilityQuery->bindValue(":id_user", userInfo->id()); visibilityQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(visibilityQuery)) { if (!sqlInterface->execSqlQuery(visibilityQuery) || !visibilityQuery->next()) {
return Response::RespContextError; return Response::RespContextError;
} }
const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool();
Response_DeckUpload *re = new Response_DeckUpload; Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(cmd.deck_id()); fileInfo->set_id(cmd.deck_id());
fileInfo->set_name(deckName.toStdString()); fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); fileInfo->mutable_file()->set_creation_time(visibilityQuery->value(1).toUInt());
fileInfo->mutable_file()->set_is_public(isPublic); fileInfo->mutable_file()->set_is_public(visibilityQuery->value(0).toBool());
fileInfo->mutable_file()->set_guid(guid.toStdString());
fileInfo->mutable_file()->set_hash(deckHash.toStdString());
fileInfo->mutable_file()->set_last_modified(visibilityQuery->value(2).toULongLong());
rc.setResponseExtension(re); rc.setResponseExtension(re);
} else { } else {
return Response::RespInvalidData; return Response::RespInvalidData;
@ -1147,11 +1305,29 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm
} catch (Response::ResponseCode &r) { } catch (Response::ResponseCode &r) {
return r; return r;
} }
const QString deckNative = deck->writeToString_Native();
delete deck;
QSqlQuery *syncQuery =
sqlInterface->prepareQuery("select guid, hash, last_modified from {prefix}_decklist_files where id = :id and "
"id_user = :id_user");
syncQuery->bindValue(":id", cmd.deck_id());
syncQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(syncQuery)) {
return Response::RespContextError;
}
Response_DeckDownload *re = new Response_DeckDownload; Response_DeckDownload *re = new Response_DeckDownload;
re->set_deck(deck->writeToString_Native().toStdString()); re->set_deck(deckNative.toStdString());
if (syncQuery->next()) {
re->set_guid(syncQuery->value(0).toString().toStdString());
re->set_hash(syncQuery->value(1).toString().toStdString());
const QDateTime lastModified = syncQuery->value(2).toDateTime();
if (lastModified.isValid()) {
re->set_last_modified(lastModified.toSecsSinceEpoch());
}
}
rc.setResponseExtension(re); rc.setResponseExtension(re);
delete deck;
return Response::RespOk; return Response::RespOk;
} }
@ -1245,7 +1421,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C
// issues its own query on the same cached statement set. // issues its own query on the same cached statement set.
QSqlQuery *query = QSqlQuery *query =
sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = " 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_folder", folderId);
query->bindValue(":id_user", userInfo->id()); query->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(query)) { if (!sqlInterface->execSqlQuery(query)) {

View file

@ -154,6 +154,7 @@ private:
Response::ResponseCode cmdDeckDelDir(const Command_DeckDelDir &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckDelDir(const Command_DeckDelDir &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDel(const Command_DeckDel &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckDel(const Command_DeckDel &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc);
bool storeDeckVersion(int deckId, const QString &content, const QString &hash);
DeckList *getDeckFromDatabase(int deckId); DeckList *getDeckFromDatabase(int deckId);
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);

View file

@ -27,6 +27,10 @@ add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test)
add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test)
set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15)
add_test(NAME deck_diff_test COMMAND deck_diff_test)
add_test(NAME deck_content_hash_test COMMAND deck_content_hash_test)
add_test(NAME deck_sync_profile_test COMMAND deck_sync_profile_test)
add_test(NAME deck_sync_sidecar_test COMMAND deck_sync_sidecar_test)
# Find GTest # Find GTest
@ -37,6 +41,18 @@ add_executable(test_age_formatting test_age_formatting.cpp)
add_executable(password_hash_test password_hash_test.cpp) add_executable(password_hash_test password_hash_test.cpp)
add_executable(playmat_resolver_test playmat_resolver_test.cpp) add_executable(playmat_resolver_test playmat_resolver_test.cpp)
add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
add_executable(deck_diff_test deck_diff_test.cpp)
add_executable(deck_content_hash_test deck_content_hash_test.cpp)
add_executable(
deck_sync_profile_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
deck_sync_profile_test.cpp
)
target_include_directories(deck_sync_profile_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync)
add_executable(
deck_sync_sidecar_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
deck_sync_sidecar_test.cpp
)
target_include_directories(deck_sync_sidecar_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync)
add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_card_counter_test server_card_counter_test.cpp)
add_executable(server_counter_test server_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp)
add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
@ -137,6 +153,10 @@ if(NOT GTEST_FOUND)
add_dependencies(password_hash_test gtest) add_dependencies(password_hash_test gtest)
add_dependencies(playmat_resolver_test gtest) add_dependencies(playmat_resolver_test gtest)
add_dependencies(deck_hash_performance_test gtest) add_dependencies(deck_hash_performance_test gtest)
add_dependencies(deck_diff_test gtest)
add_dependencies(deck_content_hash_test gtest)
add_dependencies(deck_sync_profile_test gtest)
add_dependencies(deck_sync_sidecar_test gtest)
add_dependencies(server_card_counter_test gtest) add_dependencies(server_card_counter_test gtest)
add_dependencies(server_counter_test gtest) add_dependencies(server_counter_test gtest)
add_dependencies(server_rate_limiter_test gtest) add_dependencies(server_rate_limiter_test gtest)
@ -175,6 +195,16 @@ target_link_libraries(
deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES} ${TEST_QT_MODULES}
) )
target_link_libraries(
deck_diff_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(
deck_content_hash_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(deck_sync_profile_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(deck_sync_sidecar_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries( target_link_libraries(
server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
) )

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

137
tests/deck_diff_test.cpp Normal file
View file

@ -0,0 +1,137 @@
#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;
}
} // 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);
}
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();
}