Compare commits

...

14 commits

Author SHA1 Message Date
Lukas Brübach
dcccb0f332 Fix remaining review threads on the conflict classification
- Remove the duplicated download loop and the stray '<' plus extra brace
  that left the head unbuildable.
- Skip the fallback upload under DownloadMode so a local-only deck is not
  pushed when the user asked for downloads only.
- Treat a server row without a hash as unknown rather than changed, so
  pre-migration decks are not re-downloaded on every sync.
2026-11-02 21:58:07 +01:00
Lukas Brübach
9e4d734373 Fix ZeldaZach review for PR 7495: three-way baseHash check before conflict 2026-11-02 21:58:07 +01:00
Lukas Brübach
d2e2e722e7 [Client] Sync conflict dialog, onboarding and conflict default 2026-11-02 21:58:07 +01:00
Lukas Brübach
de3468cc33 [DeckSync] Address tombstone review: remote deletes, revival and purge clock
- Treat a record whose server row is gone as a remote delete only when the
  local file still matches the sidecar's base hash. An edit made after the
  remote delete is newer than the delete, so it uploads and revives the row
  instead of being removed.
- Revive a tombstone with the same user and guid in cmdDeckUpload instead of
  inserting a second row next to it, so the deck keeps its id and version
  history and the promise in servatrice.ini.example holds.
- Run tombstone purging on its own clock, driven by the new
  [deck_sync] purge_interval option, so disabling share cleanup no longer
  disables purging silently.
- Write the base snapshot through QSaveFile and log a failed commit, so a
  crash or a full disk cannot leave a truncated base for DeckDiff::merge().
2026-11-01 22:25:56 +01:00
Lukas Brübach
157e63b179 Fix ZeldaZach review for PR 7494: match by remoteId/guid before remote-delete, only tombstone when file truly missing 2026-11-01 22:25:51 +01:00
Lukas Brübach
e43862c3bc [Server+Client] Tombstone deck deletes with purge and base snapshots 2026-11-01 22:25:51 +01:00
Lukas Brübach
2916fa45af [DeckSync] Match server rows by identity and hash the whole deck
Sync keyed local files to server rows by path, but the server names a row
after the deck, not after the file, so any deck whose file name and deck name
differ uploaded again on every run. A second pass then brought the server copy
back as an extra local file.

- Match by the sidecar's row id, then guid, then content, and only then by the
  server path, so renaming either side no longer duplicates a deck.
- Key the server tree by row id: the server allows several rows with the same
  name in one folder, and keying by path silently dropped all but one.
- Stop discarding rows whose name contains '/' ("U/W Control"). They stay
  matchable, and only the download file name is sanitised.
- Build the download path from the folder plus a sanitised row name, including
  every folder segment, so a hostile server name cannot escape the deck folder.
- Download a colliding server row under a unique name and count it, instead of
  skipping it without a trace.
- Hash the whole deck instead of the card hash: SHA-1 over the native deck
  string covers name, printings, banner, tokens and sideboard plans. The client
  and the server hash the exact bytes they exchange, so both agree.

Adds deck_content_hash_test for the client/server hashing invariant and for the
name-only change the card hash cannot see.
2026-11-01 22:23:25 +01:00
Lukas Brübach
ad0da98a86 [DeckSync] Pass the sync summary through the signal and tidy the manager API
- Emit SyncSummary from syncFinished() instead of four positional ints, and
  build it from a single currentSummary() helper, so the counts cannot drift
  between the status message and the signal.
- Use a designated initializer for LocalFile, like the other records.
- Drop setClient() and setColorIdentityProvider(): both fields are already
  constructor parameters and the setters had no callers. setProfile() and
  setDeckPath() stay, since the tab re-reads them before every run.
- Fix the include paths in tab_deck_storage.h and the narrowing conversion of
  the tree item id, so the branch builds again.
2026-11-01 22:23:25 +01:00
BruebachL
c0a62a2486 [Format] Fix include order in tab_deck_storage.h 2026-11-01 22:23:25 +01:00
BruebachL
0757d3d867 [DeckSync] Use designated initializer for ServerFile per review 2026-11-01 22:23:25 +01:00
BruebachL
8a541531da [DeckSync] Prefer ctor params for set-once client/provider per review 2026-11-01 22:23:25 +01:00
BruebachL
e1a9f7766d [DeckSync] Move SyncSummary into DeckSyncManager per review 2026-11-01 22:23:25 +01:00
Lukas Brübach
ad1cb8f133 Fix ZeldaZach review for PR 7493: match uploads by sidecar remoteId/guid, validate server paths 2026-11-01 22:23:25 +01:00
Lukas Brübach
e63b72fe11 [Client] Manual deck collection sync with Sync Now action 2026-11-01 22:23:25 +01:00
31 changed files with 2212 additions and 45 deletions

View file

