Compare commits

...

3 commits

27 changed files with 1902 additions and 18 deletions

View file

@ -29,6 +29,7 @@ set(cockatrice_SOURCES
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_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp
@ -57,6 +58,8 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
src/interface/widgets/dialogs/dlg_sync_conflict.cpp
src/interface/widgets/dialogs/dlg_sync_onboarding.cpp
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp

View file

@ -25,6 +25,7 @@
#include <libcockatrice/settings/commander_bracket_settings.h>
#include <libcockatrice/settings/debug_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/game_filters_settings.h>
#include <libcockatrice/settings/game_settings.h>
@ -155,6 +156,7 @@ SettingsCache::SettingsCache()
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
interfaceSettings = new InterfaceSettings(settingsPath, this);
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
deckSyncSettings = new DeckSyncSettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
appearanceSettings = new AppearanceSettings(settingsPath, this);
@ -456,6 +458,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const
return *deckEditorSettings;
}
DeckSyncSettings &SettingsCache::deckSync() const
{
return *deckSyncSettings;
}
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
{
return *visualDeckStorageSettings;

View file

@ -33,6 +33,7 @@ class ChatSettings;
class CommanderBracketSettings;
class DebugSettings;
class DeckEditorSettings;
class DeckSyncSettings;
class DownloadSettings;
class GameFiltersSettings;
class GameSettings;
@ -82,6 +83,7 @@ private:
CardsDisplaySettings *cardsDisplaySettings = nullptr;
InterfaceSettings *interfaceSettings = nullptr;
DeckEditorSettings *deckEditorSettings = nullptr;
DeckSyncSettings *deckSyncSettings = nullptr;
PathsSettings *pathsSettings = nullptr;
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
AppearanceSettings *appearanceSettings = nullptr;
@ -150,6 +152,7 @@ public:
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
[[nodiscard]] InterfaceSettings &userInterface() const;
[[nodiscard]] DeckEditorSettings &deckEditor() const;
[[nodiscard]] DeckSyncSettings &deckSync() const;
[[nodiscard]] PathsSettings &paths() const;
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
[[nodiscard]] AppearanceSettings &appearance() const;

View file

@ -0,0 +1,50 @@
/**
* @file deck_sync_conflict.h
* @brief Conflict payload between the sync engine and the conflict dialog.
*/
#ifndef DECK_SYNC_CONFLICT_H
#define DECK_SYNC_CONFLICT_H
#include <QString>
/** @brief How a both-sides-changed deck is resolved. */
enum DeckSyncResolutionAction
{
DeckSyncKeepMine, ///< Upload the local deck, discarding the server version.
DeckSyncKeepServer, ///< Download the server deck, discarding local changes.
DeckSyncKeepBoth, ///< Keep the local deck and store the server version aside.
DeckSyncUseMerge, ///< Upload the auto-merged deck (only when mergeable).
DeckSyncSkipConflict, ///< Leave both sides untouched for this run.
};
/** @brief Sync direction for one run. */
enum class DeckSyncMode
{
TwoWay, ///< Mirror both sides (default).
UploadOnly, ///< Push local content; never download or propagate deletes.
DownloadOnly, ///< Take server content; never upload or push deletes.
Separate, ///< First-sync verdict only: keep this profile out of sync.
};
/** @brief One deck that changed both locally and on the server. */
struct DeckSyncConflict
{
QString relativePath; ///< Local deck path relative to the deck folder.
QString localAbsolutePath; ///< Absolute local file path.
QString serverDeckString; ///< Server deck content in native format.
QString baseDeckString; ///< Last-synced content, empty when no snapshot exists.
bool hasBase = false; ///< False when the base snapshot is missing.
qint64 localSecs = 0; ///< Local modification time, seconds since epoch.
qint64 serverSecs = 0; ///< Server modification time, seconds since epoch.
};
/** @brief The dialog verdict for one DeckSyncConflict. */
struct DeckSyncResolution
{
DeckSyncResolutionAction action = DeckSyncKeepMine;
bool rememberSession = false; ///< Reuse this verdict for later conflicts this run.
bool rememberAlways = false; ///< Store this verdict as the conflict default setting.
};
#endif // DECK_SYNC_CONFLICT_H

View file

@ -0,0 +1,928 @@
#include "deck_sync_manager.h"
#include "../deck_loader/deck_file_format.h"
#include "../deck_loader/deck_loader.h"
#include "deck_sync_sidecar.h"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QUuid>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/string_limits.h>
namespace
{
constexpr const char *SYNC_META_DIR = ".sync";
constexpr const char *BACKUP_SUFFIX = ".sync-backup-";
QString serverPathFor(const QString &relativePath)
{
QString path = relativePath;
if (path.endsWith(".cod", Qt::CaseInsensitive)) {
path.chop(4);
}
return path;
}
} // namespace
DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent)
{
}
void DeckSyncManager::setClient(AbstractClient *_client)
{
client = _client;
}
void DeckSyncManager::setProfile(const DeckSyncProfile &_profile)
{
profile = _profile;
}
void DeckSyncManager::setDeckPath(const QString &path)
{
deckPath = path;
}
void DeckSyncManager::setColorIdentityProvider(ColorIdentityProvider provider)
{
colorIdentityProvider = std::move(provider);
}
void DeckSyncManager::setConflictDefault(int _conflictDefault)
{
conflictDefault = _conflictDefault;
}
void DeckSyncManager::setSyncMode(DeckSyncMode mode)
{
syncMode = mode;
}
bool DeckSyncManager::isSyncing() const
{
return syncing;
}
void DeckSyncManager::syncNow()
{
if (syncing) {
return;
}
if (client == nullptr || !profile.isValid() || deckPath.isEmpty()) {
emit syncFinished(0, 0, 0, 0, tr("Not connected to a server."));
return;
}
QDir deckDir(deckPath);
if (!deckDir.exists()) {
emit syncFinished(0, 0, 0, 0, tr("Deck folder not found."));
return;
}
syncing = true;
uploadedCount = 0;
downloadedCount = 0;
unchangedCount = 0;
skippedCount = 0;
pendingUploads.clear();
pendingDownloads.clear();
pendingDeletes.clear();
conflictPaths.clear();
stashedServerFiles.clear();
activeConflict = ActiveConflict();
ensuredDirs.clear();
ensuredDirs.insert(QString());
localFiles.clear();
sessionDefault = -1;
syncMode = DeckSyncMode::TwoWay;
firstSyncRun = false;
emit syncStarted();
emit statusMessage(tr("Scanning local decks..."));
sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, profile.key());
DeckSyncSidecar sidecar(sidecarPath);
if (!sidecar.load()) {
failSync(tr("Could not read sync state."));
return;
}
if (sidecar.mode == DeckSyncSidecar::MODE_SEPARATE) {
failSync(tr("Sync is disabled for this server."));
return;
}
firstSyncRun = !QFileInfo(sidecarPath).exists();
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;
}
LocalFile file;
file.absolutePath = absolutePath;
file.hash = loaded->deckList.getDeckHash();
file.modified = QFileInfo(absolutePath).lastModified();
localFiles.insert(relativePath, file);
}
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<QString, ServerFile> &files,
QSet<QString> &dirs)
{
for (const auto &item : folder.items()) {
const QString name = QString::fromStdString(item.name());
if (item.has_folder()) {
const QString path = prefix.isEmpty() ? name : prefix + "/" + name;
dirs.insert(path);
collectServerFiles(item.folder(), path, files, dirs);
} else if (item.has_file()) {
const QString path = prefix.isEmpty() ? name : prefix + "/" + name;
const ServerInfo_DeckStorage_File &file = item.file();
ServerFile entry;
entry.id = item.id();
entry.guid = QString::fromStdString(file.guid());
entry.hash = QString::fromStdString(file.hash());
entry.lastModifiedSecs = file.has_last_modified() ? static_cast<qint64>(file.last_modified()) : 0;
files.insert(path, 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<QString, ServerFile> serverFiles;
QSet<QString> serverDirs;
const Response_DeckList &list = response.GetExtension(Response_DeckList::ext);
collectServerFiles(list.root(), QString(), serverFiles, serverDirs);
ensuredDirs.unite(serverDirs);
if (firstSyncRun && !localFiles.isEmpty() && !serverFiles.isEmpty()) {
stashedServerFiles = serverFiles;
emit firstSyncDetected(localFiles.size(), serverFiles.size());
return;
}
classify(serverFiles);
}
void DeckSyncManager::continueFirstSync(DeckSyncMode mode)
{
if (!syncing || !firstSyncRun) {
return;
}
firstSyncRun = false;
if (mode == DeckSyncMode::Separate) {
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
sidecar.mode = DeckSyncSidecar::MODE_SEPARATE;
sidecar.save();
stashedServerFiles.clear();
failSync(tr("Sync is disabled for this server."));
return;
}
syncMode = mode;
classify(stashedServerFiles);
stashedServerFiles.clear();
}
void DeckSyncManager::cancelFirstSync()
{
if (!syncing || !firstSyncRun) {
return;
}
firstSyncRun = false;
stashedServerFiles.clear();
failSync(tr("Sync cancelled."));
}
void DeckSyncManager::classify(const QMap<QString, ServerFile> &serverFiles)
{
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();
for (auto it = localFiles.begin(); it != localFiles.end(); ++it) {
const QString serverPath = serverPathFor(it.key());
auto serverIt = serverFiles.find(serverPath);
if (serverIt == serverFiles.end()) {
const DeckSyncRecord known = sidecar.get(it.key());
if (known.remoteId != -1) {
if (syncMode == DeckSyncMode::UploadOnly) {
continue;
}
// The server no longer lists this deck: it was deleted elsewhere.
// Apply the delete locally, keeping a backup of the file.
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;
}
if (syncMode == DeckSyncMode::DownloadOnly) {
continue;
}
UploadOp op;
op.relativePath = it.key();
op.serverPath = serverPath;
op.deckId = -1;
op.guid = !known.guid.isEmpty() ? known.guid : QUuid::createUuid().toString(QUuid::WithoutBraces);
pendingUploads.append(op);
continue;
}
if (it.value().hash == serverIt.value().hash) {
DeckSyncRecord record = sidecar.get(it.key());
if (record.guid.isEmpty()) {
record.guid = serverIt.value().guid;
}
record.remoteId = serverIt.value().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();
const int effective = sessionDefault >= 0 ? sessionDefault : conflictDefault;
// A server row without last_modified predates sync: the local file wins.
// Otherwise strictly newer wins; ties go to the server.
if (syncMode == DeckSyncMode::UploadOnly || effective == DeckSyncConflictKeepMine) {
applyUpload(it.key(), serverPath, serverIt.value().id, serverIt.value().guid);
} else if (syncMode == DeckSyncMode::DownloadOnly || effective == DeckSyncConflictKeepServer) {
applyDownload(it.key(), serverIt.value().id, serverIt.value().guid, serverIt.value().hash,
serverIt.value().lastModifiedSecs);
} else if (effective == DeckSyncConflictKeepNewer &&
(serverIt.value().lastModifiedSecs == 0 || localSecs > serverIt.value().lastModifiedSecs)) {
applyUpload(it.key(), serverPath, serverIt.value().id, serverIt.value().guid);
} else if (effective == DeckSyncConflictKeepNewer) {
applyDownload(it.key(), serverIt.value().id, serverIt.value().guid, serverIt.value().hash,
serverIt.value().lastModifiedSecs);
} else {
queueConflict(it.key(), serverPath, serverIt.value().id, serverIt.value().guid, serverIt.value().hash,
serverIt.value().lastModifiedSecs);
}
}
if (syncMode != DeckSyncMode::UploadOnly) {
for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) {
const QString wantedPath = it.key().endsWith(".cod", Qt::CaseInsensitive) ? it.key() : it.key() + ".cod";
if (localFiles.contains(wantedPath) || QFileInfo(deckPath + "/" + wantedPath).exists()) {
continue;
}
const QString relativePath = uniqueLocalPath(wantedPath);
if (relativePath.isEmpty()) {
++skippedCount;
continue;
}
DownloadOp op;
op.relativePath = relativePath;
op.deckId = it.value().id;
op.guid = it.value().guid;
op.hash = it.value().hash;
op.lastModifiedSecs = it.value().lastModifiedSecs;
pendingDownloads.append(op);
}
}
if (syncMode != DeckSyncMode::DownloadOnly) {
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
if (localFiles.contains(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 || activeConflict.active) {
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::applyUpload(const QString &relativePath,
const QString &serverPath,
int deckId,
const QString &guid)
{
UploadOp op;
op.relativePath = relativePath;
op.serverPath = serverPath;
op.deckId = deckId;
op.guid = guid;
pendingUploads.append(op);
}
void DeckSyncManager::applyDownload(const QString &relativePath,
int deckId,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs)
{
DownloadOp op;
op.relativePath = relativePath;
op.deckId = deckId;
op.guid = guid;
op.hash = hash;
op.lastModifiedSecs = lastModifiedSecs;
pendingDownloads.append(op);
}
QString DeckSyncManager::readBaseSnapshot(const QString &guid) const
{
if (guid.isEmpty()) {
return {};
}
QFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod");
if (!snapshot.open(QIODevice::ReadOnly)) {
return {};
}
return QString::fromUtf8(snapshot.readAll());
}
void DeckSyncManager::queueConflict(const QString &relativePath,
const QString &serverPath,
int deckId,
const QString &serverGuid,
const QString &serverHash,
qint64 serverLastModifiedSecs)
{
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
const QString guid = !serverGuid.isEmpty() ? serverGuid : sidecar.get(relativePath).guid;
if (readBaseSnapshot(guid).isEmpty()) {
emit statusMessage(tr("No base snapshot for %1, keeping the newer version.").arg(relativePath));
const qint64 localSecs = localFiles.value(relativePath).modified.toSecsSinceEpoch();
if (serverLastModifiedSecs == 0 || localSecs > serverLastModifiedSecs) {
applyUpload(relativePath, serverPath, deckId, serverGuid);
} else {
applyDownload(relativePath, deckId, serverGuid, serverHash, serverLastModifiedSecs);
}
return;
}
ConflictDownload ctx;
ctx.serverPath = serverPath;
ctx.deckId = deckId;
ctx.guid = serverGuid;
ctx.hash = serverHash;
ctx.lastModifiedSecs = serverLastModifiedSecs;
pendingConflicts.insert(relativePath, ctx);
conflictPaths.insert(relativePath);
applyDownload(relativePath, deckId, serverGuid, serverHash, serverLastModifiedSecs);
}
void DeckSyncManager::handleConflictDownload(const QString &relativePath, const QString &serverDeckString)
{
const ConflictDownload ctx = pendingConflicts.take(relativePath);
if (sessionDefault == DeckSyncConflictKeepMine) {
applyUpload(relativePath, ctx.serverPath, ctx.deckId, ctx.guid);
processNext();
return;
}
if (sessionDefault == DeckSyncConflictKeepServer) {
applyDownloadedDeck(relativePath, ctx.deckId, serverDeckString, ctx.guid, ctx.hash, ctx.lastModifiedSecs,
ctx.lastModifiedSecs != 0);
return;
}
std::optional<LoadedDeck> loaded =
DeckLoader::loadFromFile(localFiles.value(relativePath).absolutePath, DeckFileFormat::Cockatrice);
DeckList serverDeck;
if (!loaded || !serverDeck.loadFromString_Native(serverDeckString)) {
++skippedCount;
processNext();
return;
}
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
const QString guid = !ctx.guid.isEmpty() ? ctx.guid : sidecar.get(relativePath).guid;
activeConflict.active = true;
activeConflict.relativePath = relativePath;
activeConflict.serverPath = ctx.serverPath;
activeConflict.deckId = ctx.deckId;
activeConflict.serverGuid = ctx.guid;
activeConflict.serverHash = ctx.hash;
activeConflict.serverLastModifiedSecs = ctx.lastModifiedSecs;
activeConflict.serverDeckString = serverDeckString;
DeckSyncConflict conflict;
conflict.relativePath = relativePath;
conflict.localAbsolutePath = localFiles.value(relativePath).absolutePath;
conflict.serverDeckString = serverDeckString;
conflict.baseDeckString = readBaseSnapshot(guid);
conflict.hasBase = !conflict.baseDeckString.isEmpty();
conflict.localSecs = localFiles.value(relativePath).modified.toSecsSinceEpoch();
conflict.serverSecs = ctx.lastModifiedSecs;
emit conflictRequest(conflict);
}
void DeckSyncManager::resolveConflict(const DeckSyncResolution &resolution)
{
if (!syncing || !activeConflict.active) {
return;
}
if (resolution.rememberSession &&
(resolution.action == DeckSyncKeepMine || resolution.action == DeckSyncKeepServer)) {
sessionDefault = resolution.action == DeckSyncKeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer;
}
const ActiveConflict conflict = activeConflict;
activeConflict.active = false;
switch (resolution.action) {
case DeckSyncKeepMine:
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
break;
case DeckSyncKeepServer:
applyDownload(conflict.relativePath, conflict.deckId, conflict.serverGuid, conflict.serverHash,
conflict.serverLastModifiedSecs);
break;
case DeckSyncKeepBoth: {
// Persist the already-fetched server bytes synchronously: queuing a
// download here would race the upload below and fetch back Mine.
// The aside copy is a new local file, so it gets a fresh guid and
// uploads as its own deck on the next run.
QString asideBase = conflict.relativePath;
if (asideBase.endsWith(".cod", Qt::CaseInsensitive)) {
asideBase.chop(4);
}
const QString aside = uniqueLocalPath(asideBase + " (server).cod");
DeckList serverDeck;
if (aside.isEmpty() || !serverDeck.loadFromString_Native(conflict.serverDeckString)) {
emit statusMessage(tr("Could not store the server version of %1 aside, keeping the local deck.")
.arg(conflict.relativePath));
++skippedCount;
} else {
const QString asideAbsolute = deckPath + "/" + aside;
QDir().mkpath(QFileInfo(asideAbsolute).absolutePath());
if (!DeckLoader::saveToFile(serverDeck, asideAbsolute, DeckFileFormat::Cockatrice)) {
++skippedCount;
} else {
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
DeckSyncRecord asideRecord;
asideRecord.guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
asideRecord.baseHash = serverDeck.getDeckHash();
sidecar.set(aside, asideRecord);
sidecar.save();
snapshotLocalFile(aside, asideRecord.guid);
}
}
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
break;
}
case DeckSyncUseMerge: {
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
const QString guid =
!conflict.serverGuid.isEmpty() ? conflict.serverGuid : sidecar.get(conflict.relativePath).guid;
DeckList localDeck;
DeckList serverDeck;
DeckList baseDeck;
std::optional<LoadedDeck> loaded = DeckLoader::loadFromFile(
localFiles.value(conflict.relativePath).absolutePath, DeckFileFormat::Cockatrice);
if (!loaded || !serverDeck.loadFromString_Native(conflict.serverDeckString) ||
!baseDeck.loadFromString_Native(readBaseSnapshot(guid))) {
emit statusMessage(
tr("Could not build the merge for %1, keeping the local deck.").arg(conflict.relativePath));
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
break;
}
localDeck = loaded->deckList;
const DeckDiffResult localChanges = DeckDiff::diff(baseDeck, localDeck);
const DeckDiffResult remoteChanges = DeckDiff::diff(baseDeck, serverDeck);
const DeckList merged = DeckDiff::merge(baseDeck, localChanges, remoteChanges);
const QString absolutePath = deckPath + "/" + conflict.relativePath;
const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
if (!QFile::copy(absolutePath, backupPath) ||
!DeckLoader::saveToFile(merged, absolutePath, DeckFileFormat::Cockatrice)) {
++skippedCount;
break;
}
std::optional<LoadedDeck> reloaded = DeckLoader::loadFromFile(absolutePath, DeckFileFormat::Cockatrice);
if (reloaded) {
localFiles[conflict.relativePath].hash = reloaded->deckList.getDeckHash();
localFiles[conflict.relativePath].modified = QFileInfo(absolutePath).lastModified();
}
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
break;
}
case DeckSyncSkipConflict:
++skippedCount;
break;
}
processNext();
}
void DeckSyncManager::processNextUpload()
{
const UploadOp op = pendingUploads.first();
const QString dir = parentDir(op.serverPath);
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(parentDir(op.serverPath).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 = 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();
int finishedDeckId = -1;
for (int i = 0; i < pendingDownloads.size(); ++i) {
if (pendingDownloads.at(i).relativePath == relativePath) {
finishedDeckId = pendingDownloads.at(i).deckId;
pendingDownloads.removeAt(i);
break;
}
}
if (response.response_code() != Response::RespOk) {
++skippedCount;
processNext();
return;
}
const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext);
if (conflictPaths.contains(relativePath)) {
conflictPaths.remove(relativePath);
handleConflictDownload(relativePath, QString::fromStdString(download.deck()));
return;
}
applyDownloadedDeck(relativePath, finishedDeckId, QString::fromStdString(download.deck()),
QString::fromStdString(download.guid()), QString::fromStdString(download.hash()),
static_cast<qint64>(download.last_modified()), download.has_last_modified());
}
void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
int deckId,
const QString &deckString,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs,
bool hasLastModified)
{
DeckList deck;
if (!deck.loadFromString_Native(deckString)) {
++skippedCount;
processNext();
return;
}
const QString absolutePath = deckPath + "/" + relativePath;
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 (deckId != -1) {
record.remoteId = deckId;
}
if (!guid.isEmpty()) {
record.guid = guid;
}
if (!hash.isEmpty()) {
record.baseHash = hash;
} else {
record.baseHash = deck.getDeckHash();
}
record.lastSyncSecs = hasLastModified ? lastModifiedSecs : 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");
QFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod");
if (!snapshot.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return;
}
snapshot.write(source.readAll());
}
void DeckSyncManager::failSync(const QString &error)
{
syncing = false;
emit syncFinished(uploadedCount, downloadedCount, unchangedCount, skippedCount, error);
}
void DeckSyncManager::finishSync()
{
syncing = false;
emit statusMessage(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(uploadedCount)
.arg(downloadedCount)
.arg(unchangedCount)
.arg(skippedCount));
emit syncFinished(uploadedCount, downloadedCount, unchangedCount, skippedCount, QString());
}

View file

@ -0,0 +1,208 @@
/**
* @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_conflict.h"
#include "deck_sync_profile.h"
#include <QDateTime>
#include <QList>
#include <QMap>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariant>
#include <functional>
#include <libcockatrice/settings/deck_sync_settings.h>
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). Matching is path-based; a rename looks like a
* delete plus an add. 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.
* Both-sides-changed decks pause the queue for a verdict via conflictRequest /
* resolveConflict (or the conflict default setting); the first sync of an
* unknown profile pauses for an onboarding verdict via firstSyncDetected /
* continueFirstSync. No watcher and no auto triggers — those arrive later.
*
* 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 &)>;
explicit DeckSyncManager(QObject *parent = nullptr);
void setClient(AbstractClient *_client);
void setProfile(const DeckSyncProfile &_profile);
void setDeckPath(const QString &path);
void setColorIdentityProvider(ColorIdentityProvider provider);
void setConflictDefault(int conflictDefault);
void setSyncMode(DeckSyncMode mode);
[[nodiscard]] bool isSyncing() const;
public slots:
void syncNow();
/** @brief Continues a paused first sync after the onboarding verdict. */
void continueFirstSync(DeckSyncMode mode);
/** @brief Aborts a paused first sync without writing any verdict. */
void cancelFirstSync();
/** @brief Applies the dialog verdict for the active conflict and resumes. */
void resolveConflict(const DeckSyncResolution &resolution);
signals:
void syncStarted();
void statusMessage(const QString &message);
void syncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error);
/** @brief First sync with content on both sides: the tab must ask how to proceed. */
void firstSyncDetected(int localCount, int serverCount);
/** @brief A both-sides-changed deck needs a verdict; the queue pauses until resolveConflict. */
void conflictRequest(const DeckSyncConflict &conflict);
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 guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct LocalFile
{
QString absolutePath;
QString hash;
QDateTime modified;
};
struct UploadOp
{
QString relativePath;
QString serverPath;
int deckId = -1;
QString guid;
};
struct DownloadOp
{
QString relativePath;
int deckId = -1;
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct ActiveConflict
{
bool active = false;
QString relativePath;
QString serverPath;
int deckId = -1;
QString serverGuid;
QString serverHash;
qint64 serverLastModifiedSecs = 0;
QString serverDeckString;
};
struct ConflictDownload
{
QString serverPath;
int deckId = -1;
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
void failSync(const QString &error);
void finishSync();
void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<QString, 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);
QString readBaseSnapshot(const QString &guid) const;
void classify(const QMap<QString, ServerFile> &serverFiles);
void queueConflict(const QString &relativePath,
const QString &serverPath,
int deckId,
const QString &serverGuid,
const QString &serverHash,
qint64 serverLastModifiedSecs);
void handleConflictDownload(const QString &relativePath, const QString &serverDeckString);
void applyDownloadedDeck(const QString &relativePath,
int deckId,
const QString &deckString,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs,
bool hasLastModified);
void applyUpload(const QString &relativePath, const QString &serverPath, int deckId, const QString &guid);
void applyDownload(const QString &relativePath,
int deckId,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs);
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;
QSet<QString> conflictPaths;
QMap<QString, ConflictDownload> pendingConflicts;
QMap<QString, ServerFile> stashedServerFiles;
ActiveConflict activeConflict;
DeckSyncMode syncMode = DeckSyncMode::TwoWay;
int conflictDefault = DeckSyncConflictAsk;
int sessionDefault = -1;
bool firstSyncRun = false;
int uploadedCount = 0;
int downloadedCount = 0;
int unchangedCount = 0;
int skippedCount = 0;
};
#endif // DECK_SYNC_MANAGER_H

View file

@ -15,9 +15,15 @@ DeckSyncSidecar::DeckSyncSidecar(const QString &_filePath) : filePath(_filePath)
{
}
QString DeckSyncSidecar::pathForDeckPath(const QString &deckPath, const QString &profileKey)
{
return QDir(deckPath).filePath(".sync/" + profileKey + ".json");
}
bool DeckSyncSidecar::load()
{
recordMap.clear();
mode = MODE_LINKED;
QFile file(filePath);
if (!file.exists()) {
@ -30,6 +36,8 @@ bool DeckSyncSidecar::load()
if (!document.isObject() || !document.object().value("records").isObject()) {
return false;
}
const QJsonValue modeValue = document.object().value("mode");
mode = modeValue.isString() ? modeValue.toString() : MODE_LINKED;
const QJsonObject records = document.object().value("records").toObject();
for (auto it = records.begin(); it != records.end(); ++it) {
if (!it.value().isObject()) {
@ -61,6 +69,7 @@ bool DeckSyncSidecar::save() const
}
QJsonObject root;
root.insert("version", SIDECAR_FORMAT_VERSION);
root.insert("mode", mode);
root.insert("records", records);
QDir().mkpath(QFileInfo(filePath).absolutePath());

View file

@ -30,10 +30,17 @@ struct DeckSyncRecord
class DeckSyncSidecar
{
public:
QString filePath; ///< Full path of the sidecar JSON file.
static constexpr const char *MODE_LINKED = "linked";
static constexpr const char *MODE_SEPARATE = "separate";
QString filePath; ///< Full path of the sidecar JSON file.
QString mode = MODE_LINKED; ///< Linked normally; separate keeps this profile out of sync.
explicit DeckSyncSidecar(const QString &_filePath);
/** @brief Sidecar file path for a deck folder and profile key. */
static QString pathForDeckPath(const QString &deckPath, const QString &profileKey);
/** @brief Loads records; a missing file loads as empty and returns true. */
bool load();
/** @brief Stores records, creating parent folders as needed. */

View file

@ -0,0 +1,178 @@
#include "dlg_sync_conflict.h"
#include <QCheckBox>
#include <QDateTime>
#include <QDialogButtonBox>
#include <QFile>
#include <QLabel>
#include <QListWidget>
#include <QLocale>
#include <QRadioButton>
#include <QVBoxLayout>
#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
{
QString formatTime(qint64 secsSinceEpoch)
{
return QLocale().toString(QDateTime::fromSecsSinceEpoch(secsSinceEpoch), QLocale::ShortFormat);
}
int zoneCardCount(const DeckList &deck, const QString &zone)
{
int count = 0;
deck.forEachCard([&count, &zone](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
if (zoneNode->getName() == zone) {
count += card->getNumber();
}
});
return count;
}
} // namespace
DlgSyncConflict::DlgSyncConflict(const DeckSyncConflict &_conflict, QWidget *parent)
: QDialog(parent), conflict(_conflict)
{
auto *layout = new QVBoxLayout(this);
titleLabel = new QLabel(this);
detailLabel = new QLabel(this);
detailLabel->setWordWrap(true);
keepMineRadio = new QRadioButton(this);
keepServerRadio = new QRadioButton(this);
keepBothRadio = new QRadioButton(this);
mergeRadio = new QRadioButton(this);
changesList = new QListWidget(this);
mergeSummaryLabel = new QLabel(this);
mergeSummaryLabel->setWordWrap(true);
rememberSessionBox = new QCheckBox(this);
rememberAlwaysBox = new QCheckBox(this);
layout->addWidget(titleLabel);
layout->addWidget(detailLabel);
layout->addWidget(keepMineRadio);
layout->addWidget(keepServerRadio);
layout->addWidget(keepBothRadio);
layout->addWidget(mergeRadio);
layout->addWidget(changesList);
layout->addWidget(mergeSummaryLabel);
layout->addWidget(rememberSessionBox);
layout->addWidget(rememberAlwaysBox);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(buttons);
DeckList localDeck;
DeckList serverDeck;
QFile localFile(conflict.localAbsolutePath);
decksParsed = localFile.open(QIODevice::ReadOnly) && localDeck.loadFromFile_Native(&localFile) &&
serverDeck.loadFromString_Native(conflict.serverDeckString);
localIsNewer = conflict.localSecs >= conflict.serverSecs;
if (decksParsed) {
shownChanges = DeckDiff::diff(localDeck, serverDeck);
if (conflict.hasBase) {
DeckList baseDeck;
if (baseDeck.loadFromString_Native(conflict.baseDeckString)) {
const DeckDiffResult localChanges = DeckDiff::diff(baseDeck, localDeck);
const DeckDiffResult remoteChanges = DeckDiff::diff(baseDeck, serverDeck);
mergeable = DeckDiff::canAutoMerge(localChanges, remoteChanges);
if (mergeable) {
const DeckList merged = DeckDiff::merge(baseDeck, localChanges, remoteChanges);
mergedMainCount = zoneCardCount(merged, "main");
mergedSideCount = zoneCardCount(merged, "side");
}
}
}
}
if (localIsNewer) {
keepMineRadio->setChecked(true);
} else {
keepServerRadio->setChecked(true);
}
mergeRadio->setEnabled(mergeable);
connect(keepMineRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
connect(keepServerRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
connect(keepBothRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
connect(mergeRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
updateRememberBoxes();
retranslateUi();
}
void DlgSyncConflict::retranslateUi()
{
setWindowTitle(tr("Sync conflict"));
titleLabel->setText(conflict.relativePath);
if (!decksParsed) {
detailLabel->setText(tr("This deck changed on both sides, but one version could not be read."));
} else {
detailLabel->setText(tr("This deck changed both on this device and on the server."));
}
keepMineRadio->setText(
tr("Keep my deck (%1%2)").arg(formatTime(conflict.localSecs), localIsNewer ? tr(", newest") : QString()));
keepServerRadio->setText(tr("Keep the server deck (%1%2)")
.arg(formatTime(conflict.serverSecs), localIsNewer ? QString() : tr(", newest")));
keepBothRadio->setText(tr("Keep both (store the server version aside)"));
mergeRadio->setText(mergeable ? tr("Use the auto-merge") : tr("Use the auto-merge (not available)"));
changesList->clear();
for (const DeckCardChange &change : shownChanges.changes) {
changesList->addItem(
tr("%1: %2 %3 → %4").arg(change.zone).arg(change.cardName).arg(change.oldCount).arg(change.newCount));
}
if (mergeable) {
mergeSummaryLabel->setText(tr("Auto-merge proposal: union of both sides (%1 main, %2 side).")
.arg(mergedMainCount)
.arg(mergedSideCount));
} else if (!conflict.hasBase) {
mergeSummaryLabel->setText(tr("No base snapshot exists, so the versions cannot be merged automatically."));
} else {
mergeSummaryLabel->setText(tr("The changes overlap, so they cannot be merged automatically."));
}
rememberSessionBox->setText(tr("Remember for the rest of this sync"));
rememberAlwaysBox->setText(tr("Always use this rule"));
}
void DlgSyncConflict::updateRememberBoxes()
{
const bool single = keepMineRadio->isChecked() || keepServerRadio->isChecked();
rememberSessionBox->setEnabled(single);
rememberAlwaysBox->setEnabled(single);
if (!single) {
rememberSessionBox->setChecked(false);
rememberAlwaysBox->setChecked(false);
}
}
DeckSyncResolutionAction DlgSyncConflict::selectedAction() const
{
if (keepServerRadio->isChecked()) {
return DeckSyncKeepServer;
}
if (keepBothRadio->isChecked()) {
return DeckSyncKeepBoth;
}
if (mergeRadio->isChecked()) {
return DeckSyncUseMerge;
}
return DeckSyncKeepMine;
}
DeckSyncResolution DlgSyncConflict::resolution() const
{
DeckSyncResolution result;
result.action = selectedAction();
result.rememberSession = rememberSessionBox->isEnabled() && rememberSessionBox->isChecked();
result.rememberAlways = rememberAlwaysBox->isEnabled() && rememberAlwaysBox->isChecked();
return result;
}

View file

@ -0,0 +1,63 @@
/**
* @file dlg_sync_conflict.h
* @brief Dialog for resolving a deck that changed both locally and on the server.
*/
#ifndef DLG_SYNC_CONFLICT_H
#define DLG_SYNC_CONFLICT_H
#include "../../deck_sync/deck_sync_conflict.h"
#include <QDialog>
#include <QString>
#include <libcockatrice/deck_list/deck_diff.h>
class QCheckBox;
class QLabel;
class QListWidget;
class QRadioButton;
/**
* @class DlgSyncConflict
* @brief Shows both versions of a conflicted deck and returns the verdict.
*
* Radio choice between Keep mine / Keep server (newest preselected), Keep both
* and Use merge (enabled only when the changes touch disjoint cards). The
* change list shows card-count deltas between the two versions; the merge
* summary describes the auto-merge proposal. Rejected dialogs resolve as Skip.
*/
class DlgSyncConflict : public QDialog
{
Q_OBJECT
public:
explicit DlgSyncConflict(const DeckSyncConflict &conflict, QWidget *parent = nullptr);
[[nodiscard]] DeckSyncResolution resolution() const;
void retranslateUi();
private slots:
void updateRememberBoxes();
private:
DeckSyncResolutionAction selectedAction() const;
DeckSyncConflict conflict;
bool decksParsed = false;
bool localIsNewer = true;
DeckDiffResult shownChanges;
bool mergeable = false;
int mergedMainCount = 0;
int mergedSideCount = 0;
QLabel *titleLabel;
QLabel *detailLabel;
QRadioButton *keepMineRadio;
QRadioButton *keepServerRadio;
QRadioButton *keepBothRadio;
QRadioButton *mergeRadio;
QListWidget *changesList;
QLabel *mergeSummaryLabel;
QCheckBox *rememberSessionBox;
QCheckBox *rememberAlwaysBox;
};
#endif // DLG_SYNC_CONFLICT_H

View file

@ -0,0 +1,59 @@
#include "dlg_sync_onboarding.h"
#include <QDialogButtonBox>
#include <QLabel>
#include <QRadioButton>
#include <QVBoxLayout>
DlgSyncOnboarding::DlgSyncOnboarding(int _localCount, int _serverCount, QWidget *parent)
: QDialog(parent), localCount(_localCount), serverCount(_serverCount)
{
auto *layout = new QVBoxLayout(this);
detailLabel = new QLabel(this);
detailLabel->setWordWrap(true);
mergeRadio = new QRadioButton(this);
uploadRadio = new QRadioButton(this);
downloadRadio = new QRadioButton(this);
separateRadio = new QRadioButton(this);
mergeRadio->setChecked(true);
layout->addWidget(detailLabel);
layout->addWidget(mergeRadio);
layout->addWidget(uploadRadio);
layout->addWidget(downloadRadio);
layout->addWidget(separateRadio);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(buttons);
retranslateUi();
}
void DlgSyncOnboarding::retranslateUi()
{
setWindowTitle(tr("Sync decks to this server"));
detailLabel->setText(tr("You have %1 local decks and %2 decks stored on this server. How should they be combined?")
.arg(localCount)
.arg(serverCount));
mergeRadio->setText(tr("Merge: keep everything, sync both ways (recommended)"));
uploadRadio->setText(tr("Upload mine: push local decks to the server"));
downloadRadio->setText(tr("Download server: take the server decks"));
separateRadio->setText(tr("Keep separate: do not sync with this server"));
}
DeckSyncMode DlgSyncOnboarding::selectedMode() const
{
if (uploadRadio->isChecked()) {
return DeckSyncMode::UploadOnly;
}
if (downloadRadio->isChecked()) {
return DeckSyncMode::DownloadOnly;
}
if (separateRadio->isChecked()) {
return DeckSyncMode::Separate;
}
return DeckSyncMode::TwoWay;
}

View file

@ -0,0 +1,43 @@
/**
* @file dlg_sync_onboarding.h
* @brief First-sync choice when local decks and server decks meet.
*/
#ifndef DLG_SYNC_ONBOARDING_H
#define DLG_SYNC_ONBOARDING_H
#include "../../deck_sync/deck_sync_conflict.h"
#include <QDialog>
class QLabel;
class QRadioButton;
/**
* @class DlgSyncOnboarding
* @brief Asks how to combine a non-empty local collection with a non-empty server collection.
*
* Merge (two-way) is the default. Upload and download restrict the run to one
* direction; keeping separate records the profile as sync-disabled.
* Rejected dialogs cancel the sync without recording anything.
*/
class DlgSyncOnboarding : public QDialog
{
Q_OBJECT
public:
DlgSyncOnboarding(int _localCount, int _serverCount, QWidget *parent = nullptr);
[[nodiscard]] DeckSyncMode selectedMode() const;
void retranslateUi();
private:
int localCount = 0;
int serverCount = 0;
QLabel *detailLabel;
QRadioButton *mergeRadio;
QRadioButton *uploadRadio;
QRadioButton *downloadRadio;
QRadioButton *separateRadio;
};
#endif // DLG_SYNC_ONBOARDING_H

View file

@ -28,6 +28,7 @@
#include <QtGlobal>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
@ -332,12 +333,32 @@ GeneralSettingsPage::GeneralSettingsPage()
pathsGroupBox = new QGroupBox;
pathsGroupBox->setLayout(pathsGrid);
// deck sync settings
conflictDefaultSelector.addItem("", DeckSyncConflictAsk);
conflictDefaultSelector.addItem("", DeckSyncConflictKeepMine);
conflictDefaultSelector.addItem("", DeckSyncConflictKeepServer);
conflictDefaultSelector.addItem("", DeckSyncConflictKeepNewer);
int conflictIndex = conflictDefaultSelector.findData(settings.deckSync().getConflictDefault());
if (conflictIndex >= 0) {
conflictDefaultSelector.setCurrentIndex(conflictIndex);
}
connect(&conflictDefaultSelector, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().deckSync().setConflictDefault(conflictDefaultSelector.itemData(index).toInt());
});
auto *deckSyncGrid = new QGridLayout;
deckSyncGrid->addWidget(&conflictDefaultLabel, 0, 0);
deckSyncGrid->addWidget(&conflictDefaultSelector, 0, 1);
deckSyncGroupBox = new QGroupBox;
deckSyncGroupBox->setLayout(deckSyncGrid);
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(languageGroupBox);
mainLayout->addWidget(versionGroupBox);
mainLayout->addWidget(cardDatabaseGroupBox);
mainLayout->addWidget(startupGroupBox);
mainLayout->addWidget(pathsGroupBox);
mainLayout->addWidget(deckSyncGroupBox);
mainLayout->addStretch();
GeneralSettingsPage::retranslateUi();
@ -607,5 +628,13 @@ void GeneralSettingsPage::retranslateUi()
cardDatabasePathLabel.setText(tr("Card database:"));
customCardDatabasePathLabel.setText(tr("Custom database directory:"));
tokenDatabasePathLabel.setText(tr("Token database:"));
deckSyncGroupBox->setTitle(tr("Deck sync"));
conflictDefaultLabel.setText(tr("When a deck changed on both sides:"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictAsk), tr("Ask me every time"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepMine), tr("Keep my deck"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepServer),
tr("Keep the server deck"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepNewer),
tr("Keep the newer deck"));
resetAllPathsButton->setText(tr("Reset all paths"));
}

View file

@ -52,6 +52,7 @@ private:
QGroupBox *cardDatabaseGroupBox;
QGroupBox *startupGroupBox;
QGroupBox *pathsGroupBox;
QGroupBox *deckSyncGroupBox;
QLabel languageLabel;
QComboBox languageBox;
@ -101,6 +102,9 @@ private:
QLineEdit *tokenDatabasePathEdit;
QPushButton *resetAllPathsButton;
QLabel *allPathsResetLabel;
QLabel conflictDefaultLabel;
QComboBox conflictDefaultSelector;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -4,10 +4,15 @@
#include "../../deck_loader/deck_file_format.h"
#include "../../deck_loader/deck_loader.h"
#include "../../deck_loader/loaded_deck.h"
#include "../../deck_sync/deck_sync_conflict.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../../deck_sync/deck_sync_profile.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h"
#include "../deck_share/share_bar_widget.h"
#include "../dialogs/dlg_sync_conflict.h"
#include "../dialogs/dlg_sync_onboarding.h"
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include "abstract_client.h"
@ -32,6 +37,7 @@
#include <QInputDialog>
#include <QItemSelection>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QModelIndex>
@ -63,6 +69,7 @@
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/string_limits.h>
@ -178,6 +185,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout);
syncStatusLabel = new QLabel(this);
syncStatusLabel->setWordWrap(true);
rightVbox->addWidget(syncStatusLabel);
rightGroupBox = new QGroupBox;
rightGroupBox->setLayout(rightVbox);
@ -229,6 +239,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
aSyncNow = new QAction(this);
aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload));
connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow);
// Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal);
@ -240,11 +254,27 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aSyncNow);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
syncManager = new DeckSyncManager(this);
syncManager->setClient(client);
syncManager->setColorIdentityProvider(
[](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); });
syncManager->setConflictDefault(SettingsCache::instance().deckSync().getConflictDefault());
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::conflictDefaultChanged, syncManager,
&DeckSyncManager::setConflictDefault);
connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage);
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected);
connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest);
if (currentUserInfo != nullptr) {
syncUsername = QString::fromStdString(currentUserInfo->name());
}
retranslateUi();
QWidget *mainWidget = new QWidget(this);
@ -273,6 +303,8 @@ void TabDeckStorage::retranslateUi()
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder"));
aSyncNow->setText(tr("Sync now"));
renderSyncStatus();
shareBar->retranslateUi();
if (shareBar->isVisible()) {
onServerSelectionChanged();
@ -300,6 +332,7 @@ QString TabDeckStorage::getTargetPath() const
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{
syncUsername = QString::fromStdString(userInfo.name());
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
}
@ -317,6 +350,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
void TabDeckStorage::setRemoteEnabled(bool enabled)
{
remoteEnabled = enabled;
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
@ -324,6 +358,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
if (enabled) {
serverDirView->refreshTree();
@ -527,6 +562,89 @@ void TabDeckStorage::actOpenDecksFolder()
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::onFirstSyncDetected(int localCount, int serverCount)
{
DlgSyncOnboarding dialog(localCount, serverCount, this);
if (dialog.exec() == QDialog::Accepted) {
syncManager->continueFirstSync(dialog.selectedMode());
} else {
syncManager->cancelFirstSync();
}
}
void TabDeckStorage::onConflictRequest(const DeckSyncConflict &conflict)
{
DlgSyncConflict dialog(conflict, this);
if (dialog.exec() != QDialog::Accepted) {
DeckSyncResolution skip;
skip.action = DeckSyncSkipConflict;
syncManager->resolveConflict(skip);
return;
}
const DeckSyncResolution resolution = dialog.resolution();
if (resolution.rememberAlways &&
(resolution.action == DeckSyncKeepMine || resolution.action == DeckSyncKeepServer)) {
SettingsCache::instance().deckSync().setConflictDefault(
resolution.action == DeckSyncKeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer);
}
syncManager->resolveConflict(resolution);
}
void TabDeckStorage::onSyncStatusMessage(const QString &message)
{
syncStatusType = SyncStatusType::Working;
syncStatusDetail = message;
renderSyncStatus();
}
void TabDeckStorage::onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error)
{
aSyncNow->setEnabled(remoteEnabled);
if (!error.isEmpty()) {
syncStatusType = SyncStatusType::Failed;
syncStatusDetail = error;
} else {
syncStatusType = SyncStatusType::Finished;
syncStatusDetail.clear();
lastSyncSummary = {uploaded, downloaded, unchanged, skipped};
}
renderSyncStatus();
if (error.isEmpty()) {
serverDirView->refreshTree();
}
}
void TabDeckStorage::renderSyncStatus()
{
switch (syncStatusType) {
case SyncStatusType::Working:
syncStatusLabel->setText(syncStatusDetail);
break;
case SyncStatusType::Failed:
syncStatusLabel->setText(tr("Sync failed: %1").arg(syncStatusDetail));
break;
case SyncStatusType::Finished:
syncStatusLabel->setText(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(lastSyncSummary.uploaded)
.arg(lastSyncSummary.downloaded)
.arg(lastSyncSummary.unchanged)
.arg(lastSyncSummary.skipped));
break;
case SyncStatusType::Idle:
syncStatusLabel->setText(tr("Not synced yet"));
break;
}
}
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
{
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {

View file

@ -19,15 +19,19 @@
#include <qtmetamacros.h>
struct LoadedDeck;
struct DeckSyncConflict;
struct DeckSyncResolution;
class ServerInfo_User;
class QTreeView;
class QFileSystemModel;
class QToolBar;
class QGroupBox;
class QTimer;
class QLabel;
class CommandContainer;
class Response;
class ShareBarWidget;
class DeckSyncManager;
class QAction;
class QModelIndex;
class TabSupervisor;
@ -50,6 +54,29 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
QAction *aSyncNow;
QLabel *syncStatusLabel;
DeckSyncManager *syncManager;
QString syncUsername;
bool remoteEnabled = false;
enum class SyncStatusType
{
Idle,
Working,
Finished,
Failed
};
struct SyncSummary
{
int uploaded = 0;
int downloaded = 0;
int unchanged = 0;
int skipped = 0;
};
SyncStatusType syncStatusType = SyncStatusType::Idle;
QString syncStatusDetail;
SyncSummary lastSyncSummary;
void renderSyncStatus();
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
@ -108,6 +135,12 @@ private slots:
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
void actSyncNow();
void onFirstSyncDetected(int localCount, int serverCount);
void onConflictRequest(const DeckSyncConflict &conflict);
void onSyncStatusMessage(const QString &message);
void onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error);
public:
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
void retranslateUi() override;

View file

@ -12,6 +12,7 @@ set(HEADERS
libcockatrice/settings/commander_bracket_settings.h
libcockatrice/settings/debug_settings.h
libcockatrice/settings/deck_editor_settings.h
libcockatrice/settings/deck_sync_settings.h
libcockatrice/settings/download_settings.h
libcockatrice/settings/game_filters_settings.h
libcockatrice/settings/game_settings.h
@ -45,6 +46,7 @@ add_library(
libcockatrice/settings/chat_settings.cpp
libcockatrice/settings/debug_settings.cpp
libcockatrice/settings/deck_editor_settings.cpp
libcockatrice/settings/deck_sync_settings.cpp
libcockatrice/settings/download_settings.cpp
libcockatrice/settings/game_filters_settings.cpp
libcockatrice/settings/game_settings.cpp

View file

@ -0,0 +1,21 @@
#include "deck_sync_settings.h"
#include "libcockatrice/settings/settings_manager.h"
#include <QVariant>
DeckSyncSettings::DeckSyncSettings(const QString &settingPath, QObject *parent)
: SettingsManager(settingPath + "deck_sync.ini", "decksync", QString(), parent)
{
}
int DeckSyncSettings::getConflictDefault() const
{
return getValue("conflictDefault", QString(), QString(), DeckSyncConflictAsk).toInt();
}
void DeckSyncSettings::setConflictDefault(int _conflictDefault)
{
setValue(_conflictDefault, "conflictDefault");
emit conflictDefaultChanged(_conflictDefault);
}

View file

@ -0,0 +1,38 @@
#ifndef DECK_SYNC_SETTINGS_H
#define DECK_SYNC_SETTINGS_H
#include "settings_manager.h"
#include <QString>
#include <qtmetamacros.h>
class QObject;
enum DeckSyncConflictDefault
{
DeckSyncConflictAsk = 0, ///< Ask every time a deck changed on both sides.
DeckSyncConflictKeepMine, ///< Silently keep the local deck.
DeckSyncConflictKeepServer, ///< Silently keep the server deck.
DeckSyncConflictKeepNewer, ///< Silently keep whichever side changed last.
};
class DeckSyncSettings : public SettingsManager
{
Q_OBJECT
friend class SettingsCache;
public:
[[nodiscard]] int getConflictDefault() const;
void setConflictDefault(int _conflictDefault);
signals:
void conflictDefaultChanged(int conflictDefault);
public:
explicit DeckSyncSettings(const QString &settingPath, QObject *parent = nullptr);
private:
DeckSyncSettings(const DeckSyncSettings & /*other*/);
};
#endif // DECK_SYNC_SETTINGS_H

View file

@ -488,3 +488,10 @@ max_shares_per_day=50
; Minimum enforced: 1.
; Default: 20
max_versions=20
; How many days a deleted deck stays restorable as a tombstone before the
; cleanup clock purges it (with its versions) for good. Re-uploading the
; same deck within this window resurrects it instead of creating a copy.
; A value of 0 purges tombstones on the next cleanup run.
; Default: 30
tombstone_days=30

View file

@ -460,6 +460,7 @@ bool Servatrice::initServer()
deckShareCleanupClock = new QTimer(this);
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(purgeDeckTombstones()));
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
if (deckShareCleanupInterval > 0) {
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
@ -689,6 +690,11 @@ void Servatrice::cleanupExpiredDeckShares()
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::purgeDeckTombstones()
{
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1127,6 +1133,11 @@ 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::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -157,6 +157,7 @@ private slots:
void statusUpdate();
void shutdownTimeout();
void cleanupExpiredDeckShares();
void purgeDeckTombstones();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -293,6 +294,7 @@ public:
int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const;
int getDeckSyncMaxVersions() const;
int getDeckSyncTombstoneDays() const;
int getMaxUsersPerAddress() const;
int getMessageCountingInterval() const override;
int getMaxMessageCountPerInterval() const override;

View file

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

View file

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

View file

@ -683,7 +683,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user");
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user "
"and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
@ -828,8 +829,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
sqlInterface->checkSql();
if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery(
"select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
@ -939,7 +940,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
deckDelDirHelper(query->value(0).toInt());
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id_folder = :id_folder");
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() "
"where id_folder = :id_folder and deleted = 0");
query->bindValue(":id_folder", basePathId);
sqlInterface->execSqlQuery(query);
@ -990,18 +992,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
sqlInterface->checkSql();
QSqlQuery *query =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id = :id and id_user = :id_user");
sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() where id = "
":id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
query->bindValue(":id", cmd.deck_id());
sqlInterface->execSqlQuery(query);
return Response::RespOk;
}
@ -1169,9 +1170,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
}
const QString oldHash = currentQuery->value(1).toString();
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
QString updateQuery =
"update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW(), "
"deleted=0";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
@ -1373,7 +1376,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C
// issues its own query on the same cached statement set.
QSqlQuery *query =
sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
":id_folder and id_user = :id_user and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(query)) {

View file

@ -119,6 +119,26 @@ TEST(DeckSyncSidecarTest, RemoveDropsRecord)
EXPECT_FALSE(reloaded.contains("a.cod"));
}
TEST(DeckSyncSidecarTest, ModeRoundTripsAndDefaultsToLinked)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_EQ(sidecar.mode, DeckSyncSidecar::MODE_LINKED);
sidecar.mode = DeckSyncSidecar::MODE_SEPARATE;
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
EXPECT_EQ(reloaded.mode, DeckSyncSidecar::MODE_SEPARATE);
}
TEST(DeckSyncSidecarTest, PathForDeckPathBuildsSidecarPath)
{
QString path = DeckSyncSidecar::pathForDeckPath("/decks", "abc123");
EXPECT_EQ(path, QString("/decks/.sync/abc123.json"));
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -11,6 +11,7 @@
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/game_settings.h>
#include <libcockatrice/settings/interface_settings.h>
@ -531,6 +532,25 @@ TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet)
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext);
}
// --- DeckSyncSettings ---
TEST_F(SettingsDefaultsTest, DeckSync_ConflictDefault_Default)
{
DeckSyncSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictAsk);
}
TEST_F(SettingsDefaultsTest, DeckSync_ConflictDefault_SetAndGet)
{
DeckSyncSettings s(settingsPath, nullptr);
s.setConflictDefault(DeckSyncConflictKeepNewer);
ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictKeepNewer);
s.setConflictDefault(DeckSyncConflictKeepMine);
ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictKeepMine);
s.setConflictDefault(DeckSyncConflictAsk);
ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictAsk);
}
// --- NetworkSettings ---
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)