Compare commits

...

8 commits

50 changed files with 3733 additions and 33 deletions

View file

@ -27,9 +27,13 @@ set(cockatrice_SOURCES
src/interface/deck_loader/deck_file_format.cpp
src/interface/deck_loader/deck_loader.cpp
src/interface/deck_loader/loaded_deck.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
src/interface/widgets/dialogs/dlg_deck_history.cpp
src/interface/widgets/dialogs/dlg_default_tags_editor.cpp
src/interface/widgets/dialogs/dlg_edit_avatar.cpp
src/interface/widgets/dialogs/dlg_edit_password.cpp
@ -55,6 +59,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>
@ -155,6 +156,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);
@ -456,6 +458,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const
return *deckEditorSettings;
}
DeckSyncSettings &SettingsCache::deckSync() const
{
return *deckSyncSettings;
}
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
{
return *visualDeckStorageSettings;

View file

@ -33,6 +33,7 @@ class ChatSettings;
class CommanderBracketSettings;
class DebugSettings;
class DeckEditorSettings;
class DeckSyncSettings;
class DownloadSettings;
class GameFiltersSettings;
class GameSettings;
@ -82,6 +83,7 @@ private:
CardsDisplaySettings *cardsDisplaySettings = nullptr;
InterfaceSettings *interfaceSettings = nullptr;
DeckEditorSettings *deckEditorSettings = nullptr;
DeckSyncSettings *deckSyncSettings = nullptr;
PathsSettings *pathsSettings = nullptr;
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
AppearanceSettings *appearanceSettings = nullptr;
@ -150,6 +152,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,230 @@
/**
* @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 QFileSystemWatcher;
class QTimer;
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). Matching is path-based; a rename looks like a
* delete plus an add. 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 &)>;
explicit DeckSyncManager(QObject *parent = nullptr);
void setClient(AbstractClient *_client);
void setServerIdentity(const QString &host, int port, const QString &username);
void setDeckPath(const QString &path);
void setColorIdentityProvider(ColorIdentityProvider provider);
void setConflictDefault(int conflictDefault);
void setAutoSyncEnabled(bool enabled);
void setSyncIntervalMinutes(int minutes);
[[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(int uploaded, int downloaded, int unchanged, int skipped, 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 onCheckFinished(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 guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct LocalFile
{
QString absolutePath;
QString hash;
QDateTime modified;
};
struct UploadOp
{
QString relativePath;
QString serverPath;
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();
void fetchFullList();
void sendSyncCheck(qint64 lastSyncSecs);
void autoSyncSoon();
[[nodiscard]] bool canAutoRun() const;
void onDebounceTimeout();
void onWatchedPathChanged(const QString &path);
void refreshWatcher();
void refreshInterval();
[[nodiscard]] DeckSyncProfile buildProfile() const;
void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<QString, 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<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;
AbstractClient *client = nullptr;
QString serverHost;
int serverPort = 0;
QString serverUsername;
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<QString, ServerFile> stashedServerFiles;
ActiveConflict activeConflict;
DeckSyncMode syncMode = DeckSyncMode::TwoWay;
QFileSystemWatcher *watcher = nullptr;
QTimer *debounceTimer = nullptr;
QTimer *intervalTimer = nullptr;
bool autoSyncEnabled = true;
int syncIntervalMinutes = 5;
bool offline = false;
qint64 pendingServerTime = -1;
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

@ -0,0 +1,31 @@
#include "deck_sync_profile.h"
#include <QCryptographicHash>
DeckSyncProfile::DeckSyncProfile(const QString &_host, int _port, const QString &_username)
: host(_host), port(_port), username(_username)
{
}
bool DeckSyncProfile::isValid() const
{
return !host.isEmpty() && port > 0 && !username.isEmpty();
}
QString DeckSyncProfile::key() const
{
if (!isValid()) {
return {};
}
// NUL separators keep the triple injective: neither IPv6 hosts nor
// usernames containing ':' can collide two profiles into one file.
// Lowercasing is safe on both parts: DNS names are case-insensitive, and
// Servatrice usernames are unique under a case-insensitive collation
// (cockatrice_users uses utf8mb4_unicode_ci), so both spellings are one account.
QByteArray blob = host.trimmed().toLower().toUtf8();
blob.append('\0');
blob.append(QByteArray::number(port));
blob.append('\0');
blob.append(username.trimmed().toLower().toUtf8());
return QCryptographicHash::hash(blob, QCryptographicHash::Sha1).toHex();
}

View file

@ -0,0 +1,43 @@
/**
* @file deck_sync_profile.h
* @brief Identity of one sync target (server + user) for deck collection sync.
*/
#ifndef DECK_SYNC_PROFILE_H
#define DECK_SYNC_PROFILE_H
#include <QString>
/**
* @class DeckSyncProfile
* @brief Identifies which server and login a sync sidecar belongs to.
*
* A local deck folder can sync against several servers. Every server login
* gets its own profile, and every profile gets its own sidecar file, so
* switching servers never mixes up remote deck ids.
*/
class DeckSyncProfile
{
public:
QString host; ///< Server hostname as entered by the user.
int port = 0; ///< Server port.
QString username; ///< Login name on that server.
DeckSyncProfile() = default;
DeckSyncProfile(const QString &_host, int _port, const QString &_username);
/** @brief False when any of host, port or username is missing. */
[[nodiscard]] bool isValid() const;
/**
* @brief Stable file-system-safe key for this profile.
*
* SHA1 hex over the NUL-separated lowercased "host", port and "username"
* triple, so the same server reached with different hostname
* capitalisation maps to one file, while IPv6 hosts and usernames
* containing ':' can never collide two profiles. Empty for invalid
* profiles, so no two half-configured profiles share a file.
*/
[[nodiscard]] QString key() const;
};
#endif // DECK_SYNC_PROFILE_H

View file

@ -0,0 +1,110 @@
#include "deck_sync_sidecar.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
namespace
{
constexpr int SIDECAR_FORMAT_VERSION = 1;
} // namespace
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;
lastSyncSecs = 0;
QFile file(filePath);
if (!file.exists()) {
return true;
}
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject() || !document.object().value("records").isObject()) {
return false;
}
const QJsonValue modeValue = document.object().value("mode");
mode = modeValue.isString() ? modeValue.toString() : MODE_LINKED;
lastSyncSecs = static_cast<qint64>(document.object().value("lastSync").toDouble());
const QJsonObject records = document.object().value("records").toObject();
for (auto it = records.begin(); it != records.end(); ++it) {
if (!it.value().isObject()) {
continue;
}
const QJsonObject entry = it.value().toObject();
DeckSyncRecord record;
record.guid = entry.value("guid").toString();
record.remoteId = entry.value("remoteId").toInt(-1);
record.baseHash = entry.value("baseHash").toString();
record.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble());
record.deleted = entry.value("deleted").toBool(false);
recordMap.insert(it.key(), record);
}
return true;
}
bool DeckSyncSidecar::save() const
{
QJsonObject records;
for (auto it = recordMap.begin(); it != recordMap.end(); ++it) {
QJsonObject entry;
entry.insert("guid", it.value().guid);
entry.insert("remoteId", it.value().remoteId);
entry.insert("baseHash", it.value().baseHash);
entry.insert("lastSync", static_cast<double>(it.value().lastSyncSecs));
entry.insert("deleted", it.value().deleted);
records.insert(it.key(), entry);
}
QJsonObject root;
root.insert("version", SIDECAR_FORMAT_VERSION);
root.insert("mode", mode);
root.insert("lastSync", static_cast<double>(lastSyncSecs));
root.insert("records", records);
QDir().mkpath(QFileInfo(filePath).absolutePath());
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
return true;
}
bool DeckSyncSidecar::contains(const QString &relativePath) const
{
return recordMap.contains(relativePath);
}
DeckSyncRecord DeckSyncSidecar::get(const QString &relativePath) const
{
return recordMap.value(relativePath, DeckSyncRecord());
}
void DeckSyncSidecar::set(const QString &relativePath, const DeckSyncRecord &record)
{
recordMap.insert(relativePath, record);
}
void DeckSyncSidecar::remove(const QString &relativePath)
{
recordMap.remove(relativePath);
}
QMap<QString, DeckSyncRecord> DeckSyncSidecar::records() const
{
return recordMap;
}

View file

