[Client] Sync conflict dialog, onboarding and conflict default

This commit is contained in:
Lukas Brübach 2026-10-11 16:46:46 +02:00 • committed by Lukas Brübach
parent a94db4171b
commit ed3f21a155
21 changed files with 1020 additions and 48 deletions

View file

@ -11,6 +11,7 @@
#include <QSaveFile>
#include <QStringList>
#include <QUuid>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_file_format.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
@ -90,6 +91,16 @@ void DeckSyncManager::setDeckPath(const QString &path)
deckPath = path;
}
void DeckSyncManager::setConflictDefault(int _conflictDefault)
{
conflictDefault = _conflictDefault;
}
void DeckSyncManager::setSyncMode(DeckSyncMode mode)
{
syncMode = mode;
}
bool DeckSyncManager::isSyncing() const
{
return syncing;
@ -118,18 +129,29 @@ void DeckSyncManager::syncNow()
pendingUploads.clear();
pendingDownloads.clear();
pendingDeletes.clear();
conflictPaths.clear();
stashedServerFiles.clear();
activeConflict = ActiveConflict();
ensuredDirs.clear();
ensuredDirs.insert(QString());
localFiles.clear();
sessionDefault = -1;
syncMode = DeckSyncMode::TwoWay;
firstSyncRun = false;
emit syncStarted();
emit statusMessage(tr("Scanning local decks..."));
sidecarPath = deckDir.filePath(SYNC_META_DIR + QString("/") + profile.key() + ".json");
sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, profile.key());
DeckSyncSidecar sidecar(sidecarPath);
if (!sidecar.load()) {
failSync(tr("Could not read sync state."));
return;
}
if (sidecar.mode == DeckSyncSidecar::MODE_SEPARATE) {
failSync(tr("Sync is disabled for this server."));
return;
}
firstSyncRun = !QFileInfo(sidecarPath).exists();
QDirIterator it(deckPath, QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories);
while (it.hasNext()) {
@ -200,6 +222,47 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
const Response_DeckList &list = response.GetExtension(Response_DeckList::ext);
collectServerFiles(list.root(), QString(), serverFiles, serverDirs);
ensuredDirs.unite(serverDirs);
if (firstSyncRun && !localFiles.isEmpty() && !serverFiles.isEmpty()) {
stashedServerFiles = serverFiles;
emit firstSyncDetected(localFiles.size(), serverFiles.size());
return;
}
classify(serverFiles);
}
void DeckSyncManager::continueFirstSync(DeckSyncMode mode)
{
if (!syncing || !firstSyncRun) {
return;
}
firstSyncRun = false;
if (mode == DeckSyncMode::Separate) {
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
sidecar.mode = DeckSyncSidecar::MODE_SEPARATE;
sidecar.save();
stashedServerFiles.clear();
failSync(tr("Sync is disabled for this server."));
return;
}
syncMode = mode;
classify(stashedServerFiles);
stashedServerFiles.clear();
}
void DeckSyncManager::cancelFirstSync()
{
if (!syncing || !firstSyncRun) {
return;
}
firstSyncRun = false;
stashedServerFiles.clear();
failSync(tr("Sync cancelled."));
}
void DeckSyncManager::classify(const QMap<QString, ServerFile> &serverFiles)
{
emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size()));
DeckSyncSidecar sidecar(sidecarPath);
@ -267,6 +330,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
const bool unchangedSinceSync =
!sidecarRecord.baseHash.isEmpty() && it.value().hash == sidecarRecord.baseHash;
if (knownToServer && unchangedSinceSync) {
if (syncMode == DeckSyncMode::UploadOnly) {
continue;
}
const QString absolutePath = deckPath + "/" + it.key();
const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
@ -278,6 +344,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
++downloadedCount;
continue;
}
if (syncMode == DeckSyncMode::DownloadOnly) {
continue;
}
const QString serverPath = serverPathFor(it.key());
pendingUploads.append(UploadOp{.relativePath = it.key(),
.serverPath = serverPath,
@ -304,53 +373,57 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
continue;
}
const qint64 localSecs = it.value().modified.toSecsSinceEpoch();
const int effective = sessionDefault >= 0 ? sessionDefault : conflictDefault;
// A server row without last_modified predates sync: the local file wins.
// Otherwise strictly newer wins; ties go to the server.
if (server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs) {
pendingUploads.append(UploadOp{.relativePath = it.key(),
.serverPath = server.path,
.serverDir = server.dir,
.deckId = server.id,
.guid = server.guid});
if (syncMode == DeckSyncMode::UploadOnly || effective == DeckSyncConflictKeepMine) {
applyUpload(it.key(), server.path, server.id, server.guid);
} else if (syncMode == DeckSyncMode::DownloadOnly || effective == DeckSyncConflictKeepServer) {
applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs);
} else if (effective == DeckSyncConflictKeepNewer &&
(server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs)) {
applyUpload(it.key(), server.path, server.id, server.guid);
} else if (effective == DeckSyncConflictKeepNewer) {
applyDownload(it.key(), server.id, server.guid, server.hash, server.lastModifiedSecs);
} else {
pendingDownloads.append(DownloadOp{.relativePath = it.key(),
.deckId = server.id,
.guid = server.guid,
.hash = server.hash,
.lastModifiedSecs = server.lastModifiedSecs});
queueConflict(it.key(), server.path, server.id, server.guid, server.hash, server.lastModifiedSecs);
}
}
for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) {
if (matchedServerIds.contains(it.key())) {
continue;
if (syncMode != DeckSyncMode::UploadOnly) {
for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) {
if (matchedServerIds.contains(it.key())) {
continue;
}
const QString wantedPath = localPathFor(it.value().dir, it.value().name);
const QString relativePath = uniqueLocalPath(wantedPath);
if (relativePath.isEmpty()) {
++skippedCount;
continue;
}
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();
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
if (localFiles.contains(it.key())) {
continue;
if (syncMode != DeckSyncMode::DownloadOnly) {
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
if (localFiles.contains(it.key())) {
continue;
}
if (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();
@ -365,7 +438,7 @@ QString DeckSyncManager::parentDir(const QString &path)
void DeckSyncManager::processNext()
{
if (!syncing) {
if (!syncing || activeConflict.active) {
return;
}
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()
{
const UploadOp op = pendingUploads.first();
@ -573,8 +860,10 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
return;
}
const QString relativePath = extraData.toString();
int finishedDeckId = -1;
for (int i = 0; i < pendingDownloads.size(); ++i) {
if (pendingDownloads.at(i).relativePath == relativePath) {
finishedDeckId = pendingDownloads.at(i).deckId;
pendingDownloads.removeAt(i);
break;
}
@ -586,8 +875,26 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
}
const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext);
if (conflictPaths.contains(relativePath)) {
conflictPaths.remove(relativePath);
handleConflictDownload(relativePath, QString::fromStdString(download.deck()));
return;
}
applyDownloadedDeck(relativePath, finishedDeckId, QString::fromStdString(download.deck()),
QString::fromStdString(download.guid()), QString::fromStdString(download.hash()),
static_cast<qint64>(download.last_modified()), download.has_last_modified());
}
void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
int deckId,
const QString &deckString,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs,
bool hasLastModified)
{
DeckList deck;
if (!deck.loadFromString_Native(QString::fromStdString(download.deck()))) {
if (!deck.loadFromString_Native(deckString)) {
++skippedCount;
processNext();
return;
@ -619,16 +926,18 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
DeckSyncRecord record = sidecar.get(relativePath);
if (!download.guid().empty()) {
record.guid = QString::fromStdString(download.guid());
if (deckId != -1) {
record.remoteId = deckId;
}
if (!download.hash().empty()) {
record.baseHash = QString::fromStdString(download.hash());
if (!guid.isEmpty()) {
record.guid = guid;
}
if (!hash.isEmpty()) {
record.baseHash = hash;
} else {
record.baseHash = deck.getContentHash();
}
record.lastSyncSecs = download.has_last_modified() ? static_cast<qint64>(download.last_modified())
: QDateTime::currentDateTime().toSecsSinceEpoch();
record.lastSyncSecs = hasLastModified ? lastModifiedSecs : QDateTime::currentDateTime().toSecsSinceEpoch();
record.deleted = false;
sidecar.set(relativePath, record);
sidecar.save();