Compare commits

...

23 commits

Author SHA1 Message Date
Lukas Brübach
3cec286168 [Client+Server] Deck version history with restore 2026-11-05 12:51:21 +01:00
Lukas Brübach
6d67b76fa3 [DeckSync] Address auto-sync review: disconnect, backfill and modal conflicts
- Fail the run when the connection drops while a sync is running, and clear
  the active conflict, so the queue is not sent on a dead client and a
  re-login as another account never receives this profile's uploads.
- Backfill last_modified from upload_time in the 37 -> 38 migration and drop
  the "last_modified is null" arm from the sync-check query, so rows that
  predate the column stop reporting as changed on every poll.
- Skip conflicts during auto runs and report how many were skipped, instead
  of opening the modal conflict dialog from a timer or a file-watcher event.
2026-11-05 12:51:12 +01:00
Lukas Brübach
00cb839a30 Fix ZeldaZach review for PR 7496: deck path in ctor, server-time baseline, single-query sync check 2026-11-05 12:51:12 +01:00
Lukas Brübach
04456b7ad3 [Client+Server] Auto deck sync with change polling 2026-11-05 12:51:12 +01:00
Lukas Brübach
ae4d40e1eb Fix remaining review threads on the conflict classification
- Remove the duplicated download loop and the stray '<' plus extra brace
  that left the head unbuildable.
- Skip the fallback upload under DownloadMode so a local-only deck is not
  pushed when the user asked for downloads only.
- Treat a server row without a hash as unknown rather than changed, so
  pre-migration decks are not re-downloaded on every sync.
2026-11-05 12:51:09 +01:00
Lukas Brübach
cc68893d23 Fix ZeldaZach review for PR 7495: three-way baseHash check before conflict 2026-11-05 12:51:09 +01:00
Lukas Brübach
ed3f21a155 [Client] Sync conflict dialog, onboarding and conflict default 2026-11-05 12:51:09 +01:00
Lukas Brübach
a94db4171b [DeckSync] Address tombstone review: remote deletes, revival and purge clock
- Treat a record whose server row is gone as a remote delete only when the
  local file still matches the sidecar's base hash. An edit made after the
  remote delete is newer than the delete, so it uploads and revives the row
  instead of being removed.
- Revive a tombstone with the same user and guid in cmdDeckUpload instead of
  inserting a second row next to it, so the deck keeps its id and version
  history and the promise in servatrice.ini.example holds.
- Run tombstone purging on its own clock, driven by the new
  [deck_sync] purge_interval option, so disabling share cleanup no longer
  disables purging silently.
- Write the base snapshot through QSaveFile and log a failed commit, so a
  crash or a full disk cannot leave a truncated base for DeckDiff::merge().
2026-11-05 12:51:05 +01:00
Lukas Brübach
f8cd3126f9 Fix ZeldaZach review for PR 7494: match by remoteId/guid before remote-delete, only tombstone when file truly missing 2026-11-05 12:51:05 +01:00
Lukas Brübach
4e5c4403b2 [Server+Client] Tombstone deck deletes with purge and base snapshots 2026-11-05 12:51:05 +01:00
Lukas Brübach
34e22df5ba [DeckSync] Match server rows by identity and hash the whole deck
Sync keyed local files to server rows by path, but the server names a row
after the deck, not after the file, so any deck whose file name and deck name
differ uploaded again on every run. A second pass then brought the server copy
back as an extra local file.

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

Adds deck_content_hash_test for the client/server hashing invariant and for the
name-only change the card hash cannot see.
2026-11-05 12:51:01 +01:00
Lukas Brübach
aa0ab333f6 [DeckSync] Pass the sync summary through the signal and tidy the manager API
- Emit SyncSummary from syncFinished() instead of four positional ints, and
  build it from a single currentSummary() helper, so the counts cannot drift
  between the status message and the signal.
- Use a designated initializer for LocalFile, like the other records.
- Drop setClient() and setColorIdentityProvider(): both fields are already
  constructor parameters and the setters had no callers. setProfile() and
  setDeckPath() stay, since the tab re-reads them before every run.