@ -0,0 +1,60 @@
/**
* @file deck_sync_sidecar.h
* @brief Per-profile local bookkeeping for deck collection sync.
*/
#ifndef DECK_SYNC_SIDECAR_H
#define DECK_SYNC_SIDECAR_H
#include <QMap>
#include <QString>
/** @brief Sync bookkeeping for one local deck file. */
struct DeckSyncRecord
{
QString guid; ///< Stable deck identity shared with the server.
int remoteId = -1; ///< Server row id, -1 when never uploaded.
QString baseHash; ///< Deck hash at the last successful sync.
qint64 lastSyncSecs = 0; ///< Last sync time, seconds since Unix epoch.
bool deleted = false; ///< Local tombstone waiting to be pushed.
};
/**
* @class DeckSyncSidecar
* @brief Loads and stores sync records as JSON next to the deck folder.
*
* One sidecar file per DeckSyncProfile (named by profile key), mapping paths
* relative to the deck folder to their last-synced state. Comparing the live
* file hash against baseHash tells clean apart from locally modified.
*/
class DeckSyncSidecar
{
public:
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.
qint64 lastSyncSecs = 0; ///< Last completed sync, seconds since epoch; 0 means never.
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. */
bool save() const;
[[nodiscard]] bool contains(const QString &relativePath) const;
/** @brief Returns the record, or a default record when unknown. */
[[nodiscard]] DeckSyncRecord get(const QString &relativePath) const;
void set(const QString &relativePath, const DeckSyncRecord &record);
void remove(const QString &relativePath);
[[nodiscard]] QMap<QString, DeckSyncRecord> records() const;
private:
QMap<QString, DeckSyncRecord> recordMap;
};
#endif // DECK_SYNC_SIDECAR_H

View file

@ -0,0 +1,176 @@
#include "dlg_deck_history.h"
#include <QDateTime>
#include <QDialogButtonBox>
#include <QLabel>
#include <QListWidget>
#include <QLocale>
#include <QMessageBox>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_history_list.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_history_list.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
QString formatVersionTime(qint64 secsSinceEpoch)
{
return QLocale().toString(QDateTime::fromSecsSinceEpoch(secsSinceEpoch), QLocale::ShortFormat);
}
} // namespace
DlgDeckHistory::DlgDeckHistory(int _deckId,
const QString &_deckName,
int _limit,
AbstractClient *_client,
QWidget *parent)
: QDialog(parent), deckId(_deckId), deckName(_deckName), limit(_limit), client(_client)
{
auto *layout = new QVBoxLayout(this);
detailLabel = new QLabel(this);
detailLabel->setWordWrap(true);
versionsList = new QListWidget(this);
previewList = new QListWidget(this);
restoreButton = new QPushButton(this);
restoreButton->setEnabled(false);
layout->addWidget(detailLabel);
layout->addWidget(versionsList);
layout->addWidget(previewList);
layout->addWidget(restoreButton);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(buttons);
connect(versionsList, &QListWidget::currentRowChanged, this, &DlgDeckHistory::onSelectionChanged);
connect(restoreButton, &QPushButton::clicked, this, &DlgDeckHistory::onRestoreClicked);
Command_DeckHistoryList listCmd;
listCmd.set_deck_id(deckId);
listCmd.set_limit(static_cast<google::protobuf::uint32>(limit));
PendingCommand *listPend = AbstractClient::prepareSessionCommand(listCmd);
connect(listPend, &PendingCommand::finished, this, &DlgDeckHistory::onHistoryFinished);
client->sendCommand(listPend);
Command_DeckDownload downloadCmd;
downloadCmd.set_deck_id(deckId);
PendingCommand *downloadPend = AbstractClient::prepareSessionCommand(downloadCmd);
connect(downloadPend, &PendingCommand::finished, this, &DlgDeckHistory::onCurrentFinished);
client->sendCommand(downloadPend);
retranslateUi();
}
void DlgDeckHistory::retranslateUi()
{
setWindowTitle(tr("Deck history"));
detailLabel->setText(
tr("Stored versions of %1 (newest first). Restoring uploads the version as new content.").arg(deckName));
restoreButton->setText(tr("Restore selected version"));
renderVersions();
renderPreview();
}
void DlgDeckHistory::onHistoryFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (response.response_code() != Response::RespOk) {
detailLabel->setText(tr("Could not load version history."));
return;
}
const Response_DeckHistoryList &list = response.GetExtension(Response_DeckHistoryList::ext);
versions.clear();
for (const DeckVersionInfo &version : list.versions()) {
DeckHistoryVersion entry;
entry.versionNo = version.version_no();
entry.createdAtSecs = static_cast<qint64>(version.created_at());
entry.createdByClientId = QString::fromStdString(version.created_by_clientid());
entry.hash = QString::fromStdString(version.hash());
entry.content = QString::fromStdString(version.content());
versions.append(entry);
}
historyArrived = true;
renderVersions();
renderPreview();
}
void DlgDeckHistory::onCurrentFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (response.response_code() != Response::RespOk) {
detailLabel->setText(tr("Could not load the current deck."));
return;
}
const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext);
currentContent = QString::fromStdString(download.deck());
currentArrived = true;
renderPreview();
}
void DlgDeckHistory::renderVersions()
{
const int row = versionsList->currentRow();
versionsList->clear();
for (const DeckHistoryVersion &version : versions) {
QString label = tr("Version %1 — %2").arg(version.versionNo).arg(formatVersionTime(version.createdAtSecs));
if (!version.createdByClientId.isEmpty()) {
label += tr(" — %1").arg(version.createdByClientId);
}
versionsList->addItem(label);
}
if (row >= 0 && row < versionsList->count()) {
versionsList->setCurrentRow(row);
} else if (versionsList->count() > 0) {
versionsList->setCurrentRow(0);
}
onSelectionChanged();
}
void DlgDeckHistory::renderPreview()
{
previewList->clear();
restoreButton->setEnabled(false);
const int row = versionsList->currentRow();
if (!historyArrived || !currentArrived || row < 0 || row >= versions.size()) {
return;
}
DeckList currentDeck;
DeckList selectedDeck;
if (!currentDeck.loadFromString_Native(currentContent) ||
!selectedDeck.loadFromString_Native(versions.at(row).content)) {
return;
}
const DeckDiffResult shown = DeckDiff::diff(currentDeck, selectedDeck);
for (const DeckCardChange &change : shown.changes) {
previewList->addItem(
tr("%1: %2 %3 → %4").arg(change.zone).arg(change.cardName).arg(change.oldCount).arg(change.newCount));
}
restoreButton->setEnabled(true);
}
void DlgDeckHistory::onSelectionChanged()
{
renderPreview();
}
void DlgDeckHistory::onRestoreClicked()
{
const int row = versionsList->currentRow();
if (row < 0 || row >= versions.size()) {
return;
}
if (QMessageBox::question(this, tr("Restore version"),
tr("Restore version %1 as the current deck content? This becomes a new version.")
.arg(versions.at(row).versionNo)) != QMessageBox::Yes) {
return;
}
emit restoreRequested(deckId, versions.at(row).content);
accept();
}

View file

@ -0,0 +1,73 @@
/**
* @file dlg_deck_history.h
* @brief Browser for the stored versions of one server deck.
*/
#ifndef DLG_DECK_HISTORY_H
#define DLG_DECK_HISTORY_H
#include <QDialog>
#include <QList>
#include <QString>
class AbstractClient;
class CommandContainer;
class QLabel;
class QListWidget;
class QPushButton;
class Response;
/** @brief One stored deck version for display. */
struct DeckHistoryVersion
{
unsigned int versionNo = 0;
qint64 createdAtSecs = 0;
QString createdByClientId;
QString hash;
QString content;
};
/**
* @class DlgDeckHistory
* @brief Lists stored versions with a diff preview against the current deck.
*
* Restoring uploads the selected version as the new current content through
* the regular deck-upload path, so it becomes a version itself instead of
* rewriting history. Rejected dialogs restore nothing.
*/
class DlgDeckHistory : public QDialog
{
Q_OBJECT
public:
DlgDeckHistory(int deckId, const QString &deckName, int limit, AbstractClient *client, QWidget *parent = nullptr);
void retranslateUi();
signals:
void restoreRequested(int deckId, const QString &content);
private slots:
void onHistoryFinished(const Response &response, const CommandContainer &commandContainer);
void onCurrentFinished(const Response &response, const CommandContainer &commandContainer);
void onSelectionChanged();
void onRestoreClicked();
private:
void renderVersions();
void renderPreview();
int deckId = -1;
QString deckName;
int limit = 20;
AbstractClient *client = nullptr;
QList<DeckHistoryVersion> versions;
QString currentContent;
bool historyArrived = false;
bool currentArrived = false;
QLabel *detailLabel;
QListWidget *versionsList;
QListWidget *previewList;
QPushButton *restoreButton;
};
#endif // DLG_DECK_HISTORY_H

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

@ -28,6 +28,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>
@ -332,12 +333,53 @@ 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());
});
autoSyncCheckBox.setChecked(settings.deckSync().getAutoSync());
connect(&autoSyncCheckBox, &QCheckBox::toggled, &SettingsCache::instance().deckSync(),
&DeckSyncSettings::setAutoSync);
syncIntervalEdit.setMinimum(1);
syncIntervalEdit.setMaximum(60);
syncIntervalEdit.setValue(settings.deckSync().getSyncIntervalMinutes());
connect(&syncIntervalEdit, qOverload<int>(&QSpinBox::valueChanged), &SettingsCache::instance().deckSync(),
&DeckSyncSettings::setSyncIntervalMinutes);
historyLimitEdit.setMinimum(1);
historyLimitEdit.setMaximum(100);
historyLimitEdit.setValue(settings.deckSync().getHistoryLimit());
connect(&historyLimitEdit, qOverload<int>(&QSpinBox::valueChanged), &SettingsCache::instance().deckSync(),
&DeckSyncSettings::setHistoryLimit);
auto *deckSyncGrid = new QGridLayout;
deckSyncGrid->addWidget(&conflictDefaultLabel, 0, 0);
deckSyncGrid->addWidget(&conflictDefaultSelector, 0, 1);
deckSyncGrid->addWidget(&autoSyncCheckBox, 1, 0, 1, 2);
deckSyncGrid->addWidget(&syncIntervalLabel, 2, 0);
deckSyncGrid->addWidget(&syncIntervalEdit, 2, 1);
deckSyncGrid->addWidget(&historyLimitLabel, 3, 0);
deckSyncGrid->addWidget(&historyLimitEdit, 3, 1);
deckSyncGroupBox = new QGroupBox;
deckSyncGroupBox->setLayout(deckSyncGrid);
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(languageGroupBox);
mainLayout->addWidget(versionGroupBox);
mainLayout->addWidget(cardDatabaseGroupBox);
mainLayout->addWidget(startupGroupBox);
mainLayout->addWidget(pathsGroupBox);
mainLayout->addWidget(deckSyncGroupBox);
mainLayout->addStretch();
GeneralSettingsPage::retranslateUi();
@ -607,5 +649,17 @@ 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"));
autoSyncCheckBox.setText(tr("Sync decks automatically"));
syncIntervalLabel.setText(tr("Check every:"));
syncIntervalEdit.setSuffix(" " + tr("min"));
historyLimitLabel.setText(tr("Kept versions per 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;
@ -101,6 +102,14 @@ private:
QLineEdit *tokenDatabasePathEdit;
QPushButton *resetAllPathsButton;
QLabel *allPathsResetLabel;
QLabel conflictDefaultLabel;
QComboBox conflictDefaultSelector;
QCheckBox autoSyncCheckBox;
QLabel syncIntervalLabel;
QSpinBox syncIntervalEdit;
QLabel historyLimitLabel;
QSpinBox historyLimitEdit;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -4,10 +4,15 @@
#include "../../deck_loader/deck_file_format.h"
#include "../../deck_loader/deck_loader.h"
#include "../../deck_loader/loaded_deck.h"
#include "../../deck_sync/deck_sync_conflict.h"
#include "../../deck_sync/deck_sync_manager.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_deck_history.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"
@ -32,6 +37,7 @@
#include <QInputDialog>
#include <QItemSelection>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QModelIndex>
@ -63,6 +69,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/string_limits.h>
@ -178,6 +185,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);
@ -229,6 +239,14 @@ 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);
aDeckHistory = new QAction(this);
aDeckHistory->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogDetailedView));
connect(aDeckHistory, &QAction::triggered, this, &TabDeckStorage::actDeckHistory);
// Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal);
@ -240,11 +258,35 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aSyncNow);
rightToolBar->addAction(aDeckHistory);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
syncManager = new DeckSyncManager(this);
syncManager->setClient(client);
syncManager->setColorIdentityProvider(
[](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); });
syncManager->setConflictDefault(SettingsCache::instance().deckSync().getConflictDefault());
syncManager->setAutoSyncEnabled(SettingsCache::instance().deckSync().getAutoSync());
syncManager->setSyncIntervalMinutes(SettingsCache::instance().deckSync().getSyncIntervalMinutes());
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::conflictDefaultChanged, syncManager,
&DeckSyncManager::setConflictDefault);
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::autoSyncChanged, syncManager,
&DeckSyncManager::setAutoSyncEnabled);
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::syncIntervalMinutesChanged, syncManager,
&DeckSyncManager::setSyncIntervalMinutes);
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());
}
syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername);
retranslateUi();
QWidget *mainWidget = new QWidget(this);
@ -273,6 +315,9 @@ 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"));
aDeckHistory->setText(tr("Version history"));
renderSyncStatus();
shareBar->retranslateUi();
if (shareBar->isVisible()) {
onServerSelectionChanged();
@ -300,6 +345,8 @@ QString TabDeckStorage::getTargetPath() const
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{
syncUsername = QString::fromStdString(userInfo.name());
syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername);
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
}
@ -317,6 +364,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
void TabDeckStorage::setRemoteEnabled(bool enabled)
{
remoteEnabled = enabled;
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
@ -324,6 +372,8 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
aDeckHistory->setEnabled(enabled);
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
if (enabled) {
serverDirView->refreshTree();
@ -527,6 +577,139 @@ void TabDeckStorage::actOpenDecksFolder()
QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
}
void TabDeckStorage::actSyncNow()
{
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(int uploaded, int downloaded, int unchanged, int skipped, const QString &error)
{
aSyncNow->setEnabled(remoteEnabled);
if (!error.isEmpty()) {
syncStatusType = SyncStatusType::Failed;
syncStatusDetail = error;
} else {
syncStatusType = SyncStatusType::Finished;
syncStatusDetail.clear();
lastSyncSummary = {uploaded, downloaded, unchanged, skipped};
}
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::actDeckHistory()
{
QList<RemoteDeckList_TreeModel::FileNode *> files;
for (const auto curRight : serverDirView->getCurrentSelection()) {
if (auto *file = dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(curRight)) {
files.append(file);
}
}
if (files.size() != 1) {
QMessageBox::information(this, tr("Version history"), tr("Select a single server deck to view its history."));
return;
}
auto *dialog = new DlgDeckHistory(files.first()->getId(), files.first()->getName(),
SettingsCache::instance().deckSync().getHistoryLimit(), client, this);
connect(dialog, &DlgDeckHistory::restoreRequested, this, &TabDeckStorage::onHistoryRestoreRequested);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->show();
}
void TabDeckStorage::onHistoryRestoreRequested(int deckId, const QString &content)
{
DeckList deck;
if (!deck.loadFromString_Native(content)) {
return;
}
const QString deckString = deck.writeToString_Native();
if (deckString.length() > MAX_FILE_LENGTH) {
QMessageBox::critical(this, tr("Error"), tr("Restored deck is too large"));
return;
}
Command_DeckUpload cmd;
cmd.set_deck_id(deckId);
cmd.set_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::onHistoryRestoreFinished);
client->sendCommand(pend);
}
void TabDeckStorage::onHistoryRestoreFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Failed to restore deck version"));
return;
}
onSyncStatusMessage(tr("Version restored."));
serverDirView->refreshTree();
}
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
{
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {

View file

@ -19,15 +19,19 @@
#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;
class DeckSyncManager;
class QAction;
class QModelIndex;
class TabSupervisor;
@ -50,6 +54,30 @@ private:
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
QAction *aSyncNow;
QAction *aDeckHistory;
QLabel *syncStatusLabel;
DeckSyncManager *syncManager;
QString syncUsername;
bool remoteEnabled = false;
enum class SyncStatusType
{
Idle,
Working,
Finished,
Failed
};
struct SyncSummary
{
int uploaded = 0;
int downloaded = 0;
int unchanged = 0;
int skipped = 0;
};
SyncStatusType syncStatusType = SyncStatusType::Idle;
QString syncStatusDetail;
SyncSummary lastSyncSummary;
void renderSyncStatus();
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
@ -108,6 +136,15 @@ private slots:
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
void actSyncNow();
void actDeckHistory();
void onHistoryRestoreRequested(int deckId, const QString &content);
void onHistoryRestoreFinished(const Response &response, const CommandContainer &commandContainer);
void onFirstSyncDetected(int localCount, int serverCount);
void onConflictRequest(const DeckSyncConflict &conflict);
void onSyncStatusMessage(const QString &message);
void onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error);
public:
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
void retranslateUi() override;

View file

@ -7,6 +7,7 @@ set(HEADERS
libcockatrice/deck_list/tree/abstract_deck_list_node.h
libcockatrice/deck_list/tree/deck_list_card_node.h
libcockatrice/deck_list/tree/inner_deck_list_node.h
libcockatrice/deck_list/deck_diff.h
libcockatrice/deck_list/deck_list.h
libcockatrice/deck_list/deck_list_history_manager.h
libcockatrice/deck_list/deck_list_node_tree.h
@ -25,6 +26,7 @@ add_library(
libcockatrice/deck_list/tree/abstract_deck_list_node.cpp
libcockatrice/deck_list/tree/deck_list_card_node.cpp
libcockatrice/deck_list/tree/inner_deck_list_node.cpp
libcockatrice/deck_list/deck_diff.cpp
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp
libcockatrice/deck_list/deck_list_node_tree.cpp

View file

@ -0,0 +1,89 @@
#include "deck_diff.h"
#include "deck_list.h"
#include "tree/deck_list_card_node.h"
#include "tree/inner_deck_list_node.h"
#include <QMap>
#include <QPair>
#include <QSet>
#include <algorithm>
namespace
{
using ZoneCardKey = QPair<QString, QString>;
QMap<ZoneCardKey, int> zoneCardCounts(const DeckList &deck)
{
QMap<ZoneCardKey, int> counts;
deck.forEachCard([&counts](InnerDecklistNode *zone, DecklistCardNode *card) {
counts[{zone->getName(), card->getName()}] += card->getNumber();
});
return counts;
}
bool changeLessThan(const DeckCardChange &a, const DeckCardChange &b)
{
if (a.zone != b.zone) {
return a.zone < b.zone;
}
return a.cardName < b.cardName;
}
} // namespace
DeckDiffResult DeckDiff::diff(const DeckList &oldDeck, const DeckList &newDeck)
{
const QMap<ZoneCardKey, int> oldCounts = zoneCardCounts(oldDeck);
const QMap<ZoneCardKey, int> newCounts = zoneCardCounts(newDeck);
QSet<ZoneCardKey> keys(oldCounts.keyBegin(), oldCounts.keyEnd());
keys.unite(QSet<ZoneCardKey>(newCounts.keyBegin(), newCounts.keyEnd()));
DeckDiffResult result;
for (const ZoneCardKey &key : keys) {
const int oldCount = oldCounts.value(key, 0);
const int newCount = newCounts.value(key, 0);
if (oldCount != newCount) {
result.changes.append({key.first, key.second, oldCount, newCount});
}
}
std::sort(result.changes.begin(), result.changes.end(), changeLessThan);
return result;
}
bool DeckDiff::canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
{
QSet<ZoneCardKey> localKeys;
for (const DeckCardChange &change : localChanges.changes) {
localKeys.insert({change.zone, change.cardName});
}
for (const DeckCardChange &change : remoteChanges.changes) {
if (localKeys.contains({change.zone, change.cardName})) {
return false;
}
}
return true;
}
DeckList
DeckDiff::merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
{
QMap<ZoneCardKey, int> counts = zoneCardCounts(baseDeck);
for (const DeckCardChange &change : localChanges.changes) {
counts[{change.zone, change.cardName}] = change.newCount;
}
for (const DeckCardChange &change : remoteChanges.changes) {
counts[{change.zone, change.cardName}] = change.newCount;
}
DeckList merged(baseDeck.getMetadata(), DecklistNodeTree(), baseDeck.getSideboardPlans());
QList<ZoneCardKey> keys = counts.keys();
std::sort(keys.begin(), keys.end());
for (const ZoneCardKey &key : keys) {
const int count = qMax(0, counts.value(key));
for (int i = 0; i < count; ++i) {
merged.addCard(key.second, key.first);
}
}
return merged;
}

View file

@ -0,0 +1,71 @@
/**
* @file deck_diff.h
* @brief Card-level diff and three-way merge for deck collection sync.
*/
#ifndef DECK_DIFF_H
#define DECK_DIFF_H
#include <QList>
#include <QString>
class DeckList;
/** @brief A single card-count change within one zone. */
struct DeckCardChange
{
QString zone; ///< Zone name, e.g. "main".
QString cardName; ///< Card name.
int oldCount = 0; ///< Copies in the old deck.
int newCount = 0; ///< Copies in the new deck.
/** @brief Copies added (positive) or removed (negative). */
[[nodiscard]] int delta() const
{
return newCount - oldCount;
}
};
/** @brief Card-level difference between two decks, sorted by zone then card name. */
struct DeckDiffResult
{
QList<DeckCardChange> changes;
/** @brief True when both decks hold the same cards in the same zones. */
[[nodiscard]] bool isEmpty() const
{
return changes.isEmpty();
}
};
/**
* @class DeckDiff
* @brief Pure card-count diff and three-way merge for deck sync.
*
* Compares card names and copy counts per zone only; printing information
* (set, collector number, provider) is not tracked, so a merged deck keeps
* base printings with merged counts.
*/
class DeckDiff
{
public:
/** @brief Diffs oldDeck against newDeck. */
static DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck);
/**
* @brief True when both change sets touch disjoint (zone, card) keys and a merge needs no arbitration.
*/
static bool canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
/**
* @brief Applies both change sets onto a copy of baseDeck.
*
* @precondition localChanges and remoteChanges were both computed with
* diff() against baseDeck. Stale or foreign change sets silently
* resurrect or delete cards.
*
* Counts are clamped at zero. On overlapping keys the remote change wins;
* callers offer this result as "Use Merge" only when canAutoMerge is true.
* Metadata and sideboard plans come from baseDeck.
*/
static DeckList
merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
};
#endif // DECK_DIFF_H

View file

@ -16,6 +16,7 @@ set(PROTO_FILES
command_deck_del_dir.proto
command_deck_download.proto
command_deck_download_public.proto
command_deck_history_list.proto
command_deck_list.proto
command_deck_list_other_user.proto
command_deck_new_dir.proto
@ -26,6 +27,7 @@ set(PROTO_FILES
command_deck_share_list.proto
command_deck_share_list_mine.proto
command_deck_share_remove.proto
command_deck_sync_check.proto
command_deck_upload.proto
command_del_counter.proto
command_delete_arrow.proto
@ -144,11 +146,13 @@ set(PROTO_FILES
response_ban_history.proto
response_card_art_rule_entry.proto
response_deck_download.proto
response_deck_history_list.proto
response_deck_list.proto
response_deck_share_create.proto
response_deck_share_download.proto
response_deck_share_list.proto
response_deck_share_list_mine.proto
response_deck_sync_status.proto
response_deck_upload.proto
response_dump_zone.proto
response_forgotpasswordrequest.proto

View file

@ -0,0 +1,12 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckHistoryList {
extend SessionCommand {
optional Command_DeckHistoryList ext = 1035;
}
optional sint32 deck_id = 1 [default = -1];
// Maximum versions to return, newest first. Capped server-side by
// deck_sync/max_versions.
optional uint32 limit = 2 [default = 20];
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckSyncCheck {
extend SessionCommand {
optional Command_DeckSyncCheck ext = 1034;
}
// Seconds since Unix epoch of the client's last completed sync.
// 0 means "never synced": the server reports every stored row.
optional uint64 last_sync_time = 1 [default = 0];
}

View file

@ -14,4 +14,7 @@ message Command_DeckUpload {
reserved 5, 6, 8;
reserved "banner_card_name", "banner_card_provider", "tags";
optional string color_identity = 7;
// Stable per-deck identity for two-way collection sync. Sent on first
// synced upload, echoed back in ServerInfo_DeckStorage_File afterwards.
optional string guid = 9;
}

View file

@ -85,6 +85,8 @@ message Response {
DECK_SHARE_LIST = 1104; // Response listing shared decks
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares
DECK_SYNC_STATUS = 1107; // Response with changed decks since a timestamp
DECK_HISTORY_LIST = 1108; // Response listing deck versions
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
}

View file

@ -6,4 +6,9 @@ message Response_DeckDownload {
optional Response_DeckDownload ext = 1007;
}
optional string deck = 1;
// Sync identity echoed so the client can update its sidecar without an
// extra list fetch.
optional string guid = 2;
optional string hash = 3;
optional uint64 last_modified = 4;
}

View file

@ -0,0 +1,19 @@
syntax = "proto2";
import "response.proto";
message DeckVersionInfo {
optional uint32 version_no = 1;
optional uint64 created_at = 2;
optional string created_by_clientid = 3;
optional string hash = 4;
// Full deck content in native format, so the client can diff and
// restore without further round trips.
optional string content = 5;
}
message Response_DeckHistoryList {
extend Response {
optional Response_DeckHistoryList ext = 1108;
}
repeated DeckVersionInfo versions = 1;
}

View file

@ -0,0 +1,21 @@
syntax = "proto2";
import "response.proto";
message DeckSyncChangedFile {
// Server-relative folder path without file extension, e.g. "Modern/Affinity".
optional string path = 1;
optional uint32 deck_id = 2;
optional string guid = 3;
optional string hash = 4;
optional uint64 last_modified = 5;
optional bool deleted = 6 [default = false];
}
message Response_DeckSyncStatus {
extend Response {
optional Response_DeckSyncStatus ext = 1107;
}
repeated DeckSyncChangedFile changed_files = 1;
// Server clock at query time; the client adopts it as its sync baseline.
optional uint64 server_time = 2;
}

View file

@ -11,6 +11,15 @@ message ServerInfo_DeckStorage_File {
// Tag names associated with the deck. Empty for decks uploaded before the
// tags column existed.
repeated string tags = 6;
// Stable per-deck identity for two-way collection sync. Empty for decks
// uploaded before the sync migration until they are re-uploaded.
optional string guid = 7;
// Content hash (DeckList::getDeckHash) for change detection without
// downloading the full list.
optional string hash = 8;
// Last modification time, seconds since Unix epoch. Drives newest-wins
// default and conflict-dialog recency highlight.
optional uint64 last_modified = 9;
}
message ServerInfo_DeckStorage_Folder {

View file

@ -36,6 +36,8 @@ message SessionCommand {
DECK_DOWNLOAD_PUBLIC = 1031;
DECK_SHARE_LIST_MINE = 1032;
DECK_SHARE_REMOVE = 1033;
DECK_SYNC_CHECK = 1034;
DECK_HISTORY_LIST = 1035;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;

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
@ -45,6 +46,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,55 @@
#include "deck_sync_settings.h"
#include "libcockatrice/settings/settings_manager.h"
#include <QVariant>
#include <QtGlobal>
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);
}
bool DeckSyncSettings::getAutoSync() const
{
return getValue("autoSync", QString(), QString(), true).toBool();
}
void DeckSyncSettings::setAutoSync(bool _autoSync)
{
setValue(_autoSync, "autoSync");
emit autoSyncChanged(_autoSync);
}
int DeckSyncSettings::getSyncIntervalMinutes() const
{
return qBound(1, getValue("syncIntervalMinutes", QString(), QString(), 5).toInt(), 60);
}
void DeckSyncSettings::setSyncIntervalMinutes(int _syncIntervalMinutes)
{
setValue(_syncIntervalMinutes, "syncIntervalMinutes");
emit syncIntervalMinutesChanged(_syncIntervalMinutes);
}
int DeckSyncSettings::getHistoryLimit() const
{
return qBound(1, getValue("historyLimit", QString(), QString(), 20).toInt(), 100);
}
void DeckSyncSettings::setHistoryLimit(int _historyLimit)
{
setValue(_historyLimit, "historyLimit");
emit historyLimitChanged(_historyLimit);
}

View file

@ -0,0 +1,46 @@
#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:
explicit DeckSyncSettings(const QString &settingPath, QObject *parent = nullptr);
[[nodiscard]] int getConflictDefault() const;
void setConflictDefault(int _conflictDefault);
[[nodiscard]] bool getAutoSync() const;
void setAutoSync(bool _autoSync);
[[nodiscard]] int getSyncIntervalMinutes() const;
void setSyncIntervalMinutes(int _syncIntervalMinutes);
[[nodiscard]] int getHistoryLimit() const;
void setHistoryLimit(int _historyLimit);
signals:
void conflictDefaultChanged(int conflictDefault);
void autoSyncChanged(bool autoSync);
void syncIntervalMinutesChanged(int syncIntervalMinutes);
void historyLimitChanged(int historyLimit);
private:
DeckSyncSettings(const DeckSyncSettings & /*other*/);
};
#endif // DECK_SYNC_SETTINGS_H

View file

@ -0,0 +1,32 @@
-- Servatrice db migration from version 37 to version 38
-- Deck collection sync + versioning.
--
-- 1. Stable per-deck identity and change detection for two-way sync:
-- guid links a local file to its server row across renames, hash detects
-- content changes without downloading, last_modified drives newest-wins
-- default and conflict highlighting, deleted implements tombstones so a
-- delete on one device does not resurrect from the other.
-- 2. Limited per-deck version history: every hash-changing upload inserts a
-- row; the server prunes beyond the configured retention (default 20).
ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `guid` char(36) NULL AFTER `tags`,
ADD COLUMN `hash` char(40) NULL AFTER `guid`,
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`;
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`deck_id` int(7) unsigned zerofill NOT NULL,
`version_no` int(7) unsigned NOT NULL,
`content` text NOT NULL,
`hash` char(40) NULL,
`created_at` datetime NOT NULL,
`created_by_clientid` varchar(15) NULL,
PRIMARY KEY (`id`),
KEY `deck_id` (`deck_id`),
FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
UPDATE cockatrice_schema_version SET version=38 WHERE version=37;

View file

@ -479,3 +479,19 @@ max_decks_per_share=50
; A value of 0 disables the limit.
; Default: 50
max_shares_per_day=50
[deck_sync]
; Maximum number of stored versions kept per deck. Each upload with changed
; content adds a version; older versions beyond this limit are pruned.
; Minimum enforced: 1.
; Default: 20
max_versions=20
; How many days a deleted deck stays restorable as a tombstone before the
; cleanup clock purges it (with its versions) for good. Re-uploading the
; same deck within this window resurrects it instead of creating a copy.
; A value of 0 purges tombstones on the next cleanup run.
; Default: 30
tombstone_days=30

View file

@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
INSERT INTO cockatrice_schema_version VALUES(37);
INSERT INTO cockatrice_schema_version VALUES(38);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -71,11 +71,30 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` (
`banner_card_provider` varchar(32) NULL,
`color_identity` varchar(5) NULL,
`tags` text NULL,
`guid` char(36) NULL,
`hash` char(40) NULL,
`last_modified` datetime NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `FolderPlusUser` (`id_folder`,`id_user`),
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Per-deck version history for collection sync.
-- One row per hash-changing upload; pruned beyond retention (default 20).
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`deck_id` int(7) unsigned zerofill NOT NULL,
`version_no` int(7) unsigned NOT NULL,
`content` text NOT NULL,
`hash` char(40) NULL,
`created_at` datetime NOT NULL,
`created_by_clientid` varchar(15) NULL,
PRIMARY KEY (`id`),
KEY `deck_id` (`deck_id`),
FOREIGN KEY(`deck_id`) REFERENCES `cockatrice_decklist_files`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
-- Temporary deck shares: a named bundle of decks that can be fetched by
-- anyone who knows the (unguessable) token, until the share expires.
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (

View file

@ -460,6 +460,7 @@ bool Servatrice::initServer()
deckShareCleanupClock = new QTimer(this);
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(purgeDeckTombstones()));
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
if (deckShareCleanupInterval > 0) {
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
@ -689,6 +690,11 @@ void Servatrice::cleanupExpiredDeckShares()
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::purgeDeckTombstones()
{
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1122,6 +1128,16 @@ int Servatrice::getDeckShareMaxSharesPerDay() const
return settingsCache->value("deck_share/max_shares_per_day", 50).toInt();
}
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::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -157,6 +157,7 @@ private slots:
void statusUpdate();
void shutdownTimeout();
void cleanupExpiredDeckShares();
void purgeDeckTombstones();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -292,6 +293,8 @@ public:
int getDeckShareCleanupInterval() const;
int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const;
int getDeckSyncMaxVersions() const;
int getDeckSyncTombstoneDays() const;
int getMaxUsersPerAddress() const;
int getMessageCountingInterval() const override;
int getMaxMessageCountPerInterval() const override;

View file

@ -1088,8 +1088,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);
@ -1240,6 +1240,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

@ -21,7 +21,7 @@
class QSqlQuery;
#define DATABASE_SCHEMA_VERSION 37
#define DATABASE_SCHEMA_VERSION 38
class Servatrice;
@ -140,6 +140,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

@ -70,6 +70,7 @@
#include <QString>
#include <QStringLiteral>
#include <QTextStream>
#include <QUuid>
#include <QVariant>
#include <QtGlobal>
#include <algorithm>
@ -86,6 +87,7 @@
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download_public.pb.h>
#include <libcockatrice/protocol/pb/command_deck_history_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
@ -95,6 +97,7 @@
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list_mine.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_remove.pb.h>
#include <libcockatrice/protocol/pb/command_deck_sync_check.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
@ -124,11 +127,13 @@
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_history_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list_mine.pb.h>
#include <libcockatrice/protocol/pb/response_deck_sync_status.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
@ -352,6 +357,10 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdDeckUpload(cmd.GetExtension(Command_DeckUpload::ext), rc);
case SessionCommand::DECK_DOWNLOAD:
return cmdDeckDownload(cmd.GetExtension(Command_DeckDownload::ext), rc);
case SessionCommand::DECK_SYNC_CHECK:
return cmdDeckSyncCheck(cmd.GetExtension(Command_DeckSyncCheck::ext), rc);
case SessionCommand::DECK_HISTORY_LIST:
return cmdDeckHistoryList(cmd.GetExtension(Command_DeckHistoryList::ext), rc);
case SessionCommand::REPLAY_LIST:
return cmdReplayList(cmd.GetExtension(Command_ReplayList::ext), rc);
case SessionCommand::REPLAY_DOWNLOAD:
@ -681,8 +690,9 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
}
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags from {prefix}_decklist_files where "
"id_folder = :id_folder and id_user = :id_user");
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"{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)) {
@ -708,6 +718,12 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
for (const QString &tag : deserializeDeckTags(query->value(7).toString())) {
newFile->add_tags(tag.toStdString());
}
newFile->set_guid(query->value(8).toString().toStdString());
newFile->set_hash(query->value(9).toString().toStdString());
const QDateTime lastModified = query->value(10).toDateTime();
if (lastModified.isValid()) {
newFile->set_last_modified(lastModified.toSecsSinceEpoch());
}
}
return true;
@ -821,8 +837,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);
@ -860,6 +876,124 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
return Response::RespOk;
}
bool AbstractServerSocketInterface::syncCheckHelper(int folderId,
const QString &prefix,
int userId,
qint64 sinceSecs,
Response_DeckSyncStatus *status)
{
QSqlQuery *query = sqlInterface->prepareQuery("select id, name from {prefix}_decklist_folders where id_parent = "
":id_parent and id_user = :id_user");
query->bindValue(":id_parent", folderId);
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
QList<std::pair<int, QString>> subfolders;
while (query->next()) {
subfolders.append({query->value(0).toInt(), query->value(1).toString()});
}
for (const auto &[subfolderId, subfolderName] : subfolders) {
const QString path = prefix.isEmpty() ? subfolderName : prefix + "/" + subfolderName;
if (!syncCheckHelper(subfolderId, path, userId, sinceSecs, status)) {
return false;
}
}
query = sqlInterface->prepareQuery("select id, name, guid, hash, last_modified, deleted from "
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user "
"and (last_modified is null or last_modified > FROM_UNIXTIME(:since))");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userId);
query->bindValue(":since", sinceSecs);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
while (query->next()) {
const QString name = query->value(1).toString();
DeckSyncChangedFile *changed = status->add_changed_files();
changed->set_path((prefix.isEmpty() ? name : prefix + "/" + name).toStdString());
changed->set_deck_id(query->value(0).toUInt());
changed->set_guid(query->value(2).toString().toStdString());
changed->set_hash(query->value(3).toString().toStdString());
const QDateTime lastModified = query->value(4).toDateTime();
if (lastModified.isValid()) {
changed->set_last_modified(lastModified.toSecsSinceEpoch());
}
changed->set_deleted(query->value(5).toBool());
}
return true;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckSyncCheck(const Command_DeckSyncCheck &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
Response_DeckSyncStatus *re = new Response_DeckSyncStatus;
if (!syncCheckHelper(0, QString(), userInfo->id(), static_cast<qint64>(cmd.last_sync_time()), re)) {
delete re;
return Response::RespContextError;
}
QSqlQuery *clockQuery = sqlInterface->prepareQuery("select unix_timestamp(now())");
if (!sqlInterface->execSqlQuery(clockQuery) || !clockQuery->next()) {
delete re;
return Response::RespContextError;
}
re->set_server_time(clockQuery->value(0).toULongLong());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckHistoryList(const Command_DeckHistoryList &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
QSqlQuery *ownerQuery =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
ownerQuery->bindValue(":id", cmd.deck_id());
ownerQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(ownerQuery)) {
return Response::RespContextError;
}
if (!ownerQuery->next()) {
return Response::RespNameNotFound;
}
const int limit = qMin(cmd.has_limit() ? static_cast<int>(cmd.limit()) : 20, servatrice->getDeckSyncMaxVersions());
QSqlQuery *query =
sqlInterface->prepareQuery("select version_no, unix_timestamp(created_at), created_by_clientid, hash, "
"content from {prefix}_deck_versions where deck_id = :deck_id order by version_no "
"desc limit :limit");
query->bindValue(":deck_id", cmd.deck_id());
query->bindValue(":limit", qMax(1, limit));
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
Response_DeckHistoryList *re = new Response_DeckHistoryList;
while (query->next()) {
DeckVersionInfo *version = re->add_versions();
version->set_version_no(query->value(0).toUInt());
version->set_created_at(query->value(1).toULongLong());
version->set_created_by_clientid(query->value(2).toString().toStdString());
version->set_hash(query->value(3).toString().toStdString());
version->set_content(query->value(4).toString().toStdString());
}
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd,
ResponseContainer &rc)
{
@ -932,7 +1066,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);
@ -983,18 +1118,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;
}
@ -1014,6 +1148,38 @@ QString sanitizeColorIdentity(const QString &colorIdentity)
}
} // namespace
bool AbstractServerSocketInterface::storeDeckVersion(int deckId, const QString &content, const QString &hash)
{
QSqlQuery *maxQuery =
sqlInterface->prepareQuery("select coalesce(max(version_no), 0) from {prefix}_deck_versions where deck_id = "
":deck_id");
maxQuery->bindValue(":deck_id", deckId);
if (!sqlInterface->execSqlQuery(maxQuery) || !maxQuery->next()) {
return false;
}
const int nextVersion = maxQuery->value(0).toInt() + 1;
QSqlQuery *insertQuery =
sqlInterface->prepareQuery("insert into {prefix}_deck_versions (deck_id, version_no, content, hash, "
"created_at, created_by_clientid) values(:deck_id, :version_no, :content, :hash, "
"NOW(), :created_by_clientid)");
insertQuery->bindValue(":deck_id", deckId);
insertQuery->bindValue(":version_no", nextVersion);
insertQuery->bindValue(":content", content);
insertQuery->bindValue(":hash", hash);
insertQuery->bindValue(":created_by_clientid", QString::fromStdString(userInfo->clientid()));
if (!sqlInterface->execSqlQuery(insertQuery)) {
return false;
}
QSqlQuery *pruneQuery = sqlInterface->prepareQuery("delete from {prefix}_deck_versions where deck_id = :deck_id "
"and version_no <= :min_kept_version");
pruneQuery->bindValue(":deck_id", deckId);
pruneQuery->bindValue(":min_kept_version", nextVersion - servatrice->getDeckSyncMaxVersions());
sqlInterface->execSqlQuery(pruneQuery);
return true;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd,
ResponseContainer &rc)
{
@ -1049,6 +1215,11 @@ 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();
QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString();
if (QUuid::fromString(guid).isNull()) {
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
}
if (cmd.has_path()) {
int folderId = getDeckPathId(nameFromStdString(cmd.path()));
@ -1059,8 +1230,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
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) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, "
":banner_card_name, :banner_card_provider, :color_identity, :tags)");
"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);
@ -1070,21 +1242,65 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
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;
}
const int deckId = query->lastInsertId().toInt();
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
return Response::RespContextError;
}
QSqlQuery *stampQuery =
sqlInterface->prepareQuery("select unix_timestamp(upload_time), unix_timestamp(last_modified) from "
"{prefix}_decklist_files where id = :id and id_user = :id_user");
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);
return Response::RespContextError;
}
Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(query->lastInsertId().toInt());
fileInfo->set_id(deckId);
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_creation_time(stampQuery->value(0).toUInt());
fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public());
fileInfo->mutable_file()->set_guid(guid.toStdString());
fileInfo->mutable_file()->set_hash(deckHash.toStdString());
fileInfo->mutable_file()->set_last_modified(stampQuery->value(1).toULongLong());
rc.setResponseExtension(re);
} else if (cmd.has_deck_id()) {
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";
QSqlQuery *currentQuery =
sqlInterface->prepareQuery("select guid, hash from {prefix}_decklist_files where id = :id and id_user = "
":id_user");
currentQuery->bindValue(":id", cmd.deck_id());
currentQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(currentQuery)) {
return Response::RespContextError;
}
if (!currentQuery->next()) {
return Response::RespNameNotFound;
}
if (!QUuid::fromString(currentQuery->value(0).toString()).isNull()) {
guid = currentQuery->value(0).toString();
} else if (cmd.has_guid() && !QUuid::fromString(nameFromStdString(cmd.guid()).left(36)).isNull()) {
guid = nameFromStdString(cmd.guid()).left(36);
}
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(), "
"deleted=0";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
@ -1099,33 +1315,56 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (cmd.has_is_public()) {
query->bindValue(":is_public", cmd.is_public() ? 1 : 0);
}
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
if (oldHash != deckHash) {
if (!storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) {
return Response::RespContextError;
}
} else {
// Self-heal a version gap left by an earlier failed version write:
// a same-content re-upload must still leave a version behind.
QSqlQuery *versionQuery =
sqlInterface->prepareQuery("select count(*) from {prefix}_deck_versions where deck_id = :deck_id and "
"hash = :hash");
versionQuery->bindValue(":deck_id", cmd.deck_id());
versionQuery->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(versionQuery) || !versionQuery->next()) {
return Response::RespContextError;
}
if (versionQuery->value(0).toInt() == 0 && !storeDeckVersion(cmd.deck_id(), deckStr, deckHash)) {
return Response::RespContextError;
}
}
QSqlQuery *visibilityQuery =
sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and "
"id_user = :id_user");
sqlInterface->prepareQuery("select is_public, unix_timestamp(upload_time), unix_timestamp(last_modified) "
"from {prefix}_decklist_files where id = :id and id_user = :id_user");
visibilityQuery->bindValue(":id", cmd.deck_id());
visibilityQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(visibilityQuery)) {
if (!sqlInterface->execSqlQuery(visibilityQuery) || !visibilityQuery->next()) {
return Response::RespContextError;
}
const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool();
Response_DeckUpload *re = new Response_DeckUpload;
ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file();
fileInfo->set_id(cmd.deck_id());
fileInfo->set_name(deckName.toStdString());
fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch());
fileInfo->mutable_file()->set_is_public(isPublic);
fileInfo->mutable_file()->set_creation_time(visibilityQuery->value(1).toUInt());
fileInfo->mutable_file()->set_is_public(visibilityQuery->value(0).toBool());
fileInfo->mutable_file()->set_guid(guid.toStdString());
fileInfo->mutable_file()->set_hash(deckHash.toStdString());
fileInfo->mutable_file()->set_last_modified(visibilityQuery->value(2).toULongLong());
rc.setResponseExtension(re);
} else {
return Response::RespInvalidData;
@ -1147,11 +1386,29 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm
} catch (Response::ResponseCode &r) {
return r;
}
const QString deckNative = deck->writeToString_Native();
delete deck;
QSqlQuery *syncQuery =
sqlInterface->prepareQuery("select guid, hash, last_modified from {prefix}_decklist_files where id = :id and "
"id_user = :id_user");
syncQuery->bindValue(":id", cmd.deck_id());
syncQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(syncQuery)) {
return Response::RespContextError;
}
Response_DeckDownload *re = new Response_DeckDownload;
re->set_deck(deck->writeToString_Native().toStdString());
re->set_deck(deckNative.toStdString());
if (syncQuery->next()) {
re->set_guid(syncQuery->value(0).toString().toStdString());
re->set_hash(syncQuery->value(1).toString().toStdString());
const QDateTime lastModified = syncQuery->value(2).toDateTime();
if (lastModified.isValid()) {
re->set_last_modified(lastModified.toSecsSinceEpoch());
}
}
rc.setResponseExtension(re);
delete deck;
return Response::RespOk;
}
@ -1245,7 +1502,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

@ -56,6 +56,8 @@ class Command_DeckShareList;
class Command_DeckShareListMine;
class Command_DeckShareRemove;
class Command_DeckShareDownload;
class Command_DeckHistoryList;
class Command_DeckSyncCheck;
class Command_ReplayList;
class Command_ReplayDownload;
class Command_ReplayModifyMatch;
@ -107,6 +109,8 @@ class Command_ViewLogHistory;
class Command_WarnUser;
class QObject;
class ResponseContainer;
class Response_DeckHistoryList;
class Response_DeckSyncStatus;
class AbstractServerSocketInterface : public Server_ProtocolHandler
{
@ -154,8 +158,13 @@ private:
Response::ResponseCode cmdDeckDelDir(const Command_DeckDelDir &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDel(const Command_DeckDel &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc);
bool storeDeckVersion(int deckId, const QString &content, const QString &hash);
DeckList *getDeckFromDatabase(int deckId);
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
bool
syncCheckHelper(int folderId, const QString &prefix, int userId, qint64 sinceSecs, Response_DeckSyncStatus *status);
Response::ResponseCode cmdDeckSyncCheck(const Command_DeckSyncCheck &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckHistoryList(const Command_DeckHistoryList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc);

View file

@ -27,6 +27,9 @@ add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test)
add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test)
set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15)
add_test(NAME deck_diff_test COMMAND deck_diff_test)
add_test(NAME deck_sync_profile_test COMMAND deck_sync_profile_test)
add_test(NAME deck_sync_sidecar_test COMMAND deck_sync_sidecar_test)
# Find GTest
@ -37,6 +40,17 @@ add_executable(test_age_formatting test_age_formatting.cpp)
add_executable(password_hash_test password_hash_test.cpp)
add_executable(playmat_resolver_test playmat_resolver_test.cpp)
add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
add_executable(deck_diff_test deck_diff_test.cpp)
add_executable(
deck_sync_profile_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
deck_sync_profile_test.cpp
)
target_include_directories(deck_sync_profile_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync)
add_executable(
deck_sync_sidecar_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
deck_sync_sidecar_test.cpp
)
target_include_directories(deck_sync_sidecar_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync)
add_executable(server_card_counter_test server_card_counter_test.cpp)
add_executable(server_counter_test server_counter_test.cpp)
add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
@ -137,6 +151,9 @@ if(NOT GTEST_FOUND)
add_dependencies(password_hash_test gtest)
add_dependencies(playmat_resolver_test gtest)
add_dependencies(deck_hash_performance_test gtest)
add_dependencies(deck_diff_test gtest)
add_dependencies(deck_sync_profile_test gtest)
add_dependencies(deck_sync_sidecar_test gtest)
add_dependencies(server_card_counter_test gtest)
add_dependencies(server_counter_test gtest)
add_dependencies(server_rate_limiter_test gtest)
@ -175,6 +192,12 @@ target_link_libraries(
deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(
deck_diff_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(deck_sync_profile_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(deck_sync_sidecar_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(
server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)

137
tests/deck_diff_test.cpp Normal file
View file

@ -0,0 +1,137 @@
#include "gtest/gtest.h"
#include <QList>
#include <QString>
#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
{
DeckList makeDeck(std::initializer_list<std::pair<const char *, int>> mainCards,
std::initializer_list<std::pair<const char *, int>> sideCards = {})
{
DeckList deck;
for (const auto &[name, count] : mainCards) {
for (int i = 0; i < count; ++i) {
deck.addCard(name, "main");
}
}
for (const auto &[name, count] : sideCards) {
for (int i = 0; i < count; ++i) {
deck.addCard(name, "side");
}
}
return deck;
}
int countOf(const DeckList &deck, const char *zone, const char *name)
{
int count = 0;
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
if (zoneNode->getName() == zone && card->getName() == name) {
count += card->getNumber();
}
});
return count;
}
} // namespace
TEST(DeckDiffTest, IdenticalDecksHaveEmptyDiff)
{
DeckList first = makeDeck({{"Lightning Bolt", 4}});
DeckList second = makeDeck({{"Lightning Bolt", 4}});
EXPECT_TRUE(DeckDiff::diff(first, second).isEmpty());
}
TEST(DeckDiffTest, ChangesAreSortedByZoneThenName)
{
DeckList oldDeck = makeDeck({{"Mountain", 20}, {"Lightning Bolt", 4}}, {{"Shock", 2}});
DeckList newDeck = makeDeck({{"Mountain", 19}, {"Shock", 5}}, {{"Shock", 1}, {"Teferi", 1}});
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
ASSERT_EQ(result.changes.size(), 5);
EXPECT_EQ(result.changes.at(0).zone, "main");
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
EXPECT_EQ(result.changes.at(1).cardName, "Mountain");
EXPECT_EQ(result.changes.at(2).cardName, "Shock");
EXPECT_EQ(result.changes.at(3).zone, "side");
EXPECT_EQ(result.changes.at(3).cardName, "Shock");
EXPECT_EQ(result.changes.at(4).cardName, "Teferi");
}
TEST(DeckDiffTest, ReportsAddedAndRemovedCopies)
{
DeckList oldDeck = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
DeckList newDeck = makeDeck({{"Lightning Bolt", 2}, {"Shock", 4}, {"Mountain", 20}});
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
ASSERT_EQ(result.changes.size(), 2);
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
EXPECT_EQ(result.changes.at(0).oldCount, 4);
EXPECT_EQ(result.changes.at(0).newCount, 2);
EXPECT_EQ(result.changes.at(0).delta(), -2);
EXPECT_EQ(result.changes.at(1).cardName, "Shock");
EXPECT_EQ(result.changes.at(1).oldCount, 0);
EXPECT_EQ(result.changes.at(1).newCount, 4);
}
TEST(DeckDiffTest, ZoneMoveIsTwoChanges)
{
DeckList oldDeck = makeDeck({{"Teferi", 1}});
DeckList newDeck = makeDeck({}, {{"Teferi", 1}});
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
ASSERT_EQ(result.changes.size(), 2);
EXPECT_EQ(result.changes.at(0).zone, "main");
EXPECT_EQ(result.changes.at(0).newCount, 0);
EXPECT_EQ(result.changes.at(1).zone, "side");
EXPECT_EQ(result.changes.at(1).oldCount, 0);
}
TEST(DeckDiffTest, DisjointChangesCanAutoMerge)
{
DeckList base = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
DeckList local = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 18}});
DeckList remote = makeDeck({{"Shock", 2}, {"Lightning Bolt", 4}, {"Mountain", 20}});
DeckDiffResult localChanges = DeckDiff::diff(base, local);
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
EXPECT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
EXPECT_EQ(countOf(merged, "main", "Mountain"), 18);
EXPECT_EQ(countOf(merged, "main", "Shock"), 2);
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 4);
}
TEST(DeckDiffTest, OverlappingChangesCannotAutoMergeAndRemoteWins)
{
DeckList base = makeDeck({{"Lightning Bolt", 4}});
DeckList local = makeDeck({{"Lightning Bolt", 3}});
DeckList remote = makeDeck({{"Lightning Bolt", 1}});
DeckDiffResult localChanges = DeckDiff::diff(base, local);
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
EXPECT_FALSE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 1);
}
TEST(DeckDiffTest, MergeKeepsBaseMetadata)
{
DeckList base = makeDeck({{"Lightning Bolt", 4}});
base.setName("Affinity v3");
DeckList remote = makeDeck({{"Lightning Bolt", 4}, {"Shock", 1}});
DeckList merged = DeckDiff::merge(base, DeckDiff::diff(base, base), DeckDiff::diff(base, remote));
EXPECT_EQ(merged.getName(), "Affinity v3");
EXPECT_EQ(countOf(merged, "main", "Shock"), 1);
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,63 @@
#include "gtest/gtest.h"
#include <QString>
#include <deck_sync_profile.h>
TEST(DeckSyncProfileTest, KeyIsStable)
{
DeckSyncProfile first("woogerworks.com", 4747, "Skog7");
DeckSyncProfile second("woogerworks.com", 4747, "Skog7");
EXPECT_EQ(first.key(), second.key());
EXPECT_FALSE(first.key().isEmpty());
}
TEST(DeckSyncProfileTest, KeyIgnoresCase)
{
DeckSyncProfile lower("woogerworks.com", 4747, "skog7");
DeckSyncProfile upper("WOOGERWORKS.COM", 4747, "SKOG7");
EXPECT_EQ(lower.key(), upper.key());
}
TEST(DeckSyncProfileTest, KeyDiffersPerServerAndUser)
{
DeckSyncProfile base("woogerworks.com", 4747, "skog7");
EXPECT_NE(base.key(), DeckSyncProfile("other.server", 4747, "skog7").key());
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4748, "skog7").key());
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4747, "other").key());
}
TEST(DeckSyncProfileTest, KeyIsUnambiguousAcrossSeparators)
{
DeckSyncProfile left("a:1", 2, "b");
DeckSyncProfile right("a", 1, "2:b");
EXPECT_NE(left.key(), right.key());
EXPECT_NE(DeckSyncProfile("::1", 4747, "skog7").key(), DeckSyncProfile("::2", 4747, "skog7").key());
}
TEST(DeckSyncProfileTest, InvalidProfilesHaveNoKey)
{
EXPECT_TRUE(DeckSyncProfile().key().isEmpty());
EXPECT_TRUE(DeckSyncProfile("", 4747, "skog7").key().isEmpty());
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 0, "skog7").key().isEmpty());
}
TEST(DeckSyncProfileTest, KeyIgnoresSurroundingWhitespace)
{
DeckSyncProfile plain("woogerworks.com", 4747, "skog7");
DeckSyncProfile padded(" woogerworks.com ", 4747, " skog7 ");
EXPECT_EQ(plain.key(), padded.key());
}
TEST(DeckSyncProfileTest, ValidityRequiresAllFields)
{
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 4747, "skog7").isValid());
EXPECT_FALSE(DeckSyncProfile("", 4747, "skog7").isValid());
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 0, "skog7").isValid());
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 4747, "").isValid());
EXPECT_FALSE(DeckSyncProfile().isValid());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,149 @@
#include "gtest/gtest.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QString>
#include <QTemporaryDir>
#include <deck_sync_sidecar.h>
namespace
{
QString sidecarPath(QTemporaryDir &dir)
{
return dir.filePath(".sync/profilekey.json");
}
} // namespace
TEST(DeckSyncSidecarTest, MissingFileLoadsEmpty)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_TRUE(sidecar.load());
EXPECT_TRUE(sidecar.records().isEmpty());
EXPECT_FALSE(sidecar.contains("affinity.cod"));
}
TEST(DeckSyncSidecarTest, RoundTripsRecords)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
DeckSyncRecord record;
record.guid = "123e4567-e89b-12d3-a456-426614174000";
record.remoteId = 42;
record.baseHash = "5cac19qm";
record.lastSyncSecs = 1791739200;
record.deleted = true;
sidecar.set("decks/affinity.cod", record);
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
EXPECT_TRUE(reloaded.contains("decks/affinity.cod"));
DeckSyncRecord back = reloaded.get("decks/affinity.cod");
EXPECT_EQ(back.guid, record.guid);
EXPECT_EQ(back.remoteId, 42);
EXPECT_EQ(back.baseHash, "5cac19qm");
EXPECT_EQ(back.lastSyncSecs, 1791739200);
EXPECT_TRUE(back.deleted);
}
TEST(DeckSyncSidecarTest, UnknownPathReturnsDefaultRecord)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
DeckSyncRecord back = sidecar.get("missing.cod");
EXPECT_EQ(back.remoteId, -1);
EXPECT_TRUE(back.guid.isEmpty());
}
namespace
{
bool writeRaw(const QString &path, const QByteArray &bytes)
{
QDir().mkpath(QFileInfo(path).absolutePath());
QFile file(path);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size();
}
} // namespace
TEST(DeckSyncSidecarTest, GarbageFileFailsToLoad)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(writeRaw(sidecarPath(dir), "not json at all"));
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_FALSE(sidecar.load());
}
TEST(DeckSyncSidecarTest, WrongRecordsShapeFailsToLoad)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":["a.cod"]})"));
DeckSyncSidecar missing(sidecarPath(dir));
EXPECT_FALSE(missing.load());
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"foo":1})"));
DeckSyncSidecar other(sidecarPath(dir));
EXPECT_FALSE(other.load());
}
TEST(DeckSyncSidecarTest, NonObjectEntriesAreSkipped)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":{"a.cod":42,"b.cod":{"guid":"g"}}})"));
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_TRUE(sidecar.load());
EXPECT_FALSE(sidecar.contains("a.cod"));
EXPECT_TRUE(sidecar.contains("b.cod"));
EXPECT_EQ(sidecar.get("b.cod").guid, "g");
}
TEST(DeckSyncSidecarTest, RemoveDropsRecord)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
sidecar.set("a.cod", DeckSyncRecord());
sidecar.remove("a.cod");
EXPECT_FALSE(sidecar.contains("a.cod"));
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
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);
EXPECT_EQ(sidecar.lastSyncSecs, 0);
sidecar.mode = DeckSyncSidecar::MODE_SEPARATE;
sidecar.lastSyncSecs = 1791739200;
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
EXPECT_EQ(reloaded.mode, DeckSyncSidecar::MODE_SEPARATE);
EXPECT_EQ(reloaded.lastSyncSecs, 1791739200);
}
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);
return RUN_ALL_TESTS();
}

View file

@ -11,6 +11,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>
@ -531,6 +532,64 @@ 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);
}
TEST_F(SettingsDefaultsTest, DeckSync_AutoSync_Default)
{
DeckSyncSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getAutoSync(), true);
}
TEST_F(SettingsDefaultsTest, DeckSync_AutoSync_SetAndGet)
{
DeckSyncSettings s(settingsPath, nullptr);
s.setAutoSync(false);
ASSERT_EQ(s.getAutoSync(), false);
s.setAutoSync(true);
ASSERT_EQ(s.getAutoSync(), true);
}
TEST_F(SettingsDefaultsTest, DeckSync_SyncIntervalMinutes_DefaultAndBounds)
{
DeckSyncSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getSyncIntervalMinutes(), 5);
s.setSyncIntervalMinutes(15);
ASSERT_EQ(s.getSyncIntervalMinutes(), 15);
s.setSyncIntervalMinutes(0);
ASSERT_EQ(s.getSyncIntervalMinutes(), 1);
s.setSyncIntervalMinutes(600);
ASSERT_EQ(s.getSyncIntervalMinutes(), 60);
}
TEST_F(SettingsDefaultsTest, DeckSync_HistoryLimit_DefaultAndBounds)
{
DeckSyncSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getHistoryLimit(), 20);
s.setHistoryLimit(50);
ASSERT_EQ(s.getHistoryLimit(), 50);
s.setHistoryLimit(0);
ASSERT_EQ(s.getHistoryLimit(), 1);
s.setHistoryLimit(500);
ASSERT_EQ(s.getHistoryLimit(), 100);
}
// --- NetworkSettings ---
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)