@ -28,6 +28,7 @@ set(cockatrice_SOURCES
src/interface/deck_loader/deck_loader.cpp
src/interface/deck_sync/deck_sync_profile.cpp
src/interface/deck_sync/deck_sync_sidecar.cpp
src/interface/deck_sync/deck_sync_manager.cpp
src/interface/widgets/dialogs/dlg_connect.cpp
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp
@ -57,6 +58,8 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
src/interface/widgets/dialogs/dlg_settings.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_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp

View file

@ -25,6 +25,7 @@
#include <libcockatrice/settings/commander_bracket_settings.h>
#include <libcockatrice/settings/debug_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/game_filters_settings.h>
#include <libcockatrice/settings/game_settings.h>
@ -159,6 +160,7 @@ SettingsCache::SettingsCache()
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
interfaceSettings = new InterfaceSettings(settingsPath, this);
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
deckSyncSettings = new DeckSyncSettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
appearanceSettings = new AppearanceSettings(settingsPath, this);
@ -460,6 +462,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const
return *deckEditorSettings;
}
DeckSyncSettings &SettingsCache::deckSync() const
{
return *deckSyncSettings;
}
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
{
return *visualDeckStorageSettings;

View file

@ -32,6 +32,7 @@ class ChatSettings;
class CommanderBracketSettings;
class DebugSettings;
class DeckEditorSettings;
class DeckSyncSettings;
class DownloadSettings;
class GameFiltersSettings;
class GameSettings;
@ -81,6 +82,7 @@ private:
CardsDisplaySettings *cardsDisplaySettings = nullptr;
InterfaceSettings *interfaceSettings = nullptr;
DeckEditorSettings *deckEditorSettings = nullptr;
DeckSyncSettings *deckSyncSettings = nullptr;
PathsSettings *pathsSettings = nullptr;
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
AppearanceSettings *appearanceSettings = nullptr;
@ -149,6 +151,7 @@ public:
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
[[nodiscard]] InterfaceSettings &userInterface() const;
[[nodiscard]] DeckEditorSettings &deckEditor() const;
[[nodiscard]] DeckSyncSettings &deckSync() const;
[[nodiscard]] PathsSettings &paths() const;
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
[[nodiscard]] AppearanceSettings &appearance() const;

View 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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
/**
* @file deck_sync_manager.h
* @brief One-shot two-way deck collection sync between local files and the server.
*/
#ifndef DECK_SYNC_MANAGER_H
#define DECK_SYNC_MANAGER_H
#include "deck_sync_conflict.h"
#include "deck_sync_profile.h"
#include <QDateTime>
#include <QList>
#include <QMap>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariant>
#include <functional>
#include <libcockatrice/settings/deck_sync_settings.h>
class AbstractClient;
class CommandContainer;
class DeckList;
class Response;
class ServerInfo_DeckStorage_Folder;
/**
* @class DeckSyncManager
* @brief Runs a manual whole-collection sync: local *.cod files vs server deck storage.
*
* Each syncNow() run loads the profile sidecar, scans the deck folder, fetches
* the server tree and mirrors both sides: unknown local decks upload, unknown
* server decks download, and decks changed on both sides resolve newer-wins
* (ties go to the server). A local file is matched to its server row by the
* sidecar's row id, then guid, then the server path, and finally by content, so
* renaming either side does not duplicate a deck. Before a download overwrites a
* locally modified file, the local content is copied to a .sync-backup file next
* to it.
*
* Ownership: fire-and-forget per run, driven by PendingCommand round trips.
* 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
* the file to a .sync-backup, and resurrection reuses the stored deck id.
*/
class DeckSyncManager : public QObject
{
Q_OBJECT
public:
/** @brief Computes the color identity string uploaded alongside a deck. May be empty. */
using ColorIdentityProvider = std::function<QString(const DeckList &)>;
/** @brief Outcome counts for a finished sync run, carried by syncFinished(). */
struct SyncSummary
{
int uploaded = 0;
int downloaded = 0;
int unchanged = 0;
int skipped = 0;
};
explicit DeckSyncManager(QObject *parent = nullptr);
DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr);
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);
void
onEnsureDirFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onUploadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onDownloadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onDeleteFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
private:
struct ServerFile
{
int id = -1;
QString path; ///< Server-side address: folder prefix plus row name.
QString dir; ///< Folder prefix, empty in the root.
QString name; ///< Row name exactly as the server stores it.
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct LocalFile
{
QString absolutePath;
QString hash;
QDateTime modified;
};
struct UploadOp
{
QString relativePath;
QString serverPath; ///< Target address of a brand-new row.
QString serverDir; ///< Folder that must exist before the upload.
int deckId = -1;
QString guid;
};
struct DownloadOp
{
QString relativePath;
int deckId = -1;
QString guid;
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();
[[nodiscard]] SyncSummary currentSummary() const;
void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<int, ServerFile> &files,
QSet<QString> &dirs);
void processNext();
void processNextUpload();
void sendMkDirFor(const QString &dir);
void sendUpload(const UploadOp &op);
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<int, 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;
AbstractClient *client = nullptr;
DeckSyncProfile profile;
QString deckPath;
ColorIdentityProvider colorIdentityProvider;
bool syncing = false;
QString sidecarPath;
QMap<QString, LocalFile> localFiles;
QSet<QString> ensuredDirs;
QList<UploadOp> pendingUploads;
QList<DownloadOp> pendingDownloads;
QList<QString> pendingDeletes;
QSet<QString> conflictPaths;
QMap<QString, ConflictDownload> pendingConflicts;
QMap<int, 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;
int skippedCount = 0;
};
#endif // DECK_SYNC_MANAGER_H

View file

@ -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());

View file

@ -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. */

View file

@ -0,0 +1,178 @@
#include "dlg_sync_conflict.h"
#include <QCheckBox>
#include <QDateTime>
#include <QDialogButtonBox>
#include <QFile>
#include <QLabel>
#include <QListWidget>
#include <QLocale>
#include <QRadioButton>
#include <QVBoxLayout>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
namespace
{
QString formatTime(qint64 secsSinceEpoch)
{
return QLocale().toString(QDateTime::fromSecsSinceEpoch(secsSinceEpoch), QLocale::ShortFormat);
}
int zoneCardCount(const DeckList &deck, const QString &zone)
{
int count = 0;
deck.forEachCard([&count, &zone](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
if (zoneNode->getName() == zone) {
count += card->getNumber();
}
});
return count;
}
} // namespace
DlgSyncConflict::DlgSyncConflict(const DeckSyncConflict &_conflict, QWidget *parent)
: QDialog(parent), conflict(_conflict)
{
auto *layout = new QVBoxLayout(this);
titleLabel = new QLabel(this);
detailLabel = new QLabel(this);
detailLabel->setWordWrap(true);
keepMineRadio = new QRadioButton(this);
keepServerRadio = new QRadioButton(this);
keepBothRadio = new QRadioButton(this);
mergeRadio = new QRadioButton(this);
changesList = new QListWidget(this);
mergeSummaryLabel = new QLabel(this);
mergeSummaryLabel->setWordWrap(true);
rememberSessionBox = new QCheckBox(this);
rememberAlwaysBox = new QCheckBox(this);
layout->addWidget(titleLabel);
layout->addWidget(detailLabel);
layout->addWidget(keepMineRadio);
layout->addWidget(keepServerRadio);
layout->addWidget(keepBothRadio);
layout->addWidget(mergeRadio);
layout->addWidget(changesList);
layout->addWidget(mergeSummaryLabel);
layout->addWidget(rememberSessionBox);
layout->addWidget(rememberAlwaysBox);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(buttons);
DeckList localDeck;
DeckList serverDeck;
QFile localFile(conflict.localAbsolutePath);
decksParsed = localFile.open(QIODevice::ReadOnly) && localDeck.loadFromFile_Native(&localFile) &&
serverDeck.loadFromString_Native(conflict.serverDeckString);
localIsNewer = conflict.localSecs >= conflict.serverSecs;
if (decksParsed) {
shownChanges = DeckDiff::diff(localDeck, serverDeck);
if (conflict.hasBase) {
DeckList baseDeck;
if (baseDeck.loadFromString_Native(conflict.baseDeckString)) {
const DeckDiffResult localChanges = DeckDiff::diff(baseDeck, localDeck);
const DeckDiffResult remoteChanges = DeckDiff::diff(baseDeck, serverDeck);
mergeable = DeckDiff::canAutoMerge(localChanges, remoteChanges);
if (mergeable) {
const DeckList merged = DeckDiff::merge(baseDeck, localChanges, remoteChanges);
mergedMainCount = zoneCardCount(merged, "main");
mergedSideCount = zoneCardCount(merged, "side");
}
}
}
}
if (localIsNewer) {
keepMineRadio->setChecked(true);
} else {
keepServerRadio->setChecked(true);
}
mergeRadio->setEnabled(mergeable);
connect(keepMineRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
connect(keepServerRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
connect(keepBothRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
connect(mergeRadio, &QRadioButton::toggled, this, &DlgSyncConflict::updateRememberBoxes);
updateRememberBoxes();
retranslateUi();
}
void DlgSyncConflict::retranslateUi()
{
setWindowTitle(tr("Sync conflict"));
titleLabel->setText(conflict.relativePath);
if (!decksParsed) {
detailLabel->setText(tr("This deck changed on both sides, but one version could not be read."));
} else {
detailLabel->setText(tr("This deck changed both on this device and on the server."));
}
keepMineRadio->setText(
tr("Keep my deck (%1%2)").arg(formatTime(conflict.localSecs), localIsNewer ? tr(", newest") : QString()));
keepServerRadio->setText(tr("Keep the server deck (%1%2)")
.arg(formatTime(conflict.serverSecs), localIsNewer ? QString() : tr(", newest")));
keepBothRadio->setText(tr("Keep both (store the server version aside)"));
mergeRadio->setText(mergeable ? tr("Use the auto-merge") : tr("Use the auto-merge (not available)"));
changesList->clear();
for (const DeckCardChange &change : shownChanges.changes) {
changesList->addItem(
tr("%1: %2 %3 → %4").arg(change.zone).arg(change.cardName).arg(change.oldCount).arg(change.newCount));
}
if (mergeable) {
mergeSummaryLabel->setText(tr("Auto-merge proposal: union of both sides (%1 main, %2 side).")
.arg(mergedMainCount)
.arg(mergedSideCount));
} else if (!conflict.hasBase) {
mergeSummaryLabel->setText(tr("No base snapshot exists, so the versions cannot be merged automatically."));
} else {
mergeSummaryLabel->setText(tr("The changes overlap, so they cannot be merged automatically."));
}
rememberSessionBox->setText(tr("Remember for the rest of this sync"));
rememberAlwaysBox->setText(tr("Always use this rule"));
}
void DlgSyncConflict::updateRememberBoxes()
{
const bool single = keepMineRadio->isChecked() || keepServerRadio->isChecked();
rememberSessionBox->setEnabled(single);
rememberAlwaysBox->setEnabled(single);
if (!single) {
rememberSessionBox->setChecked(false);
rememberAlwaysBox->setChecked(false);
}
}
DeckSyncResolutionAction DlgSyncConflict::selectedAction() const
{
if (keepServerRadio->isChecked()) {
return DeckSyncKeepServer;
}
if (keepBothRadio->isChecked()) {
return DeckSyncKeepBoth;
}
if (mergeRadio->isChecked()) {
return DeckSyncUseMerge;
}
return DeckSyncKeepMine;
}
DeckSyncResolution DlgSyncConflict::resolution() const
{
DeckSyncResolution result;
result.action = selectedAction();
result.rememberSession = rememberSessionBox->isEnabled() && rememberSessionBox->isChecked();
result.rememberAlways = rememberAlwaysBox->isEnabled() && rememberAlwaysBox->isChecked();
return result;
}

View file

@ -0,0 +1,63 @@
/**
* @file dlg_sync_conflict.h
* @brief Dialog for resolving a deck that changed both locally and on the server.
*/
#ifndef DLG_SYNC_CONFLICT_H
#define DLG_SYNC_CONFLICT_H
#include "../../deck_sync/deck_sync_conflict.h"
#include <QDialog>
#include <QString>
#include <libcockatrice/deck_list/deck_diff.h>
class QCheckBox;
class QLabel;
class QListWidget;
class QRadioButton;
/**
* @class DlgSyncConflict
* @brief Shows both versions of a conflicted deck and returns the verdict.
*
* Radio choice between Keep mine / Keep server (newest preselected), Keep both
* and Use merge (enabled only when the changes touch disjoint cards). The
* change list shows card-count deltas between the two versions; the merge
* summary describes the auto-merge proposal. Rejected dialogs resolve as Skip.
*/
class DlgSyncConflict : public QDialog
{
Q_OBJECT
public:
explicit DlgSyncConflict(const DeckSyncConflict &conflict, QWidget *parent = nullptr);
[[nodiscard]] DeckSyncResolution resolution() const;
void retranslateUi();
private slots:
void updateRememberBoxes();
private:
DeckSyncResolutionAction selectedAction() const;
DeckSyncConflict conflict;
bool decksParsed = false;
bool localIsNewer = true;
DeckDiffResult shownChanges;
bool mergeable = false;
int mergedMainCount = 0;
int mergedSideCount = 0;
QLabel *titleLabel;
QLabel *detailLabel;
QRadioButton *keepMineRadio;
QRadioButton *keepServerRadio;
QRadioButton *keepBothRadio;
QRadioButton *mergeRadio;
QListWidget *changesList;
QLabel *mergeSummaryLabel;
QCheckBox *rememberSessionBox;
QCheckBox *rememberAlwaysBox;
};
#endif // DLG_SYNC_CONFLICT_H

View file

@ -0,0 +1,59 @@
#include "dlg_sync_onboarding.h"
#include <QDialogButtonBox>
#include <QLabel>
#include <QRadioButton>
#include <QVBoxLayout>
DlgSyncOnboarding::DlgSyncOnboarding(int _localCount, int _serverCount, QWidget *parent)
: QDialog(parent), localCount(_localCount), serverCount(_serverCount)
{
auto *layout = new QVBoxLayout(this);
detailLabel = new QLabel(this);
detailLabel->setWordWrap(true);
mergeRadio = new QRadioButton(this);
uploadRadio = new QRadioButton(this);
downloadRadio = new QRadioButton(this);
separateRadio = new QRadioButton(this);
mergeRadio->setChecked(true);
layout->addWidget(detailLabel);
layout->addWidget(mergeRadio);
layout->addWidget(uploadRadio);
layout->addWidget(downloadRadio);
layout->addWidget(separateRadio);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(buttons);
retranslateUi();
}
void DlgSyncOnboarding::retranslateUi()
{
setWindowTitle(tr("Sync decks to this server"));
detailLabel->setText(tr("You have %1 local decks and %2 decks stored on this server. How should they be combined?")
.arg(localCount)
.arg(serverCount));
mergeRadio->setText(tr("Merge: keep everything, sync both ways (recommended)"));
uploadRadio->setText(tr("Upload mine: push local decks to the server"));
downloadRadio->setText(tr("Download server: take the server decks"));
separateRadio->setText(tr("Keep separate: do not sync with this server"));
}
DeckSyncMode DlgSyncOnboarding::selectedMode() const
{
if (uploadRadio->isChecked()) {
return DeckSyncMode::UploadOnly;
}
if (downloadRadio->isChecked()) {
return DeckSyncMode::DownloadOnly;
}
if (separateRadio->isChecked()) {
return DeckSyncMode::Separate;
}
return DeckSyncMode::TwoWay;
}

View file

@ -0,0 +1,43 @@
/**
* @file dlg_sync_onboarding.h
* @brief First-sync choice when local decks and server decks meet.
*/
#ifndef DLG_SYNC_ONBOARDING_H
#define DLG_SYNC_ONBOARDING_H
#include "../../deck_sync/deck_sync_conflict.h"
#include <QDialog>
class QLabel;
class QRadioButton;
/**
* @class DlgSyncOnboarding
* @brief Asks how to combine a non-empty local collection with a non-empty server collection.
*
* Merge (two-way) is the default. Upload and download restrict the run to one
* direction; keeping separate records the profile as sync-disabled.
* Rejected dialogs cancel the sync without recording anything.
*/
class DlgSyncOnboarding : public QDialog
{
Q_OBJECT
public:
DlgSyncOnboarding(int _localCount, int _serverCount, QWidget *parent = nullptr);
[[nodiscard]] DeckSyncMode selectedMode() const;
void retranslateUi();
private:
int localCount = 0;
int serverCount = 0;
QLabel *detailLabel;
QRadioButton *mergeRadio;
QRadioButton *uploadRadio;
QRadioButton *downloadRadio;
QRadioButton *separateRadio;
};
#endif // DLG_SYNC_ONBOARDING_H

View file

@ -27,6 +27,7 @@
#include <QtGlobal>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
@ -359,6 +360,25 @@ GeneralSettingsPage::GeneralSettingsPage()
pathsGroupBox = new QGroupBox;
pathsGroupBox->setLayout(pathsGrid);
// deck sync settings
conflictDefaultSelector.addItem("", DeckSyncConflictAsk);
conflictDefaultSelector.addItem("", DeckSyncConflictKeepMine);
conflictDefaultSelector.addItem("", DeckSyncConflictKeepServer);
conflictDefaultSelector.addItem("", DeckSyncConflictKeepNewer);
int conflictIndex = conflictDefaultSelector.findData(settings.deckSync().getConflictDefault());
if (conflictIndex >= 0) {
conflictDefaultSelector.setCurrentIndex(conflictIndex);
}
connect(&conflictDefaultSelector, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().deckSync().setConflictDefault(conflictDefaultSelector.itemData(index).toInt());
});
auto *deckSyncGrid = new QGridLayout;
deckSyncGrid->addWidget(&conflictDefaultLabel, 0, 0);
deckSyncGrid->addWidget(&conflictDefaultSelector, 0, 1);
deckSyncGroupBox = new QGroupBox;
deckSyncGroupBox->setLayout(deckSyncGrid);
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(languageGroupBox);
mainLayout->addWidget(versionGroupBox);
@ -366,6 +386,7 @@ GeneralSettingsPage::GeneralSettingsPage()
mainLayout->addWidget(cardDatabaseGroupBox);
mainLayout->addWidget(startupGroupBox);
mainLayout->addWidget(pathsGroupBox);
mainLayout->addWidget(deckSyncGroupBox);
mainLayout->addStretch();
GeneralSettingsPage::retranslateUi();
@ -647,5 +668,13 @@ void GeneralSettingsPage::retranslateUi()
cardDatabasePathLabel.setText(tr("Card database:"));
customCardDatabasePathLabel.setText(tr("Custom database directory:"));
tokenDatabasePathLabel.setText(tr("Token database:"));
deckSyncGroupBox->setTitle(tr("Deck sync"));
conflictDefaultLabel.setText(tr("When a deck changed on both sides:"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictAsk), tr("Ask me every time"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepMine), tr("Keep my deck"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepServer),
tr("Keep the server deck"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepNewer),
tr("Keep the newer deck"));
resetAllPathsButton->setText(tr("Reset all paths"));
}

View file

@ -52,6 +52,7 @@ private:
QGroupBox *cardDatabaseGroupBox;
QGroupBox *startupGroupBox;
QGroupBox *pathsGroupBox;
QGroupBox *deckSyncGroupBox;
QLabel languageLabel;
QComboBox languageBox;
@ -104,6 +105,9 @@ private:
QLineEdit *tokenDatabasePathEdit;
QPushButton *resetAllPathsButton;
QLabel *allPathsResetLabel;
QLabel conflictDefaultLabel;
QComboBox conflictDefaultSelector;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -2,10 +2,15 @@
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../../deck_sync/deck_sync_conflict.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../../deck_sync/deck_sync_profile.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h"
#include "../deck_share/share_bar_widget.h"
#include "../dialogs/dlg_sync_conflict.h"
#include "../dialogs/dlg_sync_onboarding.h"
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include "abstract_client.h"
@ -29,6 +34,7 @@
#include <QInputDialog>
#include <QItemSelection>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QModelIndex>
@ -61,6 +67,7 @@
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/qt_utils.h>
@ -177,6 +184,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout);
syncStatusLabel = new QLabel(this);
syncStatusLabel->setWordWrap(true);
rightVbox->addWidget(syncStatusLabel);
rightGroupBox = new QGroupBox;
rightGroupBox->setLayout(rightVbox);
@ -228,6 +238,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
aSyncNow = new QAction(this);
aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload));
connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow);
// Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal);
@ -239,11 +253,25 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aSyncNow);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
syncManager = new DeckSyncManager(
client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this);
syncManager->setConflictDefault(SettingsCache::instance().deckSync().getConflictDefault());
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::conflictDefaultChanged, syncManager,
&DeckSyncManager::setConflictDefault);
connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage);
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected);
connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest);
if (currentUserInfo != nullptr) {
syncUsername = QString::fromStdString(currentUserInfo->name());
}
retranslateUi();
QWidget *mainWidget = new QWidget(this);
@ -272,6 +300,8 @@ void TabDeckStorage::retranslateUi()
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder"));
aSyncNow->setText(tr("Sync now"));
renderSyncStatus();
shareBar->retranslateUi();
if (shareBar->isVisible()) {
onServerSelectionChanged();
@ -299,6 +329,7 @@ QString TabDeckStorage::getTargetPath() const
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{
syncUsername = QString::fromStdString(userInfo.name());
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
}
@ -316,6 +347,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
void TabDeckStorage::setRemoteEnabled(bool enabled)
{
remoteEnabled = enabled;
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
@ -323,6 +355,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
if (enabled) {
serverDirView->refreshTree();
@ -526,6 +559,89 @@ void TabDeckStorage::actOpenDecksFolder()
QtUtils::openPathInFileManager(dir);
}
void TabDeckStorage::actSyncNow()
{
DeckSyncProfile profile(client->serverName(), client->serverPort(), syncUsername);
syncManager->setProfile(profile);
syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath());
aSyncNow->setEnabled(false);
syncManager->syncNow();
}
void TabDeckStorage::onFirstSyncDetected(int localCount, int serverCount)
{
DlgSyncOnboarding dialog(localCount, serverCount, this);
if (dialog.exec() == QDialog::Accepted) {
syncManager->continueFirstSync(dialog.selectedMode());
} else {
syncManager->cancelFirstSync();
}
}
void TabDeckStorage::onConflictRequest(const DeckSyncConflict &conflict)
{
DlgSyncConflict dialog(conflict, this);
if (dialog.exec() != QDialog::Accepted) {
DeckSyncResolution skip;
skip.action = DeckSyncSkipConflict;
syncManager->resolveConflict(skip);
return;
}
const DeckSyncResolution resolution = dialog.resolution();
if (resolution.rememberAlways &&
(resolution.action == DeckSyncKeepMine || resolution.action == DeckSyncKeepServer)) {
SettingsCache::instance().deckSync().setConflictDefault(
resolution.action == DeckSyncKeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer);
}
syncManager->resolveConflict(resolution);
}
void TabDeckStorage::onSyncStatusMessage(const QString &message)
{
syncStatusType = SyncStatusType::Working;
syncStatusDetail = message;
renderSyncStatus();
}
void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &error)
{
aSyncNow->setEnabled(remoteEnabled);
if (!error.isEmpty()) {
syncStatusType = SyncStatusType::Failed;
syncStatusDetail = error;
} else {
syncStatusType = SyncStatusType::Finished;
syncStatusDetail.clear();
lastSyncSummary = summary;
}
renderSyncStatus();
if (error.isEmpty()) {
serverDirView->refreshTree();
}
}
void TabDeckStorage::renderSyncStatus()
{
switch (syncStatusType) {
case SyncStatusType::Working:
syncStatusLabel->setText(syncStatusDetail);
break;
case SyncStatusType::Failed:
syncStatusLabel->setText(tr("Sync failed: %1").arg(syncStatusDetail));
break;
case SyncStatusType::Finished:
syncStatusLabel->setText(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(lastSyncSummary.uploaded)
.arg(lastSyncSummary.downloaded)
.arg(lastSyncSummary.unchanged)
.arg(lastSyncSummary.skipped));
break;
case SyncStatusType::Idle:
syncStatusLabel->setText(tr("Not synced yet"));
break;
}
}
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
{
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {

View file

@ -8,7 +8,8 @@
#ifndef TAB_DECK_STORAGE_H
#define TAB_DECK_STORAGE_H
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../server/remote/remote_decklist_tree_widget.h"
#include "tab.h"
#include <QList>
@ -19,12 +20,15 @@
#include <qtmetamacros.h>
struct LoadedDeck;
struct DeckSyncConflict;
struct DeckSyncResolution;
class ServerInfo_User;
class QTreeView;
class QFileSystemModel;
class QToolBar;
class QGroupBox;
class QTimer;
class QLabel;
class CommandContainer;
class Response;
class ShareBarWidget;
@ -50,6 +54,23 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
QAction *aSyncNow;
QLabel *syncStatusLabel;
DeckSyncManager *syncManager;
QString syncUsername;
bool remoteEnabled = false;
enum class SyncStatusType
{
Idle,
Working,
Finished,
Failed
};
using SyncSummary = DeckSyncManager::SyncSummary;
SyncStatusType syncStatusType = SyncStatusType::Idle;
QString syncStatusDetail;
SyncSummary lastSyncSummary;
void renderSyncStatus();
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
@ -108,6 +129,12 @@ private slots:
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
void actSyncNow();
void onFirstSyncDetected(int localCount, int serverCount);
void onConflictRequest(const DeckSyncConflict &conflict);
void onSyncStatusMessage(const QString &message);
void onSyncFinished(const SyncSummary &summary, const QString &error);
public:
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
void retranslateUi() override;

View file

@ -9,6 +9,7 @@
#include "tree/inner_deck_list_node.h"
#include <QAnyStringView>
#include <QCryptographicHash>
#include <QDebug>
#include <QSet>
#include <QSharedPointer>
@ -453,6 +454,16 @@ void DeckList::refreshDeckHash()
cachedDeckHash = QString();
}
QString DeckList::contentHash(const QString &nativeDeck)
{
return QString::fromLatin1(QCryptographicHash::hash(nativeDeck.toUtf8(), QCryptographicHash::Sha1).toHex());
}
QString DeckList::getContentHash() const
{
return contentHash(writeToString_Native());
}
/**
* Calls a given function on each card in the deck.
*/

View file

@ -289,6 +289,16 @@ public:
/** @name Deck identity */
///@{
QString getDeckHash() const;
/**
* @brief SHA-1 of a native deck string, covering everything the card hash
* ignores (name, printings, banner, tokens, comments, sideboard plans).
*
* Deck-collection sync hashes the exact string it sends or receives, so both
* peers compare the same bytes.
*/
static QString contentHash(const QString &nativeDeck);
/** @brief contentHash() of this deck's native serialisation. */
QString getContentHash() const;
void refreshDeckHash();
///@}

View file

@ -12,6 +12,7 @@ set(HEADERS
libcockatrice/settings/commander_bracket_settings.h
libcockatrice/settings/debug_settings.h
libcockatrice/settings/deck_editor_settings.h
libcockatrice/settings/deck_sync_settings.h
libcockatrice/settings/download_settings.h
libcockatrice/settings/game_filters_settings.h
libcockatrice/settings/game_settings.h
@ -43,6 +44,7 @@ add_library(
libcockatrice/settings/chat_settings.cpp
libcockatrice/settings/debug_settings.cpp
libcockatrice/settings/deck_editor_settings.cpp
libcockatrice/settings/deck_sync_settings.cpp
libcockatrice/settings/download_settings.cpp
libcockatrice/settings/game_filters_settings.cpp
libcockatrice/settings/game_settings.cpp

View file

@ -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);
}

View file

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

View file

@ -598,3 +598,16 @@ max_shares_per_day=50
; Minimum enforced: 1.
; Default: 20
max_versions=20
; How many days a deleted deck stays restorable as a tombstone before the
; purge clock removes it (with its versions) for good. Re-uploading the
; same deck within this window revives the tombstone instead of creating a copy.
; A value of 0 purges tombstones on the next purge run.
; Default: 30
tombstone_days=30
; How often, in minutes, the tombstone purge runs. This is independent of
; [deck_share] cleanup_interval, so disabling share cleanup does not disable
; purging. A value of 0 disables purging entirely.
; Default: 60
purge_interval=60

View file

@ -632,6 +632,16 @@ bool Servatrice::initServer()
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
// Tombstone purging runs on its own clock: turning share cleanup off must not
// silently disable it as well.
deckTombstoneClock = new QTimer(this);
connect(deckTombstoneClock, SIGNAL(timeout()), this, SLOT(purgeDeckTombstones()));
const int deckSyncPurgeInterval = getDeckSyncPurgeInterval();
if (deckSyncPurgeInterval > 0) {
qDebug() << "Starting deck tombstone purge clock, interval" << deckSyncPurgeInterval << "ms";
deckTombstoneClock->start(deckSyncPurgeInterval);
}
// SOCKET SERVER
if (getNumberOfTCPPools() > 0) {
gameServer =
@ -856,6 +866,11 @@ void Servatrice::cleanupExpiredDeckShares()
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::purgeDeckTombstones()
{
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1401,6 +1416,17 @@ int Servatrice::getDeckSyncMaxVersions() const
return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt());
}
int Servatrice::getDeckSyncTombstoneDays() const
{
return qMax(0, settingsCache->value("deck_sync/tombstone_days", 30).toInt());
}
int Servatrice::getDeckSyncPurgeInterval() const
{
// default: every 60 minutes, independent of deck_share/cleanup_interval
return qMax(0, settingsCache->value("deck_sync/purge_interval", 60).toInt()) * 60000;
}
int Servatrice::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -166,6 +166,7 @@ private slots:
void sweepLegacyBackups();
void shutdownTimeout();
void cleanupExpiredDeckShares();
void purgeDeckTombstones();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -181,6 +182,7 @@ private:
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
QTimer *legacyBackupSweepClock;
QTimer *deckTombstoneClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -321,6 +323,8 @@ public:
int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const;
int getDeckSyncMaxVersions() const;
int getDeckSyncTombstoneDays() const;
int getDeckSyncPurgeInterval() const;
int getMaxUsersPerAddress() const;
/// Raw security/trusted_sources list; match addresses against it with isTrustedSource().
QString getTrustedSources() const;

View file

@ -1370,8 +1370,8 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
{
checkSql();
QSqlQuery *query =
prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = prepareQuery(
"select content from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", deckId);
query->bindValue(":id_user", userId);
execSqlQuery(query);
@ -1522,6 +1522,19 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::purgeDeckTombstones(int olderThanDays)
{
checkSql();
// DB-native clock: tombstones are stamped with NOW() on this same
// database, so the grace window is immune to app/db clock skew.
// The null arm only catches manually flagged rows.
QSqlQuery *query = prepareQuery("delete from {prefix}_decklist_files where deleted = 1 and (last_modified < "
"DATE_SUB(NOW(), INTERVAL :days DAY) or last_modified is null)");
query->bindValue(":days", qMax(0, olderThanDays));
execSqlQuery(query);
}
bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares)
{
checkSql();

View file

@ -152,6 +152,11 @@ public:
bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares();
/**
* @brief Hard-deletes tombstoned decks older than the grace period (cascades to versions).
* @param olderThanDays Tombstones older than this many days are purged; 0 purges all tombstones.
*/
void purgeDeckTombstones(int olderThanDays);
int getNextGameId() override;
int getNextReplayId() override;

View file

@ -709,7 +709,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user");
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user "
"and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
@ -854,8 +855,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
sqlInterface->checkSql();
if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery(
"select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
@ -965,7 +966,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
deckDelDirHelper(query->value(0).toInt());
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id_folder = :id_folder");
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() "
"where id_folder = :id_folder and deleted = 0");
query->bindValue(":id_folder", basePathId);
sqlInterface->execSqlQuery(query);
@ -1016,18 +1018,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
sqlInterface->checkSql();
QSqlQuery *query =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id = :id and id_user = :id_user");
sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() where id = "
":id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
query->bindValue(":id", cmd.deck_id());
sqlInterface->execSqlQuery(query);
return Response::RespOk;
}
@ -1114,7 +1115,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
const QString tagsJson = serializeDeckTags(deck.getTags());
const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity()));
const QString deckHash = deck.getDeckHash();
// Hash the exact bytes the client sent: both peers must compare the same
// string, not each side's re-serialisation of it.
const QString deckHash = DeckList::contentHash(deckStr);
QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString();
if (QUuid::fromString(guid).isNull()) {
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
@ -1126,31 +1129,71 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
return Response::RespNameNotFound;
}
QSqlQuery *query =
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
// A deck that was deleted elsewhere leaves a tombstone behind. Revive it
// instead of inserting a second row with the same guid, which would split
// the deck's version history and leave the tombstone to be purged.
int deckId = -1;
bool inserted = false;
QSqlQuery *tombstoneQuery =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_user = :id_user and guid = "
":guid and deleted = 1 order by last_modified desc limit 1");
tombstoneQuery->bindValue(":id_user", userInfo->id());
tombstoneQuery->bindValue(":guid", guid);
if (!sqlInterface->execSqlQuery(tombstoneQuery)) {
return Response::RespContextError;
}
const int deckId = query->lastInsertId().toInt();
if (tombstoneQuery->next()) {
deckId = tombstoneQuery->value(0).toInt();
QSqlQuery *reviveQuery = sqlInterface->prepareQuery(
"update {prefix}_decklist_files set id_folder=:id_folder, name=:name, upload_time=NOW(), "
"content=:content, is_public=:is_public, banner_card_name=:banner_card_name, "
"banner_card_provider=:banner_card_provider, color_identity=:color_identity, tags=:tags, "
"hash=:hash, last_modified=NOW(), deleted=0 where id=:id and id_user=:id_user");
reviveQuery->bindValue(":id_folder", folderId);
reviveQuery->bindValue(":id_user", userInfo->id());
reviveQuery->bindValue(":id", deckId);
reviveQuery->bindValue(":name", deckName);
reviveQuery->bindValue(":content", deckStr);
reviveQuery->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
reviveQuery->bindValue(":banner_card_name", bannerCardName);
reviveQuery->bindValue(":banner_card_provider", bannerCardProvider);
reviveQuery->bindValue(":color_identity", colorIdentity);
reviveQuery->bindValue(":tags", tagsJson);
reviveQuery->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(reviveQuery)) {
return Response::RespContextError;
}
} else {
QSqlQuery *query = sqlInterface->prepareQuery(
"insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
deckId = query->lastInsertId().toInt();
inserted = true;
}
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1160,9 +1203,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
stampQuery->bindValue(":id", deckId);
stampQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1195,9 +1241,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
}
const QString oldHash = currentQuery->value(1).toString();
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
QString updateQuery =
"update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW(), "
"deleted=0";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
@ -1399,7 +1447,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C
// issues its own query on the same cached statement set.
QSqlQuery *query =
sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
":id_folder and id_user = :id_user and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(query)) {

