[DeckSync] Keep the sync baseline when a server change is skipped

The check's server_time became the new baseline even when the run could
not apply a server change (a failed download, or a mirrored delete whose
local copy could not be removed), and manual Sync now goes through the
same fast path, so the change never came back. Track those skips and
keep the previous baseline so the next check reports them again.

Also disable Sync now while any run is in progress, drop the redundant
baseline assignment in onCheckFinished, and tell the user that only
two-way sync checks for changes automatically.
This commit is contained in:
Lukas Brübach 2026-11-08 19:37:34 +01:00
parent 2992bfdac1
commit fa6c2d75d6
5 changed files with 29 additions and 3 deletions

View file

@ -321,6 +321,7 @@ void DeckSyncManager::startSync(bool _autoRun)
sessionDefault = -1; sessionDefault = -1;
firstSyncRun = false; firstSyncRun = false;
pendingServerTime = -1; pendingServerTime = -1;
serverChangeSkipped = false;
emit syncStarted(); emit syncStarted();
emit statusMessage(tr("Scanning local decks...")); emit statusMessage(tr("Scanning local decks..."));
@ -426,7 +427,6 @@ void DeckSyncManager::onCheckFinished(const Response &response, const CommandCon
return; return;
} }
pendingServerTime = status.has_server_time() ? static_cast<qint64>(status.server_time()) : -1;
unchangedCount = localFiles.size(); unchangedCount = localFiles.size();
emit statusMessage(tr("Decks are already in sync.")); emit statusMessage(tr("Decks are already in sync."));
finishSync(); finishSync();
@ -641,6 +641,10 @@ void DeckSyncManager::classify(const QMap<int, ServerFile> &serverFiles)
const QString backupPath = const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
if (!QFile::copy(absolutePath, backupPath) || !QFile::remove(absolutePath)) { if (!QFile::copy(absolutePath, backupPath) || !QFile::remove(absolutePath)) {
// The server row is gone but the local file could not be
// removed. Keep the baseline so the next check still reports
// the delete instead of hiding it behind a newer sync time.
serverChangeSkipped = true;
++skippedCount; ++skippedCount;
continue; continue;
} }
@ -1249,6 +1253,7 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
return; return;
} }
if (response.response_code() != Response::RespOk) { if (response.response_code() != Response::RespOk) {
serverChangeSkipped = true;
++skippedCount; ++skippedCount;
processNext(); processNext();
return; return;
@ -1275,6 +1280,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
{ {
DeckList deck; DeckList deck;
if (!deck.loadFromString_Native(deckString)) { if (!deck.loadFromString_Native(deckString)) {
serverChangeSkipped = true;
++skippedCount; ++skippedCount;
processNext(); processNext();
return; return;
@ -1283,6 +1289,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
const QString cleanDeckPath = QDir::cleanPath(deckPath); const QString cleanDeckPath = QDir::cleanPath(deckPath);
const QString absolutePath = QDir::cleanPath(deckPath + "/" + relativePath); const QString absolutePath = QDir::cleanPath(deckPath + "/" + relativePath);
if (absolutePath != cleanDeckPath && !absolutePath.startsWith(cleanDeckPath + "/")) { if (absolutePath != cleanDeckPath && !absolutePath.startsWith(cleanDeckPath + "/")) {
serverChangeSkipped = true;
++skippedCount; ++skippedCount;
processNext(); processNext();
return; return;
@ -1291,6 +1298,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
const QString backupPath = const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
if (!QFile::copy(absolutePath, backupPath)) { if (!QFile::copy(absolutePath, backupPath)) {
serverChangeSkipped = true;
++skippedCount; ++skippedCount;
processNext(); processNext();
return; return;
@ -1298,6 +1306,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath,
} }
QDir().mkpath(QFileInfo(absolutePath).absolutePath()); QDir().mkpath(QFileInfo(absolutePath).absolutePath());
if (!DeckLoader::saveToFile(deck, absolutePath, DeckFileFormat::Cockatrice)) { if (!DeckLoader::saveToFile(deck, absolutePath, DeckFileFormat::Cockatrice)) {
serverChangeSkipped = true;
++skippedCount; ++skippedCount;
processNext(); processNext();
return; return;
@ -1406,7 +1415,10 @@ void DeckSyncManager::finishSync()
{ {
syncing = false; syncing = false;
DeckSyncSidecar sidecar(sidecarPath); DeckSyncSidecar sidecar(sidecarPath);
if (sidecar.load()) { if (sidecar.load() && !serverChangeSkipped) {
// Advance the baseline only when every server change was applied. If one
// was skipped, keep the previous baseline so the next check reports it
// again instead of hiding it behind a newer sync time.
sidecar.lastSyncSecs = sidecar.lastSyncSecs =
pendingServerTime >= 0 ? pendingServerTime : QDateTime::currentDateTime().toSecsSinceEpoch(); pendingServerTime >= 0 ? pendingServerTime : QDateTime::currentDateTime().toSecsSinceEpoch();
sidecar.save(); sidecar.save();

View file

@ -255,6 +255,7 @@ private:
int removedCount = 0; int removedCount = 0;
int unchangedCount = 0; int unchangedCount = 0;
int skippedCount = 0; int skippedCount = 0;
bool serverChangeSkipped = false; ///< A server change could not be applied; keep the sync baseline.
int conflictCount = 0; int conflictCount = 0;
bool autoRun = false; bool autoRun = false;
}; };

View file

@ -37,7 +37,8 @@ void DlgSyncOnboarding::retranslateUi()
setWindowTitle(tr("Sync decks to this server")); setWindowTitle(tr("Sync decks to this server"));
detailLabel->setText( detailLabel->setText(
tr("You have %1 local decks and %2 decks stored on this server. This choice sets how decks sync with this " tr("You have %1 local decks and %2 decks stored on this server. This choice sets how decks sync with this "
"server from now on; you can reset it to two-way sync from the deck storage tab.") "server from now on; you can reset it to two-way sync from the deck storage tab. Only two-way sync keeps "
"checking for changes automatically.")
.arg(localCount) .arg(localCount)
.arg(serverCount)); .arg(serverCount));
mergeRadio->setText(tr("Merge: keep everything, sync both ways (recommended)")); mergeRadio->setText(tr("Merge: keep everything, sync both ways (recommended)"));

View file

@ -283,6 +283,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::syncIntervalMinutesChanged, syncManager, connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::syncIntervalMinutesChanged, syncManager,
&DeckSyncManager::setSyncIntervalMinutes); &DeckSyncManager::setSyncIntervalMinutes);
connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage); connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage);
connect(syncManager, &DeckSyncManager::syncStarted, this, &TabDeckStorage::onSyncStarted);
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished); connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected); connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected);
connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest); connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest);
@ -656,9 +657,19 @@ void TabDeckStorage::onSyncStatusMessage(const QString &message)
renderSyncStatus(); renderSyncStatus();
} }
void TabDeckStorage::onSyncStarted()
{
// An auto run starts from the watcher or the interval timer, so the button
// has to follow the manager: a click during a run would be dropped by
// syncNow() and leave the user pressing a button that looks live.
aSyncNow->setEnabled(false);
aSyncReset->setEnabled(false);
}
void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &error) void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &error)
{ {
aSyncNow->setEnabled(remoteEnabled); aSyncNow->setEnabled(remoteEnabled);
aSyncReset->setEnabled(remoteEnabled);
if (!error.isEmpty()) { if (!error.isEmpty()) {
syncStatusType = SyncStatusType::Failed; syncStatusType = SyncStatusType::Failed;
syncStatusDetail = error; syncStatusDetail = error;

View file

@ -135,6 +135,7 @@ private slots:
void onFirstSyncDetected(int localCount, int serverCount); void onFirstSyncDetected(int localCount, int serverCount);
void onConflictRequest(const DeckSyncConflict &conflict); void onConflictRequest(const DeckSyncConflict &conflict);
void onSyncStatusMessage(const QString &message); void onSyncStatusMessage(const QString &message);
void onSyncStarted();
void onSyncFinished(const SyncSummary &summary, const QString &error); void onSyncFinished(const SyncSummary &summary, const QString &error);
public: public: