diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 60dd34879..549b31f2c 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -8,9 +8,12 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -19,12 +22,15 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include #include #include @@ -32,6 +38,7 @@ 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) { @@ -74,21 +81,145 @@ QString localPathFor(const QString &dir, const QString &name) 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(); } DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider provider, QObject *parent) : QObject(parent), client(_client), colorIdentityProvider(std::move(provider)) { + 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::setProfile(const DeckSyncProfile &_profile) +void DeckSyncManager::setServerIdentity(const QString &host, int port, const QString &username) { - profile = _profile; + 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::setConflictDefault(int _conflictDefault) @@ -96,11 +227,6 @@ void DeckSyncManager::setConflictDefault(int _conflictDefault) conflictDefault = _conflictDefault; } -void DeckSyncManager::setSyncMode(DeckSyncMode mode) -{ - syncMode = mode; -} - bool DeckSyncManager::isSyncing() const { return syncing; @@ -111,7 +237,8 @@ void DeckSyncManager::syncNow() if (syncing) { return; } - if (client == nullptr || !profile.isValid() || deckPath.isEmpty()) { + const DeckSyncProfile runProfile = buildProfile(); + if (client == nullptr || !runProfile.isValid() || deckPath.isEmpty()) { emit syncFinished(SyncSummary{}, tr("Not connected to a server.")); return; } @@ -138,10 +265,11 @@ void DeckSyncManager::syncNow() sessionDefault = -1; syncMode = DeckSyncMode::TwoWay; firstSyncRun = false; + pendingServerTime = -1; emit syncStarted(); emit statusMessage(tr("Scanning local decks...")); - sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, profile.key()); + sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, runProfile.key()); DeckSyncSidecar sidecar(sidecarPath); if (!sidecar.load()) { failSync(tr("Could not read sync state.")); @@ -171,12 +299,80 @@ void DeckSyncManager::syncNow() .modified = QFileInfo(absolutePath).lastModified()}); } + 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, @@ -1035,6 +1231,13 @@ void DeckSyncManager::failSync(const QString &error) void DeckSyncManager::finishSync() { syncing = false; + DeckSyncSidecar sidecar(sidecarPath); + if (sidecar.load()) { + sidecar.lastSyncSecs = + pendingServerTime >= 0 ? pendingServerTime : QDateTime::currentDateTime().toSecsSinceEpoch(); + sidecar.save(); + } + pendingServerTime = -1; const SyncSummary summary = currentSummary(); emit statusMessage(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.") .arg(summary.uploaded) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index eecfceb7d..98c9301ac 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -22,6 +22,8 @@ class AbstractClient; class CommandContainer; class DeckList; +class QFileSystemWatcher; +class QTimer; class Response; class ServerInfo_DeckStorage_Folder; @@ -67,10 +69,11 @@ public: explicit DeckSyncManager(QObject *parent = nullptr); DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr); - void setProfile(const DeckSyncProfile &_profile); + void setServerIdentity(const QString &host, int port, const QString &username); void setDeckPath(const QString &path); void setConflictDefault(int conflictDefault); - void setSyncMode(DeckSyncMode mode); + void setAutoSyncEnabled(bool enabled); + void setSyncIntervalMinutes(int minutes); [[nodiscard]] bool isSyncing() const; public slots: @@ -93,6 +96,7 @@ signals: 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 @@ -158,6 +162,15 @@ private: void failSync(const QString &error); void finishSync(); [[nodiscard]] SyncSummary currentSummary() const; + void fetchFullList(); + void sendSyncCheck(qint64 lastSyncSecs); + void autoSyncSoon(); + [[nodiscard]] bool canAutoRun() const; + void onDebounceTimeout(); + void onWatchedPathChanged(const QString &path); + void refreshWatcher(); + void refreshInterval(); + [[nodiscard]] DeckSyncProfile buildProfile() const; void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder, const QString &prefix, QMap &files, @@ -195,7 +208,9 @@ private: QString uniqueLocalPath(const QString &relativePath) const; AbstractClient *client = nullptr; - DeckSyncProfile profile; + QString serverHost; + int serverPort = 0; + QString serverUsername; QString deckPath; ColorIdentityProvider colorIdentityProvider; bool syncing = false; @@ -211,6 +226,13 @@ private: 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; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp index 6c5a65e73..48dfeba5a 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp @@ -25,6 +25,7 @@ bool DeckSyncSidecar::load() { recordMap.clear(); mode = MODE_LINKED; + lastSyncSecs = 0; QFile file(filePath); if (!file.exists()) { @@ -39,6 +40,7 @@ bool DeckSyncSidecar::load() } 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()) { @@ -71,6 +73,7 @@ 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 2266922bc..de746b566 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -35,6 +35,7 @@ public: 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); 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 6b6678467..6a24f7db6 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -373,9 +373,22 @@ GeneralSettingsPage::GeneralSettingsPage() 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); @@ -676,5 +689,8 @@ void GeneralSettingsPage::retranslateUi() 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 92d46c265..f410abdf2 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -108,6 +108,9 @@ private: 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 cc68f9fc1..48111f6ea 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -4,7 +4,6 @@ #include "../../deck_loader/deck_loader.h" #include "../../deck_sync/deck_sync_conflict.h" #include "../../deck_sync/deck_sync_manager.h" -#include "../../deck_sync/deck_sync_profile.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" @@ -262,8 +261,14 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, syncManager = new DeckSyncManager( client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this); 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); @@ -271,6 +276,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, if (currentUserInfo != nullptr) { syncUsername = QString::fromStdString(currentUserInfo->name()); } + syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername); retranslateUi(); @@ -330,6 +336,7 @@ 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); } @@ -561,8 +568,6 @@ void TabDeckStorage::actOpenDecksFolder() void TabDeckStorage::actSyncNow() { - DeckSyncProfile profile(client->serverName(), client->serverPort(), syncUsername); - syncManager->setProfile(profile); syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath()); aSyncNow->setEnabled(false); syncManager->syncNow(); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 059e83096..0860b407b 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 @@ -151,6 +152,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 10aefe7b5..b9a80021f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -37,6 +37,7 @@ message SessionCommand { DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_REMOVE = 1033; SUBMIT_PASSWORD_VERIFIER = 1034; + DECK_SYNC_CHECK = 1035; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp index 0b86d3c84..caa9ce9c6 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp @@ -3,6 +3,7 @@ #include "libcockatrice/settings/settings_manager.h" #include +#include DeckSyncSettings::DeckSyncSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "deck_sync.ini", "decksync", QString(), parent) @@ -19,3 +20,25 @@ 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 index f984292f3..2552f52a9 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h @@ -22,14 +22,19 @@ class DeckSyncSettings : public SettingsManager 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); - -public: - explicit DeckSyncSettings(const QString &settingPath, QObject *parent = nullptr); + void autoSyncChanged(bool autoSync); + void syncIntervalMinutesChanged(int syncIntervalMinutes); private: DeckSyncSettings(const DeckSyncSettings & /*other*/); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index e88c74527..6da45fd41 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -98,6 +98,7 @@ #include #include #include +#include #include #include #include @@ -134,6 +135,7 @@ #include #include #include +#include #include #include #include @@ -371,6 +373,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: @@ -894,6 +898,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) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index f05fd5ac1..7a01f7c1c 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; @@ -111,6 +112,7 @@ class Command_ViewLogHistory; class Command_WarnUser; class QObject; class ResponseContainer; +class Response_DeckSyncStatus; class AbstractServerSocketInterface : public Server_ProtocolHandler { @@ -163,6 +165,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 76e18d797..a86c8dc6d 100644 --- a/tests/deck_sync_sidecar_test.cpp +++ b/tests/deck_sync_sidecar_test.cpp @@ -125,12 +125,15 @@ TEST(DeckSyncSidecarTest, ModeRoundTripsAndDefaultsToLinked) 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) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 0a2fb31e0..cc8b21ecd 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -606,6 +606,33 @@ TEST_F(SettingsDefaultsTest, DeckSync_ConflictDefault_SetAndGet) 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)