mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 17:43:23 -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
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 <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();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#ifndef DECK_SYNC_MANAGER_H
|
||||
#define DECK_SYNC_MANAGER_H
|
||||
|
||||
#include "deck_sync_conflict.h"
|
||||
#include "deck_sync_profile.h"
|
||||
|
||||
#include <QDateTime>
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
#include <QString>
|
||||
#include <QVariant>
|
||||
#include <functional>
|
||||
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||
|
||||
class AbstractClient;
|
||||
class CommandContainer;
|
||||
|
|
@ -37,7 +39,10 @@ class ServerInfo_DeckStorage_Folder;
|
|||
* to it.
|
||||
*
|
||||
* Ownership: fire-and-forget per run, driven by PendingCommand round trips.
|
||||
* No watcher, no auto triggers, no conflict UI — those arrive in later layers.
|
||||
* Both-sides-changed decks pause the queue for a verdict via conflictRequest /
|
||||
* resolveConflict (or the conflict default setting); the first sync of an
|
||||
* unknown profile pauses for an onboarding verdict via firstSyncDetected /
|
||||
* continueFirstSync. No watcher and no auto triggers — those arrive later.
|
||||
*
|
||||
* Deletes are tombstones, never silent: a locally deleted deck is pushed with
|
||||
* Command_DeckDel, a server-side delete is applied locally only after copying
|
||||
|
|
@ -64,15 +69,27 @@ public:
|
|||
|
||||
void setProfile(const DeckSyncProfile &_profile);
|
||||
void setDeckPath(const QString &path);
|
||||
void setConflictDefault(int conflictDefault);
|
||||
void setSyncMode(DeckSyncMode mode);
|
||||
[[nodiscard]] bool isSyncing() const;
|
||||
|
||||
public slots:
|
||||
void syncNow();
|
||||
/** @brief Continues a paused first sync after the onboarding verdict. */
|
||||
void continueFirstSync(DeckSyncMode mode);
|
||||
/** @brief Aborts a paused first sync without writing any verdict. */
|
||||
void cancelFirstSync();
|
||||
/** @brief Applies the dialog verdict for the active conflict and resumes. */
|
||||
void resolveConflict(const DeckSyncResolution &resolution);
|
||||
|
||||
signals:
|
||||
void syncStarted();
|
||||
void statusMessage(const QString &message);
|
||||
void syncFinished(const SyncSummary &summary, const QString &error);
|
||||
/** @brief First sync with content on both sides: the tab must ask how to proceed. */
|
||||
void firstSyncDetected(int localCount, int serverCount);
|
||||
/** @brief A both-sides-changed deck needs a verdict; the queue pauses until resolveConflict. */
|
||||
void conflictRequest(const DeckSyncConflict &conflict);
|
||||
|
||||
private slots:
|
||||
void onListFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
|
|
@ -118,6 +135,25 @@ private:
|
|||
QString hash;
|
||||
qint64 lastModifiedSecs = 0;
|
||||
};
|
||||
struct ActiveConflict
|
||||
{
|
||||
bool active = false;
|
||||
QString relativePath;
|
||||
QString serverPath;
|
||||
int deckId = -1;
|
||||
QString serverGuid;
|
||||
QString serverHash;
|
||||
qint64 serverLastModifiedSecs = 0;
|
||||
QString serverDeckString;
|
||||
};
|
||||
struct ConflictDownload
|
||||
{
|
||||
QString serverPath;
|
||||
int deckId = -1;
|
||||
QString guid;
|
||||
QString hash;
|
||||
qint64 lastModifiedSecs = 0;
|
||||
};
|
||||
|
||||
void failSync(const QString &error);
|
||||
void finishSync();
|
||||
|
|
@ -133,6 +169,28 @@ private:
|
|||
void sendDownload(const DownloadOp &op);
|
||||
void sendDelete(const QString &relativePath, int deckId);
|
||||
void snapshotLocalFile(const QString &relativePath, const QString &guid);
|
||||
QString readBaseSnapshot(const QString &guid) const;
|
||||
void classify(const QMap<QString, ServerFile> &serverFiles);
|
||||
void queueConflict(const QString &relativePath,
|
||||
const QString &serverPath,
|
||||
int deckId,
|
||||
const QString &serverGuid,
|
||||
const QString &serverHash,
|
||||
qint64 serverLastModifiedSecs);
|
||||
void handleConflictDownload(const QString &relativePath, const QString &serverDeckString);
|
||||
void applyDownloadedDeck(const QString &relativePath,
|
||||
int deckId,
|
||||
const QString &deckString,
|
||||
const QString &guid,
|
||||
const QString &hash,
|
||||
qint64 lastModifiedSecs,
|
||||
bool hasLastModified);
|
||||
void applyUpload(const QString &relativePath, const QString &serverPath, int deckId, const QString &guid);
|
||||
void applyDownload(const QString &relativePath,
|
||||
int deckId,
|
||||
const QString &guid,
|
||||
const QString &hash,
|
||||
qint64 lastModifiedSecs);
|
||||
static QString parentDir(const QString &path);
|
||||
QString uniqueLocalPath(const QString &relativePath) const;
|
||||
|
||||
|
|
@ -148,6 +206,14 @@ private:
|
|||
QList<UploadOp> pendingUploads;
|
||||
QList<DownloadOp> pendingDownloads;
|
||||
QList<QString> pendingDeletes;
|
||||
QSet<QString> conflictPaths;
|
||||
QMap<QString, ConflictDownload> pendingConflicts;
|
||||
QMap<QString, ServerFile> stashedServerFiles;
|
||||
ActiveConflict activeConflict;
|
||||
DeckSyncMode syncMode = DeckSyncMode::TwoWay;
|
||||
int conflictDefault = DeckSyncConflictAsk;
|
||||
int sessionDefault = -1;
|
||||
bool firstSyncRun = false;
|
||||
int uploadedCount = 0;
|
||||
int downloadedCount = 0;
|
||||
int unchangedCount = 0;
|
||||
|
|
|
|||
|
|
@ -16,9 +16,15 @@ DeckSyncSidecar::DeckSyncSidecar(const QString &_filePath) : filePath(_filePath)
|
|||
{
|
||||
}
|
||||
|
||||
QString DeckSyncSidecar::pathForDeckPath(const QString &deckPath, const QString &profileKey)
|
||||
{
|
||||
return QDir(deckPath).filePath(".sync/" + profileKey + ".json");
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::load()
|
||||
{
|
||||
recordMap.clear();
|
||||
mode = MODE_LINKED;
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.exists()) {
|
||||
|
|
@ -31,6 +37,8 @@ bool DeckSyncSidecar::load()
|
|||
if (!document.isObject() || !document.object().value("records").isObject()) {
|
||||
return false;
|
||||
}
|
||||
const QJsonValue modeValue = document.object().value("mode");
|
||||
mode = modeValue.isString() ? modeValue.toString() : MODE_LINKED;
|
||||
const QJsonObject records = document.object().value("records").toObject();
|
||||
for (auto it = records.begin(); it != records.end(); ++it) {
|
||||
if (!it.value().isObject()) {
|
||||
|
|
@ -62,6 +70,7 @@ bool DeckSyncSidecar::save() const
|
|||
}
|
||||
QJsonObject root;
|
||||
root.insert("version", SIDECAR_FORMAT_VERSION);
|
||||
root.insert("mode", mode);
|
||||
root.insert("records", records);
|
||||
|
||||
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||
|
|
|
|||
|
|
@ -30,10 +30,17 @@ struct DeckSyncRecord
|
|||
class DeckSyncSidecar
|
||||
{
|
||||
public:
|
||||
QString filePath; ///< Full path of the sidecar JSON file.
|
||||
static constexpr const char *MODE_LINKED = "linked";
|
||||
static constexpr const char *MODE_SEPARATE = "separate";
|
||||
|
||||
QString filePath; ///< Full path of the sidecar JSON file.
|
||||
QString mode = MODE_LINKED; ///< Linked normally; separate keeps this profile out of sync.
|
||||
|
||||
explicit DeckSyncSidecar(const QString &_filePath);
|
||||
|
||||
/** @brief Sidecar file path for a deck folder and profile key. */
|
||||
static QString pathForDeckPath(const QString &deckPath, const QString &profileKey);
|
||||
|
||||
/** @brief Loads records; a missing file loads as empty and returns true. */
|
||||
bool load();
|
||||
/** @brief Stores records, creating parent folders as needed. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue