mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-10 12:03:25 -08:00
Compare commits
22 commits
6d67b76fa3
...
875e87d1bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875e87d1bc | ||
|
|
8bb0a9b98a | ||
|
|
e73fcd602c | ||
|
|
dcccb0f332 | ||
|
|
9e4d734373 | ||
|
|
d2e2e722e7 | ||
|
|
de3468cc33 | ||
|
|
157e63b179 | ||
|
|
e43862c3bc | ||
|
|
2916fa45af | ||
|
|
ad0da98a86 | ||
|
|
c0a62a2486 | ||
|
|
0757d3d867 | ||
|
|
8a541531da | ||
|
|
e1a9f7766d | ||
|
|
ad1cb8f133 | ||
|
|
e63b72fe11 | ||
|
|
fa4c5766eb | ||
|
|
15673dc481 | ||
|
|
da106fdef7 | ||
|
|
f661468069 | ||
|
|
be072ffd68 |
47 changed files with 3510 additions and 44 deletions
|
|
@ -26,6 +26,9 @@ set(cockatrice_SOURCES
|
||||||
src/client/settings/shortcuts_settings.cpp
|
src/client/settings/shortcuts_settings.cpp
|
||||||
src/interface/deck_loader/card_node_function.cpp
|
src/interface/deck_loader/card_node_function.cpp
|
||||||
src/interface/deck_loader/deck_loader.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_connect.cpp
|
||||||
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.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_create_game.cpp
|
||||||
|
|
@ -55,6 +58,8 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
|
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
|
||||||
src/interface/widgets/dialogs/dlg_settings.cpp
|
src/interface/widgets/dialogs/dlg_settings.cpp
|
||||||
src/interface/widgets/dialogs/dlg_startup_card_check.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_tip_of_the_day.cpp
|
||||||
src/interface/widgets/dialogs/dlg_update.cpp
|
src/interface/widgets/dialogs/dlg_update.cpp
|
||||||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
#include <libcockatrice/settings/commander_bracket_settings.h>
|
#include <libcockatrice/settings/commander_bracket_settings.h>
|
||||||
#include <libcockatrice/settings/debug_settings.h>
|
#include <libcockatrice/settings/debug_settings.h>
|
||||||
#include <libcockatrice/settings/deck_editor_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/download_settings.h>
|
||||||
#include <libcockatrice/settings/game_filters_settings.h>
|
#include <libcockatrice/settings/game_filters_settings.h>
|
||||||
#include <libcockatrice/settings/game_settings.h>
|
#include <libcockatrice/settings/game_settings.h>
|
||||||
|
|
@ -159,6 +160,7 @@ SettingsCache::SettingsCache()
|
||||||
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
|
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
|
||||||
interfaceSettings = new InterfaceSettings(settingsPath, this);
|
interfaceSettings = new InterfaceSettings(settingsPath, this);
|
||||||
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
|
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
|
||||||
|
deckSyncSettings = new DeckSyncSettings(settingsPath, this);
|
||||||
pathsSettings = new PathsSettings(settingsPath, this);
|
pathsSettings = new PathsSettings(settingsPath, this);
|
||||||
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
|
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
|
||||||
appearanceSettings = new AppearanceSettings(settingsPath, this);
|
appearanceSettings = new AppearanceSettings(settingsPath, this);
|
||||||
|
|
@ -460,6 +462,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const
|
||||||
return *deckEditorSettings;
|
return *deckEditorSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeckSyncSettings &SettingsCache::deckSync() const
|
||||||
|
{
|
||||||
|
return *deckSyncSettings;
|
||||||
|
}
|
||||||
|
|
||||||
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
|
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
|
||||||
{
|
{
|
||||||
return *visualDeckStorageSettings;
|
return *visualDeckStorageSettings;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class ChatSettings;
|
||||||
class CommanderBracketSettings;
|
class CommanderBracketSettings;
|
||||||
class DebugSettings;
|
class DebugSettings;
|
||||||
class DeckEditorSettings;
|
class DeckEditorSettings;
|
||||||
|
class DeckSyncSettings;
|
||||||
class DownloadSettings;
|
class DownloadSettings;
|
||||||
class GameFiltersSettings;
|
class GameFiltersSettings;
|
||||||
class GameSettings;
|
class GameSettings;
|
||||||
|
|
@ -81,6 +82,7 @@ private:
|
||||||
CardsDisplaySettings *cardsDisplaySettings = nullptr;
|
CardsDisplaySettings *cardsDisplaySettings = nullptr;
|
||||||
InterfaceSettings *interfaceSettings = nullptr;
|
InterfaceSettings *interfaceSettings = nullptr;
|
||||||
DeckEditorSettings *deckEditorSettings = nullptr;
|
DeckEditorSettings *deckEditorSettings = nullptr;
|
||||||
|
DeckSyncSettings *deckSyncSettings = nullptr;
|
||||||
PathsSettings *pathsSettings = nullptr;
|
PathsSettings *pathsSettings = nullptr;
|
||||||
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
|
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
|
||||||
AppearanceSettings *appearanceSettings = nullptr;
|
AppearanceSettings *appearanceSettings = nullptr;
|
||||||
|
|
@ -149,6 +151,7 @@ public:
|
||||||
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
|
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
|
||||||
[[nodiscard]] InterfaceSettings &userInterface() const;
|
[[nodiscard]] InterfaceSettings &userInterface() const;
|
||||||
[[nodiscard]] DeckEditorSettings &deckEditor() const;
|
[[nodiscard]] DeckEditorSettings &deckEditor() const;
|
||||||
|
[[nodiscard]] DeckSyncSettings &deckSync() const;
|
||||||
[[nodiscard]] PathsSettings &paths() const;
|
[[nodiscard]] PathsSettings &paths() const;
|
||||||
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
|
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
|
||||||
[[nodiscard]] AppearanceSettings &appearance() const;
|
[[nodiscard]] AppearanceSettings &appearance() const;
|
||||||
|
|
|
||||||
50
cockatrice/src/interface/deck_sync/deck_sync_conflict.h
Normal file
50
cockatrice/src/interface/deck_sync/deck_sync_conflict.h
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* @file deck_sync_conflict.h
|
||||||
|
* @brief Conflict payload between the sync engine and the conflict dialog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DECK_SYNC_CONFLICT_H
|
||||||
|
#define DECK_SYNC_CONFLICT_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/** @brief How a both-sides-changed deck is resolved. */
|
||||||
|
enum DeckSyncResolutionAction
|
||||||
|
{
|
||||||
|
DeckSyncKeepMine, ///< Upload the local deck, discarding the server version.
|
||||||
|
DeckSyncKeepServer, ///< Download the server deck, discarding local changes.
|
||||||
|
DeckSyncKeepBoth, ///< Keep the local deck and store the server version aside.
|
||||||
|
DeckSyncUseMerge, ///< Upload the auto-merged deck (only when mergeable).
|
||||||
|
DeckSyncSkipConflict, ///< Leave both sides untouched for this run.
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief Sync direction for one run. */
|
||||||
|
enum class DeckSyncMode
|
||||||
|
{
|
||||||
|
TwoWay, ///< Mirror both sides (default).
|
||||||
|
UploadOnly, ///< Push local content; never download or propagate deletes.
|
||||||
|
DownloadOnly, ///< Take server content; never upload or push deletes.
|
||||||
|
Separate, ///< First-sync verdict only: keep this profile out of sync.
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief One deck that changed both locally and on the server. */
|
||||||
|
struct DeckSyncConflict
|
||||||
|
{
|
||||||
|
QString relativePath; ///< Local deck path relative to the deck folder.
|
||||||
|
QString localAbsolutePath; ///< Absolute local file path.
|
||||||
|
QString serverDeckString; ///< Server deck content in native format.
|
||||||
|
QString baseDeckString; ///< Last-synced content, empty when no snapshot exists.
|
||||||
|
bool hasBase = false; ///< False when the base snapshot is missing.
|
||||||
|
qint64 localSecs = 0; ///< Local modification time, seconds since epoch.
|
||||||
|
qint64 serverSecs = 0; ///< Server modification time, seconds since epoch.
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief The dialog verdict for one DeckSyncConflict. */
|
||||||
|
struct DeckSyncResolution
|
||||||
|
{
|
||||||
|
DeckSyncResolutionAction action = DeckSyncKeepMine;
|
||||||
|
bool rememberSession = false; ///< Reuse this verdict for later conflicts this run.
|
||||||
|
bool rememberAlways = false; ///< Store this verdict as the conflict default setting.
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_CONFLICT_H
|
||||||
1287
cockatrice/src/interface/deck_sync/deck_sync_manager.cpp
Normal file
1287
cockatrice/src/interface/deck_sync/deck_sync_manager.cpp
Normal file
File diff suppressed because it is too large
Load diff
248
cockatrice/src/interface/deck_sync/deck_sync_manager.h
Normal file
248
cockatrice/src/interface/deck_sync/deck_sync_manager.h
Normal 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
|
||||||
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal 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();
|
||||||
|
}
|
||||||
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* @file deck_sync_profile.h
|
||||||
|
* @brief Identity of one sync target (server + user) for deck collection sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DECK_SYNC_PROFILE_H
|
||||||
|
#define DECK_SYNC_PROFILE_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class DeckSyncProfile
|
||||||
|
* @brief Identifies which server and login a sync sidecar belongs to.
|
||||||
|
*
|
||||||
|
* A local deck folder can sync against several servers. Every server login
|
||||||
|
* gets its own profile, and every profile gets its own sidecar file, so
|
||||||
|
* switching servers never mixes up remote deck ids.
|
||||||
|
*/
|
||||||
|
class DeckSyncProfile
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
QString host; ///< Server hostname as entered by the user.
|
||||||
|
int port = 0; ///< Server port.
|
||||||
|
QString username; ///< Login name on that server.
|
||||||
|
|
||||||
|
DeckSyncProfile() = default;
|
||||||
|
DeckSyncProfile(const QString &_host, int _port, const QString &_username);
|
||||||
|
|
||||||
|
/** @brief False when any of host, port or username is missing. */
|
||||||
|
[[nodiscard]] bool isValid() const;
|
||||||
|
/**
|
||||||
|
* @brief Stable file-system-safe key for this profile.
|
||||||
|
*
|
||||||
|
* SHA1 hex over the NUL-separated lowercased "host", port and "username"
|
||||||
|
* triple, so the same server reached with different hostname
|
||||||
|
* capitalisation maps to one file, while IPv6 hosts and usernames
|
||||||
|
* containing ':' can never collide two profiles. Empty for invalid
|
||||||
|
* profiles, so no two half-configured profiles share a file.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] QString key() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_PROFILE_H
|
||||||
111
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
111
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#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();
|
||||||
|
DeckSyncRecord record;
|
||||||
|
record.guid = entry.value("guid").toString();
|
||||||
|
record.remoteId = entry.value("remoteId").toInt(-1);
|
||||||
|
record.baseHash = entry.value("baseHash").toString();
|
||||||
|
record.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble());
|
||||||
|
record.deleted = entry.value("deleted").toBool(false);
|
||||||
|
recordMap.insert(it.key(), record);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeckSyncSidecar::save() const
|
||||||
|
{
|
||||||
|
QJsonObject records;
|
||||||
|
for (auto it = recordMap.begin(); it != recordMap.end(); ++it) {
|
||||||
|
QJsonObject entry;
|
||||||
|
entry.insert("guid", it.value().guid);
|
||||||
|
entry.insert("remoteId", it.value().remoteId);
|
||||||
|
entry.insert("baseHash", it.value().baseHash);
|
||||||
|
entry.insert("lastSync", static_cast<double>(it.value().lastSyncSecs));
|
||||||
|
entry.insert("deleted", it.value().deleted);
|
||||||
|
records.insert(it.key(), entry);
|
||||||
|
}
|
||||||
|
QJsonObject root;
|
||||||
|
root.insert("version", SIDECAR_FORMAT_VERSION);
|
||||||
|
root.insert("mode", mode);
|
||||||
|
root.insert("lastSync", static_cast<double>(lastSyncSecs));
|
||||||
|
root.insert("records", records);
|
||||||
|
|
||||||
|
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||||
|
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;
|
||||||
|
}
|
||||||
60
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
60
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal 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
|
||||||
178
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp
Normal file
178
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp
Normal 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;
|
||||||
|
}
|
||||||
63
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h
Normal file
63
cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h
Normal 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
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <libcockatrice/card/card_localization.h>
|
#include <libcockatrice/card/card_localization.h>
|
||||||
#include <libcockatrice/settings/cards_display_settings.h>
|
#include <libcockatrice/settings/cards_display_settings.h>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
#include <libcockatrice/settings/download_settings.h>
|
#include <libcockatrice/settings/download_settings.h>
|
||||||
#include <libcockatrice/settings/paths_settings.h>
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
#include <libcockatrice/settings/personal_settings.h>
|
#include <libcockatrice/settings/personal_settings.h>
|
||||||
|
|
@ -359,6 +360,38 @@ GeneralSettingsPage::GeneralSettingsPage()
|
||||||
pathsGroupBox = new QGroupBox;
|
pathsGroupBox = new QGroupBox;
|
||||||
pathsGroupBox->setLayout(pathsGrid);
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
deckSyncGroupBox = new QGroupBox;
|
||||||
|
deckSyncGroupBox->setLayout(deckSyncGrid);
|
||||||
|
|
||||||
auto *mainLayout = new QVBoxLayout;
|
auto *mainLayout = new QVBoxLayout;
|
||||||
mainLayout->addWidget(languageGroupBox);
|
mainLayout->addWidget(languageGroupBox);
|
||||||
mainLayout->addWidget(versionGroupBox);
|
mainLayout->addWidget(versionGroupBox);
|
||||||
|
|
@ -366,6 +399,7 @@ GeneralSettingsPage::GeneralSettingsPage()
|
||||||
mainLayout->addWidget(cardDatabaseGroupBox);
|
mainLayout->addWidget(cardDatabaseGroupBox);
|
||||||
mainLayout->addWidget(startupGroupBox);
|
mainLayout->addWidget(startupGroupBox);
|
||||||
mainLayout->addWidget(pathsGroupBox);
|
mainLayout->addWidget(pathsGroupBox);
|
||||||
|
mainLayout->addWidget(deckSyncGroupBox);
|
||||||
mainLayout->addStretch();
|
mainLayout->addStretch();
|
||||||
|
|
||||||
GeneralSettingsPage::retranslateUi();
|
GeneralSettingsPage::retranslateUi();
|
||||||
|
|
@ -647,5 +681,16 @@ void GeneralSettingsPage::retranslateUi()
|
||||||
cardDatabasePathLabel.setText(tr("Card database:"));
|
cardDatabasePathLabel.setText(tr("Card database:"));
|
||||||
customCardDatabasePathLabel.setText(tr("Custom database directory:"));
|
customCardDatabasePathLabel.setText(tr("Custom database directory:"));
|
||||||
tokenDatabasePathLabel.setText(tr("Token database:"));
|
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"));
|
||||||
resetAllPathsButton->setText(tr("Reset all paths"));
|
resetAllPathsButton->setText(tr("Reset all paths"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ private:
|
||||||
QGroupBox *cardDatabaseGroupBox;
|
QGroupBox *cardDatabaseGroupBox;
|
||||||
QGroupBox *startupGroupBox;
|
QGroupBox *startupGroupBox;
|
||||||
QGroupBox *pathsGroupBox;
|
QGroupBox *pathsGroupBox;
|
||||||
|
QGroupBox *deckSyncGroupBox;
|
||||||
|
|
||||||
QLabel languageLabel;
|
QLabel languageLabel;
|
||||||
QComboBox languageBox;
|
QComboBox languageBox;
|
||||||
|
|
@ -104,6 +105,12 @@ private:
|
||||||
QLineEdit *tokenDatabasePathEdit;
|
QLineEdit *tokenDatabasePathEdit;
|
||||||
QPushButton *resetAllPathsButton;
|
QPushButton *resetAllPathsButton;
|
||||||
QLabel *allPathsResetLabel;
|
QLabel *allPathsResetLabel;
|
||||||
|
|
||||||
|
QLabel conflictDefaultLabel;
|
||||||
|
QComboBox conflictDefaultSelector;
|
||||||
|
QCheckBox autoSyncCheckBox;
|
||||||
|
QLabel syncIntervalLabel;
|
||||||
|
QSpinBox syncIntervalEdit;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H
|
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../deck_loader/deck_loader.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 "../../pixel_map_generator.h"
|
||||||
#include "../cards/additional_info/deck_color_identity.h"
|
#include "../cards/additional_info/deck_color_identity.h"
|
||||||
#include "../deck_share/deck_share_utils.h"
|
#include "../deck_share/deck_share_utils.h"
|
||||||
#include "../deck_share/share_bar_widget.h"
|
#include "../deck_share/share_bar_widget.h"
|
||||||
|
#include "../dialogs/dlg_sync_conflict.h"
|
||||||
|
#include "../dialogs/dlg_sync_onboarding.h"
|
||||||
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
|
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
|
||||||
#include "../interface/widgets/utility/get_text_with_max.h"
|
#include "../interface/widgets/utility/get_text_with_max.h"
|
||||||
#include "abstract_client.h"
|
#include "abstract_client.h"
|
||||||
|
|
@ -29,6 +33,7 @@
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
#include <QItemSelection>
|
#include <QItemSelection>
|
||||||
#include <QItemSelectionModel>
|
#include <QItemSelectionModel>
|
||||||
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QModelIndex>
|
#include <QModelIndex>
|
||||||
|
|
@ -61,6 +66,7 @@
|
||||||
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
|
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
|
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
|
||||||
#include <libcockatrice/protocol/pending_command.h>
|
#include <libcockatrice/protocol/pending_command.h>
|
||||||
|
#include <libcockatrice/settings/deck_sync_settings.h>
|
||||||
#include <libcockatrice/settings/network_settings.h>
|
#include <libcockatrice/settings/network_settings.h>
|
||||||
#include <libcockatrice/settings/paths_settings.h>
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
#include <libcockatrice/utility/qt_utils.h>
|
#include <libcockatrice/utility/qt_utils.h>
|
||||||
|
|
@ -177,6 +183,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
rightVbox->addWidget(shareBar);
|
rightVbox->addWidget(shareBar);
|
||||||
rightVbox->addWidget(serverDirView);
|
rightVbox->addWidget(serverDirView);
|
||||||
rightVbox->addLayout(rightToolBarLayout);
|
rightVbox->addLayout(rightToolBarLayout);
|
||||||
|
syncStatusLabel = new QLabel(this);
|
||||||
|
syncStatusLabel->setWordWrap(true);
|
||||||
|
rightVbox->addWidget(syncStatusLabel);
|
||||||
rightGroupBox = new QGroupBox;
|
rightGroupBox = new QGroupBox;
|
||||||
rightGroupBox->setLayout(rightVbox);
|
rightGroupBox->setLayout(rightVbox);
|
||||||
|
|
||||||
|
|
@ -228,6 +237,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
|
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
|
||||||
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
|
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
|
||||||
|
|
||||||
|
aSyncNow = new QAction(this);
|
||||||
|
aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload));
|
||||||
|
connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow);
|
||||||
|
|
||||||
// Add actions to toolbars
|
// Add actions to toolbars
|
||||||
leftToolBar->addAction(aOpenLocalDeck);
|
leftToolBar->addAction(aOpenLocalDeck);
|
||||||
leftToolBar->addAction(aRenameLocal);
|
leftToolBar->addAction(aRenameLocal);
|
||||||
|
|
@ -239,11 +252,39 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
|
|
||||||
rightToolBar->addAction(aOpenRemoteDeck);
|
rightToolBar->addAction(aOpenRemoteDeck);
|
||||||
rightToolBar->addAction(aDownload);
|
rightToolBar->addAction(aDownload);
|
||||||
|
rightToolBar->addAction(aSyncNow);
|
||||||
rightToolBar->addAction(aShareDecks);
|
rightToolBar->addAction(aShareDecks);
|
||||||
rightToolBar->addAction(aPublishDeck);
|
rightToolBar->addAction(aPublishDeck);
|
||||||
rightToolBar->addAction(aNewFolder);
|
rightToolBar->addAction(aNewFolder);
|
||||||
rightToolBar->addAction(aDeleteRemoteDeck);
|
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();
|
retranslateUi();
|
||||||
|
|
||||||
QWidget *mainWidget = new QWidget(this);
|
QWidget *mainWidget = new QWidget(this);
|
||||||
|
|
@ -272,6 +313,8 @@ void TabDeckStorage::retranslateUi()
|
||||||
aShareDecks->setText(tr("Share decks"));
|
aShareDecks->setText(tr("Share decks"));
|
||||||
aPublishDeck->setText(tr("Publish/unpublish deck"));
|
aPublishDeck->setText(tr("Publish/unpublish deck"));
|
||||||
aOpenDecksFolder->setText(tr("Open decks folder"));
|
aOpenDecksFolder->setText(tr("Open decks folder"));
|
||||||
|
aSyncNow->setText(tr("Sync now"));
|
||||||
|
renderSyncStatus();
|
||||||
shareBar->retranslateUi();
|
shareBar->retranslateUi();
|
||||||
if (shareBar->isVisible()) {
|
if (shareBar->isVisible()) {
|
||||||
onServerSelectionChanged();
|
onServerSelectionChanged();
|
||||||
|
|
@ -299,6 +342,8 @@ QString TabDeckStorage::getTargetPath() const
|
||||||
|
|
||||||
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
|
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);
|
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,6 +361,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
|
||||||
|
|
||||||
void TabDeckStorage::setRemoteEnabled(bool enabled)
|
void TabDeckStorage::setRemoteEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
|
remoteEnabled = enabled;
|
||||||
aUpload->setEnabled(enabled);
|
aUpload->setEnabled(enabled);
|
||||||
aOpenRemoteDeck->setEnabled(enabled);
|
aOpenRemoteDeck->setEnabled(enabled);
|
||||||
aDownload->setEnabled(enabled);
|
aDownload->setEnabled(enabled);
|
||||||
|
|
@ -323,6 +369,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
|
||||||
aPublishDeck->setEnabled(enabled);
|
aPublishDeck->setEnabled(enabled);
|
||||||
aNewFolder->setEnabled(enabled);
|
aNewFolder->setEnabled(enabled);
|
||||||
aDeleteRemoteDeck->setEnabled(enabled);
|
aDeleteRemoteDeck->setEnabled(enabled);
|
||||||
|
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
serverDirView->refreshTree();
|
serverDirView->refreshTree();
|
||||||
|
|
@ -526,6 +573,87 @@ void TabDeckStorage::actOpenDecksFolder()
|
||||||
QtUtils::openPathInFileManager(dir);
|
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::actRemoteDoubleClick(const QModelIndex &curRight)
|
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
|
||||||
{
|
{
|
||||||
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {
|
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
#ifndef TAB_DECK_STORAGE_H
|
#ifndef TAB_DECK_STORAGE_H
|
||||||
#define 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 "tab.h"
|
||||||
|
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
|
@ -19,12 +20,15 @@
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
struct LoadedDeck;
|
struct LoadedDeck;
|
||||||
|
struct DeckSyncConflict;
|
||||||
|
struct DeckSyncResolution;
|
||||||
class ServerInfo_User;
|
class ServerInfo_User;
|
||||||
class QTreeView;
|
class QTreeView;
|
||||||
class QFileSystemModel;
|
class QFileSystemModel;
|
||||||
class QToolBar;
|
class QToolBar;
|
||||||
class QGroupBox;
|
class QGroupBox;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
|
class QLabel;
|
||||||
class CommandContainer;
|
class CommandContainer;
|
||||||
class Response;
|
class Response;
|
||||||
class ShareBarWidget;
|
class ShareBarWidget;
|
||||||
|
|
@ -50,6 +54,23 @@ private:
|
||||||
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
|
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
|
||||||
QAction *aOpenDecksFolder;
|
QAction *aOpenDecksFolder;
|
||||||
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
|
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
|
||||||
|
QAction *aSyncNow;
|
||||||
|
QLabel *syncStatusLabel;
|
||||||
|
DeckSyncManager *syncManager;
|
||||||
|
QString syncUsername;
|
||||||
|
bool remoteEnabled = false;
|
||||||
|
enum class SyncStatusType
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Working,
|
||||||
|
Finished,
|
||||||
|
Failed
|
||||||
|
};
|
||||||
|
using SyncSummary = DeckSyncManager::SyncSummary;
|
||||||
|
SyncStatusType syncStatusType = SyncStatusType::Idle;
|
||||||
|
QString syncStatusDetail;
|
||||||
|
SyncSummary lastSyncSummary;
|
||||||
|
void renderSyncStatus();
|
||||||
bool visibilityRefreshStarted = false;
|
bool visibilityRefreshStarted = false;
|
||||||
QTimer *visibilityRefreshTimer;
|
QTimer *visibilityRefreshTimer;
|
||||||
QStringList visibilityFailures;
|
QStringList visibilityFailures;
|
||||||
|
|
@ -108,6 +129,12 @@ private slots:
|
||||||
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
|
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
|
||||||
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
||||||
|
|
||||||
|
void actSyncNow();
|
||||||
|
void onFirstSyncDetected(int localCount, int serverCount);
|
||||||
|
void onConflictRequest(const DeckSyncConflict &conflict);
|
||||||
|
void onSyncStatusMessage(const QString &message);
|
||||||
|
void onSyncFinished(const SyncSummary &summary, const QString &error);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
|
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
|
||||||
void retranslateUi() override;
|
void retranslateUi() override;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ set(HEADERS
|
||||||
libcockatrice/deck_list/tree/abstract_deck_list_node.h
|
libcockatrice/deck_list/tree/abstract_deck_list_node.h
|
||||||
libcockatrice/deck_list/tree/deck_list_card_node.h
|
libcockatrice/deck_list/tree/deck_list_card_node.h
|
||||||
libcockatrice/deck_list/tree/inner_deck_list_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_file_format.h
|
||||||
libcockatrice/deck_list/deck_list.h
|
libcockatrice/deck_list/deck_list.h
|
||||||
libcockatrice/deck_list/deck_list_history_manager.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/abstract_deck_list_node.cpp
|
||||||
libcockatrice/deck_list/tree/deck_list_card_node.cpp
|
libcockatrice/deck_list/tree/deck_list_card_node.cpp
|
||||||
libcockatrice/deck_list/tree/inner_deck_list_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_file_format.cpp
|
||||||
libcockatrice/deck_list/deck_list.cpp
|
libcockatrice/deck_list/deck_list.cpp
|
||||||
libcockatrice/deck_list/deck_list_history_manager.cpp
|
libcockatrice/deck_list/deck_list_history_manager.cpp
|
||||||
|
|
|
||||||
143
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp
Normal file
143
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#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) {
|
||||||
|
DeckCardChange change;
|
||||||
|
change.zone = key.first;
|
||||||
|
change.cardName = key.second;
|
||||||
|
change.oldCount = oldCount;
|
||||||
|
change.newCount = newInfo.count;
|
||||||
|
change.cardSetShortName = newInfo.cardSetShortName;
|
||||||
|
change.cardSetNumber = newInfo.cardSetNumber;
|
||||||
|
change.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
|
||||||
72
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h
Normal file
72
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h
Normal 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
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include "tree/inner_deck_list_node.h"
|
#include "tree/inner_deck_list_node.h"
|
||||||
|
|
||||||
#include <QAnyStringView>
|
#include <QAnyStringView>
|
||||||
|
#include <QCryptographicHash>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
|
|
@ -453,6 +454,16 @@ void DeckList::refreshDeckHash()
|
||||||
cachedDeckHash = QString();
|
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.
|
* Calls a given function on each card in the deck.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,16 @@ public:
|
||||||
/** @name Deck identity */
|
/** @name Deck identity */
|
||||||
///@{
|
///@{
|
||||||
QString getDeckHash() const;
|
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();
|
void refreshDeckHash();
|
||||||
///@}
|
///@}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ set(PROTO_FILES
|
||||||
command_deck_share_list.proto
|
command_deck_share_list.proto
|
||||||
command_deck_share_list_mine.proto
|
command_deck_share_list_mine.proto
|
||||||
command_deck_share_remove.proto
|
command_deck_share_remove.proto
|
||||||
|
command_deck_sync_check.proto
|
||||||
command_deck_upload.proto
|
command_deck_upload.proto
|
||||||
command_del_counter.proto
|
command_del_counter.proto
|
||||||
command_delete_arrow.proto
|
command_delete_arrow.proto
|
||||||
|
|
@ -151,6 +152,7 @@ set(PROTO_FILES
|
||||||
response_deck_share_download.proto
|
response_deck_share_download.proto
|
||||||
response_deck_share_list.proto
|
response_deck_share_list.proto
|
||||||
response_deck_share_list_mine.proto
|
response_deck_share_list_mine.proto
|
||||||
|
response_deck_sync_status.proto
|
||||||
response_deck_upload.proto
|
response_deck_upload.proto
|
||||||
response_dump_zone.proto
|
response_dump_zone.proto
|
||||||
response_forgotpasswordrequest.proto
|
response_forgotpasswordrequest.proto
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,7 @@ message Response {
|
||||||
DECK_SHARE_LIST = 1104; // Response listing shared decks
|
DECK_SHARE_LIST = 1104; // Response listing shared decks
|
||||||
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
|
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
|
||||||
DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares
|
DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares
|
||||||
|
DECK_SYNC_STATUS = 1107; // Response with changed decks since a timestamp
|
||||||
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
|
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ message SessionCommand {
|
||||||
DECK_SHARE_LIST_MINE = 1032;
|
DECK_SHARE_LIST_MINE = 1032;
|
||||||
DECK_SHARE_REMOVE = 1033;
|
DECK_SHARE_REMOVE = 1033;
|
||||||
SUBMIT_PASSWORD_VERIFIER = 1034;
|
SUBMIT_PASSWORD_VERIFIER = 1034;
|
||||||
|
DECK_SYNC_CHECK = 1035;
|
||||||
REPLAY_LIST = 1100;
|
REPLAY_LIST = 1100;
|
||||||
REPLAY_DOWNLOAD = 1101;
|
REPLAY_DOWNLOAD = 1101;
|
||||||
REPLAY_MODIFY_MATCH = 1102;
|
REPLAY_MODIFY_MATCH = 1102;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ set(HEADERS
|
||||||
libcockatrice/settings/commander_bracket_settings.h
|
libcockatrice/settings/commander_bracket_settings.h
|
||||||
libcockatrice/settings/debug_settings.h
|
libcockatrice/settings/debug_settings.h
|
||||||
libcockatrice/settings/deck_editor_settings.h
|
libcockatrice/settings/deck_editor_settings.h
|
||||||
|
libcockatrice/settings/deck_sync_settings.h
|
||||||
libcockatrice/settings/download_settings.h
|
libcockatrice/settings/download_settings.h
|
||||||
libcockatrice/settings/game_filters_settings.h
|
libcockatrice/settings/game_filters_settings.h
|
||||||
libcockatrice/settings/game_settings.h
|
libcockatrice/settings/game_settings.h
|
||||||
|
|
@ -43,6 +44,7 @@ add_library(
|
||||||
libcockatrice/settings/chat_settings.cpp
|
libcockatrice/settings/chat_settings.cpp
|
||||||
libcockatrice/settings/debug_settings.cpp
|
libcockatrice/settings/debug_settings.cpp
|
||||||
libcockatrice/settings/deck_editor_settings.cpp
|
libcockatrice/settings/deck_editor_settings.cpp
|
||||||
|
libcockatrice/settings/deck_sync_settings.cpp
|
||||||
libcockatrice/settings/download_settings.cpp
|
libcockatrice/settings/download_settings.cpp
|
||||||
libcockatrice/settings/game_filters_settings.cpp
|
libcockatrice/settings/game_filters_settings.cpp
|
||||||
libcockatrice/settings/game_settings.cpp
|
libcockatrice/settings/game_settings.cpp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
#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);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void conflictDefaultChanged(int conflictDefault);
|
||||||
|
void autoSyncChanged(bool autoSync);
|
||||||
|
void syncIntervalMinutesChanged(int syncIntervalMinutes);
|
||||||
|
|
||||||
|
private:
|
||||||
|
DeckSyncSettings(const DeckSyncSettings & /*other*/);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_SETTINGS_H
|
||||||
|
|
@ -71,6 +71,7 @@ QString PathsSettings::getRedirectCachePath() const
|
||||||
void PathsSettings::setDeckPath(const QString &_deckPath)
|
void PathsSettings::setDeckPath(const QString &_deckPath)
|
||||||
{
|
{
|
||||||
setValue(_deckPath, "decks");
|
setValue(_deckPath, "decks");
|
||||||
|
emit deckPathChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PathsSettings::setFiltersPath(const QString &_filtersPath)
|
void PathsSettings::setFiltersPath(const QString &_filtersPath)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ public:
|
||||||
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
|
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
|
void deckPathChanged();
|
||||||
void cardDatabasePathChanged();
|
void cardDatabasePathChanged();
|
||||||
void picsPathChanged();
|
void picsPathChanged();
|
||||||
void themeChanged();
|
void themeChanged();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ ALTER TABLE `cockatrice_decklist_files`
|
||||||
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
|
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
|
||||||
ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`;
|
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` (
|
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
|
||||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||||
`deck_id` int(7) unsigned zerofill NOT NULL,
|
`deck_id` int(7) unsigned zerofill NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -598,3 +598,16 @@ max_shares_per_day=50
|
||||||
; Minimum enforced: 1.
|
; Minimum enforced: 1.
|
||||||
; Default: 20
|
; Default: 20
|
||||||
max_versions=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
|
||||||
|
|
|
||||||
|
|
@ -632,6 +632,16 @@ bool Servatrice::initServer()
|
||||||
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
|
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
|
||||||
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
|
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
|
// SOCKET SERVER
|
||||||
if (getNumberOfTCPPools() > 0) {
|
if (getNumberOfTCPPools() > 0) {
|
||||||
gameServer =
|
gameServer =
|
||||||
|
|
@ -856,6 +866,11 @@ void Servatrice::cleanupExpiredDeckShares()
|
||||||
servatriceDatabaseInterface->cleanupExpiredDeckShares();
|
servatriceDatabaseInterface->cleanupExpiredDeckShares();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Servatrice::purgeDeckTombstones()
|
||||||
|
{
|
||||||
|
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
|
||||||
|
}
|
||||||
|
|
||||||
void Servatrice::statusUpdate()
|
void Servatrice::statusUpdate()
|
||||||
{
|
{
|
||||||
if (!servatriceDatabaseInterface->checkSql()) {
|
if (!servatriceDatabaseInterface->checkSql()) {
|
||||||
|
|
@ -1401,6 +1416,17 @@ int Servatrice::getDeckSyncMaxVersions() const
|
||||||
return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt());
|
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
|
int Servatrice::getNumberOfTCPPools() const
|
||||||
{
|
{
|
||||||
return settingsCache->value("server/number_pools", 1).toInt();
|
return settingsCache->value("server/number_pools", 1).toInt();
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ private slots:
|
||||||
void sweepLegacyBackups();
|
void sweepLegacyBackups();
|
||||||
void shutdownTimeout();
|
void shutdownTimeout();
|
||||||
void cleanupExpiredDeckShares();
|
void cleanupExpiredDeckShares();
|
||||||
|
void purgeDeckTombstones();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
|
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
|
||||||
|
|
@ -181,6 +182,7 @@ private:
|
||||||
QTimer *pingClock, *statusUpdateClock;
|
QTimer *pingClock, *statusUpdateClock;
|
||||||
QTimer *deckShareCleanupClock;
|
QTimer *deckShareCleanupClock;
|
||||||
QTimer *legacyBackupSweepClock;
|
QTimer *legacyBackupSweepClock;
|
||||||
|
QTimer *deckTombstoneClock;
|
||||||
Servatrice_GameServer *gameServer;
|
Servatrice_GameServer *gameServer;
|
||||||
Servatrice_WebsocketGameServer *websocketGameServer;
|
Servatrice_WebsocketGameServer *websocketGameServer;
|
||||||
Servatrice_IslServer *islServer;
|
Servatrice_IslServer *islServer;
|
||||||
|
|
@ -321,6 +323,8 @@ public:
|
||||||
int getDeckShareMaxDecksPerShare() const;
|
int getDeckShareMaxDecksPerShare() const;
|
||||||
int getDeckShareMaxSharesPerDay() const;
|
int getDeckShareMaxSharesPerDay() const;
|
||||||
int getDeckSyncMaxVersions() const;
|
int getDeckSyncMaxVersions() const;
|
||||||
|
int getDeckSyncTombstoneDays() const;
|
||||||
|
int getDeckSyncPurgeInterval() const;
|
||||||
int getMaxUsersPerAddress() const;
|
int getMaxUsersPerAddress() const;
|
||||||
/// Raw security/trusted_sources list; match addresses against it with isTrustedSource().
|
/// Raw security/trusted_sources list; match addresses against it with isTrustedSource().
|
||||||
QString getTrustedSources() const;
|
QString getTrustedSources() const;
|
||||||
|
|
|
||||||
|
|
@ -1370,8 +1370,8 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
|
||||||
{
|
{
|
||||||
checkSql();
|
checkSql();
|
||||||
|
|
||||||
QSqlQuery *query =
|
QSqlQuery *query = prepareQuery(
|
||||||
prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user");
|
"select content from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
|
||||||
query->bindValue(":id", deckId);
|
query->bindValue(":id", deckId);
|
||||||
query->bindValue(":id_user", userId);
|
query->bindValue(":id_user", userId);
|
||||||
execSqlQuery(query);
|
execSqlQuery(query);
|
||||||
|
|
@ -1522,6 +1522,19 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
|
||||||
execSqlQuery(query);
|
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)
|
bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares)
|
||||||
{
|
{
|
||||||
checkSql();
|
checkSql();
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,11 @@ public:
|
||||||
bool getDeckShareItem(const QString &token, int itemId, QString &content);
|
bool getDeckShareItem(const QString &token, int itemId, QString &content);
|
||||||
/** @brief Deletes all expired share bundles (cascades to their items). */
|
/** @brief Deletes all expired share bundles (cascades to their items). */
|
||||||
void cleanupExpiredDeckShares();
|
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 getNextGameId() override;
|
||||||
int getNextReplayId() override;
|
int getNextReplayId() override;
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
|
#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_list_mine.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_deck_share_remove.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_deck_upload.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
|
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
|
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
|
||||||
|
|
@ -134,6 +135,7 @@
|
||||||
#include <libcockatrice/protocol/pb/response_deck_share_download.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.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_deck_share_list_mine.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_deck_upload.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
|
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
|
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
|
||||||
|
|
@ -371,6 +373,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
|
||||||
return cmdDeckUpload(cmd.GetExtension(Command_DeckUpload::ext), rc);
|
return cmdDeckUpload(cmd.GetExtension(Command_DeckUpload::ext), rc);
|
||||||
case SessionCommand::DECK_DOWNLOAD:
|
case SessionCommand::DECK_DOWNLOAD:
|
||||||
return cmdDeckDownload(cmd.GetExtension(Command_DeckDownload::ext), rc);
|
return cmdDeckDownload(cmd.GetExtension(Command_DeckDownload::ext), rc);
|
||||||
|
case SessionCommand::DECK_SYNC_CHECK:
|
||||||
|
return cmdDeckSyncCheck(cmd.GetExtension(Command_DeckSyncCheck::ext), rc);
|
||||||
case SessionCommand::REPLAY_LIST:
|
case SessionCommand::REPLAY_LIST:
|
||||||
return cmdReplayList(cmd.GetExtension(Command_ReplayList::ext), rc);
|
return cmdReplayList(cmd.GetExtension(Command_ReplayList::ext), rc);
|
||||||
case SessionCommand::REPLAY_DOWNLOAD:
|
case SessionCommand::REPLAY_DOWNLOAD:
|
||||||
|
|
@ -709,7 +713,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
|
||||||
|
|
||||||
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
|
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
|
||||||
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
|
"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_folder", folderId);
|
||||||
query->bindValue(":id_user", userId);
|
query->bindValue(":id_user", userId);
|
||||||
if (!sqlInterface->execSqlQuery(query)) {
|
if (!sqlInterface->execSqlQuery(query)) {
|
||||||
|
|
@ -854,8 +859,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
|
||||||
sqlInterface->checkSql();
|
sqlInterface->checkSql();
|
||||||
|
|
||||||
if (cmd.has_deck_id()) {
|
if (cmd.has_deck_id()) {
|
||||||
QSqlQuery *query =
|
QSqlQuery *query = sqlInterface->prepareQuery(
|
||||||
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
|
"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", cmd.deck_id());
|
||||||
query->bindValue(":id_user", userInfo->id());
|
query->bindValue(":id_user", userInfo->id());
|
||||||
sqlInterface->execSqlQuery(query);
|
sqlInterface->execSqlQuery(query);
|
||||||
|
|
@ -893,6 +898,61 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
|
||||||
return Response::RespOk;
|
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::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd,
|
Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd,
|
||||||
ResponseContainer &rc)
|
ResponseContainer &rc)
|
||||||
{
|
{
|
||||||
|
|
@ -965,7 +1025,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
|
||||||
deckDelDirHelper(query->value(0).toInt());
|
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);
|
query->bindValue(":id_folder", basePathId);
|
||||||
sqlInterface->execSqlQuery(query);
|
sqlInterface->execSqlQuery(query);
|
||||||
|
|
||||||
|
|
@ -1016,18 +1077,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
|
||||||
|
|
||||||
sqlInterface->checkSql();
|
sqlInterface->checkSql();
|
||||||
QSqlQuery *query =
|
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", cmd.deck_id());
|
||||||
query->bindValue(":id_user", userInfo->id());
|
query->bindValue(":id_user", userInfo->id());
|
||||||
sqlInterface->execSqlQuery(query);
|
if (!sqlInterface->execSqlQuery(query)) {
|
||||||
if (!query->next()) {
|
return Response::RespContextError;
|
||||||
|
}
|
||||||
|
if (query->numRowsAffected() == 0) {
|
||||||
return Response::RespNameNotFound;
|
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;
|
return Response::RespOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,7 +1174,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
||||||
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
|
const QString bannerCardProvider = deck.getBannerCard().providerId.left(32);
|
||||||
const QString tagsJson = serializeDeckTags(deck.getTags());
|
const QString tagsJson = serializeDeckTags(deck.getTags());
|
||||||
const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity()));
|
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();
|
QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString();
|
||||||
if (QUuid::fromString(guid).isNull()) {
|
if (QUuid::fromString(guid).isNull()) {
|
||||||
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
guid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
|
|
@ -1126,31 +1188,71 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
||||||
return Response::RespNameNotFound;
|
return Response::RespNameNotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
QSqlQuery *query =
|
// A deck that was deleted elsewhere leaves a tombstone behind. Revive it
|
||||||
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
|
// instead of inserting a second row with the same guid, which would split
|
||||||
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
|
// the deck's version history and leave the tombstone to be purged.
|
||||||
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
|
int deckId = -1;
|
||||||
":content, :is_public, :banner_card_name, :banner_card_provider, "
|
bool inserted = false;
|
||||||
":color_identity, :tags, :guid, :hash, NOW())");
|
QSqlQuery *tombstoneQuery =
|
||||||
query->bindValue(":id_folder", folderId);
|
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_user = :id_user and guid = "
|
||||||
query->bindValue(":id_user", userInfo->id());
|
":guid and deleted = 1 order by last_modified desc limit 1");
|
||||||
query->bindValue(":name", deckName);
|
tombstoneQuery->bindValue(":id_user", userInfo->id());
|
||||||
query->bindValue(":content", deckStr);
|
tombstoneQuery->bindValue(":guid", guid);
|
||||||
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
|
if (!sqlInterface->execSqlQuery(tombstoneQuery)) {
|
||||||
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;
|
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)) {
|
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
|
||||||
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
|
if (inserted) {
|
||||||
cleanupQuery->bindValue(":id", deckId);
|
QSqlQuery *cleanupQuery =
|
||||||
sqlInterface->execSqlQuery(cleanupQuery);
|
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
|
||||||
|
cleanupQuery->bindValue(":id", deckId);
|
||||||
|
sqlInterface->execSqlQuery(cleanupQuery);
|
||||||
|
}
|
||||||
return Response::RespContextError;
|
return Response::RespContextError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1160,9 +1262,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
||||||
stampQuery->bindValue(":id", deckId);
|
stampQuery->bindValue(":id", deckId);
|
||||||
stampQuery->bindValue(":id_user", userInfo->id());
|
stampQuery->bindValue(":id_user", userInfo->id());
|
||||||
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
|
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
|
||||||
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
|
if (inserted) {
|
||||||
cleanupQuery->bindValue(":id", deckId);
|
QSqlQuery *cleanupQuery =
|
||||||
sqlInterface->execSqlQuery(cleanupQuery);
|
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
|
||||||
|
cleanupQuery->bindValue(":id", deckId);
|
||||||
|
sqlInterface->execSqlQuery(cleanupQuery);
|
||||||
|
}
|
||||||
return Response::RespContextError;
|
return Response::RespContextError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1195,9 +1300,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
|
||||||
}
|
}
|
||||||
const QString oldHash = currentQuery->value(1).toString();
|
const QString oldHash = currentQuery->value(1).toString();
|
||||||
|
|
||||||
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
|
QString updateQuery =
|
||||||
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
|
"update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
|
||||||
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
|
"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()) {
|
if (cmd.has_is_public()) {
|
||||||
updateQuery += ", is_public=:is_public";
|
updateQuery += ", is_public=:is_public";
|
||||||
}
|
}
|
||||||
|
|
@ -1399,7 +1506,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C
|
||||||
// issues its own query on the same cached statement set.
|
// issues its own query on the same cached statement set.
|
||||||
QSqlQuery *query =
|
QSqlQuery *query =
|
||||||
sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = "
|
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_folder", folderId);
|
||||||
query->bindValue(":id_user", userInfo->id());
|
query->bindValue(":id_user", userInfo->id());
|
||||||
if (!sqlInterface->execSqlQuery(query)) {
|
if (!sqlInterface->execSqlQuery(query)) {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class Command_DeckShareList;
|
||||||
class Command_DeckShareListMine;
|
class Command_DeckShareListMine;
|
||||||
class Command_DeckShareRemove;
|
class Command_DeckShareRemove;
|
||||||
class Command_DeckShareDownload;
|
class Command_DeckShareDownload;
|
||||||
|
class Command_DeckSyncCheck;
|
||||||
class Command_ReplayList;
|
class Command_ReplayList;
|
||||||
class Command_ReplayDownload;
|
class Command_ReplayDownload;
|
||||||
class Command_ReplayModifyMatch;
|
class Command_ReplayModifyMatch;
|
||||||
|
|
@ -111,6 +112,7 @@ class Command_ViewLogHistory;
|
||||||
class Command_WarnUser;
|
class Command_WarnUser;
|
||||||
class QObject;
|
class QObject;
|
||||||
class ResponseContainer;
|
class ResponseContainer;
|
||||||
|
class Response_DeckSyncStatus;
|
||||||
|
|
||||||
class AbstractServerSocketInterface : public Server_ProtocolHandler
|
class AbstractServerSocketInterface : public Server_ProtocolHandler
|
||||||
{
|
{
|
||||||
|
|
@ -163,6 +165,8 @@ private:
|
||||||
bool storeDeckVersion(int deckId, const QString &content, const QString &hash);
|
bool storeDeckVersion(int deckId, const QString &content, const QString &hash);
|
||||||
DeckList *getDeckFromDatabase(int deckId);
|
DeckList *getDeckFromDatabase(int deckId);
|
||||||
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
|
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 cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc);
|
||||||
Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc);
|
Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc);
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,43 @@ cockatrice_add_test(
|
||||||
bench
|
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.
|
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
|
||||||
if(TARGET Qt6::lrelease)
|
if(TARGET Qt6::lrelease)
|
||||||
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
|
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
|
||||||
|
|
|
||||||
51
tests/deck_content_hash_test.cpp
Normal file
51
tests/deck_content_hash_test.cpp
Normal 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
181
tests/deck_diff_test.cpp
Normal 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();
|
||||||
|
}
|
||||||
63
tests/deck_sync_profile_test.cpp
Normal file
63
tests/deck_sync_profile_test.cpp
Normal 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();
|
||||||
|
}
|
||||||
149
tests/deck_sync_sidecar_test.cpp
Normal file
149
tests/deck_sync_sidecar_test.cpp
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
#include <libcockatrice/settings/cards_display_settings.h>
|
#include <libcockatrice/settings/cards_display_settings.h>
|
||||||
#include <libcockatrice/settings/chat_settings.h>
|
#include <libcockatrice/settings/chat_settings.h>
|
||||||
#include <libcockatrice/settings/deck_editor_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/download_settings.h>
|
||||||
#include <libcockatrice/settings/game_settings.h>
|
#include <libcockatrice/settings/game_settings.h>
|
||||||
#include <libcockatrice/settings/interface_settings.h>
|
#include <libcockatrice/settings/interface_settings.h>
|
||||||
|
|
@ -586,6 +587,52 @@ TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet)
|
||||||
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// --- NetworkSettings ---
|
// --- NetworkSettings ---
|
||||||
|
|
||||||
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)
|
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue