[Client+Server] Auto deck sync with change polling

This commit is contained in:
Lukas Brübach 2026-10-11 17:02:30 +02:00
parent dcccb0f332
commit e73fcd602c
18 changed files with 448 additions and 18 deletions

View file

@ -8,9 +8,12 @@
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QFileSystemWatcher>
#include <QSaveFile>
#include <QStringList>
#include <QTimer>
#include <QUuid>
#include <QtGlobal>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_file_format.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -19,12 +22,15 @@
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_sync_check.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_sync_status.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/string_limits.h>
@ -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<google::protobuf::uint64>(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<QString, DeckSyncRecord> 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<qint64>(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<int, ServerFile> &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)

View file

@ -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<int, ServerFile> &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<int, ServerFile> stashedServerFiles;
ActiveConflict activeConflict;
DeckSyncMode syncMode = DeckSyncMode::TwoWay;
QFileSystemWatcher *watcher = nullptr;
QTimer *debounceTimer = nullptr;
QTimer *intervalTimer = nullptr;
bool autoSyncEnabled = true;
int syncIntervalMinutes = 5;
bool offline = false;
qint64 pendingServerTime = -1;
int conflictDefault = DeckSyncConflictAsk;
int sessionDefault = -1;
bool firstSyncRun = false;

View file

@ -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<qint64>(document.object().value("lastSync").toDouble());
const QJsonObject records = document.object().value("records").toObject();
for (auto it = records.begin(); it != records.end(); ++it) {
if (!it.value().isObject()) {
@ -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<double>(lastSyncSecs));
root.insert("records", records);
QDir().mkpath(QFileInfo(filePath).absolutePath());

View file

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