From dae947aeeb097a755e272c9a3c54db8ba9fbdc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 16:46:46 +0200 Subject: [PATCH 1/8] [Client] Sync conflict dialog, onboarding and conflict default --- cockatrice/CMakeLists.txt | 2 + .../src/client/settings/cache_settings.cpp | 7 + .../src/client/settings/cache_settings.h | 3 + .../interface/deck_sync/deck_sync_conflict.h | 50 ++ .../interface/deck_sync/deck_sync_manager.cpp | 428 +++++++++++++++--- .../interface/deck_sync/deck_sync_manager.h | 68 ++- .../interface/deck_sync/deck_sync_sidecar.cpp | 9 + .../interface/deck_sync/deck_sync_sidecar.h | 9 +- .../widgets/dialogs/dlg_sync_conflict.cpp | 178 ++++++++ .../widgets/dialogs/dlg_sync_conflict.h | 63 +++ .../widgets/dialogs/dlg_sync_onboarding.cpp | 59 +++ .../widgets/dialogs/dlg_sync_onboarding.h | 43 ++ .../settings_page/general_settings_page.cpp | 29 ++ .../settings_page/general_settings_page.h | 4 + .../widgets/tabs/tab_deck_storage.cpp | 38 ++ .../interface/widgets/tabs/tab_deck_storage.h | 4 + libcockatrice_settings/CMakeLists.txt | 2 + .../settings/deck_sync_settings.cpp | 21 + .../settings/deck_sync_settings.h | 38 ++ tests/deck_sync_sidecar_test.cpp | 20 + tests/settings/settings_defaults_test.cpp | 20 + 21 files changed, 1031 insertions(+), 64 deletions(-) create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_conflict.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h create mode 100644 libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp create mode 100644 libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 8d40dead4..eb1310d56 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -59,6 +59,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_turn_order.cpp src/interface/widgets/dialogs/dlg_update.cpp diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index b40e26377..12a13e2c5 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -185,6 +186,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); @@ -486,6 +488,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const return *deckEditorSettings; } +DeckSyncSettings &SettingsCache::deckSync() const +{ + return *deckSyncSettings; +} + VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const { return *visualDeckStorageSettings; diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 8ccf6f76b..3c1ee24b8 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -32,6 +32,7 @@ class ChatSettings; class CommanderBracketSettings; class DebugSettings; class DeckEditorSettings; +class DeckSyncSettings; class DownloadSettings; class GameFiltersSettings; class GameSettings; @@ -81,6 +82,7 @@ private: CardsDisplaySettings *cardsDisplaySettings = nullptr; InterfaceSettings *interfaceSettings = nullptr; DeckEditorSettings *deckEditorSettings = nullptr; + DeckSyncSettings *deckSyncSettings = nullptr; PathsSettings *pathsSettings = nullptr; VisualDeckStorageSettings *visualDeckStorageSettings = nullptr; AppearanceSettings *appearanceSettings = nullptr; @@ -154,6 +156,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; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_conflict.h b/cockatrice/src/interface/deck_sync/deck_sync_conflict.h new file mode 100644 index 000000000..a5b272ecd --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_conflict.h @@ -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 + +/** @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 diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 560f238d6..9935258c1 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,16 @@ void DeckSyncManager::setDeckPath(const QString &path) deckPath = path; } +void DeckSyncManager::setConflictDefault(int _conflictDefault) +{ + conflictDefault = _conflictDefault; +} + +void DeckSyncManager::setSyncMode(DeckSyncMode mode) +{ + syncMode = mode; +} + bool DeckSyncManager::isSyncing() const { return syncing; @@ -126,18 +137,29 @@ void DeckSyncManager::syncNow() pendingDownloads.clear(); pendingDeletes.clear(); plannedLocalPaths.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 = deckDir.filePath(SYNC_META_DIR + QString("/") + profile.key() + ".json"); + 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()) { @@ -212,6 +234,47 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont 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 &serverFiles) +{ emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size())); DeckSyncSidecar sidecar(sidecarPath); @@ -310,6 +373,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont const bool unchangedSinceSync = !sidecarRecord.baseHash.isEmpty() && it.value().hash == sidecarRecord.baseHash; if (knownToServer && unchangedSinceSync) { + if (syncMode == DeckSyncMode::UploadOnly) { + continue; + } if (remoteStateLost) { // The listing is empty although this deck was uploaded, so the // row is not known to be deleted, only missing. Keep the file @@ -330,6 +396,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont ++removedCount; continue; } + if (syncMode == DeckSyncMode::DownloadOnly) { + continue; + } const QString serverPath = serverPathFor(it.key()); pendingUploads.append(UploadOp{.relativePath = it.key(), .serverPath = serverPath, @@ -356,67 +425,71 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont 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 (server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs) { - pendingUploads.append(UploadOp{.relativePath = it.key(), - .serverPath = server.path, - .serverDir = server.dir, - .deckId = server.id, - .guid = server.guid}); + if (syncMode == DeckSyncMode::UploadOnly || effective == DeckSyncConflictKeepMine) { + applyUpload(it.key(), server.path, server.id, server.guid); + } else if (syncMode == DeckSyncMode::DownloadOnly || effective == DeckSyncConflictKeepServer) { + applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs); + } else if (effective == DeckSyncConflictKeepNewer && + (server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs)) { + applyUpload(it.key(), server.path, server.id, server.guid); + } else if (effective == DeckSyncConflictKeepNewer) { + applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs); } else { - pendingDownloads.append(DownloadOp{.relativePath = it.key(), - .deckId = server.id, - .guid = server.guid, - .hash = server.hash, - .lastModifiedSecs = server.lastModifiedSecs}); + queueConflict(it.key(), server.path, server.id, server.guid, server.hash, server.lastModifiedSecs); } } - for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) { - if (matchedServerIds.contains(it.key())) { - continue; + if (syncMode != DeckSyncMode::UploadOnly) { + 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; + } + plannedLocalPaths.insert(relativePath); + pendingDownloads.append(DownloadOp{.relativePath = relativePath, + .deckId = it.value().id, + .guid = it.value().guid, + .hash = it.value().hash, + .lastModifiedSecs = it.value().lastModifiedSecs}); } - const QString wantedPath = localPathFor(it.value().dir, it.value().name); - const QString relativePath = uniqueLocalPath(wantedPath); - if (relativePath.isEmpty()) { - ++skippedCount; - continue; - } - plannedLocalPaths.insert(relativePath); - pendingDownloads.append(DownloadOp{.relativePath = relativePath, - .deckId = it.value().id, - .guid = it.value().guid, - .hash = it.value().hash, - .lastModifiedSecs = it.value().lastModifiedSecs}); } - const QMap knownRecords = sidecar.records(); - for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) { - if (localFiles.contains(it.key())) { - continue; + if (syncMode != DeckSyncMode::DownloadOnly) { + const QMap 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) { + // Never uploaded, so there is no server row to remove. + sidecar.remove(it.key()); + continue; + } + if (matchedServerIds.contains(it.value().remoteId)) { + // Another local file (a rename or a copy) now owns this row: drop the + // stale record, but never tombstone the row it took over. + sidecar.remove(it.key()); + continue; + } + if (remoteStateLost) { + // The whole listing is missing, not this one deck: pushing the delete + // would purge rows that are probably still there. + heldBackRemovals = true; + continue; + } + pendingDeletes.append(it.key()); } - if (QFileInfo::exists(deckPath + "/" + it.key())) { - continue; - } - if (it.value().remoteId == -1) { - // Never uploaded, so there is no server row to remove. - sidecar.remove(it.key()); - continue; - } - if (matchedServerIds.contains(it.value().remoteId)) { - // Another local file (a rename or a copy) now owns this row: drop the - // stale record, but never tombstone the row it took over. - sidecar.remove(it.key()); - continue; - } - if (remoteStateLost) { - // The whole listing is missing, not this one deck: pushing the delete - // would purge rows that are probably still there. - heldBackRemovals = true; - continue; - } - pendingDeletes.append(it.key()); } if (heldBackRemovals) { @@ -436,7 +509,7 @@ QString DeckSyncManager::parentDir(const QString &path) void DeckSyncManager::processNext() { - if (!syncing) { + if (!syncing || activeConflict.active) { return; } if (!pendingDeletes.isEmpty()) { @@ -453,6 +526,220 @@ void DeckSyncManager::processNext() } } +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 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 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 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(); @@ -659,10 +946,10 @@ void DeckSyncManager::onDownloadFinished(const Response &response, return; } const QString relativePath = extraData.toString(); - int deckId = -1; + int finishedDeckId = -1; for (int i = 0; i < pendingDownloads.size(); ++i) { if (pendingDownloads.at(i).relativePath == relativePath) { - deckId = pendingDownloads.at(i).deckId; + finishedDeckId = pendingDownloads.at(i).deckId; pendingDownloads.removeAt(i); break; } @@ -678,8 +965,26 @@ void DeckSyncManager::onDownloadFinished(const Response &response, } 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(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(QString::fromStdString(download.deck()))) { + if (!deck.loadFromString_Native(deckString)) { ++skippedCount; processNext(); return; @@ -714,16 +1019,15 @@ void DeckSyncManager::onDownloadFinished(const Response &response, if (deckId != -1) { record.remoteId = deckId; } - if (!download.guid().empty()) { - record.guid = QString::fromStdString(download.guid()); + if (!guid.isEmpty()) { + record.guid = guid; } - if (!download.hash().empty()) { - record.baseHash = QString::fromStdString(download.hash()); + if (!hash.isEmpty()) { + record.baseHash = hash; } else { record.baseHash = deck.getContentHash(); } - record.lastSyncSecs = download.has_last_modified() ? static_cast(download.last_modified()) - : QDateTime::currentDateTime().toSecsSinceEpoch(); + record.lastSyncSecs = hasLastModified ? lastModifiedSecs : QDateTime::currentDateTime().toSecsSinceEpoch(); record.deleted = false; sidecar.set(relativePath, record); sidecar.save(); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index c5284d33a..06160cb3d 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -6,6 +6,7 @@ #ifndef DECK_SYNC_MANAGER_H #define DECK_SYNC_MANAGER_H +#include "deck_sync_conflict.h" #include "deck_sync_profile.h" #include @@ -16,6 +17,7 @@ #include #include #include +#include class AbstractClient; class CommandContainer; @@ -37,7 +39,10 @@ class ServerInfo_DeckStorage_Folder; * 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. + * 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 @@ -69,15 +74,27 @@ public: void setProfile(const DeckSyncProfile &_profile); void setDeckPath(const QString &path); + 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(const SyncSummary &summary, 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); @@ -123,6 +140,25 @@ private: 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(); @@ -138,6 +174,28 @@ private: 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 &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; @@ -155,6 +213,14 @@ private: QList pendingDownloads; QList pendingDeletes; QString syncNote; + QSet conflictPaths; + QMap pendingConflicts; + QMap stashedServerFiles; + ActiveConflict activeConflict; + DeckSyncMode syncMode = DeckSyncMode::TwoWay; + int conflictDefault = DeckSyncConflictAsk; + int sessionDefault = -1; + bool firstSyncRun = false; int uploadedCount = 0; int downloadedCount = 0; int removedCount = 0; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp index 9e2785f41..7a509861c 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp @@ -16,9 +16,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()) { @@ -31,6 +37,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()); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h index 7eb2db7ae..2266922bc 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -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. */ diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp new file mode 100644 index 000000000..7d548993d --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp @@ -0,0 +1,178 @@ +#include "dlg_sync_conflict.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h new file mode 100644 index 000000000..0a969f53e --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h @@ -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 +#include +#include + +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 diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp new file mode 100644 index 000000000..ae0e9446b --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp @@ -0,0 +1,59 @@ +#include "dlg_sync_onboarding.h" + +#include +#include +#include +#include + +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; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h new file mode 100644 index 000000000..9d811a28e --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h @@ -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 + +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 diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 7b89b744d..6b6678467 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -359,6 +360,25 @@ 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(&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); @@ -366,6 +386,7 @@ GeneralSettingsPage::GeneralSettingsPage() mainLayout->addWidget(cardDatabaseGroupBox); mainLayout->addWidget(startupGroupBox); mainLayout->addWidget(pathsGroupBox); + mainLayout->addWidget(deckSyncGroupBox); mainLayout->addStretch(); GeneralSettingsPage::retranslateUi(); @@ -647,5 +668,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")); } diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index 9451db434..92d46c265 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -52,6 +52,7 @@ private: QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; + QGroupBox *deckSyncGroupBox; QLabel languageLabel; QComboBox languageBox; @@ -104,6 +105,9 @@ private: QLineEdit *tokenDatabasePathEdit; QPushButton *resetAllPathsButton; QLabel *allPathsResetLabel; + + QLabel conflictDefaultLabel; + QComboBox conflictDefaultSelector; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index a2263e092..c47f00b7d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,11 +2,15 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../deck_sync/deck_sync_conflict.h" #include "../../deck_sync/deck_sync_manager.h" +#include "../../deck_sync/deck_sync_profile.h" #include "../../theme_icon.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" @@ -63,6 +67,7 @@ #include #include #include +#include #include #include #include @@ -256,8 +261,13 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, syncManager = new DeckSyncManager( client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this); + 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()); } @@ -558,6 +568,34 @@ void TabDeckStorage::actSyncNow() 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; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index c7456877e..610cce891 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -20,6 +20,8 @@ #include struct LoadedDeck; +struct DeckSyncConflict; +struct DeckSyncResolution; class ServerInfo_User; class QTreeView; class QFileSystemModel; @@ -128,6 +130,8 @@ private slots: 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(const SyncSummary &summary, const QString &error); diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index a3b9a68db..962a08a06 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -8,6 +8,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 @@ -39,6 +40,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 diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp new file mode 100644 index 000000000..0b86d3c84 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp @@ -0,0 +1,21 @@ +#include "deck_sync_settings.h" + +#include "libcockatrice/settings/settings_manager.h" + +#include + +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); +} diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h new file mode 100644 index 000000000..f984292f3 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h @@ -0,0 +1,38 @@ +#ifndef DECK_SYNC_SETTINGS_H +#define DECK_SYNC_SETTINGS_H + +#include "settings_manager.h" + +#include +#include + +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 diff --git a/tests/deck_sync_sidecar_test.cpp b/tests/deck_sync_sidecar_test.cpp index 4bd04be33..76e18d797 100644 --- a/tests/deck_sync_sidecar_test.cpp +++ b/tests/deck_sync_sidecar_test.cpp @@ -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); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index aea7f1d54..0a2fb31e0 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -586,6 +587,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) From 1dc6b81f8b230b0f5f02cf663ce832a79d6b880b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 07:19:55 +0200 Subject: [PATCH 2/8] Fix ZeldaZach review for PR 7495: three-way baseHash check before conflict --- .../interface/deck_sync/deck_sync_manager.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 9935258c1..1bf585560 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -424,6 +424,24 @@ void DeckSyncManager::classify(const QMap &serverFiles) ++unchangedCount; continue; } + const DeckSyncRecord baseRecord = sidecar.get(it.key()); + const bool localChanged = baseRecord.baseHash.isEmpty() || baseRecord.baseHash != it.value().hash; + const bool serverChanged = baseRecord.baseHash.isEmpty() || baseRecord.baseHash != serverIt.value().hash; + if (localChanged && !serverChanged) { + if (syncMode == DeckSyncMode::DownloadOnly) { + continue; + } + applyUpload(it.key(), serverPath, serverIt.value().id, serverIt.value().guid); + continue; + } + if (!localChanged && serverChanged) { + if (syncMode == DeckSyncMode::UploadOnly) { + continue; + } + applyDownload(it.key(), serverIt.value().id, serverIt.value().guid, serverIt.value().hash, + serverIt.value().lastModifiedSecs); + 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. From 9d93a399803c9b1f95476ac4b28fbef422682a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 05:08:27 +0100 Subject: [PATCH 3/8] Fix remaining review threads on the conflict classification - Remove the duplicated download loop and the stray '<' plus extra brace that left the head unbuildable. - Skip the fallback upload under DownloadMode so a local-only deck is not pushed when the user asked for downloads only. - Treat a server row without a hash as unknown rather than changed, so pre-migration decks are not re-downloaded on every sync. --- .../src/interface/deck_sync/deck_sync_manager.cpp | 12 +++++++----- .../src/interface/deck_sync/deck_sync_manager.h | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 1bf585560..894dc4b75 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -273,7 +273,7 @@ void DeckSyncManager::cancelFirstSync() failSync(tr("Sync cancelled.")); } -void DeckSyncManager::classify(const QMap &serverFiles) +void DeckSyncManager::classify(const QMap &serverFiles) { emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size())); @@ -426,20 +426,22 @@ void DeckSyncManager::classify(const QMap &serverFiles) } const DeckSyncRecord baseRecord = sidecar.get(it.key()); const bool localChanged = baseRecord.baseHash.isEmpty() || baseRecord.baseHash != it.value().hash; - const bool serverChanged = baseRecord.baseHash.isEmpty() || baseRecord.baseHash != serverIt.value().hash; + // A server row without a hash predates sync: treat it as unknown rather than changed, + // otherwise every sync re-downloads the deck. + const bool serverChanged = + !server.hash.isEmpty() && (baseRecord.baseHash.isEmpty() || baseRecord.baseHash != server.hash); if (localChanged && !serverChanged) { if (syncMode == DeckSyncMode::DownloadOnly) { continue; } - applyUpload(it.key(), serverPath, serverIt.value().id, serverIt.value().guid); + applyUpload(it.key(), server.path, server.id, server.guid); continue; } if (!localChanged && serverChanged) { if (syncMode == DeckSyncMode::UploadOnly) { continue; } - applyDownload(it.key(), serverIt.value().id, serverIt.value().guid, serverIt.value().hash, - serverIt.value().lastModifiedSecs); + applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs); continue; } const qint64 localSecs = it.value().modified.toSecsSinceEpoch(); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 06160cb3d..e1c7c5807 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -175,7 +175,7 @@ private: 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 &serverFiles); + void classify(const QMap &serverFiles); void queueConflict(const QString &relativePath, const QString &serverPath, int deckId, @@ -215,7 +215,7 @@ private: QString syncNote; QSet conflictPaths; QMap pendingConflicts; - QMap stashedServerFiles; + QMap stashedServerFiles; ActiveConflict activeConflict; DeckSyncMode syncMode = DeckSyncMode::TwoWay; int conflictDefault = DeckSyncConflictAsk; From f8c2e1105a56a698a01e63dd162519a3adb79f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:31:10 +0100 Subject: [PATCH 4/8] [DeckSync] Address conflict review nits: enum class and designated initializers --- .../interface/deck_sync/deck_sync_conflict.h | 14 +++--- .../interface/deck_sync/deck_sync_manager.cpp | 46 +++++++++---------- .../widgets/dialogs/dlg_sync_conflict.cpp | 8 ++-- .../widgets/tabs/tab_deck_storage.cpp | 11 +++-- 4 files changed, 38 insertions(+), 41 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_conflict.h b/cockatrice/src/interface/deck_sync/deck_sync_conflict.h index a5b272ecd..215a7acd3 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_conflict.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_conflict.h @@ -9,13 +9,13 @@ #include /** @brief How a both-sides-changed deck is resolved. */ -enum DeckSyncResolutionAction +enum class 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. + KeepMine, ///< Upload the local deck, discarding the server version. + KeepServer, ///< Download the server deck, discarding local changes. + KeepBoth, ///< Keep the local deck and store the server version aside. + UseMerge, ///< Upload the auto-merged deck (only when mergeable). + SkipConflict, ///< Leave both sides untouched for this run. }; /** @brief Sync direction for one run. */ @@ -42,7 +42,7 @@ struct DeckSyncConflict /** @brief The dialog verdict for one DeckSyncConflict. */ struct DeckSyncResolution { - DeckSyncResolutionAction action = DeckSyncKeepMine; + DeckSyncResolutionAction action = DeckSyncResolutionAction::KeepMine; bool rememberSession = false; ///< Reuse this verdict for later conflicts this run. bool rememberAlways = false; ///< Store this verdict as the conflict default setting. }; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 894dc4b75..10bdd8a46 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -551,11 +551,8 @@ void DeckSyncManager::applyUpload(const QString &relativePath, int deckId, const QString &guid) { - UploadOp op; - op.relativePath = relativePath; - op.serverPath = serverPath; - op.deckId = deckId; - op.guid = guid; + const UploadOp op{ + .relativePath = relativePath, .serverPath = serverPath, .serverDir = {}, .deckId = deckId, .guid = guid}; pendingUploads.append(op); } @@ -565,12 +562,11 @@ void DeckSyncManager::applyDownload(const QString &relativePath, const QString &hash, qint64 lastModifiedSecs) { - DownloadOp op; - op.relativePath = relativePath; - op.deckId = deckId; - op.guid = guid; - op.hash = hash; - op.lastModifiedSecs = lastModifiedSecs; + const DownloadOp op{.relativePath = relativePath, + .deckId = deckId, + .guid = guid, + .hash = hash, + .lastModifiedSecs = lastModifiedSecs}; pendingDownloads.append(op); } @@ -606,12 +602,11 @@ void DeckSyncManager::queueConflict(const QString &relativePath, } return; } - ConflictDownload ctx; - ctx.serverPath = serverPath; - ctx.deckId = deckId; - ctx.guid = serverGuid; - ctx.hash = serverHash; - ctx.lastModifiedSecs = serverLastModifiedSecs; + const ConflictDownload ctx{.serverPath = serverPath, + .deckId = deckId, + .guid = serverGuid, + .hash = serverHash, + .lastModifiedSecs = serverLastModifiedSecs}; pendingConflicts.insert(relativePath, ctx); conflictPaths.insert(relativePath); applyDownload(relativePath, deckId, serverGuid, serverHash, serverLastModifiedSecs); @@ -667,22 +662,23 @@ 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; + if (resolution.rememberSession && (resolution.action == DeckSyncResolutionAction::KeepMine || + resolution.action == DeckSyncResolutionAction::KeepServer)) { + sessionDefault = resolution.action == DeckSyncResolutionAction::KeepMine ? DeckSyncConflictKeepMine + : DeckSyncConflictKeepServer; } const ActiveConflict conflict = activeConflict; activeConflict.active = false; switch (resolution.action) { - case DeckSyncKeepMine: + case DeckSyncResolutionAction::KeepMine: applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); break; - case DeckSyncKeepServer: + case DeckSyncResolutionAction::KeepServer: applyDownload(conflict.relativePath, conflict.deckId, conflict.serverGuid, conflict.serverHash, conflict.serverLastModifiedSecs); break; - case DeckSyncKeepBoth: { + case DeckSyncResolutionAction::KeepBoth: { // 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 @@ -716,7 +712,7 @@ void DeckSyncManager::resolveConflict(const DeckSyncResolution &resolution) applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); break; } - case DeckSyncUseMerge: { + case DeckSyncResolutionAction::UseMerge: { DeckSyncSidecar sidecar(sidecarPath); sidecar.load(); const QString guid = @@ -753,7 +749,7 @@ void DeckSyncManager::resolveConflict(const DeckSyncResolution &resolution) applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); break; } - case DeckSyncSkipConflict: + case DeckSyncResolutionAction::SkipConflict: ++skippedCount; break; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp index 7d548993d..9264544f6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp @@ -157,15 +157,15 @@ void DlgSyncConflict::updateRememberBoxes() DeckSyncResolutionAction DlgSyncConflict::selectedAction() const { if (keepServerRadio->isChecked()) { - return DeckSyncKeepServer; + return DeckSyncResolutionAction::KeepServer; } if (keepBothRadio->isChecked()) { - return DeckSyncKeepBoth; + return DeckSyncResolutionAction::KeepBoth; } if (mergeRadio->isChecked()) { - return DeckSyncUseMerge; + return DeckSyncResolutionAction::UseMerge; } - return DeckSyncKeepMine; + return DeckSyncResolutionAction::KeepMine; } DeckSyncResolution DlgSyncConflict::resolution() const diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index c47f00b7d..6a5093e0b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -583,15 +583,16 @@ void TabDeckStorage::onConflictRequest(const DeckSyncConflict &conflict) DlgSyncConflict dialog(conflict, this); if (dialog.exec() != QDialog::Accepted) { DeckSyncResolution skip; - skip.action = DeckSyncSkipConflict; + skip.action = DeckSyncResolutionAction::SkipConflict; 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); + if (resolution.rememberAlways && (resolution.action == DeckSyncResolutionAction::KeepMine || + resolution.action == DeckSyncResolutionAction::KeepServer)) { + SettingsCache::instance().deckSync().setConflictDefault(resolution.action == DeckSyncResolutionAction::KeepMine + ? DeckSyncConflictKeepMine + : DeckSyncConflictKeepServer); } syncManager->resolveConflict(resolution); } From 26ffd1cfae1df0a75f4e9093394cfa1bae9b94b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:41:33 +0100 Subject: [PATCH 5/8] Address review findings --- .../interface/deck_sync/deck_sync_manager.cpp | 87 +++++++++++++------ .../interface/deck_sync/deck_sync_manager.h | 18 ++-- .../interface/deck_sync/deck_sync_sidecar.h | 9 +- 3 files changed, 77 insertions(+), 37 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 10bdd8a46..54447babe 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -73,6 +73,38 @@ QString localPathFor(const QString &dir, const QString &name) const QString path = segments.join('/'); return path.endsWith(".cod", Qt::CaseInsensitive) ? path : path + QStringLiteral(".cod"); } + +// The sidecar stores the first-sync verdict so the choice outlives the run that +// asked for it. An absent or unknown value means the default two-way sync, which +// keeps profiles written before the verdict was persisted usable. +DeckSyncMode modeForSidecar(const QString &mode) +{ + if (mode == DeckSyncSidecar::MODE_UPLOAD_ONLY) { + return DeckSyncMode::UploadOnly; + } + if (mode == DeckSyncSidecar::MODE_DOWNLOAD_ONLY) { + return DeckSyncMode::DownloadOnly; + } + if (mode == DeckSyncSidecar::MODE_SEPARATE) { + return DeckSyncMode::Separate; + } + return DeckSyncMode::TwoWay; +} + +const char *sidecarModeFor(DeckSyncMode mode) +{ + switch (mode) { + case DeckSyncMode::UploadOnly: + return DeckSyncSidecar::MODE_UPLOAD_ONLY; + case DeckSyncMode::DownloadOnly: + return DeckSyncSidecar::MODE_DOWNLOAD_ONLY; + case DeckSyncMode::Separate: + return DeckSyncSidecar::MODE_SEPARATE; + case DeckSyncMode::TwoWay: + break; + } + return DeckSyncSidecar::MODE_LINKED; +} } // namespace DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent) @@ -101,11 +133,6 @@ void DeckSyncManager::setConflictDefault(int _conflictDefault) conflictDefault = _conflictDefault; } -void DeckSyncManager::setSyncMode(DeckSyncMode mode) -{ - syncMode = mode; -} - bool DeckSyncManager::isSyncing() const { return syncing; @@ -144,7 +171,6 @@ void DeckSyncManager::syncNow() ensuredDirs.insert(QString()); localFiles.clear(); sessionDefault = -1; - syncMode = DeckSyncMode::TwoWay; firstSyncRun = false; emit syncStarted(); emit statusMessage(tr("Scanning local decks...")); @@ -155,7 +181,10 @@ void DeckSyncManager::syncNow() failSync(tr("Could not read sync state.")); return; } - if (sidecar.mode == DeckSyncSidecar::MODE_SEPARATE) { + // Apply the persisted first-sync verdict: the onboarding choice lasts beyond + // the run that asked for it, and a profile that never made one syncs both ways. + syncMode = modeForSidecar(sidecar.mode); + if (syncMode == DeckSyncMode::Separate) { failSync(tr("Sync is disabled for this server.")); return; } @@ -249,11 +278,16 @@ void DeckSyncManager::continueFirstSync(DeckSyncMode mode) return; } firstSyncRun = false; + DeckSyncSidecar sidecar(sidecarPath); + if (!sidecar.load()) { + failSync(tr("Could not read sync state.")); + return; + } + // Persist the verdict next to the separate marker so a later syncNow() uses + // the same direction instead of silently reverting to two-way. + sidecar.mode = sidecarModeFor(mode); + sidecar.save(); 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; @@ -444,6 +478,14 @@ void DeckSyncManager::classify(const QMap &serverFiles) applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs); continue; } + // Legacy row without a hash and an untouched local copy: upload once + // so the server stamps hash and last_modified. + if (!localChanged && !serverChanged) { + if (syncMode != DeckSyncMode::DownloadOnly) { + applyUpload(it.key(), server.path, server.id, server.guid); + } + 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. @@ -589,19 +631,9 @@ void DeckSyncManager::queueConflict(const QString &relativePath, 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; - } + // No base snapshot (the usual first two-way sync) is not a reason to pick a + // winner silently: the dialog handles hasBase == false by disabling merge, + // and Keep both still works, so queue the conflict like any other. const ConflictDownload ctx{.serverPath = serverPath, .deckId = deckId, .guid = serverGuid, @@ -662,8 +694,11 @@ void DeckSyncManager::resolveConflict(const DeckSyncResolution &resolution) if (!syncing || !activeConflict.active) { return; } - if (resolution.rememberSession && (resolution.action == DeckSyncResolutionAction::KeepMine || - resolution.action == DeckSyncResolutionAction::KeepServer)) { + // "Always use this rule" also settles the conflicts already queued for this + // run, not just the setting for future ones. + const bool remember = resolution.rememberSession || resolution.rememberAlways; + if (remember && (resolution.action == DeckSyncResolutionAction::KeepMine || + resolution.action == DeckSyncResolutionAction::KeepServer)) { sessionDefault = resolution.action == DeckSyncResolutionAction::KeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer; } diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index e1c7c5807..61c5009c3 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -31,18 +31,19 @@ class ServerInfo_DeckStorage_Folder; * * 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. + * server decks download, and a deck changed on both sides pauses for a verdict, + * so nothing is overwritten silently. 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. * 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. + * continueFirstSync, and that verdict is stored in the sidecar so later runs keep + * the chosen direction. 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 @@ -75,7 +76,6 @@ public: void setProfile(const DeckSyncProfile &_profile); void setDeckPath(const QString &path); void setConflictDefault(int conflictDefault); - void setSyncMode(DeckSyncMode mode); [[nodiscard]] bool isSyncing() const; public slots: @@ -217,7 +217,7 @@ private: QMap pendingConflicts; QMap stashedServerFiles; ActiveConflict activeConflict; - DeckSyncMode syncMode = DeckSyncMode::TwoWay; + DeckSyncMode syncMode = DeckSyncMode::TwoWay; ///< This run's direction, loaded from the sidecar. int conflictDefault = DeckSyncConflictAsk; int sessionDefault = -1; bool firstSyncRun = false; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h index 2266922bc..404d08e8d 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -32,9 +32,14 @@ class DeckSyncSidecar public: static constexpr const char *MODE_LINKED = "linked"; static constexpr const char *MODE_SEPARATE = "separate"; + static constexpr const char *MODE_UPLOAD_ONLY = "uploadOnly"; + static constexpr const char *MODE_DOWNLOAD_ONLY = "downloadOnly"; - QString filePath; ///< Full path of the sidecar JSON file. - QString mode = MODE_LINKED; ///< Linked normally; separate keeps this profile out of sync. + QString filePath; ///< Full path of the sidecar JSON file. + /// How this profile syncs: linked is the default two-way mode, separate + /// keeps it out of sync, and the first-sync verdicts narrow it to one + /// direction. An unrecognised value falls back to the two-way sync. + QString mode = MODE_LINKED; explicit DeckSyncSidecar(const QString &_filePath); From b611e8ea6fcba712e0d0e0b1f66cb1b2a1182124 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:11:59 +0100 Subject: [PATCH 6/8] Apply suggestion from @ZeldaZach Co-authored-by: Zach H --- cockatrice/src/interface/deck_sync/deck_sync_manager.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 54447babe..336e69868 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -460,10 +460,11 @@ void DeckSyncManager::classify(const QMap &serverFiles) } const DeckSyncRecord baseRecord = sidecar.get(it.key()); const bool localChanged = baseRecord.baseHash.isEmpty() || baseRecord.baseHash != it.value().hash; - // A server row without a hash predates sync: treat it as unknown rather than changed, - // otherwise every sync re-downloads the deck. + // A server row without a hash predates sync: with a base, treat it as unknown + // rather than changed (otherwise every sync re-downloads the deck). Without a + // base nothing says the server copy is unchanged, so it must not lose silently. const bool serverChanged = - !server.hash.isEmpty() && (baseRecord.baseHash.isEmpty() || baseRecord.baseHash != server.hash); + baseRecord.baseHash.isEmpty() || (!server.hash.isEmpty() && baseRecord.baseHash != server.hash); if (localChanged && !serverChanged) { if (syncMode == DeckSyncMode::DownloadOnly) { continue; From b10abad3a33e12dff331832ff2031206be424553 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:12:42 +0100 Subject: [PATCH 7/8] Apply suggestion from @ZeldaZach Co-authored-by: Zach H --- cockatrice/src/interface/deck_sync/deck_sync_manager.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 336e69868..cd51ca505 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -666,6 +666,13 @@ void DeckSyncManager::handleConflictDownload(const QString &relativePath, const processNext(); return; } + if (serverDeck.getContentHash() == localFiles.value(relativePath).hash) { + // Same deck, the row just predates hashes: upload once so the server stamps + // hash and last_modified instead of asking about identical content. + applyUpload(relativePath, ctx.serverPath, ctx.deckId, ctx.guid); + processNext(); + return; + } DeckSyncSidecar sidecar(sidecarPath); sidecar.load(); From e8ab52a2a36ce6129a8cd501a930284057e4b678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:34:41 +0100 Subject: [PATCH 8/8] [DeckSync] Say the onboarding choice persists and let the user reset it The first-sync verdict is stored in the sidecar, so "Upload mine" and "Download server" apply to every later sync, not just the first one. Say so in the dialog, label the one-way options as one-way, and add a "Reset sync direction" action to the deck storage tab that writes the linked (two-way) mode back to the sidecar. --- .../widgets/dialogs/dlg_sync_onboarding.cpp | 12 +++--- .../widgets/dialogs/dlg_sync_onboarding.h | 5 ++- .../widgets/tabs/tab_deck_storage.cpp | 39 +++++++++++++++++++ .../interface/widgets/tabs/tab_deck_storage.h | 2 + 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp index ae0e9446b..23bf663d5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp @@ -35,12 +35,14 @@ DlgSyncOnboarding::DlgSyncOnboarding(int _localCount, int _serverCount, QWidget 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)); + detailLabel->setText( + tr("You have %1 local decks and %2 decks stored on this server. This choice sets how decks sync with this " + "server from now on; you can reset it to two-way sync from the deck storage tab.") + .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")); + uploadRadio->setText(tr("Upload mine only: push local decks to the server, never download")); + downloadRadio->setText(tr("Download server only: take the server decks, never upload")); separateRadio->setText(tr("Keep separate: do not sync with this server")); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h index 9d811a28e..c11577fba 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h @@ -17,8 +17,9 @@ 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. + * Merge (two-way) is the default. Upload and download restrict sync to one + * direction for this server from then on; keeping separate records the profile + * as sync-disabled. The deck storage tab can reset the direction to two-way. * Rejected dialogs cancel the sync without recording anything. */ class DlgSyncOnboarding : public QDialog diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 6a5093e0b..2c0fbd4f8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -5,6 +5,7 @@ #include "../../deck_sync/deck_sync_conflict.h" #include "../../deck_sync/deck_sync_manager.h" #include "../../deck_sync/deck_sync_profile.h" +#include "../../deck_sync/deck_sync_sidecar.h" #include "../../theme_icon.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" @@ -241,6 +242,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aSyncNow = new QAction(this); aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload)); connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow); + aSyncReset = new QAction(this); + aSyncReset->setIcon(qApp->style()->standardIcon(QStyle::SP_DialogResetButton)); + connect(aSyncReset, &QAction::triggered, this, &TabDeckStorage::actResetSyncDirection); // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); @@ -254,6 +258,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); rightToolBar->addAction(aSyncNow); + rightToolBar->addAction(aSyncReset); rightToolBar->addAction(aShareDecks); rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aNewFolder); @@ -301,6 +306,7 @@ void TabDeckStorage::retranslateUi() aPublishDeck->setText(tr("Publish/unpublish deck")); aOpenDecksFolder->setText(tr("Open decks folder")); aSyncNow->setText(tr("Sync now")); + aSyncReset->setText(tr("Reset sync direction")); renderSyncStatus(); shareBar->retranslateUi(); if (shareBar->isVisible()) { @@ -356,6 +362,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); aSyncNow->setEnabled(enabled && !syncManager->isSyncing()); + aSyncReset->setEnabled(enabled && !syncManager->isSyncing()); if (enabled) { serverDirView->refreshTree(); @@ -565,9 +572,41 @@ void TabDeckStorage::actSyncNow() syncManager->setProfile(profile); syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath()); aSyncNow->setEnabled(false); + aSyncReset->setEnabled(false); syncManager->syncNow(); } +void TabDeckStorage::actResetSyncDirection() +{ + const DeckSyncProfile profile(client->serverName(), client->serverPort(), syncUsername); + if (!profile.isValid()) { + return; + } + const QString deckPath = SettingsCache::instance().paths().getDeckPath(); + DeckSyncSidecar sidecar(DeckSyncSidecar::pathForDeckPath(deckPath, profile.key())); + if (!sidecar.load()) { + QMessageBox::warning(this, tr("Deck sync"), tr("Could not read the sync state for this server.")); + return; + } + if (sidecar.mode == DeckSyncSidecar::MODE_LINKED) { + QMessageBox::information(this, tr("Deck sync"), tr("This server already syncs decks both ways.")); + return; + } + if (QMessageBox::question(this, tr("Deck sync"), + tr("Switch this server back to two-way deck sync? The next sync merges the local and " + "server decks."), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + sidecar.mode = DeckSyncSidecar::MODE_LINKED; + if (!sidecar.save()) { + QMessageBox::warning(this, tr("Deck sync"), tr("Could not save the sync direction.")); + return; + } + QMessageBox::information(this, tr("Deck sync"), + tr("Deck sync is set to two-way. Run Sync now to merge the decks.")); +} + void TabDeckStorage::onFirstSyncDetected(int localCount, int serverCount) { DlgSyncOnboarding dialog(localCount, serverCount, this); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index 610cce891..9889e9a51 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -55,6 +55,7 @@ private: QAction *aOpenDecksFolder; QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; QAction *aSyncNow; + QAction *aSyncReset; QLabel *syncStatusLabel; DeckSyncManager *syncManager; QString syncUsername; @@ -130,6 +131,7 @@ private slots: void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); void actSyncNow(); + void actResetSyncDirection(); void onFirstSyncDetected(int localCount, int serverCount); void onConflictRequest(const DeckSyncConflict &conflict); void onSyncStatusMessage(const QString &message);