diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9212a2902..a083c9384 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -29,6 +29,7 @@ set(cockatrice_SOURCES src/interface/deck_loader/loaded_deck.cpp src/interface/deck_sync/deck_sync_profile.cpp src/interface/deck_sync/deck_sync_sidecar.cpp + src/interface/deck_sync/deck_sync_manager.cpp src/interface/widgets/dialogs/dlg_connect.cpp src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp src/interface/widgets/dialogs/dlg_create_game.cpp @@ -57,6 +58,8 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp + src/interface/widgets/dialogs/dlg_sync_conflict.cpp + src/interface/widgets/dialogs/dlg_sync_onboarding.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 15a41e7e1..a8b0a2386 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -155,6 +156,7 @@ SettingsCache::SettingsCache() cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this); interfaceSettings = new InterfaceSettings(settingsPath, this); deckEditorSettings = new DeckEditorSettings(settingsPath, this); + deckSyncSettings = new DeckSyncSettings(settingsPath, this); pathsSettings = new PathsSettings(settingsPath, this); visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this); appearanceSettings = new AppearanceSettings(settingsPath, this); @@ -456,6 +458,11 @@ DeckEditorSettings &SettingsCache::deckEditor() const return *deckEditorSettings; } +DeckSyncSettings &SettingsCache::deckSync() const +{ + return *deckSyncSettings; +} + VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const { return *visualDeckStorageSettings; diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 5f699247d..7c798dd8e 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -33,6 +33,7 @@ class ChatSettings; class CommanderBracketSettings; class DebugSettings; class DeckEditorSettings; +class DeckSyncSettings; class DownloadSettings; class GameFiltersSettings; class GameSettings; @@ -82,6 +83,7 @@ private: CardsDisplaySettings *cardsDisplaySettings = nullptr; InterfaceSettings *interfaceSettings = nullptr; DeckEditorSettings *deckEditorSettings = nullptr; + DeckSyncSettings *deckSyncSettings = nullptr; PathsSettings *pathsSettings = nullptr; VisualDeckStorageSettings *visualDeckStorageSettings = nullptr; AppearanceSettings *appearanceSettings = nullptr; @@ -150,6 +152,7 @@ public: [[nodiscard]] CardsDisplaySettings &cardsDisplay() const; [[nodiscard]] InterfaceSettings &userInterface() const; [[nodiscard]] DeckEditorSettings &deckEditor() const; + [[nodiscard]] DeckSyncSettings &deckSync() const; [[nodiscard]] PathsSettings &paths() const; [[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const; [[nodiscard]] AppearanceSettings &appearance() const; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_conflict.h b/cockatrice/src/interface/deck_sync/deck_sync_conflict.h new file mode 100644 index 000000000..a5b272ecd --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_conflict.h @@ -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 + +/** @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 diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp new file mode 100644 index 000000000..2a1f205e1 --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -0,0 +1,1131 @@ +#include "deck_sync_manager.h" + +#include "../deck_loader/deck_file_format.h" +#include "../deck_loader/deck_loader.h" +#include "deck_sync_sidecar.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr const char *SYNC_META_DIR = ".sync"; +constexpr const char *BACKUP_SUFFIX = ".sync-backup-"; +constexpr int WATCH_DEBOUNCE_MS = 2000; + +QString serverPathFor(const QString &relativePath) +{ + QString path = relativePath; + if (path.endsWith(".cod", Qt::CaseInsensitive)) { + path.chop(4); + } + return path; +} +} // namespace + +DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent) +{ + watcher = new QFileSystemWatcher(this); + debounceTimer = new QTimer(this); + debounceTimer->setSingleShot(true); + debounceTimer->setInterval(WATCH_DEBOUNCE_MS); + intervalTimer = new QTimer(this); + connect(watcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString &path) { onWatchedPathChanged(path); }); + connect(watcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString &path) { + refreshWatcher(); + onWatchedPathChanged(path); + }); + connect(debounceTimer, &QTimer::timeout, this, &DeckSyncManager::onDebounceTimeout); + connect(intervalTimer, &QTimer::timeout, this, [this] { autoSyncSoon(); }); + refreshInterval(); +} + +void DeckSyncManager::setClient(AbstractClient *_client) +{ + client = _client; + if (client == nullptr) { + return; + } + connect( + client, &AbstractClient::statusChanged, this, + [this](ClientStatus status) { + if (status == StatusDisconnected) { + offline = true; + debounceTimer->stop(); + } + }, + Qt::UniqueConnection); + connect( + client, &AbstractClient::userInfoChanged, this, + [this](const ServerInfo_User &userInfo) { + offline = false; + serverUsername = QString::fromStdString(userInfo.name()); + autoSyncSoon(); + }, + Qt::UniqueConnection); +} + +void DeckSyncManager::setServerIdentity(const QString &host, int port, const QString &username) +{ + serverHost = host; + serverPort = port; + serverUsername = username; +} + +void DeckSyncManager::setDeckPath(const QString &path) +{ + deckPath = path; + refreshWatcher(); +} + +void DeckSyncManager::setAutoSyncEnabled(bool enabled) +{ + autoSyncEnabled = enabled; + refreshInterval(); +} + +void DeckSyncManager::setSyncIntervalMinutes(int minutes) +{ + syncIntervalMinutes = qBound(1, minutes, 60); + refreshInterval(); +} + +DeckSyncProfile DeckSyncManager::buildProfile() const +{ + return DeckSyncProfile(serverHost, serverPort, serverUsername); +} + +void DeckSyncManager::refreshInterval() +{ + intervalTimer->stop(); + if (autoSyncEnabled) { + intervalTimer->start(syncIntervalMinutes * 60000); + } +} + +void DeckSyncManager::refreshWatcher() +{ + watcher->removePaths(watcher->files() + watcher->directories()); + if (deckPath.isEmpty() || !QDir(deckPath).exists()) { + return; + } + QStringList watched; + watched.append(deckPath); + QDirIterator it(deckPath, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + if (!path.contains("/" + QString(SYNC_META_DIR) + "/") && !path.endsWith("/" + QString(SYNC_META_DIR))) { + watched.append(path); + } + } + watcher->addPaths(watched); +} + +void DeckSyncManager::onWatchedPathChanged(const QString &path) +{ + if (path.contains("/" + QString(SYNC_META_DIR) + "/")) { + return; + } + autoSyncSoon(); +} + +void DeckSyncManager::autoSyncSoon() +{ + if (!autoSyncEnabled) { + return; + } + debounceTimer->start(WATCH_DEBOUNCE_MS); +} + +void DeckSyncManager::onDebounceTimeout() +{ + if (syncing) { + debounceTimer->start(WATCH_DEBOUNCE_MS); + return; + } + if (canAutoRun()) { + syncNow(); + } +} + +bool DeckSyncManager::canAutoRun() const +{ + if (!autoSyncEnabled || offline || client == nullptr || deckPath.isEmpty()) { + return false; + } + const DeckSyncProfile autoProfile = buildProfile(); + if (!autoProfile.isValid()) { + return false; + } + const QString autoSidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, autoProfile.key()); + if (!QFileInfo::exists(autoSidecarPath)) { + return false; + } + DeckSyncSidecar sidecar(autoSidecarPath); + return sidecar.load() && sidecar.mode == DeckSyncSidecar::MODE_LINKED; +} + +void DeckSyncManager::setColorIdentityProvider(ColorIdentityProvider provider) +{ + colorIdentityProvider = std::move(provider); +} + +void DeckSyncManager::setConflictDefault(int _conflictDefault) +{ + conflictDefault = _conflictDefault; +} + +bool DeckSyncManager::isSyncing() const +{ + return syncing; +} + +void DeckSyncManager::syncNow() +{ + if (syncing) { + return; + } + const DeckSyncProfile runProfile = buildProfile(); + if (client == nullptr || !runProfile.isValid() || deckPath.isEmpty()) { + emit syncFinished(0, 0, 0, 0, tr("Not connected to a server.")); + return; + } + QDir deckDir(deckPath); + if (!deckDir.exists()) { + emit syncFinished(0, 0, 0, 0, tr("Deck folder not found.")); + return; + } + + syncing = true; + uploadedCount = 0; + downloadedCount = 0; + unchangedCount = 0; + skippedCount = 0; + pendingUploads.clear(); + pendingDownloads.clear(); + pendingDeletes.clear(); + conflictPaths.clear(); + stashedServerFiles.clear(); + activeConflict = ActiveConflict(); + ensuredDirs.clear(); + ensuredDirs.insert(QString()); + localFiles.clear(); + sessionDefault = -1; + syncMode = DeckSyncMode::TwoWay; + firstSyncRun = false; + pendingServerTime = -1; + emit syncStarted(); + emit statusMessage(tr("Scanning local decks...")); + + sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, runProfile.key()); + DeckSyncSidecar sidecar(sidecarPath); + if (!sidecar.load()) { + failSync(tr("Could not read sync state.")); + return; + } + if (sidecar.mode == DeckSyncSidecar::MODE_SEPARATE) { + failSync(tr("Sync is disabled for this server.")); + return; + } + firstSyncRun = !QFileInfo(sidecarPath).exists(); + + QDirIterator it(deckPath, QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString absolutePath = it.next(); + const QString relativePath = deckDir.relativeFilePath(absolutePath); + if (relativePath.startsWith(SYNC_META_DIR + QString("/")) || + !absolutePath.endsWith(".cod", Qt::CaseInsensitive)) { + continue; + } + std::optional loaded = DeckLoader::loadFromFile(absolutePath, DeckFileFormat::Cockatrice); + if (!loaded) { + ++skippedCount; + continue; + } + LocalFile file; + file.absolutePath = absolutePath; + file.hash = loaded->deckList.getDeckHash(); + file.modified = QFileInfo(absolutePath).lastModified(); + localFiles.insert(relativePath, file); + } + + if (!firstSyncRun && sidecar.lastSyncSecs > 0) { + sendSyncCheck(sidecar.lastSyncSecs); + return; + } + fetchFullList(); +} + +void DeckSyncManager::fetchFullList() +{ + Command_DeckList cmd; + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onListFinished); + client->sendCommand(pend); +} + +void DeckSyncManager::sendSyncCheck(qint64 lastSyncSecs) +{ + emit statusMessage(tr("Checking for server changes...")); + Command_DeckSyncCheck cmd; + cmd.set_last_sync_time(static_cast(lastSyncSecs)); + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onCheckFinished); + client->sendCommand(pend); +} + +void DeckSyncManager::onCheckFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (!syncing) { + return; + } + if (response.response_code() != Response::RespOk) { + fetchFullList(); + return; + } + const Response_DeckSyncStatus &status = response.GetExtension(Response_DeckSyncStatus::ext); + DeckSyncSidecar sidecar(sidecarPath); + if (!sidecar.load()) { + fetchFullList(); + return; + } + + bool localClean = status.changed_files_size() == 0; + if (localClean) { + for (auto it = localFiles.begin(); it != localFiles.end(); ++it) { + const DeckSyncRecord record = sidecar.get(it.key()); + if (record.remoteId == -1 || record.baseHash != it.value().hash) { + localClean = false; + break; + } + } + } + if (localClean) { + const QMap knownRecords = sidecar.records(); + for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) { + if (!localFiles.contains(it.key())) { + localClean = false; + break; + } + } + } + if (!localClean) { + if (status.changed_files_size() > 0) { + emit statusMessage(tr("%1 server changes, syncing...").arg(status.changed_files_size())); + } + fetchFullList(); + return; + } + + pendingServerTime = status.has_server_time() ? static_cast(status.server_time()) : -1; + unchangedCount = localFiles.size(); + emit statusMessage(tr("Decks are already in sync.")); + finishSync(); +} + +void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &folder, + const QString &prefix, + QMap &files, + QSet &dirs) +{ + for (const auto &item : folder.items()) { + const QString name = QString::fromStdString(item.name()); + if (item.has_folder()) { + const QString path = prefix.isEmpty() ? name : prefix + "/" + name; + dirs.insert(path); + collectServerFiles(item.folder(), path, files, dirs); + } else if (item.has_file()) { + const QString path = prefix.isEmpty() ? name : prefix + "/" + name; + const ServerInfo_DeckStorage_File &file = item.file(); + ServerFile entry; + entry.id = item.id(); + entry.guid = QString::fromStdString(file.guid()); + entry.hash = QString::fromStdString(file.hash()); + entry.lastModifiedSecs = file.has_last_modified() ? static_cast(file.last_modified()) : 0; + files.insert(path, entry); + } + } +} + +void DeckSyncManager::onListFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (!syncing) { + return; + } + if (response.response_code() != Response::RespOk) { + failSync(tr("Could not read server decks.")); + return; + } + + QMap serverFiles; + QSet serverDirs; + const Response_DeckList &list = response.GetExtension(Response_DeckList::ext); + collectServerFiles(list.root(), QString(), serverFiles, serverDirs); + ensuredDirs.unite(serverDirs); + + if (firstSyncRun && !localFiles.isEmpty() && !serverFiles.isEmpty()) { + stashedServerFiles = serverFiles; + emit firstSyncDetected(localFiles.size(), serverFiles.size()); + return; + } + classify(serverFiles); +} + +void DeckSyncManager::continueFirstSync(DeckSyncMode mode) +{ + if (!syncing || !firstSyncRun) { + return; + } + firstSyncRun = false; + if (mode == DeckSyncMode::Separate) { + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + sidecar.mode = DeckSyncSidecar::MODE_SEPARATE; + sidecar.save(); + stashedServerFiles.clear(); + failSync(tr("Sync is disabled for this server.")); + return; + } + syncMode = mode; + classify(stashedServerFiles); + stashedServerFiles.clear(); +} + +void DeckSyncManager::cancelFirstSync() +{ + if (!syncing || !firstSyncRun) { + return; + } + firstSyncRun = false; + stashedServerFiles.clear(); + failSync(tr("Sync cancelled.")); +} + +void DeckSyncManager::classify(const QMap &serverFiles) +{ + emit statusMessage(tr("Comparing %1 local and %2 server decks...").arg(localFiles.size()).arg(serverFiles.size())); + + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + const qint64 nowSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); + + for (auto it = localFiles.begin(); it != localFiles.end(); ++it) { + const QString serverPath = serverPathFor(it.key()); + auto serverIt = serverFiles.find(serverPath); + if (serverIt == serverFiles.end()) { + const DeckSyncRecord known = sidecar.get(it.key()); + if (known.remoteId != -1) { + if (syncMode == DeckSyncMode::UploadOnly) { + continue; + } + // The server no longer lists this deck: it was deleted elsewhere. + // Apply the delete locally, keeping a backup of the file. + const QString absolutePath = deckPath + "/" + it.key(); + const QString backupPath = + absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); + if (!QFile::copy(absolutePath, backupPath) || !QFile::remove(absolutePath)) { + ++skippedCount; + continue; + } + sidecar.remove(it.key()); + ++downloadedCount; + continue; + } + if (syncMode == DeckSyncMode::DownloadOnly) { + continue; + } + UploadOp op; + op.relativePath = it.key(); + op.serverPath = serverPath; + op.deckId = -1; + op.guid = !known.guid.isEmpty() ? known.guid : QUuid::createUuid().toString(QUuid::WithoutBraces); + pendingUploads.append(op); + continue; + } + if (it.value().hash == serverIt.value().hash) { + DeckSyncRecord record = sidecar.get(it.key()); + if (record.guid.isEmpty()) { + record.guid = serverIt.value().guid; + } + record.remoteId = serverIt.value().id; + record.baseHash = it.value().hash; + record.lastSyncSecs = nowSecs; + record.deleted = false; + sidecar.set(it.key(), record); + snapshotLocalFile(it.key(), record.guid); + ++unchangedCount; + continue; + } + const qint64 localSecs = it.value().modified.toSecsSinceEpoch(); + const int effective = sessionDefault >= 0 ? sessionDefault : conflictDefault; + // A server row without last_modified predates sync: the local file wins. + // Otherwise strictly newer wins; ties go to the server. + if (syncMode == DeckSyncMode::UploadOnly || effective == DeckSyncConflictKeepMine) { + applyUpload(it.key(), serverPath, serverIt.value().id, serverIt.value().guid); + } else if (syncMode == DeckSyncMode::DownloadOnly || effective == DeckSyncConflictKeepServer) { + applyDownload(it.key(), serverIt.value().id, serverIt.value().guid, serverIt.value().hash, + serverIt.value().lastModifiedSecs); + } else if (effective == DeckSyncConflictKeepNewer && + (serverIt.value().lastModifiedSecs == 0 || localSecs > serverIt.value().lastModifiedSecs)) { + applyUpload(it.key(), serverPath, serverIt.value().id, serverIt.value().guid); + } else if (effective == DeckSyncConflictKeepNewer) { + applyDownload(it.key(), serverIt.value().id, serverIt.value().guid, serverIt.value().hash, + serverIt.value().lastModifiedSecs); + } else { + queueConflict(it.key(), serverPath, serverIt.value().id, serverIt.value().guid, serverIt.value().hash, + serverIt.value().lastModifiedSecs); + } + } + + if (syncMode != DeckSyncMode::UploadOnly) { + for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) { + const QString wantedPath = it.key().endsWith(".cod", Qt::CaseInsensitive) ? it.key() : it.key() + ".cod"; + if (localFiles.contains(wantedPath) || QFileInfo(deckPath + "/" + wantedPath).exists()) { + continue; + } + const QString relativePath = uniqueLocalPath(wantedPath); + if (relativePath.isEmpty()) { + ++skippedCount; + continue; + } + DownloadOp op; + op.relativePath = relativePath; + op.deckId = it.value().id; + op.guid = it.value().guid; + op.hash = it.value().hash; + op.lastModifiedSecs = it.value().lastModifiedSecs; + pendingDownloads.append(op); + } + } + + if (syncMode != DeckSyncMode::DownloadOnly) { + const QMap knownRecords = sidecar.records(); + for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) { + if (localFiles.contains(it.key())) { + continue; + } + if (it.value().remoteId == -1) { + sidecar.remove(it.key()); + continue; + } + pendingDeletes.append(it.key()); + } + } + + sidecar.save(); + processNext(); +} + +QString DeckSyncManager::parentDir(const QString &path) +{ + const int slash = path.lastIndexOf('/'); + return slash == -1 ? QString() : path.left(slash); +} + +void DeckSyncManager::processNext() +{ + if (!syncing || activeConflict.active) { + return; + } + if (!pendingDeletes.isEmpty()) { + const QString relativePath = pendingDeletes.first(); + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + sendDelete(relativePath, sidecar.get(relativePath).remoteId); + } else if (!pendingUploads.isEmpty()) { + processNextUpload(); + } else if (!pendingDownloads.isEmpty()) { + sendDownload(pendingDownloads.first()); + } else { + finishSync(); + } +} + +void DeckSyncManager::applyUpload(const QString &relativePath, + const QString &serverPath, + int deckId, + const QString &guid) +{ + UploadOp op; + op.relativePath = relativePath; + op.serverPath = serverPath; + op.deckId = deckId; + op.guid = guid; + pendingUploads.append(op); +} + +void DeckSyncManager::applyDownload(const QString &relativePath, + int deckId, + const QString &guid, + const QString &hash, + qint64 lastModifiedSecs) +{ + DownloadOp op; + op.relativePath = relativePath; + op.deckId = deckId; + op.guid = guid; + op.hash = hash; + op.lastModifiedSecs = lastModifiedSecs; + pendingDownloads.append(op); +} + +QString DeckSyncManager::readBaseSnapshot(const QString &guid) const +{ + if (guid.isEmpty()) { + return {}; + } + QFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod"); + if (!snapshot.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(snapshot.readAll()); +} + +void DeckSyncManager::queueConflict(const QString &relativePath, + const QString &serverPath, + int deckId, + const QString &serverGuid, + const QString &serverHash, + qint64 serverLastModifiedSecs) +{ + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + const QString guid = !serverGuid.isEmpty() ? serverGuid : sidecar.get(relativePath).guid; + if (readBaseSnapshot(guid).isEmpty()) { + emit statusMessage(tr("No base snapshot for %1, keeping the newer version.").arg(relativePath)); + const qint64 localSecs = localFiles.value(relativePath).modified.toSecsSinceEpoch(); + if (serverLastModifiedSecs == 0 || localSecs > serverLastModifiedSecs) { + applyUpload(relativePath, serverPath, deckId, serverGuid); + } else { + applyDownload(relativePath, deckId, serverGuid, serverHash, serverLastModifiedSecs); + } + return; + } + ConflictDownload ctx; + ctx.serverPath = serverPath; + ctx.deckId = deckId; + ctx.guid = serverGuid; + ctx.hash = serverHash; + ctx.lastModifiedSecs = serverLastModifiedSecs; + pendingConflicts.insert(relativePath, ctx); + conflictPaths.insert(relativePath); + applyDownload(relativePath, deckId, serverGuid, serverHash, serverLastModifiedSecs); +} + +void DeckSyncManager::handleConflictDownload(const QString &relativePath, const QString &serverDeckString) +{ + const ConflictDownload ctx = pendingConflicts.take(relativePath); + if (sessionDefault == DeckSyncConflictKeepMine) { + applyUpload(relativePath, ctx.serverPath, ctx.deckId, ctx.guid); + processNext(); + return; + } + if (sessionDefault == DeckSyncConflictKeepServer) { + applyDownloadedDeck(relativePath, ctx.deckId, serverDeckString, ctx.guid, ctx.hash, ctx.lastModifiedSecs, + ctx.lastModifiedSecs != 0); + return; + } + std::optional loaded = + DeckLoader::loadFromFile(localFiles.value(relativePath).absolutePath, DeckFileFormat::Cockatrice); + DeckList serverDeck; + if (!loaded || !serverDeck.loadFromString_Native(serverDeckString)) { + ++skippedCount; + processNext(); + return; + } + + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + const QString guid = !ctx.guid.isEmpty() ? ctx.guid : sidecar.get(relativePath).guid; + activeConflict.active = true; + activeConflict.relativePath = relativePath; + activeConflict.serverPath = ctx.serverPath; + activeConflict.deckId = ctx.deckId; + activeConflict.serverGuid = ctx.guid; + activeConflict.serverHash = ctx.hash; + activeConflict.serverLastModifiedSecs = ctx.lastModifiedSecs; + activeConflict.serverDeckString = serverDeckString; + + DeckSyncConflict conflict; + conflict.relativePath = relativePath; + conflict.localAbsolutePath = localFiles.value(relativePath).absolutePath; + conflict.serverDeckString = serverDeckString; + conflict.baseDeckString = readBaseSnapshot(guid); + conflict.hasBase = !conflict.baseDeckString.isEmpty(); + conflict.localSecs = localFiles.value(relativePath).modified.toSecsSinceEpoch(); + conflict.serverSecs = ctx.lastModifiedSecs; + emit conflictRequest(conflict); +} + +void DeckSyncManager::resolveConflict(const DeckSyncResolution &resolution) +{ + if (!syncing || !activeConflict.active) { + return; + } + if (resolution.rememberSession && + (resolution.action == DeckSyncKeepMine || resolution.action == DeckSyncKeepServer)) { + sessionDefault = resolution.action == DeckSyncKeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer; + } + const ActiveConflict conflict = activeConflict; + activeConflict.active = false; + + switch (resolution.action) { + case DeckSyncKeepMine: + applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); + break; + case DeckSyncKeepServer: + applyDownload(conflict.relativePath, conflict.deckId, conflict.serverGuid, conflict.serverHash, + conflict.serverLastModifiedSecs); + break; + case DeckSyncKeepBoth: { + // Persist the already-fetched server bytes synchronously: queuing a + // download here would race the upload below and fetch back Mine. + // The aside copy is a new local file, so it gets a fresh guid and + // uploads as its own deck on the next run. + QString asideBase = conflict.relativePath; + if (asideBase.endsWith(".cod", Qt::CaseInsensitive)) { + asideBase.chop(4); + } + const QString aside = uniqueLocalPath(asideBase + " (server).cod"); + DeckList serverDeck; + if (aside.isEmpty() || !serverDeck.loadFromString_Native(conflict.serverDeckString)) { + emit statusMessage(tr("Could not store the server version of %1 aside, keeping the local deck.") + .arg(conflict.relativePath)); + ++skippedCount; + } else { + const QString asideAbsolute = deckPath + "/" + aside; + QDir().mkpath(QFileInfo(asideAbsolute).absolutePath()); + if (!DeckLoader::saveToFile(serverDeck, asideAbsolute, DeckFileFormat::Cockatrice)) { + ++skippedCount; + } else { + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + DeckSyncRecord asideRecord; + asideRecord.guid = QUuid::createUuid().toString(QUuid::WithoutBraces); + asideRecord.baseHash = serverDeck.getDeckHash(); + sidecar.set(aside, asideRecord); + sidecar.save(); + snapshotLocalFile(aside, asideRecord.guid); + } + } + applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); + break; + } + case DeckSyncUseMerge: { + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + const QString guid = + !conflict.serverGuid.isEmpty() ? conflict.serverGuid : sidecar.get(conflict.relativePath).guid; + DeckList localDeck; + DeckList serverDeck; + DeckList baseDeck; + std::optional loaded = DeckLoader::loadFromFile( + localFiles.value(conflict.relativePath).absolutePath, DeckFileFormat::Cockatrice); + if (!loaded || !serverDeck.loadFromString_Native(conflict.serverDeckString) || + !baseDeck.loadFromString_Native(readBaseSnapshot(guid))) { + emit statusMessage( + tr("Could not build the merge for %1, keeping the local deck.").arg(conflict.relativePath)); + applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); + break; + } + localDeck = loaded->deckList; + const DeckDiffResult localChanges = DeckDiff::diff(baseDeck, localDeck); + const DeckDiffResult remoteChanges = DeckDiff::diff(baseDeck, serverDeck); + const DeckList merged = DeckDiff::merge(baseDeck, localChanges, remoteChanges); + const QString absolutePath = deckPath + "/" + conflict.relativePath; + const QString backupPath = + absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); + if (!QFile::copy(absolutePath, backupPath) || + !DeckLoader::saveToFile(merged, absolutePath, DeckFileFormat::Cockatrice)) { + ++skippedCount; + break; + } + std::optional reloaded = DeckLoader::loadFromFile(absolutePath, DeckFileFormat::Cockatrice); + if (reloaded) { + localFiles[conflict.relativePath].hash = reloaded->deckList.getDeckHash(); + localFiles[conflict.relativePath].modified = QFileInfo(absolutePath).lastModified(); + } + applyUpload(conflict.relativePath, conflict.serverPath, conflict.deckId, conflict.serverGuid); + break; + } + case DeckSyncSkipConflict: + ++skippedCount; + break; + } + processNext(); +} + +void DeckSyncManager::processNextUpload() +{ + const UploadOp op = pendingUploads.first(); + const QString dir = parentDir(op.serverPath); + if (!ensuredDirs.contains(dir)) { + sendMkDirFor(dir); + return; + } + pendingUploads.removeFirst(); + sendUpload(op); +} + +void DeckSyncManager::sendMkDirFor(const QString &dir) +{ + QStringList parts = dir.split('/'); + QString made; + for (const QString &part : parts) { + const QString candidate = made.isEmpty() ? part : made + "/" + part; + if (!ensuredDirs.contains(candidate)) { + Command_DeckNewDir cmd; + cmd.set_path(made.toStdString()); + cmd.set_dir_name(part.toStdString()); + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + pend->setExtraData(dir); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onEnsureDirFinished); + client->sendCommand(pend); + return; + } + made = candidate; + } + processNextUpload(); +} + +void DeckSyncManager::onEnsureDirFinished(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) +{ + if (!syncing) { + return; + } + if (response.response_code() != Response::RespOk) { + failSync(tr("Could not create server folder.")); + return; + } + const QString dir = extraData.toString(); + QStringList parts = dir.split('/'); + QString made; + for (const QString &part : parts) { + made = made.isEmpty() ? part : made + "/" + part; + if (!ensuredDirs.contains(made)) { + ensuredDirs.insert(made); + break; + } + } + processNextUpload(); +} + +void DeckSyncManager::sendUpload(const UploadOp &op) +{ + emit statusMessage(tr("Uploading %1...").arg(op.relativePath)); + std::optional loaded = + DeckLoader::loadFromFile(localFiles.value(op.relativePath).absolutePath, DeckFileFormat::Cockatrice); + if (!loaded) { + ++skippedCount; + processNext(); + return; + } + const QString deckString = loaded->deckList.writeToString_Native(); + if (deckString.length() > MAX_FILE_LENGTH) { + ++skippedCount; + processNext(); + return; + } + + Command_DeckUpload cmd; + if (op.deckId == -1) { + cmd.set_path(parentDir(op.serverPath).toStdString()); + } else { + cmd.set_deck_id(op.deckId); + } + cmd.set_deck_list(deckString.toStdString()); + cmd.set_guid(op.guid.toStdString()); + if (colorIdentityProvider) { + cmd.set_color_identity(colorIdentityProvider(loaded->deckList).toStdString()); + } + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + QVariantMap context; + context.insert("path", op.relativePath); + context.insert("serverPath", op.serverPath); + context.insert("deckId", op.deckId); + context.insert("guid", op.guid); + pend->setExtraData(context); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onUploadFinished); + client->sendCommand(pend); +} + +void DeckSyncManager::onUploadFinished(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) +{ + if (!syncing) { + return; + } + const QVariantMap context = extraData.toMap(); + const QString relativePath = context.value("path").toString(); + const int deckId = context.value("deckId", -1).toInt(); + if (response.response_code() != Response::RespOk) { + if (response.response_code() == Response::RespNameNotFound && deckId != -1) { + // The server row is gone (purged tombstone or removed elsewhere): + // upload the deck as new instead of updating a dead id. + UploadOp retry; + retry.relativePath = relativePath; + retry.serverPath = context.value("serverPath").toString(); + retry.deckId = -1; + retry.guid = context.value("guid").toString(); + pendingUploads.prepend(retry); + processNext(); + return; + } + ++skippedCount; + processNext(); + return; + } + + const Response_DeckUpload &upload = response.GetExtension(Response_DeckUpload::ext); + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + DeckSyncRecord record = sidecar.get(relativePath); + if (upload.has_new_file()) { + const auto &file = upload.new_file(); + if (!file.file().guid().empty()) { + record.guid = QString::fromStdString(file.file().guid()); + } + if (!file.file().hash().empty()) { + record.baseHash = QString::fromStdString(file.file().hash()); + } + if (file.file().has_last_modified()) { + record.lastSyncSecs = static_cast(file.file().last_modified()); + } else { + record.lastSyncSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); + } + record.remoteId = file.id(); + } else if (localFiles.contains(relativePath)) { + record.baseHash = localFiles.value(relativePath).hash; + record.lastSyncSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); + } + record.deleted = false; + sidecar.set(relativePath, record); + sidecar.save(); + snapshotLocalFile(relativePath, record.guid); + ++uploadedCount; + processNext(); +} + +QString DeckSyncManager::uniqueLocalPath(const QString &relativePath) const +{ + if (!QFileInfo(deckPath + "/" + relativePath).exists()) { + return relativePath; + } + QString base = relativePath; + if (base.endsWith(".cod", Qt::CaseInsensitive)) { + base.chop(4); + } + for (int i = 2; i < 1000; ++i) { + const QString candidate = base + " (" + QString::number(i) + ").cod"; + if (!QFileInfo(deckPath + "/" + candidate).exists()) { + return candidate; + } + } + return QString(); +} + +void DeckSyncManager::sendDownload(const DownloadOp &op) +{ + emit statusMessage(tr("Downloading %1...").arg(op.relativePath)); + Command_DeckDownload cmd; + cmd.set_deck_id(op.deckId); + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + pend->setExtraData(op.relativePath); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onDownloadFinished); + client->sendCommand(pend); +} + +void DeckSyncManager::onDownloadFinished(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) +{ + if (!syncing) { + return; + } + const QString relativePath = extraData.toString(); + int finishedDeckId = -1; + for (int i = 0; i < pendingDownloads.size(); ++i) { + if (pendingDownloads.at(i).relativePath == relativePath) { + finishedDeckId = pendingDownloads.at(i).deckId; + pendingDownloads.removeAt(i); + break; + } + } + if (response.response_code() != Response::RespOk) { + ++skippedCount; + processNext(); + return; + } + + const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext); + if (conflictPaths.contains(relativePath)) { + conflictPaths.remove(relativePath); + handleConflictDownload(relativePath, QString::fromStdString(download.deck())); + return; + } + applyDownloadedDeck(relativePath, finishedDeckId, QString::fromStdString(download.deck()), + QString::fromStdString(download.guid()), QString::fromStdString(download.hash()), + static_cast(download.last_modified()), download.has_last_modified()); +} + +void DeckSyncManager::applyDownloadedDeck(const QString &relativePath, + int deckId, + const QString &deckString, + const QString &guid, + const QString &hash, + qint64 lastModifiedSecs, + bool hasLastModified) +{ + DeckList deck; + if (!deck.loadFromString_Native(deckString)) { + ++skippedCount; + processNext(); + return; + } + + const QString absolutePath = deckPath + "/" + relativePath; + if (localFiles.contains(relativePath)) { + const QString backupPath = + absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); + if (!QFile::copy(absolutePath, backupPath)) { + ++skippedCount; + processNext(); + return; + } + } + QDir().mkpath(QFileInfo(absolutePath).absolutePath()); + if (!DeckLoader::saveToFile(deck, absolutePath, DeckFileFormat::Cockatrice)) { + ++skippedCount; + processNext(); + return; + } + + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + DeckSyncRecord record = sidecar.get(relativePath); + if (deckId != -1) { + record.remoteId = deckId; + } + if (!guid.isEmpty()) { + record.guid = guid; + } + if (!hash.isEmpty()) { + record.baseHash = hash; + } else { + record.baseHash = deck.getDeckHash(); + } + record.lastSyncSecs = hasLastModified ? lastModifiedSecs : QDateTime::currentDateTime().toSecsSinceEpoch(); + record.deleted = false; + sidecar.set(relativePath, record); + sidecar.save(); + snapshotLocalFile(relativePath, record.guid); + ++downloadedCount; + processNext(); +} + +void DeckSyncManager::sendDelete(const QString &relativePath, int deckId) +{ + emit statusMessage(tr("Deleting %1 from server...").arg(relativePath)); + Command_DeckDel cmd; + cmd.set_deck_id(deckId); + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + pend->setExtraData(relativePath); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onDeleteFinished); + client->sendCommand(pend); +} + +void DeckSyncManager::onDeleteFinished(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) +{ + if (!syncing) { + return; + } + const QString relativePath = extraData.toString(); + for (int i = 0; i < pendingDeletes.size(); ++i) { + if (pendingDeletes.at(i) == relativePath) { + pendingDeletes.removeAt(i); + break; + } + } + // Both outcomes converge the record: pushed deletes and server-side + // deletes (already tombstoned, answering NameNotFound) are done. + if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) { + DeckSyncSidecar sidecar(sidecarPath); + sidecar.load(); + sidecar.remove(relativePath); + sidecar.save(); + } else { + ++skippedCount; + } + processNext(); +} + +void DeckSyncManager::snapshotLocalFile(const QString &relativePath, const QString &guid) +{ + if (guid.isEmpty()) { + return; + } + QFile source(deckPath + "/" + relativePath); + if (!source.open(QIODevice::ReadOnly)) { + return; + } + QDir().mkpath(deckPath + "/" + SYNC_META_DIR + "/bases"); + QFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod"); + if (!snapshot.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return; + } + snapshot.write(source.readAll()); +} + +void DeckSyncManager::failSync(const QString &error) +{ + syncing = false; + emit syncFinished(uploadedCount, downloadedCount, unchangedCount, skippedCount, error); +} + +void DeckSyncManager::finishSync() +{ + syncing = false; + DeckSyncSidecar sidecar(sidecarPath); + if (sidecar.load()) { + sidecar.lastSyncSecs = + pendingServerTime >= 0 ? pendingServerTime : QDateTime::currentDateTime().toSecsSinceEpoch(); + sidecar.save(); + } + pendingServerTime = -1; + emit statusMessage(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.") + .arg(uploadedCount) + .arg(downloadedCount) + .arg(unchangedCount) + .arg(skippedCount)); + emit syncFinished(uploadedCount, downloadedCount, unchangedCount, skippedCount, QString()); +} diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h new file mode 100644 index 000000000..d3bb8ee06 --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -0,0 +1,230 @@ +/** + * @file deck_sync_manager.h + * @brief One-shot two-way deck collection sync between local files and the server. + */ + +#ifndef DECK_SYNC_MANAGER_H +#define DECK_SYNC_MANAGER_H + +#include "deck_sync_conflict.h" +#include "deck_sync_profile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class DeckList; +class QFileSystemWatcher; +class QTimer; +class Response; +class ServerInfo_DeckStorage_Folder; + +/** + * @class DeckSyncManager + * @brief Runs a manual whole-collection sync: local *.cod files vs server deck storage. + * + * Each syncNow() run loads the profile sidecar, scans the deck folder, fetches + * the server tree and mirrors both sides: unknown local decks upload, unknown + * server decks download, and decks changed on both sides resolve newer-wins + * (ties go to the server). Matching is path-based; a rename looks like a + * delete plus an add. Before a download overwrites a locally modified file, the + * local content is copied to a .sync-backup file next to it. + * + * Ownership: fire-and-forget per run, driven by PendingCommand round trips. + * Both-sides-changed decks pause the queue for a verdict via conflictRequest / + * resolveConflict (or the conflict default setting); the first sync of an + * unknown profile pauses for an onboarding verdict via firstSyncDetected / + * continueFirstSync. No watcher and no auto triggers — those arrive later. + * + * Deletes are tombstones, never silent: a locally deleted deck is pushed with + * Command_DeckDel, a server-side delete is applied locally only after copying + * the file to a .sync-backup, and resurrection reuses the stored deck id. + */ +class DeckSyncManager : public QObject +{ + Q_OBJECT +public: + /** @brief Computes the color identity string uploaded alongside a deck. May be empty. */ + using ColorIdentityProvider = std::function; + + explicit DeckSyncManager(QObject *parent = nullptr); + + void setClient(AbstractClient *_client); + void setServerIdentity(const QString &host, int port, const QString &username); + void setDeckPath(const QString &path); + void setColorIdentityProvider(ColorIdentityProvider provider); + void setConflictDefault(int conflictDefault); + void setAutoSyncEnabled(bool enabled); + void setSyncIntervalMinutes(int minutes); + [[nodiscard]] bool isSyncing() const; + +public slots: + void syncNow(); + /** @brief Continues a paused first sync after the onboarding verdict. */ + void continueFirstSync(DeckSyncMode mode); + /** @brief Aborts a paused first sync without writing any verdict. */ + void cancelFirstSync(); + /** @brief Applies the dialog verdict for the active conflict and resumes. */ + void resolveConflict(const DeckSyncResolution &resolution); + +signals: + void syncStarted(); + void statusMessage(const QString &message); + void syncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error); + /** @brief First sync with content on both sides: the tab must ask how to proceed. */ + void firstSyncDetected(int localCount, int serverCount); + /** @brief A both-sides-changed deck needs a verdict; the queue pauses until resolveConflict. */ + void conflictRequest(const DeckSyncConflict &conflict); + +private slots: + void onListFinished(const Response &response, const CommandContainer &commandContainer); + void onCheckFinished(const Response &response, const CommandContainer &commandContainer); + void + onEnsureDirFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); + void + onUploadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); + void + onDownloadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); + void + onDeleteFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); + +private: + struct ServerFile + { + int id = -1; + QString guid; + QString hash; + qint64 lastModifiedSecs = 0; + }; + struct LocalFile + { + QString absolutePath; + QString hash; + QDateTime modified; + }; + struct UploadOp + { + QString relativePath; + QString serverPath; + int deckId = -1; + QString guid; + }; + struct DownloadOp + { + QString relativePath; + int deckId = -1; + QString guid; + QString hash; + qint64 lastModifiedSecs = 0; + }; + struct ActiveConflict + { + bool active = false; + QString relativePath; + QString serverPath; + int deckId = -1; + QString serverGuid; + QString serverHash; + qint64 serverLastModifiedSecs = 0; + QString serverDeckString; + }; + struct ConflictDownload + { + QString serverPath; + int deckId = -1; + QString guid; + QString hash; + qint64 lastModifiedSecs = 0; + }; + + void failSync(const QString &error); + void finishSync(); + void fetchFullList(); + void sendSyncCheck(qint64 lastSyncSecs); + void autoSyncSoon(); + [[nodiscard]] bool canAutoRun() const; + void onDebounceTimeout(); + void onWatchedPathChanged(const QString &path); + void refreshWatcher(); + void refreshInterval(); + [[nodiscard]] DeckSyncProfile buildProfile() const; + void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder, + const QString &prefix, + QMap &files, + QSet &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 &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 localFiles; + QSet ensuredDirs; + QList pendingUploads; + QList pendingDownloads; + QList pendingDeletes; + QSet conflictPaths; + QMap pendingConflicts; + QMap stashedServerFiles; + ActiveConflict activeConflict; + DeckSyncMode syncMode = DeckSyncMode::TwoWay; + QFileSystemWatcher *watcher = nullptr; + QTimer *debounceTimer = nullptr; + QTimer *intervalTimer = nullptr; + bool autoSyncEnabled = true; + int syncIntervalMinutes = 5; + bool offline = false; + qint64 pendingServerTime = -1; + int conflictDefault = DeckSyncConflictAsk; + int sessionDefault = -1; + bool firstSyncRun = false; + int uploadedCount = 0; + int downloadedCount = 0; + int unchangedCount = 0; + int skippedCount = 0; +}; + +#endif // DECK_SYNC_MANAGER_H diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp index 861149464..17493643d 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp @@ -15,9 +15,16 @@ 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()) { @@ -30,6 +37,9 @@ bool DeckSyncSidecar::load() if (!document.isObject() || !document.object().value("records").isObject()) { return false; } + const QJsonValue modeValue = document.object().value("mode"); + mode = modeValue.isString() ? modeValue.toString() : MODE_LINKED; + lastSyncSecs = static_cast(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()) { @@ -61,6 +71,8 @@ bool DeckSyncSidecar::save() const } QJsonObject root; root.insert("version", SIDECAR_FORMAT_VERSION); + root.insert("mode", mode); + root.insert("lastSync", static_cast(lastSyncSecs)); root.insert("records", records); QDir().mkpath(QFileInfo(filePath).absolutePath()); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h index 7eb2db7ae..de746b566 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -30,10 +30,18 @@ struct DeckSyncRecord class DeckSyncSidecar { public: - QString filePath; ///< Full path of the sidecar JSON file. + static constexpr const char *MODE_LINKED = "linked"; + static constexpr const char *MODE_SEPARATE = "separate"; + + QString filePath; ///< Full path of the sidecar JSON file. + QString mode = MODE_LINKED; ///< Linked normally; separate keeps this profile out of sync. + 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. */ diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp new file mode 100644 index 000000000..7d548993d --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.cpp @@ -0,0 +1,178 @@ +#include "dlg_sync_conflict.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h new file mode 100644 index 000000000..0a969f53e --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_conflict.h @@ -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 +#include +#include + +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 diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp new file mode 100644 index 000000000..ae0e9446b --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp @@ -0,0 +1,59 @@ +#include "dlg_sync_onboarding.h" + +#include +#include +#include +#include + +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; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h new file mode 100644 index 000000000..9d811a28e --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.h @@ -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 + +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 diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 73a0f7be2..e62c72e59 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -332,12 +333,45 @@ GeneralSettingsPage::GeneralSettingsPage() pathsGroupBox = new QGroupBox; pathsGroupBox->setLayout(pathsGrid); + // deck sync settings + conflictDefaultSelector.addItem("", DeckSyncConflictAsk); + conflictDefaultSelector.addItem("", DeckSyncConflictKeepMine); + conflictDefaultSelector.addItem("", DeckSyncConflictKeepServer); + conflictDefaultSelector.addItem("", DeckSyncConflictKeepNewer); + int conflictIndex = conflictDefaultSelector.findData(settings.deckSync().getConflictDefault()); + if (conflictIndex >= 0) { + conflictDefaultSelector.setCurrentIndex(conflictIndex); + } + connect(&conflictDefaultSelector, qOverload(&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(&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; mainLayout->addWidget(languageGroupBox); mainLayout->addWidget(versionGroupBox); mainLayout->addWidget(cardDatabaseGroupBox); mainLayout->addWidget(startupGroupBox); mainLayout->addWidget(pathsGroupBox); + mainLayout->addWidget(deckSyncGroupBox); mainLayout->addStretch(); GeneralSettingsPage::retranslateUi(); @@ -607,5 +641,16 @@ void GeneralSettingsPage::retranslateUi() cardDatabasePathLabel.setText(tr("Card database:")); customCardDatabasePathLabel.setText(tr("Custom database directory:")); tokenDatabasePathLabel.setText(tr("Token database:")); + deckSyncGroupBox->setTitle(tr("Deck sync")); + conflictDefaultLabel.setText(tr("When a deck changed on both sides:")); + conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictAsk), tr("Ask me every time")); + conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepMine), tr("Keep my deck")); + conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepServer), + tr("Keep the server deck")); + conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepNewer), + tr("Keep the newer deck")); + autoSyncCheckBox.setText(tr("Sync decks automatically")); + syncIntervalLabel.setText(tr("Check every:")); + syncIntervalEdit.setSuffix(" " + tr("min")); resetAllPathsButton->setText(tr("Reset all paths")); } diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index fcf1c78d0..677e1b8bd 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -52,6 +52,7 @@ private: QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; + QGroupBox *deckSyncGroupBox; QLabel languageLabel; QComboBox languageBox; @@ -101,6 +102,12 @@ private: QLineEdit *tokenDatabasePathEdit; QPushButton *resetAllPathsButton; QLabel *allPathsResetLabel; + + QLabel conflictDefaultLabel; + QComboBox conflictDefaultSelector; + QCheckBox autoSyncCheckBox; + QLabel syncIntervalLabel; + QSpinBox syncIntervalEdit; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 44d04bd24..aae42397b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -4,10 +4,14 @@ #include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" #include "../../deck_loader/loaded_deck.h" +#include "../../deck_sync/deck_sync_conflict.h" +#include "../../deck_sync/deck_sync_manager.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" +#include "../dialogs/dlg_sync_conflict.h" +#include "../dialogs/dlg_sync_onboarding.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "abstract_client.h" @@ -32,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -178,6 +184,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightVbox->addWidget(shareBar); rightVbox->addWidget(serverDirView); rightVbox->addLayout(rightToolBarLayout); + syncStatusLabel = new QLabel(this); + syncStatusLabel->setWordWrap(true); + rightVbox->addWidget(syncStatusLabel); rightGroupBox = new QGroupBox; rightGroupBox->setLayout(rightVbox); @@ -229,6 +238,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aPublishDeck->setIcon(QPixmap("theme:icons/lock")); connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); + aSyncNow = new QAction(this); + aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload)); + connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow); + // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aRenameLocal); @@ -240,11 +253,34 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); + rightToolBar->addAction(aSyncNow); rightToolBar->addAction(aShareDecks); rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); + syncManager = new DeckSyncManager(this); + syncManager->setClient(client); + syncManager->setColorIdentityProvider( + [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }); + syncManager->setConflictDefault(SettingsCache::instance().deckSync().getConflictDefault()); + syncManager->setAutoSyncEnabled(SettingsCache::instance().deckSync().getAutoSync()); + syncManager->setSyncIntervalMinutes(SettingsCache::instance().deckSync().getSyncIntervalMinutes()); + connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::conflictDefaultChanged, syncManager, + &DeckSyncManager::setConflictDefault); + connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::autoSyncChanged, syncManager, + &DeckSyncManager::setAutoSyncEnabled); + connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::syncIntervalMinutesChanged, syncManager, + &DeckSyncManager::setSyncIntervalMinutes); + connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage); + connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished); + connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected); + connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest); + if (currentUserInfo != nullptr) { + syncUsername = QString::fromStdString(currentUserInfo->name()); + } + syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername); + retranslateUi(); QWidget *mainWidget = new QWidget(this); @@ -273,6 +309,8 @@ void TabDeckStorage::retranslateUi() aShareDecks->setText(tr("Share decks")); aPublishDeck->setText(tr("Publish/unpublish deck")); aOpenDecksFolder->setText(tr("Open decks folder")); + aSyncNow->setText(tr("Sync now")); + renderSyncStatus(); shareBar->retranslateUi(); if (shareBar->isVisible()) { onServerSelectionChanged(); @@ -300,6 +338,8 @@ QString TabDeckStorage::getTargetPath() const void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo) { + syncUsername = QString::fromStdString(userInfo.name()); + syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername); setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered); } @@ -317,6 +357,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status) void TabDeckStorage::setRemoteEnabled(bool enabled) { + remoteEnabled = enabled; aUpload->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); @@ -324,6 +365,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aPublishDeck->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); + aSyncNow->setEnabled(enabled && !syncManager->isSyncing()); if (enabled) { serverDirView->refreshTree(); @@ -527,6 +569,87 @@ void TabDeckStorage::actOpenDecksFolder() QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); } +void TabDeckStorage::actSyncNow() +{ + syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + aSyncNow->setEnabled(false); + syncManager->syncNow(); +} + +void TabDeckStorage::onFirstSyncDetected(int localCount, int serverCount) +{ + DlgSyncOnboarding dialog(localCount, serverCount, this); + if (dialog.exec() == QDialog::Accepted) { + syncManager->continueFirstSync(dialog.selectedMode()); + } else { + syncManager->cancelFirstSync(); + } +} + +void TabDeckStorage::onConflictRequest(const DeckSyncConflict &conflict) +{ + DlgSyncConflict dialog(conflict, this); + if (dialog.exec() != QDialog::Accepted) { + DeckSyncResolution skip; + skip.action = DeckSyncSkipConflict; + syncManager->resolveConflict(skip); + return; + } + const DeckSyncResolution resolution = dialog.resolution(); + if (resolution.rememberAlways && + (resolution.action == DeckSyncKeepMine || resolution.action == DeckSyncKeepServer)) { + SettingsCache::instance().deckSync().setConflictDefault( + resolution.action == DeckSyncKeepMine ? DeckSyncConflictKeepMine : DeckSyncConflictKeepServer); + } + syncManager->resolveConflict(resolution); +} + +void TabDeckStorage::onSyncStatusMessage(const QString &message) +{ + syncStatusType = SyncStatusType::Working; + syncStatusDetail = message; + renderSyncStatus(); +} + +void TabDeckStorage::onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error) +{ + aSyncNow->setEnabled(remoteEnabled); + if (!error.isEmpty()) { + syncStatusType = SyncStatusType::Failed; + syncStatusDetail = error; + } else { + syncStatusType = SyncStatusType::Finished; + syncStatusDetail.clear(); + lastSyncSummary = {uploaded, downloaded, unchanged, skipped}; + } + renderSyncStatus(); + if (error.isEmpty()) { + serverDirView->refreshTree(); + } +} + +void TabDeckStorage::renderSyncStatus() +{ + switch (syncStatusType) { + case SyncStatusType::Working: + syncStatusLabel->setText(syncStatusDetail); + break; + case SyncStatusType::Failed: + syncStatusLabel->setText(tr("Sync failed: %1").arg(syncStatusDetail)); + break; + case SyncStatusType::Finished: + syncStatusLabel->setText(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.") + .arg(lastSyncSummary.uploaded) + .arg(lastSyncSummary.downloaded) + .arg(lastSyncSummary.unchanged) + .arg(lastSyncSummary.skipped)); + break; + case SyncStatusType::Idle: + syncStatusLabel->setText(tr("Not synced yet")); + break; + } +} + void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight) { if (dynamic_cast(serverDirView->getNode(curRight))) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index 35a4649c7..29bf9dfcc 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -19,15 +19,19 @@ #include struct LoadedDeck; +struct DeckSyncConflict; +struct DeckSyncResolution; class ServerInfo_User; class QTreeView; class QFileSystemModel; class QToolBar; class QGroupBox; class QTimer; +class QLabel; class CommandContainer; class Response; class ShareBarWidget; +class DeckSyncManager; class QAction; class QModelIndex; class TabSupervisor; @@ -50,6 +54,29 @@ private: QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenDecksFolder; QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; + QAction *aSyncNow; + QLabel *syncStatusLabel; + DeckSyncManager *syncManager; + QString syncUsername; + bool remoteEnabled = false; + enum class SyncStatusType + { + Idle, + Working, + Finished, + Failed + }; + struct SyncSummary + { + int uploaded = 0; + int downloaded = 0; + int unchanged = 0; + int skipped = 0; + }; + SyncStatusType syncStatusType = SyncStatusType::Idle; + QString syncStatusDetail; + SyncSummary lastSyncSummary; + void renderSyncStatus(); bool visibilityRefreshStarted = false; QTimer *visibilityRefreshTimer; QStringList visibilityFailures; @@ -108,6 +135,12 @@ private slots: void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); + void actSyncNow(); + void onFirstSyncDetected(int localCount, int serverCount); + void onConflictRequest(const DeckSyncConflict &conflict); + void onSyncStatusMessage(const QString &message); + void onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error); + public: TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo); void retranslateUi() override; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index bc4814d5e..de89950b0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -26,6 +26,7 @@ set(PROTO_FILES command_deck_share_list.proto command_deck_share_list_mine.proto command_deck_share_remove.proto + command_deck_sync_check.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -149,6 +150,7 @@ set(PROTO_FILES response_deck_share_download.proto response_deck_share_list.proto response_deck_share_list_mine.proto + response_deck_sync_status.proto response_deck_upload.proto response_dump_zone.proto response_forgotpasswordrequest.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.proto new file mode 100644 index 000000000..1afdfee42 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.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]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index caf9febde..8ec4c3554 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -85,6 +85,7 @@ message Response { DECK_SHARE_LIST = 1104; // Response listing shared decks DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares + DECK_SYNC_STATUS = 1107; // Response with changed decks since a timestamp CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto new file mode 100644 index 000000000..6d6bd2cd1 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 4b7fe9c85..13ba917d1 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -36,6 +36,7 @@ message SessionCommand { DECK_DOWNLOAD_PUBLIC = 1031; DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_REMOVE = 1033; + DECK_SYNC_CHECK = 1034; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 80a465b6d..73732a182 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -12,6 +12,7 @@ set(HEADERS libcockatrice/settings/commander_bracket_settings.h libcockatrice/settings/debug_settings.h libcockatrice/settings/deck_editor_settings.h + libcockatrice/settings/deck_sync_settings.h libcockatrice/settings/download_settings.h libcockatrice/settings/game_filters_settings.h libcockatrice/settings/game_settings.h @@ -45,6 +46,7 @@ add_library( libcockatrice/settings/chat_settings.cpp libcockatrice/settings/debug_settings.cpp libcockatrice/settings/deck_editor_settings.cpp + libcockatrice/settings/deck_sync_settings.cpp libcockatrice/settings/download_settings.cpp libcockatrice/settings/game_filters_settings.cpp libcockatrice/settings/game_settings.cpp diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp new file mode 100644 index 000000000..caa9ce9c6 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp @@ -0,0 +1,44 @@ +#include "deck_sync_settings.h" + +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +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); +} diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h new file mode 100644 index 000000000..2552f52a9 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h @@ -0,0 +1,43 @@ +#ifndef DECK_SYNC_SETTINGS_H +#define DECK_SYNC_SETTINGS_H + +#include "settings_manager.h" + +#include +#include + +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 diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 209f616f7..e01dc5066 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -488,3 +488,10 @@ max_shares_per_day=50 ; Minimum enforced: 1. ; Default: 20 max_versions=20 + +; How many days a deleted deck stays restorable as a tombstone before the +; cleanup clock purges it (with its versions) for good. Re-uploading the +; same deck within this window resurrects it instead of creating a copy. +; A value of 0 purges tombstones on the next cleanup run. +; Default: 30 +tombstone_days=30 diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index a979a74b2..0c9cf7cee 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -460,6 +460,7 @@ bool Servatrice::initServer() deckShareCleanupClock = new QTimer(this); connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(purgeDeckTombstones())); const int deckShareCleanupInterval = getDeckShareCleanupInterval(); if (deckShareCleanupInterval > 0) { qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; @@ -689,6 +690,11 @@ void Servatrice::cleanupExpiredDeckShares() servatriceDatabaseInterface->cleanupExpiredDeckShares(); } +void Servatrice::purgeDeckTombstones() +{ + servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays()); +} + void Servatrice::statusUpdate() { if (!servatriceDatabaseInterface->checkSql()) { @@ -1127,6 +1133,11 @@ int Servatrice::getDeckSyncMaxVersions() const return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt()); } +int Servatrice::getDeckSyncTombstoneDays() const +{ + return qMax(0, settingsCache->value("deck_sync/tombstone_days", 30).toInt()); +} + int Servatrice::getNumberOfTCPPools() const { return settingsCache->value("server/number_pools", 1).toInt(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index a6bbacfc6..64361fb4a 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -157,6 +157,7 @@ private slots: void statusUpdate(); void shutdownTimeout(); void cleanupExpiredDeckShares(); + void purgeDeckTombstones(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -293,6 +294,7 @@ public: int getDeckShareMaxDecksPerShare() const; int getDeckShareMaxSharesPerDay() const; int getDeckSyncMaxVersions() const; + int getDeckSyncTombstoneDays() const; int getMaxUsersPerAddress() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index ac3a19c8e..832d99efa 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1088,8 +1088,8 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user { checkSql(); - QSqlQuery *query = - prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user"); + QSqlQuery *query = prepareQuery( + "select content from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0"); query->bindValue(":id", deckId); query->bindValue(":id_user", userId); execSqlQuery(query); @@ -1240,6 +1240,19 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares() execSqlQuery(query); } +void Servatrice_DatabaseInterface::purgeDeckTombstones(int olderThanDays) +{ + checkSql(); + + // DB-native clock: tombstones are stamped with NOW() on this same + // database, so the grace window is immune to app/db clock skew. + // The null arm only catches manually flagged rows. + QSqlQuery *query = prepareQuery("delete from {prefix}_decklist_files where deleted = 1 and (last_modified < " + "DATE_SUB(NOW(), INTERVAL :days DAY) or last_modified is null)"); + query->bindValue(":days", qMax(0, olderThanDays)); + execSqlQuery(query); +} + bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList &shares) { checkSql(); diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index ee8074171..9196508b3 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -140,6 +140,11 @@ public: bool getDeckShareItem(const QString &token, int itemId, QString &content); /** @brief Deletes all expired share bundles (cascades to their items). */ void cleanupExpiredDeckShares(); + /** + * @brief Hard-deletes tombstoned decks older than the grace period (cascades to versions). + * @param olderThanDays Tombstones older than this many days are purged; 0 purges all tombstones. + */ + void purgeDeckTombstones(int olderThanDays); int getNextGameId() override; int getNextReplayId() override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 4c58748a6..b436f1ddf 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -96,6 +96,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,7 @@ #include #include #include +#include #include #include #include @@ -353,6 +355,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdDeckUpload(cmd.GetExtension(Command_DeckUpload::ext), rc); case SessionCommand::DECK_DOWNLOAD: return cmdDeckDownload(cmd.GetExtension(Command_DeckDownload::ext), rc); + case SessionCommand::DECK_SYNC_CHECK: + return cmdDeckSyncCheck(cmd.GetExtension(Command_DeckSyncCheck::ext), rc); case SessionCommand::REPLAY_LIST: return cmdReplayList(cmd.GetExtension(Command_ReplayList::ext), rc); case SessionCommand::REPLAY_DOWNLOAD: @@ -683,7 +687,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId, query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, " "banner_card_provider, color_identity, tags, guid, hash, last_modified from " - "{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user"); + "{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user " + "and deleted = 0"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { @@ -828,8 +833,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const sqlInterface->checkSql(); if (cmd.has_deck_id()) { - QSqlQuery *query = - sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user"); + QSqlQuery *query = sqlInterface->prepareQuery( + "select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0"); query->bindValue(":id", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); sqlInterface->execSqlQuery(query); @@ -867,6 +872,80 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const return Response::RespOk; } +bool AbstractServerSocketInterface::syncCheckHelper(int folderId, + const QString &prefix, + int userId, + qint64 sinceSecs, + Response_DeckSyncStatus *status) +{ + QSqlQuery *query = sqlInterface->prepareQuery("select id, name from {prefix}_decklist_folders where id_parent = " + ":id_parent and id_user = :id_user"); + query->bindValue(":id_parent", folderId); + query->bindValue(":id_user", userId); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + QList> subfolders; + while (query->next()) { + subfolders.append({query->value(0).toInt(), query->value(1).toString()}); + } + for (const auto &[subfolderId, subfolderName] : subfolders) { + const QString path = prefix.isEmpty() ? subfolderName : prefix + "/" + subfolderName; + if (!syncCheckHelper(subfolderId, path, userId, sinceSecs, status)) { + return false; + } + } + + query = sqlInterface->prepareQuery("select id, name, guid, hash, last_modified, deleted from " + "{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user " + "and (last_modified is null or last_modified > FROM_UNIXTIME(:since))"); + query->bindValue(":id_folder", folderId); + query->bindValue(":id_user", userId); + query->bindValue(":since", sinceSecs); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + while (query->next()) { + const QString name = query->value(1).toString(); + DeckSyncChangedFile *changed = status->add_changed_files(); + changed->set_path((prefix.isEmpty() ? name : prefix + "/" + name).toStdString()); + changed->set_deck_id(query->value(0).toUInt()); + changed->set_guid(query->value(2).toString().toStdString()); + changed->set_hash(query->value(3).toString().toStdString()); + const QDateTime lastModified = query->value(4).toDateTime(); + if (lastModified.isValid()) { + changed->set_last_modified(lastModified.toSecsSinceEpoch()); + } + changed->set_deleted(query->value(5).toBool()); + } + return true; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckSyncCheck(const Command_DeckSyncCheck &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + Response_DeckSyncStatus *re = new Response_DeckSyncStatus; + if (!syncCheckHelper(0, QString(), userInfo->id(), static_cast(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, ResponseContainer &rc) { @@ -939,7 +1018,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId) deckDelDirHelper(query->value(0).toInt()); } - query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id_folder = :id_folder"); + query = sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() " + "where id_folder = :id_folder and deleted = 0"); query->bindValue(":id_folder", basePathId); sqlInterface->execSqlQuery(query); @@ -990,18 +1070,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D sqlInterface->checkSql(); QSqlQuery *query = - sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id = :id and id_user = :id_user"); + sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() where id = " + ":id and id_user = :id_user and deleted = 0"); query->bindValue(":id", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); - sqlInterface->execSqlQuery(query); - if (!query->next()) { + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + if (query->numRowsAffected() == 0) { return Response::RespNameNotFound; } - query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id"); - query->bindValue(":id", cmd.deck_id()); - sqlInterface->execSqlQuery(query); - return Response::RespOk; } @@ -1169,9 +1248,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman } const QString oldHash = currentQuery->value(1).toString(); - QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, " - "banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, " - "color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()"; + QString updateQuery = + "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, " + "banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, " + "color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW(), " + "deleted=0"; if (cmd.has_is_public()) { updateQuery += ", is_public=:is_public"; } @@ -1373,7 +1454,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C // issues its own query on the same cached statement set. QSqlQuery *query = sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = " - ":id_folder and id_user = :id_user"); + ":id_folder and id_user = :id_user and deleted = 0"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userInfo->id()); if (!sqlInterface->execSqlQuery(query)) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 6f000d478..9b831a619 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -56,6 +56,7 @@ class Command_DeckShareList; class Command_DeckShareListMine; class Command_DeckShareRemove; class Command_DeckShareDownload; +class Command_DeckSyncCheck; class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; @@ -107,6 +108,7 @@ class Command_ViewLogHistory; class Command_WarnUser; class QObject; class ResponseContainer; +class Response_DeckSyncStatus; class AbstractServerSocketInterface : public Server_ProtocolHandler { @@ -157,6 +159,9 @@ private: bool storeDeckVersion(int deckId, const QString &content, const QString &hash); DeckList *getDeckFromDatabase(int deckId); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); + bool + syncCheckHelper(int folderId, const QString &prefix, 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 cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc); diff --git a/tests/deck_sync_sidecar_test.cpp b/tests/deck_sync_sidecar_test.cpp index 4bd04be33..a86c8dc6d 100644 --- a/tests/deck_sync_sidecar_test.cpp +++ b/tests/deck_sync_sidecar_test.cpp @@ -119,6 +119,29 @@ TEST(DeckSyncSidecarTest, RemoveDropsRecord) EXPECT_FALSE(reloaded.contains("a.cod")); } +TEST(DeckSyncSidecarTest, ModeRoundTripsAndDefaultsToLinked) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + DeckSyncSidecar sidecar(sidecarPath(dir)); + EXPECT_EQ(sidecar.mode, DeckSyncSidecar::MODE_LINKED); + 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); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 65e41af76..58f5a1445 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -531,6 +532,52 @@ TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet) ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext); } +// --- DeckSyncSettings --- + +TEST_F(SettingsDefaultsTest, DeckSync_ConflictDefault_Default) +{ + DeckSyncSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictAsk); +} + +TEST_F(SettingsDefaultsTest, DeckSync_ConflictDefault_SetAndGet) +{ + DeckSyncSettings s(settingsPath, nullptr); + s.setConflictDefault(DeckSyncConflictKeepNewer); + ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictKeepNewer); + s.setConflictDefault(DeckSyncConflictKeepMine); + ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictKeepMine); + s.setConflictDefault(DeckSyncConflictAsk); + ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictAsk); +} + +TEST_F(SettingsDefaultsTest, DeckSync_AutoSync_Default) +{ + DeckSyncSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getAutoSync(), true); +} + +TEST_F(SettingsDefaultsTest, DeckSync_AutoSync_SetAndGet) +{ + DeckSyncSettings s(settingsPath, nullptr); + s.setAutoSync(false); + ASSERT_EQ(s.getAutoSync(), false); + s.setAutoSync(true); + ASSERT_EQ(s.getAutoSync(), true); +} + +TEST_F(SettingsDefaultsTest, DeckSync_SyncIntervalMinutes_DefaultAndBounds) +{ + DeckSyncSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSyncIntervalMinutes(), 5); + s.setSyncIntervalMinutes(15); + ASSERT_EQ(s.getSyncIntervalMinutes(), 15); + s.setSyncIntervalMinutes(0); + ASSERT_EQ(s.getSyncIntervalMinutes(), 1); + s.setSyncIntervalMinutes(600); + ASSERT_EQ(s.getSyncIntervalMinutes(), 60); +} + // --- NetworkSettings --- TEST_F(SettingsDefaultsTest, Network_ClientID_Default)