- Fix the include paths in tab_deck_storage.h and the narrowing conversion of
  the tree item id, so the branch builds again.
2026-11-05 12:51:01 +01:00
BruebachL
8367deefaf [Format] Fix include order in tab_deck_storage.h 2026-11-05 12:51:01 +01:00
BruebachL
8846a6f5ca [DeckSync] Use designated initializer for ServerFile per review 2026-11-05 12:51:01 +01:00
BruebachL
5b51fc8810 [DeckSync] Prefer ctor params for set-once client/provider per review 2026-11-05 12:51:01 +01:00
BruebachL
aa5cadd4eb [DeckSync] Move SyncSummary into DeckSyncManager per review 2026-11-05 12:51:01 +01:00
Lukas Brübach
c0aa30f75f Fix ZeldaZach review for PR 7493: match uploads by sidecar remoteId/guid, validate server paths 2026-11-05 12:51:01 +01:00
Lukas Brübach
9c5ddc889c [Client] Manual deck collection sync with Sync Now action 2026-11-05 12:51:01 +01:00
Lukas Brübach
0c6d64c565 Cover merge printing preservation with a test
Base deck with 4 Mountain (DOM 250), local adds 2 Shock (M19 156), remote
adds an unrelated card. Asserts the merge keeps one Mountain node with its
printing and adds a single Shock node carrying M19/156.
2026-11-05 12:50:48 +01:00
BruebachL
507a80f0b2 [DeckDiff] Use namespace instead of static-only class per review 2026-11-05 12:50:48 +01:00
Lukas Brübach
02ccad2374 Fixup: clang-format alignment in deck_diff.h 2026-11-05 12:50:48 +01:00
Lukas Brübach
473b255959 Fix ZeldaZach review for PR 7492: preserve printing in DeckDiff::merge, atomic sidecar save with QSaveFile 2026-11-05 12:50:48 +01:00
Lukas Brübach
1e97ab086b [Client] DeckSyncProfile + sidecar + DeckDiff with tests 2026-11-05 12:50:48 +01:00
51 changed files with 3943 additions and 44 deletions

View file

@ -26,10 +26,14 @@ set(cockatrice_SOURCES
src/client/settings/shortcuts_settings.cpp
src/interface/deck_loader/card_node_function.cpp
src/interface/deck_loader/deck_loader.cpp
src/interface/deck_sync/deck_sync_profile.cpp
src/interface/deck_sync/deck_sync_sidecar.cpp
src/interface/deck_sync/deck_sync_manager.cpp
src/interface/widgets/dialogs/dlg_connect.cpp
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
src/interface/widgets/dialogs/dlg_create_game.cpp
src/interface/widgets/dialogs/dlg_crash_report.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>
@ -159,6 +160,7 @@ SettingsCache::SettingsCache()
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
interfaceSettings = new InterfaceSettings(settingsPath, this);
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
deckSyncSettings = new DeckSyncSettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
appearanceSettings = new AppearanceSettings(settingsPath, this);
@ -460,6 +462,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const
return *deckEditorSettings;
}
DeckSyncSettings &SettingsCache::deckSync() const
{
return *deckSyncSettings;
}
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
{
return *visualDeckStorageSettings;

View file

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

View file

@ -0,0 +1,50 @@
/**
* @file deck_sync_conflict.h
* @brief Conflict payload between the sync engine and the conflict dialog.
*/
#ifndef DECK_SYNC_CONFLICT_H
#define DECK_SYNC_CONFLICT_H
#include <QString>
/** @brief How a both-sides-changed deck is resolved. */
enum DeckSyncResolutionAction
{
DeckSyncKeepMine, ///< Upload the local deck, discarding the server version.
DeckSyncKeepServer, ///< Download the server deck, discarding local changes.
DeckSyncKeepBoth, ///< Keep the local deck and store the server version aside.
DeckSyncUseMerge, ///< Upload the auto-merged deck (only when mergeable).
DeckSyncSkipConflict, ///< Leave both sides untouched for this run.
};
/** @brief Sync direction for one run. */
enum class DeckSyncMode
{
TwoWay, ///< Mirror both sides (default).
UploadOnly, ///< Push local content; never download or propagate deletes.
DownloadOnly, ///< Take server content; never upload or push deletes.
Separate, ///< First-sync verdict only: keep this profile out of sync.
};
/** @brief One deck that changed both locally and on the server. */
struct DeckSyncConflict
{
QString relativePath; ///< Local deck path relative to the deck folder.
QString localAbsolutePath; ///< Absolute local file path.
QString serverDeckString; ///< Server deck content in native format.
QString baseDeckString; ///< Last-synced content, empty when no snapshot exists.
bool hasBase = false; ///< False when the base snapshot is missing.
qint64 localSecs = 0; ///< Local modification time, seconds since epoch.
qint64 serverSecs = 0; ///< Server modification time, seconds since epoch.
};
/** @brief The dialog verdict for one DeckSyncConflict. */
struct DeckSyncResolution
{
DeckSyncResolutionAction action = DeckSyncKeepMine;
bool rememberSession = false; ///< Reuse this verdict for later conflicts this run.
bool rememberAlways = false; ///< Store this verdict as the conflict default setting.
};
#endif // DECK_SYNC_CONFLICT_H

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,248 @@
/**
* @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). A local file is matched to its server row by the
* sidecar's row id, then guid, then the server path, and finally by content, so
* renaming either side does not duplicate a deck. Before a download overwrites a
* locally modified file, the local content is copied to a .sync-backup file next
* to it.
*
* Ownership: fire-and-forget per run, driven by PendingCommand round trips.
* Both-sides-changed decks pause the queue for a verdict via conflictRequest /
* resolveConflict (or the conflict default setting); the first sync of an
* unknown profile pauses for an onboarding verdict via firstSyncDetected /
* continueFirstSync. No watcher and no auto triggers — those arrive later.
*
* Deletes are tombstones, never silent: a locally deleted deck is pushed with
* Command_DeckDel, a server-side delete is applied locally only after copying
* the file to a .sync-backup, and resurrection reuses the stored deck id.
*/
class DeckSyncManager : public QObject
{
Q_OBJECT
public:
/** @brief Computes the color identity string uploaded alongside a deck. May be empty. */
using ColorIdentityProvider = std::function<QString(const DeckList &)>;
/** @brief Outcome counts for a finished sync run, carried by syncFinished(). */
struct SyncSummary
{
int uploaded = 0;
int downloaded = 0;
int unchanged = 0;
int skipped = 0;
};
explicit DeckSyncManager(QObject *parent = nullptr);
DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr);
void setServerIdentity(const QString &host, int port, const QString &username);
void setDeckPath(const QString &path);
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(const SyncSummary &summary, const QString &error);
/** @brief First sync with content on both sides: the tab must ask how to proceed. */
void firstSyncDetected(int localCount, int serverCount);
/** @brief A both-sides-changed deck needs a verdict; the queue pauses until resolveConflict. */
void conflictRequest(const DeckSyncConflict &conflict);
private slots:
void onListFinished(const Response &response, const CommandContainer &commandContainer);
void 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 path; ///< Server-side address: folder prefix plus row name.
QString dir; ///< Folder prefix, empty in the root.
QString name; ///< Row name exactly as the server stores it.
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct LocalFile
{
QString absolutePath;
QString hash;
QDateTime modified;
};
struct UploadOp
{
QString relativePath;
QString serverPath; ///< Target address of a brand-new row.
QString serverDir; ///< Folder that must exist before the upload.
int deckId = -1;
QString guid;
};
struct DownloadOp
{
QString relativePath;
int deckId = -1;
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
struct ActiveConflict
{
bool active = false;
QString relativePath;
QString serverPath;
int deckId = -1;
QString serverGuid;
QString serverHash;
qint64 serverLastModifiedSecs = 0;
QString serverDeckString;
};
struct ConflictDownload
{
QString serverPath;
int deckId = -1;
QString guid;
QString hash;
qint64 lastModifiedSecs = 0;
};
void startSync(bool autoRun);
void failSync(const QString &error);
void finishSync();
[[nodiscard]] SyncSummary currentSummary() const;
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<int, ServerFile> &files,
QSet<QString> &dirs);
void processNext();
void processNextUpload();
void sendMkDirFor(const QString &dir);
void sendUpload(const UploadOp &op);
void sendDownload(const DownloadOp &op);
void sendDelete(const QString &relativePath, int deckId);
void snapshotLocalFile(const QString &relativePath, const QString &guid);
QString readBaseSnapshot(const QString &guid) const;
void classify(const QMap<int, ServerFile> &serverFiles);
void queueConflict(const QString &relativePath,
const QString &serverPath,
int deckId,
const QString &serverGuid,
const QString &serverHash,
qint64 serverLastModifiedSecs);
void handleConflictDownload(const QString &relativePath, const QString &serverDeckString);
void applyDownloadedDeck(const QString &relativePath,
int deckId,
const QString &deckString,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs,
bool hasLastModified);
void applyUpload(const QString &relativePath, const QString &serverPath, int deckId, const QString &guid);
void applyDownload(const QString &relativePath,
int deckId,
const QString &guid,
const QString &hash,
qint64 lastModifiedSecs);
static QString parentDir(const QString &path);
QString uniqueLocalPath(const QString &relativePath) const;
AbstractClient *client = nullptr;
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<int, 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;
int conflictCount = 0;
bool autoRun = false;
};
#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,42 @@
/**
* @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>
/**
* @struct 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.
*/
struct DeckSyncProfile
{
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>
#include <QSaveFile>
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();
const DeckSyncRecord record{.guid = entry.value("guid").toString(),
.remoteId = entry.value("remoteId").toInt(-1),
.baseHash = entry.value("baseHash").toString(),
.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble()),
.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());
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
return file.commit();
}
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,175 @@
#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()) {
const DeckHistoryVersion entry{.versionNo = version.version_no(),
.createdAtSecs = static_cast<qint64>(version.created_at()),
.createdByClientId = QString::fromStdString(version.created_by_clientid()),
.hash = QString::fromStdString(version.hash()),
.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

@ -27,6 +27,7 @@
#include <QtGlobal>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
@ -359,6 +360,46 @@ 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);
@ -366,6 +407,7 @@ GeneralSettingsPage::GeneralSettingsPage()
mainLayout->addWidget(cardDatabaseGroupBox);
mainLayout->addWidget(startupGroupBox);
mainLayout->addWidget(pathsGroupBox);
mainLayout->addWidget(deckSyncGroupBox);
mainLayout->addStretch();
GeneralSettingsPage::retranslateUi();
@ -647,5 +689,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;
@ -104,6 +105,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

@ -2,10 +2,15 @@
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../../deck_sync/deck_sync_conflict.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../../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"
@ -29,6 +34,7 @@
#include <QInputDialog>
#include <QItemSelection>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QModelIndex>
@ -61,6 +67,7 @@
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/qt_utils.h>
@ -177,6 +184,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout);
syncStatusLabel = new QLabel(this);
syncStatusLabel->setWordWrap(true);
rightVbox->addWidget(syncStatusLabel);
rightGroupBox = new QGroupBox;
rightGroupBox->setLayout(rightVbox);
@ -228,6 +238,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);
@ -239,11 +257,40 @@ 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(
client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this);
syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath());
connect(&SettingsCache::instance().paths(), &PathsSettings::deckPathChanged, this, [this] {
const QString deckPath = SettingsCache::instance().paths().getDeckPath();
localDirModel->setRootPath(deckPath);
localDirView->setRootIndex(localDirModel->index(localDirModel->rootPath(), 0));
syncManager->setDeckPath(deckPath);
});
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);
@ -272,6 +319,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();
@ -299,6 +349,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);
}
@ -316,6 +368,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
void TabDeckStorage::setRemoteEnabled(bool enabled)
{
remoteEnabled = enabled;
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
@ -323,6 +376,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();
@ -526,6 +581,139 @@ void TabDeckStorage::actOpenDecksFolder()
QtUtils::openPathInFileManager(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(const SyncSummary &summary, const QString &error)
{
aSyncNow->setEnabled(remoteEnabled);
if (!error.isEmpty()) {
syncStatusType = SyncStatusType::Failed;
syncStatusDetail = error;
} else {
syncStatusType = SyncStatusType::Finished;
syncStatusDetail.clear();
lastSyncSummary = summary;
}
renderSyncStatus();
if (error.isEmpty()) {
serverDirView->refreshTree();
}
}
void TabDeckStorage::renderSyncStatus()
{
switch (syncStatusType) {
case SyncStatusType::Working:
syncStatusLabel->setText(syncStatusDetail);
break;
case SyncStatusType::Failed:
syncStatusLabel->setText(tr("Sync failed: %1").arg(syncStatusDetail));
break;
case SyncStatusType::Finished:
syncStatusLabel->setText(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(lastSyncSummary.uploaded)
.arg(lastSyncSummary.downloaded)
.arg(lastSyncSummary.unchanged)
.arg(lastSyncSummary.skipped));
break;
case SyncStatusType::Idle:
syncStatusLabel->setText(tr("Not synced yet"));
break;
}
}
void TabDeckStorage::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

@ -8,7 +8,8 @@
#ifndef TAB_DECK_STORAGE_H
#define TAB_DECK_STORAGE_H
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../server/remote/remote_decklist_tree_widget.h"
#include "tab.h"
#include <QList>
@ -19,12 +20,15 @@
#include <qtmetamacros.h>
struct LoadedDeck;
struct DeckSyncConflict;
struct DeckSyncResolution;
class ServerInfo_User;
class QTreeView;
class QFileSystemModel;
class QToolBar;
class QGroupBox;
class QTimer;
class QLabel;
class CommandContainer;
class Response;
class ShareBarWidget;
@ -50,6 +54,24 @@ 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
};
using SyncSummary = DeckSyncManager::SyncSummary;
SyncStatusType syncStatusType = SyncStatusType::Idle;
QString syncStatusDetail;
SyncSummary lastSyncSummary;
void renderSyncStatus();
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
@ -108,6 +130,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(const SyncSummary &summary, 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_file_format.h
libcockatrice/deck_list/deck_list.h
libcockatrice/deck_list/deck_list_history_manager.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_file_format.cpp
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp

View file

@ -0,0 +1,142 @@
#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>;
struct ZoneCardInfo
{
int count = 0;
QString cardSetShortName;
QString cardSetNumber;
QString cardProviderId;
};
QMap<ZoneCardKey, ZoneCardInfo> zoneCardInfos(const DeckList &deck)
{
QMap<ZoneCardKey, ZoneCardInfo> infos;
deck.forEachCard([&infos](InnerDecklistNode *zone, DecklistCardNode *card) {
ZoneCardInfo &info = infos[{zone->getName(), card->getName()}];
if (info.count == 0) {
info.cardSetShortName = card->getCardSetShortName();
info.cardSetNumber = card->getCardCollectorNumber();
info.cardProviderId = card->getCardProviderId();
}
info.count += card->getNumber();
});
return infos;
}
QMap<ZoneCardKey, int> zoneCardCounts(const DeckList &deck)
{
QMap<ZoneCardKey, int> counts;
const QMap<ZoneCardKey, ZoneCardInfo> infos = zoneCardInfos(deck);
for (auto it = infos.begin(); it != infos.end(); ++it) {
counts.insert(it.key(), it.value().count);
}
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
namespace DeckDiff
{
DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck)
{
const QMap<ZoneCardKey, int> oldCounts = zoneCardCounts(oldDeck);
const QMap<ZoneCardKey, ZoneCardInfo> newInfos = zoneCardInfos(newDeck);
QSet<ZoneCardKey> keys(oldCounts.keyBegin(), oldCounts.keyEnd());
keys.unite(QSet<ZoneCardKey>(newInfos.keyBegin(), newInfos.keyEnd()));
DeckDiffResult result;
for (const ZoneCardKey &key : keys) {
const int oldCount = oldCounts.value(key, 0);
const ZoneCardInfo newInfo = newInfos.value(key, ZoneCardInfo());
if (oldCount != newInfo.count) {
const DeckCardChange change{.zone = key.first,
.cardName = key.second,
.oldCount = oldCount,
.newCount = newInfo.count,
.cardSetShortName = newInfo.cardSetShortName,
.cardSetNumber = newInfo.cardSetNumber,
.cardProviderId = newInfo.cardProviderId};
result.changes.append(change);
}
}
std::sort(result.changes.begin(), result.changes.end(), changeLessThan);
return result;
}
bool 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 merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
{
QMap<ZoneCardKey, int> counts = zoneCardCounts(baseDeck);
QMap<ZoneCardKey, DeckCardChange> printings;
for (const DeckCardChange &change : localChanges.changes) {
counts[{change.zone, change.cardName}] = change.newCount;
printings[{change.zone, change.cardName}] = change;
}
for (const DeckCardChange &change : remoteChanges.changes) {
counts[{change.zone, change.cardName}] = change.newCount;
printings[{change.zone, change.cardName}] = change;
}
DeckList merged = baseDeck;
QMap<ZoneCardKey, QList<DecklistCardNode *>> nodesByKey;
merged.forEachCard([&nodesByKey](InnerDecklistNode *zone, DecklistCardNode *card) {
nodesByKey[{zone->getName(), card->getName()}].append(card);
});
for (auto it = counts.begin(); it != counts.end(); ++it) {
const ZoneCardKey &key = it.key();
const int target = qMax(0, it.value());
QList<DecklistCardNode *> nodes = nodesByKey.value(key);
if (target == 0) {
for (DecklistCardNode *node : nodes) {
merged.getTree()->deleteNode(node);
}
} else if (nodes.isEmpty()) {
const DeckCardChange printing = printings.value(key);
merged.getTree()->addCard(printing.cardName, target, printing.zone, -1, printing.cardSetShortName,
printing.cardSetNumber, printing.cardProviderId, true);
} else {
nodes.first()->setNumber(target);
for (int i = 1; i < nodes.size(); ++i) {
merged.getTree()->deleteNode(nodes.at(i));
}
}
}
merged.refreshDeckHash();
return merged;
}
} // namespace DeckDiff

View file

@ -0,0 +1,72 @@
/**
* @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.
QString cardSetShortName; ///< Printing set carried from the new side, empty when unchanged.
QString cardSetNumber; ///< Printing collector number carried from the new side.
QString cardProviderId; ///< Printing provider id carried from the new side.
/** @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();
}
};
/**
* @namespace DeckDiff
* @brief Pure card-count diff and three-way merge for deck sync.
*
* Compares card names and copy counts per zone only; a merged deck keeps
* base printings for existing cards and carries the changed side's printing
* for newly added cards.
*/
namespace DeckDiff
{
/** @brief Diffs oldDeck against newDeck. */
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.
*/
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.
*/
DeckList merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
} // namespace DeckDiff
#endif // DECK_DIFF_H

View file

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

View file

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

View file

@ -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
@ -146,11 +148,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

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

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

@ -37,6 +37,8 @@ message SessionCommand {
DECK_SHARE_LIST_MINE = 1032;
DECK_SHARE_REMOVE = 1033;
SUBMIT_PASSWORD_VERIFIER = 1034;
DECK_SYNC_CHECK = 1035;
DECK_HISTORY_LIST = 1036;
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
@ -43,6 +44,7 @@ add_library(
libcockatrice/settings/chat_settings.cpp
libcockatrice/settings/debug_settings.cpp
libcockatrice/settings/deck_editor_settings.cpp
libcockatrice/settings/deck_sync_settings.cpp
libcockatrice/settings/download_settings.cpp
libcockatrice/settings/game_filters_settings.cpp
libcockatrice/settings/game_settings.cpp

View file

@ -0,0 +1,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

@ -71,6 +71,7 @@ QString PathsSettings::getRedirectCachePath() const
void PathsSettings::setDeckPath(const QString &_deckPath)
{
setValue(_deckPath, "decks");
emit deckPathChanged();
}
void PathsSettings::setFiltersPath(const QString &_filtersPath)

View file

@ -59,6 +59,7 @@ public:
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
signals:
void deckPathChanged();
void cardDatabasePathChanged();
void picsPathChanged();
void themeChanged();

View file

@ -16,6 +16,12 @@ ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`;
-- Rows created before this migration only get a last_modified when a client
-- re-uploads them, so every sync check would keep reporting them as changed.
-- Backfill from the upload time once, here, instead of carrying a
-- "last_modified is null" branch in the poll query forever.
UPDATE `cockatrice_decklist_files` SET `last_modified` = `upload_time` WHERE `last_modified` IS NULL;
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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -89,6 +89,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>
@ -98,6 +99,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>
@ -129,11 +131,13 @@
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_clear_blacklisted_banners.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>
@ -371,6 +375,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:
@ -709,7 +717,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user");
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user "
"and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
@ -854,8 +863,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);
@ -893,6 +902,105 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
return Response::RespOk;
}
bool AbstractServerSocketInterface::syncCheckHelper(int userId, qint64 sinceSecs, Response_DeckSyncStatus *status)
{
// Single query over the user's files: folder rows are hard-deleted while
// their files linger as tombstones, so walking the live folder tree would
// miss deletes pointing at nonexistent folders. The client only checks
// changed_files_size(), so folder paths are not reconstructed here.
// last_modified is backfilled by the 37 -> 38 migration, so a plain range
// check is enough.
QSqlQuery *query = sqlInterface->prepareQuery("select id, guid, hash, last_modified, deleted from "
"{prefix}_decklist_files where id_user = :id_user "
"and last_modified >= FROM_UNIXTIME(:since)");
query->bindValue(":id_user", userId);
query->bindValue(":since", sinceSecs);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
while (query->next()) {
DeckSyncChangedFile *changed = status->add_changed_files();
changed->set_deck_id(query->value(0).toUInt());
changed->set_guid(query->value(1).toString().toStdString());
changed->set_hash(query->value(2).toString().toStdString());
const QDateTime lastModified = query->value(3).toDateTime();
if (lastModified.isValid()) {
changed->set_last_modified(lastModified.toSecsSinceEpoch());
}
changed->set_deleted(query->value(4).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(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)
{
@ -965,7 +1073,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
deckDelDirHelper(query->value(0).toInt());
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id_folder = :id_folder");
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() "
"where id_folder = :id_folder and deleted = 0");
query->bindValue(":id_folder", basePathId);
sqlInterface->execSqlQuery(query);
@ -1016,18 +1125,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
sqlInterface->checkSql();
QSqlQuery *query =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id = :id and id_user = :id_user");
sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() where id = "
":id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
query->bindValue(":id", cmd.deck_id());
sqlInterface->execSqlQuery(query);
return Response::RespOk;
}
@ -1114,7 +1222,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
const QString tagsJson = serializeDeckTags(deck.getTags());
const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity()));
const QString deckHash = deck.getDeckHash();
// Hash the exact bytes the client sent: both peers must compare the same
// string, not each side's re-serialisation of it.
const QString deckHash = DeckList::contentHash(deckStr);
QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString();
if (QUuid::fromString(guid).isNull()) {
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
@ -1126,31 +1236,71 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
return Response::RespNameNotFound;
}
QSqlQuery *query =
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
// A deck that was deleted elsewhere leaves a tombstone behind. Revive it
// instead of inserting a second row with the same guid, which would split
// the deck's version history and leave the tombstone to be purged.
int deckId = -1;
bool inserted = false;
QSqlQuery *tombstoneQuery =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_user = :id_user and guid = "
":guid and deleted = 1 order by last_modified desc limit 1");
tombstoneQuery->bindValue(":id_user", userInfo->id());
tombstoneQuery->bindValue(":guid", guid);
if (!sqlInterface->execSqlQuery(tombstoneQuery)) {
return Response::RespContextError;
}
const int deckId = query->lastInsertId().toInt();
if (tombstoneQuery->next()) {
deckId = tombstoneQuery->value(0).toInt();
QSqlQuery *reviveQuery = sqlInterface->prepareQuery(
"update {prefix}_decklist_files set id_folder=:id_folder, name=:name, upload_time=NOW(), "
"content=:content, is_public=:is_public, banner_card_name=:banner_card_name, "
"banner_card_provider=:banner_card_provider, color_identity=:color_identity, tags=:tags, "
"hash=:hash, last_modified=NOW(), deleted=0 where id=:id and id_user=:id_user");
reviveQuery->bindValue(":id_folder", folderId);
reviveQuery->bindValue(":id_user", userInfo->id());
reviveQuery->bindValue(":id", deckId);
reviveQuery->bindValue(":name", deckName);
reviveQuery->bindValue(":content", deckStr);
reviveQuery->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
reviveQuery->bindValue(":banner_card_name", bannerCardName);
reviveQuery->bindValue(":banner_card_provider", bannerCardProvider);
reviveQuery->bindValue(":color_identity", colorIdentity);
reviveQuery->bindValue(":tags", tagsJson);
reviveQuery->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(reviveQuery)) {
return Response::RespContextError;
}
} else {
QSqlQuery *query = sqlInterface->prepareQuery(
"insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
deckId = query->lastInsertId().toInt();
inserted = true;
}
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1160,9 +1310,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
stampQuery->bindValue(":id", deckId);
stampQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1195,9 +1348,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
}
const QString oldHash = currentQuery->value(1).toString();
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
QString updateQuery =
"update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW(), "
"deleted=0";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
@ -1399,7 +1554,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;
@ -111,6 +113,8 @@ class Command_ViewLogHistory;
class Command_WarnUser;
class QObject;
class ResponseContainer;
class Response_DeckHistoryList;
class Response_DeckSyncStatus;
class AbstractServerSocketInterface : public Server_ProtocolHandler
{
@ -163,6 +167,9 @@ private:
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 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

@ -407,6 +407,43 @@ cockatrice_add_test(
bench
)
cockatrice_add_test(
NAME
deck_diff_test
SOURCES
deck_diff_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
deck_content_hash_test
SOURCES
deck_content_hash_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
deck_sync_profile_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
deck_sync_profile_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync
)
cockatrice_add_test(
NAME
deck_sync_sidecar_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
deck_sync_sidecar_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync
)
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
if(TARGET Qt6::lrelease)
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")

View file

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

181
tests/deck_diff_test.cpp Normal file
View file

@ -0,0 +1,181 @@
#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;
}
QList<DecklistCardNode *> nodesOf(const DeckList &deck, const char *zone, const char *name)
{
QList<DecklistCardNode *> nodes;
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
if (zoneNode->getName() == zone && card->getName() == name) {
nodes.append(card);
}
});
return nodes;
}
} // 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);
}
TEST(DeckDiffTest, MergePreservesBasePrintingAndCollapsesNodes)
{
DeckList base;
base.addCard("Mountain", "main", -1, "DOM", "250")->setNumber(4);
base.refreshDeckHash();
DeckList local = base;
local.addCard("Shock", "main", -1, "M19", "156")->setNumber(2);
local.refreshDeckHash();
DeckList remote = base;
remote.addCard("Lightning Bolt", "main", -1, "M10", "146")->setNumber(4);
remote.refreshDeckHash();
const DeckDiffResult localChanges = DeckDiff::diff(base, local);
const DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
ASSERT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
const DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
const QList<DecklistCardNode *> mountains = nodesOf(merged, "main", "Mountain");
ASSERT_EQ(mountains.size(), 1);
EXPECT_EQ(mountains.first()->getNumber(), 4);
EXPECT_EQ(mountains.first()->getCardSetShortName(), "DOM");
EXPECT_EQ(mountains.first()->getCardCollectorNumber(), "250");
const QList<DecklistCardNode *> shocks = nodesOf(merged, "main", "Shock");
ASSERT_EQ(shocks.size(), 1);
EXPECT_EQ(shocks.first()->getNumber(), 2);
EXPECT_EQ(shocks.first()->getCardSetShortName(), "M19");
EXPECT_EQ(shocks.first()->getCardCollectorNumber(), "156");
}
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

@ -13,6 +13,7 @@
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/deck_sync_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/game_settings.h>
#include <libcockatrice/settings/interface_settings.h>
@ -586,6 +587,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)