View file

@ -416,6 +416,15 @@ cockatrice_add_test(
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
deck_content_hash_test
SOURCES
deck_content_hash_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
deck_sync_profile_test

View file

@ -0,0 +1,51 @@
#include "gtest/gtest.h"
#include <QString>
#include <libcockatrice/deck_list/deck_list.h>
namespace
{
QString deckWithName(const QString &name)
{
return QStringLiteral(R"(<?xml version="1.0"?><cockatrice_deck version="1">)"
R"(<deckname>%1</deckname><comments></comments>)"
R"(<zone name="main"><card number="4" name="Lightning Bolt"/></zone>)"
R"(<zone name="side"><card number="2" name="Pyroblast"/></zone>)"
R"(</cockatrice_deck>)")
.arg(name);
}
} // namespace
TEST(DeckContentHashTest, MatchesNativeSerialisation)
{
DeckList deck;
ASSERT_TRUE(deck.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
EXPECT_EQ(deck.getContentHash(), DeckList::contentHash(deck.writeToString_Native()));
}
TEST(DeckContentHashTest, IsStableAcrossLoads)
{
DeckList first;
DeckList second;
ASSERT_TRUE(first.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
ASSERT_TRUE(second.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
EXPECT_EQ(first.getContentHash(), second.getContentHash());
}
TEST(DeckContentHashTest, CoversDeckName)
{
DeckList burn;
DeckList renamed;
ASSERT_TRUE(burn.loadFromString_Native(deckWithName(QStringLiteral("Burn"))));
ASSERT_TRUE(renamed.loadFromString_Native(deckWithName(QStringLiteral("Burn v2"))));
// The card hash only looks at card names and counts, so it cannot tell the
// two decks apart; the content hash must.
EXPECT_EQ(burn.getDeckHash(), renamed.getDeckHash());
EXPECT_NE(burn.getContentHash(), renamed.getContentHash());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -119,6 +119,26 @@ TEST(DeckSyncSidecarTest, RemoveDropsRecord)
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)
{
::testing::InitGoogleTest(&argc, argv);

View file

@ -13,6 +13,7 @@
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/chat_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/game_settings.h>
#include <libcockatrice/settings/interface_settings.h>
@ -586,6 +587,25 @@ TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet)
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 ---
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)