[Client] Sync conflict dialog, onboarding and conflict default

This commit is contained in:
Lukas Brübach 2026-10-11 16:46:46 +02:00
parent b0f5cb4c42
commit 339d712308
21 changed files with 1020 additions and 48 deletions

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

@ -27,6 +27,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>
@ -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<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);
@ -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"));
}

View file

@ -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

View file

@ -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 "../../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"
@ -63,6 +67,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/qt_utils.h>
@ -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;

View file

@ -20,6 +20,8 @@
#include <qtmetamacros.h>
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);