mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
[Client] Sync conflict dialog, onboarding and conflict default
This commit is contained in:
parent
de3468cc33
commit
d2e2e722e7
21 changed files with 1020 additions and 48 deletions
|
|
@ -58,6 +58,8 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
|
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
|
||||||
src/interface/widgets/dialogs/dlg_settings.cpp
|
src/interface/widgets/dialogs/dlg_settings.cpp
|
||||||
src/interface/widgets/dialogs/dlg_startup_card_check.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_tip_of_the_day.cpp
|
||||||
src/interface/widgets/dialogs/dlg_update.cpp
|
src/interface/widgets/dialogs/dlg_update.cpp
|
||||||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
#include <libcockatrice/settings/commander_bracket_settings.h>
|
#include <libcockatrice/settings/commander_bracket_settings.h>
|
||||||
#include <libcockatrice/settings/debug_settings.h>
|
#include <libcockatrice/settings/debug_settings.h>
|
||||||
#include <libcockatrice/settings/deck_editor_settings.h>
|
#include <libcockatrice/settings/deck_editor_settings.h>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
#include <libcockatrice/settings/download_settings.h>
|
#include <libcockatrice/settings/download_settings.h>
|
||||||
#include <libcockatrice/settings/game_filters_settings.h>
|
#include <libcockatrice/settings/game_filters_settings.h>
|
||||||
#include <libcockatrice/settings/game_settings.h>
|
#include <libcockatrice/settings/game_settings.h>
|
||||||
|
|
@ -159,6 +160,7 @@ SettingsCache::SettingsCache()
|
||||||
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
|
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
|
||||||
interfaceSettings = new InterfaceSettings(settingsPath, this);
|
interfaceSettings = new InterfaceSettings(settingsPath, this);
|
||||||
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
|
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
|
||||||
|
deckSyncSettings = new DeckSyncSettings(settingsPath, this);
|
||||||
pathsSettings = new PathsSettings(settingsPath, this);
|
pathsSettings = new PathsSettings(settingsPath, this);
|
||||||
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
|
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
|
||||||
appearanceSettings = new AppearanceSettings(settingsPath, this);
|
appearanceSettings = new AppearanceSettings(settingsPath, this);
|
||||||
|
|
@ -460,6 +462,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const
|
||||||
return *deckEditorSettings;
|
return *deckEditorSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeckSyncSettings &SettingsCache::deckSync() const
|
||||||
|
{
|
||||||
|
return *deckSyncSettings;
|
||||||
|
}
|
||||||
|
|
||||||
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
|
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
|
||||||
{
|
{
|
||||||
return *visualDeckStorageSettings;
|
return *visualDeckStorageSettings;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class ChatSettings;
|
||||||
class CommanderBracketSettings;
|
class CommanderBracketSettings;
|
||||||
class DebugSettings;
|
class DebugSettings;
|
||||||
class DeckEditorSettings;
|
class DeckEditorSettings;
|
||||||
|
class DeckSyncSettings;
|
||||||
class DownloadSettings;
|
class DownloadSettings;
|
||||||
class GameFiltersSettings;
|
class GameFiltersSettings;
|
||||||
class GameSettings;
|
class GameSettings;
|
||||||
|
|
@ -81,6 +82,7 @@ private:
|
||||||
CardsDisplaySettings *cardsDisplaySettings = nullptr;
|
CardsDisplaySettings *cardsDisplaySettings = nullptr;
|
||||||
InterfaceSettings *interfaceSettings = nullptr;
|
InterfaceSettings *interfaceSettings = nullptr;
|
||||||
DeckEditorSettings *deckEditorSettings = nullptr;
|
DeckEditorSettings *deckEditorSettings = nullptr;
|
||||||
|
DeckSyncSettings *deckSyncSettings = nullptr;
|
||||||
PathsSettings *pathsSettings = nullptr;
|
PathsSettings *pathsSettings = nullptr;
|
||||||
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
|
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
|
||||||
AppearanceSettings *appearanceSettings = nullptr;
|
AppearanceSettings *appearanceSettings = nullptr;
|
||||||
|
|
@ -149,6 +151,7 @@ public:
|
||||||
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
|
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
|
||||||
[[nodiscard]] InterfaceSettings &userInterface() const;
|
[[nodiscard]] InterfaceSettings &userInterface() const;
|
||||||
[[nodiscard]] DeckEditorSettings &deckEditor() const;
|
[[nodiscard]] DeckEditorSettings &deckEditor() const;
|
||||||
|
[[nodiscard]] DeckSyncSettings &deckSync() const;
|
||||||
[[nodiscard]] PathsSettings &paths() const;
|
[[nodiscard]] PathsSettings &paths() const;
|
||||||
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
|
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
|
||||||
[[nodiscard]] AppearanceSettings &appearance() const;
|
[[nodiscard]] AppearanceSettings &appearance() const;
|
||||||
|
|
|
||||||
50
cockatrice/src/interface/deck_sync/deck_sync_conflict.h
Normal file
50
cockatrice/src/interface/deck_sync/deck_sync_conflict.h
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* @file deck_sync_conflict.h
|
||||||
|
* @brief Conflict payload between the sync engine and the conflict dialog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DECK_SYNC_CONFLICT_H
|
||||||
|
#define DECK_SYNC_CONFLICT_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/** @brief How a both-sides-changed deck is resolved. */
|
||||||
|
enum DeckSyncResolutionAction
|
||||||
|
{
|
||||||
|
DeckSyncKeepMine, ///< Upload the local deck, discarding the server version.
|
||||||
|
DeckSyncKeepServer, ///< Download the server deck, discarding local changes.
|
||||||
|
DeckSyncKeepBoth, ///< Keep the local deck and store the server version aside.
|
||||||
|
DeckSyncUseMerge, ///< Upload the auto-merged deck (only when mergeable).
|
||||||
|
DeckSyncSkipConflict, ///< Leave both sides untouched for this run.
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief Sync direction for one run. */
|
||||||
|
enum class DeckSyncMode
|
||||||
|
{
|
||||||
|
TwoWay, ///< Mirror both sides (default).
|
||||||
|
UploadOnly, ///< Push local content; never download or propagate deletes.
|
||||||
|
DownloadOnly, ///< Take server content; never upload or push deletes.
|
||||||
|
Separate, ///< First-sync verdict only: keep this profile out of sync.
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief One deck that changed both locally and on the server. */
|
||||||
|
struct DeckSyncConflict
|
||||||
|
{
|
||||||
|
QString relativePath; ///< Local deck path relative to the deck folder.
|
||||||
|
QString localAbsolutePath; ///< Absolute local file path.
|
||||||
|
QString serverDeckString; ///< Server deck content in native format.
|
||||||
|
QString baseDeckString; ///< Last-synced content, empty when no snapshot exists.
|
||||||
|
bool hasBase = false; ///< False when the base snapshot is missing.
|
||||||
|
qint64 localSecs = 0; ///< Local modification time, seconds since epoch.
|
||||||
|
qint64 serverSecs = 0; ///< Server modification time, seconds since epoch.
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief The dialog verdict for one DeckSyncConflict. */
|
||||||
|
struct DeckSyncResolution
|
||||||
|
{
|
||||||
|
DeckSyncResolutionAction action = DeckSyncKeepMine;
|
||||||
|
bool rememberSession = false; ///< Reuse this verdict for later conflicts this run.
|
||||||
|
bool rememberAlways = false; ///< Store this verdict as the conflict default setting.
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_CONFLICT_H
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
#include <libcockatrice/deck_list/deck_diff.h>
|
||||||
#include <libcockatrice/deck_list/deck_file_format.h>
|
#include <libcockatrice/deck_list/deck_file_format.h>
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||||
|
|
@ -90,6 +91,16 @@ void DeckSyncManager::setDeckPath(const QString &path)
|
||||||
deckPath = path;
|
deckPath = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::setConflictDefault(int _conflictDefault)
|
||||||
|
{
|
||||||
|
conflictDefault = _conflictDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::setSyncMode(DeckSyncMode mode)
|
||||||
|
{
|
||||||
|
syncMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
bool DeckSyncManager::isSyncing() const
|
bool DeckSyncManager::isSyncing() const
|
||||||
{
|
{
|
||||||
return syncing;
|
return syncing;
|
||||||
|
|
@ -118,18 +129,29 @@ void DeckSyncManager::syncNow()
|
||||||
pendingUploads.clear();
|
pendingUploads.clear();
|
||||||
pendingDownloads.clear();
|
pendingDownloads.clear();
|
||||||
pendingDeletes.clear();
|
pendingDeletes.clear();
|
||||||
|
conflictPaths.clear();
|
||||||
|
stashedServerFiles.clear();
|
||||||
|
activeConflict = ActiveConflict();
|
||||||
ensuredDirs.clear();
|
ensuredDirs.clear();
|
||||||
ensuredDirs.insert(QString());
|
ensuredDirs.insert(QString());
|
||||||
localFiles.clear();
|
localFiles.clear();
|
||||||
|
sessionDefault = -1;
|
||||||
|
syncMode = DeckSyncMode::TwoWay;
|
||||||
|
firstSyncRun = false;
|
||||||
emit syncStarted();
|
emit syncStarted();
|
||||||
emit statusMessage(tr("Scanning local decks..."));
|
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);
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
if (!sidecar.load()) {
|
if (!sidecar.load()) {
|
||||||
failSync(tr("Could not read sync state."));
|
failSync(tr("Could not read sync state."));
|
||||||
return;
|
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);
|
QDirIterator it(deckPath, QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories);
|
||||||
while (it.hasNext()) {
|
while (it.hasNext()) {
|
||||||
|
|
@ -200,6 +222,47 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
|
||||||
const Response_DeckList &list = response.GetExtension(Response_DeckList::ext);
|
const Response_DeckList &list = response.GetExtension(Response_DeckList::ext);
|
||||||
collectServerFiles(list.root(), QString(), serverFiles, serverDirs);
|
collectServerFiles(list.root(), QString(), serverFiles, serverDirs);
|
||||||
ensuredDirs.unite(serverDirs);
|
ensuredDirs.unite(serverDirs);
|
||||||
|
|
||||||
|
if (firstSyncRun && !localFiles.isEmpty() && !serverFiles.isEmpty()) {
|
||||||
|
stashedServerFiles = serverFiles;
|
||||||
|
emit firstSyncDetected(localFiles.size(), serverFiles.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
classify(serverFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::continueFirstSync(DeckSyncMode mode)
|
||||||
|
{
|
||||||
|
if (!syncing || !firstSyncRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
firstSyncRun = false;
|
||||||
|
if (mode == DeckSyncMode::Separate) {
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
|
sidecar.load();
|
||||||
|
sidecar.mode = DeckSyncSidecar::MODE_SEPARATE;
|
||||||
|
sidecar.save();
|
||||||
|
stashedServerFiles.clear();
|
||||||
|
failSync(tr("Sync is disabled for this server."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncMode = mode;
|
||||||
|
classify(stashedServerFiles);
|
||||||
|
stashedServerFiles.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::cancelFirstSync()
|
||||||
|
{
|
||||||
|
if (!syncing || !firstSyncRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
firstSyncRun = false;
|
||||||
|
stashedServerFiles.clear();
|
||||||
|
failSync(tr("Sync cancelled."));
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::classify(const QMap<QString, ServerFile> &serverFiles)
|
||||||
|
{
|
||||||
emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size()));
|
emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size()));
|
||||||
|
|
||||||
DeckSyncSidecar sidecar(sidecarPath);
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
|
|
@ -267,6 +330,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
|
||||||
const bool unchangedSinceSync =
|
const bool unchangedSinceSync =
|
||||||
!sidecarRecord.baseHash.isEmpty() && it.value().hash == sidecarRecord.baseHash;
|
!sidecarRecord.baseHash.isEmpty() && it.value().hash == sidecarRecord.baseHash;
|
||||||
if (knownToServer && unchangedSinceSync) {
|
if (knownToServer && unchangedSinceSync) {
|
||||||
|
if (syncMode == DeckSyncMode::UploadOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const QString absolutePath = deckPath + "/" + it.key();
|
const QString absolutePath = deckPath + "/" + it.key();
|
||||||
const QString backupPath =
|
const QString backupPath =
|
||||||
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
|
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
|
||||||
|
|
@ -278,6 +344,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
|
||||||
++downloadedCount;
|
++downloadedCount;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (syncMode == DeckSyncMode::DownloadOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const QString serverPath = serverPathFor(it.key());
|
const QString serverPath = serverPathFor(it.key());
|
||||||
pendingUploads.append(UploadOp{.relativePath = it.key(),
|
pendingUploads.append(UploadOp{.relativePath = it.key(),
|
||||||
.serverPath = serverPath,
|
.serverPath = serverPath,
|
||||||
|
|
@ -304,53 +373,57 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const qint64 localSecs = it.value().modified.toSecsSinceEpoch();
|
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.
|
// A server row without last_modified predates sync: the local file wins.
|
||||||
// Otherwise strictly newer wins; ties go to the server.
|
// Otherwise strictly newer wins; ties go to the server.
|
||||||
if (server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs) {
|
if (syncMode == DeckSyncMode::UploadOnly || effective == DeckSyncConflictKeepMine) {
|
||||||
pendingUploads.append(UploadOp{.relativePath = it.key(),
|
applyUpload(it.key(), server.path, server.id, server.guid);
|
||||||
.serverPath = server.path,
|
} else if (syncMode == DeckSyncMode::DownloadOnly || effective == DeckSyncConflictKeepServer) {
|
||||||
.serverDir = server.dir,
|
applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs);
|
||||||
.deckId = server.id,
|
} else if (effective == DeckSyncConflictKeepNewer &&
|
||||||
.guid = server.guid});
|
(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 {
|
} else {
|
||||||
pendingDownloads.append(DownloadOp{.relativePath = it.key(),
|
queueConflict(it.key(), server.path, server.id, server.guid, server.hash, server.lastModifiedSecs);
|
||||||
.deckId = server.id,
|
|
||||||
.guid = server.guid,
|
|
||||||
.hash = server.hash,
|
|
||||||
.lastModifiedSecs = server.lastModifiedSecs});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) {
|
if (syncMode != DeckSyncMode::UploadOnly) {
|
||||||
if (matchedServerIds.contains(it.key())) {
|
for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) {
|
||||||
continue;
|
if (matchedServerIds.contains(it.key())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString wantedPath = localPathFor(it.value().dir, it.value().name);
|
||||||
|
const QString relativePath = uniqueLocalPath(wantedPath);
|
||||||
|
if (relativePath.isEmpty()) {
|
||||||
|
++skippedCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pendingDownloads.append(DownloadOp{.relativePath = relativePath,
|
||||||
|
.deckId = it.value().id,
|
||||||
|
.guid = it.value().guid,
|
||||||
|
.hash = it.value().hash,
|
||||||
|
.lastModifiedSecs = it.value().lastModifiedSecs});
|
||||||
}
|
}
|
||||||
const QString wantedPath = localPathFor(it.value().dir, it.value().name);
|
|
||||||
const QString relativePath = uniqueLocalPath(wantedPath);
|
|
||||||
if (relativePath.isEmpty()) {
|
|
||||||
++skippedCount;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pendingDownloads.append(DownloadOp{.relativePath = relativePath,
|
|
||||||
.deckId = it.value().id,
|
|
||||||
.guid = it.value().guid,
|
|
||||||
.hash = it.value().hash,
|
|
||||||
.lastModifiedSecs = it.value().lastModifiedSecs});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
|
if (syncMode != DeckSyncMode::DownloadOnly) {
|
||||||
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
|
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
|
||||||
if (localFiles.contains(it.key())) {
|
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
|
||||||
continue;
|
if (localFiles.contains(it.key())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (QFileInfo::exists(deckPath + "/" + it.key())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (it.value().remoteId == -1) {
|
||||||
|
sidecar.remove(it.key());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pendingDeletes.append(it.key());
|
||||||
}
|
}
|
||||||
if (QFileInfo::exists(deckPath + "/" + it.key())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (it.value().remoteId == -1) {
|
|
||||||
sidecar.remove(it.key());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pendingDeletes.append(it.key());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sidecar.save();
|
sidecar.save();
|
||||||
|
|
@ -365,7 +438,7 @@ QString DeckSyncManager::parentDir(const QString &path)
|
||||||
|
|
||||||
void DeckSyncManager::processNext()
|
void DeckSyncManager::processNext()
|
||||||
{
|
{
|
||||||
if (!syncing) {
|
if (!syncing || activeConflict.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pendingDeletes.isEmpty()) {
|
if (!pendingDeletes.isEmpty()) {
|
||||||
|
|
@ -382,6 +455,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<LoadedDeck> loaded =
|
||||||
|
DeckLoader::loadFromFile(localFiles.value(relativePath).absolutePath, DeckFileFormat::Cockatrice);
|
||||||
|
DeckList serverDeck;
|
||||||
|
if (!loaded || !serverDeck.loadFromString_Native(serverDeckString)) {
|
||||||
|
++skippedCount;
|
||||||
|
processNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
|
sidecar.load();
|
||||||
|
const QString guid = !ctx.guid.isEmpty() ? ctx.guid : sidecar.get(relativePath).guid;
|
||||||
|
activeConflict.active = true;
|
||||||
|
activeConflict.relativePath = relativePath;
|
||||||
|
activeConflict.serverPath = ctx.serverPath;
|
||||||
|
activeConflict.deckId = ctx.deckId;
|
||||||
|
activeConflict.serverGuid = ctx.guid;
|
||||||
|
activeConflict.serverHash = ctx.hash;
|
||||||
|
activeConflict.serverLastModifiedSecs = ctx.lastModifiedSecs;
|
||||||
|
activeConflict.serverDeckString = serverDeckString;
|
||||||
|
|
||||||
|
DeckSyncConflict conflict;
|
||||||
|
conflict.relativePath = relativePath;
|
||||||
|
conflict.localAbsolutePath = localFiles.value(relativePath).absolutePath;
|
||||||
|
conflict.serverDeckString = serverDeckString;
|
||||||
|
conflict.baseDeckString = readBaseSnapshot(guid);
|
||||||
|
conflict.hasBase = !conflict.baseDeckString.isEmpty();
|
||||||
|
conflict.localSecs = localFiles.value(relativePath).modified.toSecsSinceEpoch();
|
||||||
|
conflict.serverSecs = ctx.lastModifiedSecs;
|
||||||
|
emit conflictRequest(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::resolveConflict(const DeckSyncResolution &resolution)
|
||||||
|
{
|
||||||
|
if (!syncing || !activeConflict.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolution.rememberSession &&
|
||||||
|
(resolution.action == DeckSyncKeepMine || resolution.action == DeckSyncKeepServer)) {
|
||||||
|
sessionDefault = resolution.action == DeckSyncKeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer;
|
||||||
|
}
|
||||||
|
const ActiveConflict conflict = activeConflict;
|
||||||
|
activeConflict.active = false;
|
||||||
|
|
||||||
|
switch (resolution.action) {
|
||||||
|
case DeckSyncKeepMine:
|
||||||
|
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
|
||||||
|
break;
|
||||||
|
case DeckSyncKeepServer:
|
||||||
|
applyDownload(conflict.relativePath, conflict.deckId, conflict.serverGuid, conflict.serverHash,
|
||||||
|
conflict.serverLastModifiedSecs);
|
||||||
|
break;
|
||||||
|
case DeckSyncKeepBoth: {
|
||||||
|
// Persist the already-fetched server bytes synchronously: queuing a
|
||||||
|
// download here would race the upload below and fetch back Mine.
|
||||||
|
// The aside copy is a new local file, so it gets a fresh guid and
|
||||||
|
// uploads as its own deck on the next run.
|
||||||
|
QString asideBase = conflict.relativePath;
|
||||||
|
if (asideBase.endsWith(".cod", Qt::CaseInsensitive)) {
|
||||||
|
asideBase.chop(4);
|
||||||
|
}
|
||||||
|
const QString aside = uniqueLocalPath(asideBase + " (server).cod");
|
||||||
|
DeckList serverDeck;
|
||||||
|
if (aside.isEmpty() || !serverDeck.loadFromString_Native(conflict.serverDeckString)) {
|
||||||
|
emit statusMessage(tr("Could not store the server version of %1 aside, keeping the local deck.")
|
||||||
|
.arg(conflict.relativePath));
|
||||||
|
++skippedCount;
|
||||||
|
} else {
|
||||||
|
const QString asideAbsolute = deckPath + "/" + aside;
|
||||||
|
QDir().mkpath(QFileInfo(asideAbsolute).absolutePath());
|
||||||
|
if (!DeckLoader::saveToFile(serverDeck, asideAbsolute, DeckFileFormat::Cockatrice)) {
|
||||||
|
++skippedCount;
|
||||||
|
} else {
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
|
sidecar.load();
|
||||||
|
DeckSyncRecord asideRecord;
|
||||||
|
asideRecord.guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
|
asideRecord.baseHash = serverDeck.getDeckHash();
|
||||||
|
sidecar.set(aside, asideRecord);
|
||||||
|
sidecar.save();
|
||||||
|
snapshotLocalFile(aside, asideRecord.guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DeckSyncUseMerge: {
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
|
sidecar.load();
|
||||||
|
const QString guid =
|
||||||
|
!conflict.serverGuid.isEmpty() ? conflict.serverGuid : sidecar.get(conflict.relativePath).guid;
|
||||||
|
DeckList localDeck;
|
||||||
|
DeckList serverDeck;
|
||||||
|
DeckList baseDeck;
|
||||||
|
std::optional<LoadedDeck> loaded = DeckLoader::loadFromFile(
|
||||||
|
localFiles.value(conflict.relativePath).absolutePath, DeckFileFormat::Cockatrice);
|
||||||
|
if (!loaded || !serverDeck.loadFromString_Native(conflict.serverDeckString) ||
|
||||||
|
!baseDeck.loadFromString_Native(readBaseSnapshot(guid))) {
|
||||||
|
emit statusMessage(
|
||||||
|
tr("Could not build the merge for %1, keeping the local deck.").arg(conflict.relativePath));
|
||||||
|
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
localDeck = loaded->deckList;
|
||||||
|
const DeckDiffResult localChanges = DeckDiff::diff(baseDeck, localDeck);
|
||||||
|
const DeckDiffResult remoteChanges = DeckDiff::diff(baseDeck, serverDeck);
|
||||||
|
const DeckList merged = DeckDiff::merge(baseDeck, localChanges, remoteChanges);
|
||||||
|
const QString absolutePath = deckPath + "/" + conflict.relativePath;
|
||||||
|
const QString backupPath =
|
||||||
|
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
|
||||||
|
if (!QFile::copy(absolutePath, backupPath) ||
|
||||||
|
!DeckLoader::saveToFile(merged, absolutePath, DeckFileFormat::Cockatrice)) {
|
||||||
|
++skippedCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::optional<LoadedDeck> reloaded = DeckLoader::loadFromFile(absolutePath, DeckFileFormat::Cockatrice);
|
||||||
|
if (reloaded) {
|
||||||
|
localFiles[conflict.relativePath].hash = reloaded->deckList.getDeckHash();
|
||||||
|
localFiles[conflict.relativePath].modified = QFileInfo(absolutePath).lastModified();
|
||||||
|
}
|
||||||
|
applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DeckSyncSkipConflict:
|
||||||
|
++skippedCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
processNext();
|
||||||
|
}
|
||||||
|
|
||||||
void DeckSyncManager::processNextUpload()
|
void DeckSyncManager::processNextUpload()
|
||||||
{
|
{
|
||||||
const UploadOp op = pendingUploads.first();
|
const UploadOp op = pendingUploads.first();
|
||||||
|
|
@ -573,8 +860,10 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const QString relativePath = extraData.toString();
|
const QString relativePath = extraData.toString();
|
||||||
|
int finishedDeckId = -1;
|
||||||
for (int i = 0; i < pendingDownloads.size(); ++i) {
|
for (int i = 0; i < pendingDownloads.size(); ++i) {
|
||||||
if (pendingDownloads.at(i).relativePath == relativePath) {
|
if (pendingDownloads.at(i).relativePath == relativePath) {
|
||||||
|
finishedDeckId = pendingDownloads.at(i).deckId;
|
||||||
pendingDownloads.removeAt(i);
|
pendingDownloads.removeAt(i);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -586,8 +875,26 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext);
|
const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext);
|
||||||
|
if (conflictPaths.contains(relativePath)) {
|
||||||
|
conflictPaths.remove(relativePath);
|
||||||
|
handleConflictDownload(relativePath, QString::fromStdString(download.deck()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyDownloadedDeck(relativePath, finishedDeckId, QString::fromStdString(download.deck()),
|
||||||
|
QString::fromStdString(download.guid()), QString::fromStdString(download.hash()),
|
||||||
|
static_cast<qint64>(download.last_modified()), download.has_last_modified());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
|
||||||
|
int deckId,
|
||||||
|
const QString &deckString,
|
||||||
|
const QString &guid,
|
||||||
|
const QString &hash,
|
||||||
|
qint64 lastModifiedSecs,
|
||||||
|
bool hasLastModified)
|
||||||
|
{
|
||||||
DeckList deck;
|
DeckList deck;
|
||||||
if (!deck.loadFromString_Native(QString::fromStdString(download.deck()))) {
|
if (!deck.loadFromString_Native(deckString)) {
|
||||||
++skippedCount;
|
++skippedCount;
|
||||||
processNext();
|
processNext();
|
||||||
return;
|
return;
|
||||||
|
|
@ -619,16 +926,18 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
|
||||||
DeckSyncSidecar sidecar(sidecarPath);
|
DeckSyncSidecar sidecar(sidecarPath);
|
||||||
sidecar.load();
|
sidecar.load();
|
||||||
DeckSyncRecord record = sidecar.get(relativePath);
|
DeckSyncRecord record = sidecar.get(relativePath);
|
||||||
if (!download.guid().empty()) {
|
if (deckId != -1) {
|
||||||
record.guid = QString::fromStdString(download.guid());
|
record.remoteId = deckId;
|
||||||
}
|
}
|
||||||
if (!download.hash().empty()) {
|
if (!guid.isEmpty()) {
|
||||||
record.baseHash = QString::fromStdString(download.hash());
|
record.guid = guid;
|
||||||
|
}
|
||||||
|
if (!hash.isEmpty()) {
|
||||||
|
record.baseHash = hash;
|
||||||
} else {
|
} else {
|
||||||
record.baseHash = deck.getContentHash();
|
record.baseHash = deck.getContentHash();
|
||||||
}
|
}
|
||||||
record.lastSyncSecs = download.has_last_modified() ? static_cast<qint64>(download.last_modified())
|
record.lastSyncSecs = hasLastModified ? lastModifiedSecs : QDateTime::currentDateTime().toSecsSinceEpoch();
|
||||||
: QDateTime::currentDateTime().toSecsSinceEpoch();
|
|
||||||
record.deleted = false;
|
record.deleted = false;
|
||||||
sidecar.set(relativePath, record);
|
sidecar.set(relativePath, record);
|
||||||
sidecar.save();
|
sidecar.save();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#ifndef DECK_SYNC_MANAGER_H
|
#ifndef DECK_SYNC_MANAGER_H
|
||||||
#define DECK_SYNC_MANAGER_H
|
#define DECK_SYNC_MANAGER_H
|
||||||
|
|
||||||
|
#include "deck_sync_conflict.h"
|
||||||
#include "deck_sync_profile.h"
|
#include "deck_sync_profile.h"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
@ -16,6 +17,7 @@
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
|
|
||||||
class AbstractClient;
|
class AbstractClient;
|
||||||
class CommandContainer;
|
class CommandContainer;
|
||||||
|
|
@ -37,7 +39,10 @@ class ServerInfo_DeckStorage_Folder;
|
||||||
* to it.
|
* to it.
|
||||||
*
|
*
|
||||||
* Ownership: fire-and-forget per run, driven by PendingCommand round trips.
|
* 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
|
* Deletes are tombstones, never silent: a locally deleted deck is pushed with
|
||||||
* Command_DeckDel, a server-side delete is applied locally only after copying
|
* Command_DeckDel, a server-side delete is applied locally only after copying
|
||||||
|
|
@ -64,15 +69,27 @@ public:
|
||||||
|
|
||||||
void setProfile(const DeckSyncProfile &_profile);
|
void setProfile(const DeckSyncProfile &_profile);
|
||||||
void setDeckPath(const QString &path);
|
void setDeckPath(const QString &path);
|
||||||
|
void setConflictDefault(int conflictDefault);
|
||||||
|
void setSyncMode(DeckSyncMode mode);
|
||||||
[[nodiscard]] bool isSyncing() const;
|
[[nodiscard]] bool isSyncing() const;
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void syncNow();
|
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:
|
signals:
|
||||||
void syncStarted();
|
void syncStarted();
|
||||||
void statusMessage(const QString &message);
|
void statusMessage(const QString &message);
|
||||||
void syncFinished(const SyncSummary &summary, const QString &error);
|
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:
|
private slots:
|
||||||
void onListFinished(const Response &response, const CommandContainer &commandContainer);
|
void onListFinished(const Response &response, const CommandContainer &commandContainer);
|
||||||
|
|
@ -118,6 +135,25 @@ private:
|
||||||
QString hash;
|
QString hash;
|
||||||
qint64 lastModifiedSecs = 0;
|
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 failSync(const QString &error);
|
||||||
void finishSync();
|
void finishSync();
|
||||||
|
|
@ -133,6 +169,28 @@ private:
|
||||||
void sendDownload(const DownloadOp &op);
|
void sendDownload(const DownloadOp &op);
|
||||||
void sendDelete(const QString &relativePath, int deckId);
|
void sendDelete(const QString &relativePath, int deckId);
|
||||||
void snapshotLocalFile(const QString &relativePath, const QString &guid);
|
void snapshotLocalFile(const QString &relativePath, const QString &guid);
|
||||||
|
QString readBaseSnapshot(const QString &guid) const;
|
||||||
|
void classify(const QMap<QString, ServerFile> &serverFiles);
|
||||||
|
void queueConflict(const QString &relativePath,
|
||||||
|
const QString &serverPath,
|
||||||
|
int deckId,
|
||||||
|
const QString &serverGuid,
|
||||||
|
const QString &serverHash,
|
||||||
|
qint64 serverLastModifiedSecs);
|
||||||
|
void handleConflictDownload(const QString &relativePath, const QString &serverDeckString);
|
||||||
|
void applyDownloadedDeck(const QString &relativePath,
|
||||||
|
int deckId,
|
||||||
|
const QString &deckString,
|
||||||
|
const QString &guid,
|
||||||
|
const QString &hash,
|
||||||
|
qint64 lastModifiedSecs,
|
||||||
|
bool hasLastModified);
|
||||||
|
void applyUpload(const QString &relativePath, const QString &serverPath, int deckId, const QString &guid);
|
||||||
|
void applyDownload(const QString &relativePath,
|
||||||
|
int deckId,
|
||||||
|
const QString &guid,
|
||||||
|
const QString &hash,
|
||||||
|
qint64 lastModifiedSecs);
|
||||||
static QString parentDir(const QString &path);
|
static QString parentDir(const QString &path);
|
||||||
QString uniqueLocalPath(const QString &relativePath) const;
|
QString uniqueLocalPath(const QString &relativePath) const;
|
||||||
|
|
||||||
|
|
@ -148,6 +206,14 @@ private:
|
||||||
QList<UploadOp> pendingUploads;
|
QList<UploadOp> pendingUploads;
|
||||||
QList<DownloadOp> pendingDownloads;
|
QList<DownloadOp> pendingDownloads;
|
||||||
QList<QString> pendingDeletes;
|
QList<QString> pendingDeletes;
|
||||||
|
QSet<QString> conflictPaths;
|
||||||
|
QMap<QString, ConflictDownload> pendingConflicts;
|
||||||
|
QMap<QString, ServerFile> stashedServerFiles;
|
||||||
|
ActiveConflict activeConflict;
|
||||||
|
DeckSyncMode syncMode = DeckSyncMode::TwoWay;
|
||||||
|
int conflictDefault = DeckSyncConflictAsk;
|
||||||
|
int sessionDefault = -1;
|
||||||
|
bool firstSyncRun = false;
|
||||||
int uploadedCount = 0;
|
int uploadedCount = 0;
|
||||||
int downloadedCount = 0;
|
int downloadedCount = 0;
|
||||||
int unchangedCount = 0;
|
int unchangedCount = 0;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
bool DeckSyncSidecar::load()
|
||||||
{
|
{
|
||||||
recordMap.clear();
|
recordMap.clear();
|
||||||
|
mode = MODE_LINKED;
|
||||||
|
|
||||||
QFile file(filePath);
|
QFile file(filePath);
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
|
|
@ -31,6 +37,8 @@ bool DeckSyncSidecar::load()
|
||||||
if (!document.isObject() || !document.object().value("records").isObject()) {
|
if (!document.isObject() || !document.object().value("records").isObject()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const QJsonValue modeValue = document.object().value("mode");
|
||||||
|
mode = modeValue.isString() ? modeValue.toString() : MODE_LINKED;
|
||||||
const QJsonObject records = document.object().value("records").toObject();
|
const QJsonObject records = document.object().value("records").toObject();
|
||||||
for (auto it = records.begin(); it != records.end(); ++it) {
|
for (auto it = records.begin(); it != records.end(); ++it) {
|
||||||
if (!it.value().isObject()) {
|
if (!it.value().isObject()) {
|
||||||
|
|
@ -62,6 +70,7 @@ bool DeckSyncSidecar::save() const
|
||||||
}
|
}
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
root.insert("version", SIDECAR_FORMAT_VERSION);
|
root.insert("version", SIDECAR_FORMAT_VERSION);
|
||||||
|
root.insert("mode", mode);
|
||||||
root.insert("records", records);
|
root.insert("records", records);
|
||||||
|
|
||||||
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,17 @@ struct DeckSyncRecord
|
||||||
class DeckSyncSidecar
|
class DeckSyncSidecar
|
||||||
{
|
{
|
||||||
public:
|
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);
|
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. */
|
/** @brief Loads records; a missing file loads as empty and returns true. */
|
||||||
bool load();
|
bool load();
|
||||||
/** @brief Stores records, creating parent folders as needed. */
|
/** @brief Stores records, creating parent folders as needed. */
|
||||||
|
|
|
||||||
178
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp
Normal file
178
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp
Normal 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;
|
||||||
|
}
|
||||||
63
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h
Normal file
63
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h
Normal 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
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <libcockatrice/card/card_localization.h>
|
#include <libcockatrice/card/card_localization.h>
|
||||||
#include <libcockatrice/settings/cards_display_settings.h>
|
#include <libcockatrice/settings/cards_display_settings.h>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
#include <libcockatrice/settings/download_settings.h>
|
#include <libcockatrice/settings/download_settings.h>
|
||||||
#include <libcockatrice/settings/paths_settings.h>
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
#include <libcockatrice/settings/personal_settings.h>
|
#include <libcockatrice/settings/personal_settings.h>
|
||||||
|
|
@ -359,6 +360,25 @@ GeneralSettingsPage::GeneralSettingsPage()
|
||||||
pathsGroupBox = new QGroupBox;
|
pathsGroupBox = new QGroupBox;
|
||||||
pathsGroupBox->setLayout(pathsGrid);
|
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;
|
auto *mainLayout = new QVBoxLayout;
|
||||||
mainLayout->addWidget(languageGroupBox);
|
mainLayout->addWidget(languageGroupBox);
|
||||||
mainLayout->addWidget(versionGroupBox);
|
mainLayout->addWidget(versionGroupBox);
|
||||||
|
|
@ -366,6 +386,7 @@ GeneralSettingsPage::GeneralSettingsPage()
|
||||||
mainLayout->addWidget(cardDatabaseGroupBox);
|
mainLayout->addWidget(cardDatabaseGroupBox);
|
||||||
mainLayout->addWidget(startupGroupBox);
|
mainLayout->addWidget(startupGroupBox);
|
||||||
mainLayout->addWidget(pathsGroupBox);
|
mainLayout->addWidget(pathsGroupBox);
|
||||||
|
mainLayout->addWidget(deckSyncGroupBox);
|
||||||
mainLayout->addStretch();
|
mainLayout->addStretch();
|
||||||
|
|
||||||
GeneralSettingsPage::retranslateUi();
|
GeneralSettingsPage::retranslateUi();
|
||||||
|
|
@ -647,5 +668,13 @@ void GeneralSettingsPage::retranslateUi()
|
||||||
cardDatabasePathLabel.setText(tr("Card database:"));
|
cardDatabasePathLabel.setText(tr("Card database:"));
|
||||||
customCardDatabasePathLabel.setText(tr("Custom database directory:"));
|
customCardDatabasePathLabel.setText(tr("Custom database directory:"));
|
||||||
tokenDatabasePathLabel.setText(tr("Token database:"));
|
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"));
|
resetAllPathsButton->setText(tr("Reset all paths"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ private:
|
||||||
QGroupBox *cardDatabaseGroupBox;
|
QGroupBox *cardDatabaseGroupBox;
|
||||||
QGroupBox *startupGroupBox;
|
QGroupBox *startupGroupBox;
|
||||||
QGroupBox *pathsGroupBox;
|
QGroupBox *pathsGroupBox;
|
||||||
|
QGroupBox *deckSyncGroupBox;
|
||||||
|
|
||||||
QLabel languageLabel;
|
QLabel languageLabel;
|
||||||
QComboBox languageBox;
|
QComboBox languageBox;
|
||||||
|
|
@ -104,6 +105,9 @@ private:
|
||||||
QLineEdit *tokenDatabasePathEdit;
|
QLineEdit *tokenDatabasePathEdit;
|
||||||
QPushButton *resetAllPathsButton;
|
QPushButton *resetAllPathsButton;
|
||||||
QLabel *allPathsResetLabel;
|
QLabel *allPathsResetLabel;
|
||||||
|
|
||||||
|
QLabel conflictDefaultLabel;
|
||||||
|
QComboBox conflictDefaultSelector;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H
|
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../deck_loader/deck_loader.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_manager.h"
|
||||||
|
#include "../../deck_sync/deck_sync_profile.h"
|
||||||
#include "../../pixel_map_generator.h"
|
#include "../../pixel_map_generator.h"
|
||||||
#include "../cards/additional_info/deck_color_identity.h"
|
#include "../cards/additional_info/deck_color_identity.h"
|
||||||
#include "../deck_share/deck_share_utils.h"
|
#include "../deck_share/deck_share_utils.h"
|
||||||
#include "../deck_share/share_bar_widget.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/server/remote/remote_decklist_tree_widget.h"
|
||||||
#include "../interface/widgets/utility/get_text_with_max.h"
|
#include "../interface/widgets/utility/get_text_with_max.h"
|
||||||
#include "abstract_client.h"
|
#include "abstract_client.h"
|
||||||
|
|
@ -63,6 +67,7 @@
|
||||||
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
|
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
|
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
|
||||||
#include <libcockatrice/protocol/pending_command.h>
|
#include <libcockatrice/protocol/pending_command.h>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
#include <libcockatrice/settings/network_settings.h>
|
#include <libcockatrice/settings/network_settings.h>
|
||||||
#include <libcockatrice/settings/paths_settings.h>
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
#include <libcockatrice/utility/qt_utils.h>
|
#include <libcockatrice/utility/qt_utils.h>
|
||||||
|
|
@ -256,8 +261,13 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
|
|
||||||
syncManager = new DeckSyncManager(
|
syncManager = new DeckSyncManager(
|
||||||
client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this);
|
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::statusMessage, this, &TabDeckStorage::onSyncStatusMessage);
|
||||||
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
|
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
|
||||||
|
connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected);
|
||||||
|
connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest);
|
||||||
if (currentUserInfo != nullptr) {
|
if (currentUserInfo != nullptr) {
|
||||||
syncUsername = QString::fromStdString(currentUserInfo->name());
|
syncUsername = QString::fromStdString(currentUserInfo->name());
|
||||||
}
|
}
|
||||||
|
|
@ -558,6 +568,34 @@ void TabDeckStorage::actSyncNow()
|
||||||
syncManager->syncNow();
|
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)
|
void TabDeckStorage::onSyncStatusMessage(const QString &message)
|
||||||
{
|
{
|
||||||
syncStatusType = SyncStatusType::Working;
|
syncStatusType = SyncStatusType::Working;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
struct LoadedDeck;
|
struct LoadedDeck;
|
||||||
|
struct DeckSyncConflict;
|
||||||
|
struct DeckSyncResolution;
|
||||||
class ServerInfo_User;
|
class ServerInfo_User;
|
||||||
class QTreeView;
|
class QTreeView;
|
||||||
class QFileSystemModel;
|
class QFileSystemModel;
|
||||||
|
|
@ -128,6 +130,8 @@ private slots:
|
||||||
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
||||||
|
|
||||||
void actSyncNow();
|
void actSyncNow();
|
||||||
|
void onFirstSyncDetected(int localCount, int serverCount);
|
||||||
|
void onConflictRequest(const DeckSyncConflict &conflict);
|
||||||
void onSyncStatusMessage(const QString &message);
|
void onSyncStatusMessage(const QString &message);
|
||||||
void onSyncFinished(const SyncSummary &summary, const QString &error);
|
void onSyncFinished(const SyncSummary &summary, const QString &error);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ set(HEADERS
|
||||||
libcockatrice/settings/commander_bracket_settings.h
|
libcockatrice/settings/commander_bracket_settings.h
|
||||||
libcockatrice/settings/debug_settings.h
|
libcockatrice/settings/debug_settings.h
|
||||||
libcockatrice/settings/deck_editor_settings.h
|
libcockatrice/settings/deck_editor_settings.h
|
||||||
|
libcockatrice/settings/deck_sync_settings.h
|
||||||
libcockatrice/settings/download_settings.h
|
libcockatrice/settings/download_settings.h
|
||||||
libcockatrice/settings/game_filters_settings.h
|
libcockatrice/settings/game_filters_settings.h
|
||||||
libcockatrice/settings/game_settings.h
|
libcockatrice/settings/game_settings.h
|
||||||
|
|
@ -43,6 +44,7 @@ add_library(
|
||||||
libcockatrice/settings/chat_settings.cpp
|
libcockatrice/settings/chat_settings.cpp
|
||||||
libcockatrice/settings/debug_settings.cpp
|
libcockatrice/settings/debug_settings.cpp
|
||||||
libcockatrice/settings/deck_editor_settings.cpp
|
libcockatrice/settings/deck_editor_settings.cpp
|
||||||
|
libcockatrice/settings/deck_sync_settings.cpp
|
||||||
libcockatrice/settings/download_settings.cpp
|
libcockatrice/settings/download_settings.cpp
|
||||||
libcockatrice/settings/game_filters_settings.cpp
|
libcockatrice/settings/game_filters_settings.cpp
|
||||||
libcockatrice/settings/game_settings.cpp
|
libcockatrice/settings/game_settings.cpp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
#include "deck_sync_settings.h"
|
||||||
|
|
||||||
|
#include "libcockatrice/settings/settings_manager.h"
|
||||||
|
|
||||||
|
#include <QVariant>
|
||||||
|
|
||||||
|
DeckSyncSettings::DeckSyncSettings(const QString &settingPath, QObject *parent)
|
||||||
|
: SettingsManager(settingPath + "deck_sync.ini", "decksync", QString(), parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int DeckSyncSettings::getConflictDefault() const
|
||||||
|
{
|
||||||
|
return getValue("conflictDefault", QString(), QString(), DeckSyncConflictAsk).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncSettings::setConflictDefault(int _conflictDefault)
|
||||||
|
{
|
||||||
|
setValue(_conflictDefault, "conflictDefault");
|
||||||
|
emit conflictDefaultChanged(_conflictDefault);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
#ifndef DECK_SYNC_SETTINGS_H
|
||||||
|
#define DECK_SYNC_SETTINGS_H
|
||||||
|
|
||||||
|
#include "settings_manager.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
class QObject;
|
||||||
|
|
||||||
|
enum DeckSyncConflictDefault
|
||||||
|
{
|
||||||
|
DeckSyncConflictAsk = 0, ///< Ask every time a deck changed on both sides.
|
||||||
|
DeckSyncConflictKeepMine, ///< Silently keep the local deck.
|
||||||
|
DeckSyncConflictKeepServer, ///< Silently keep the server deck.
|
||||||
|
DeckSyncConflictKeepNewer, ///< Silently keep whichever side changed last.
|
||||||
|
};
|
||||||
|
|
||||||
|
class DeckSyncSettings : public SettingsManager
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
friend class SettingsCache;
|
||||||
|
|
||||||
|
public:
|
||||||
|
[[nodiscard]] int getConflictDefault() const;
|
||||||
|
void setConflictDefault(int _conflictDefault);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void conflictDefaultChanged(int conflictDefault);
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DeckSyncSettings(const QString &settingPath, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
private:
|
||||||
|
DeckSyncSettings(const DeckSyncSettings & /*other*/);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_SETTINGS_H
|
||||||
|
|
@ -119,6 +119,26 @@ TEST(DeckSyncSidecarTest, RemoveDropsRecord)
|
||||||
EXPECT_FALSE(reloaded.contains("a.cod"));
|
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)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
::testing::InitGoogleTest(&argc, argv);
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
#include <libcockatrice/settings/cards_display_settings.h>
|
#include <libcockatrice/settings/cards_display_settings.h>
|
||||||
#include <libcockatrice/settings/chat_settings.h>
|
#include <libcockatrice/settings/chat_settings.h>
|
||||||
#include <libcockatrice/settings/deck_editor_settings.h>
|
#include <libcockatrice/settings/deck_editor_settings.h>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
#include <libcockatrice/settings/download_settings.h>
|
#include <libcockatrice/settings/download_settings.h>
|
||||||
#include <libcockatrice/settings/game_settings.h>
|
#include <libcockatrice/settings/game_settings.h>
|
||||||
#include <libcockatrice/settings/interface_settings.h>
|
#include <libcockatrice/settings/interface_settings.h>
|
||||||
|
|
@ -586,6 +587,25 @@ TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet)
|
||||||
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext);
|
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 ---
|
// --- NetworkSettings ---
|
||||||
|
|
||||||
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)
|
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue