From e63b72fe110a3e81f653309f02767babfa3af9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 14:37:01 +0200 Subject: [PATCH 1/8] [Client] Manual deck collection sync with Sync Now action --- cockatrice/CMakeLists.txt | 1 + .../interface/deck_sync/deck_sync_manager.cpp | 510 ++++++++++++++++++ .../interface/deck_sync/deck_sync_manager.h | 133 +++++ .../widgets/tabs/tab_deck_storage.cpp | 80 +++ .../interface/widgets/tabs/tab_deck_storage.h | 29 + 5 files changed, 753 insertions(+) create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_manager.cpp create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_manager.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index d76d89211..5519d2f3b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -28,6 +28,7 @@ set(cockatrice_SOURCES src/interface/deck_loader/deck_loader.cpp src/interface/deck_sync/deck_sync_profile.cpp src/interface/deck_sync/deck_sync_sidecar.cpp + src/interface/deck_sync/deck_sync_manager.cpp src/interface/widgets/dialogs/dlg_connect.cpp src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp src/interface/widgets/dialogs/dlg_create_game.cpp 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..9fe76445b --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -0,0 +1,510 @@ +#include "deck_sync_manager.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 + +namespace +{ +constexpr const char *SYNC_META_DIR = ".sync"; +constexpr const char *BACKUP_SUFFIX = ".sync-backup-"; + +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) +{ +} + +void DeckSyncManager::setClient(AbstractClient *_client) +{ + client = _client; +} + +void DeckSyncManager::setProfile(const DeckSyncProfile &_profile) +{ + profile = _profile; +} + +void DeckSyncManager::setDeckPath(const QString &path) +{ + deckPath = path; +} + +void DeckSyncManager::setColorIdentityProvider(ColorIdentityProvider provider) +{ + colorIdentityProvider = std::move(provider); +} + +bool DeckSyncManager::isSyncing() const +{ + return syncing; +} + +void DeckSyncManager::syncNow() +{ + if (syncing) { + return; + } + if (client == nullptr || !profile.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(); + ensuredDirs.clear(); + ensuredDirs.insert(QString()); + localFiles.clear(); + emit syncStarted(); + emit statusMessage(tr("Scanning local decks...")); + + sidecarPath = deckDir.filePath(SYNC_META_DIR + QString("/") + profile.key() + ".json"); + DeckSyncSidecar sidecar(sidecarPath); + if (!sidecar.load()) { + failSync(tr("Could not read sync state.")); + return; + } + + 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); + } + + Command_DeckList cmd; + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onListFinished); + client->sendCommand(pend); +} + +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); + 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()) { + UploadOp op; + op.relativePath = it.key(); + op.serverPath = serverPath; + op.deckId = -1; + op.guid = sidecar.contains(it.key()) && !sidecar.get(it.key()).guid.isEmpty() + ? sidecar.get(it.key()).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); + ++unchangedCount; + continue; + } + const qint64 localSecs = it.value().modified.toSecsSinceEpoch(); + // A server row without last_modified predates sync: the local file wins. + // Otherwise strictly newer wins; ties go to the server. + if (serverIt.value().lastModifiedSecs == 0 || localSecs > serverIt.value().lastModifiedSecs) { + UploadOp op; + op.relativePath = it.key(); + op.serverPath = serverPath; + op.deckId = serverIt.value().id; + op.guid = serverIt.value().guid; + pendingUploads.append(op); + } else { + DownloadOp op; + op.relativePath = it.key(); + op.deckId = serverIt.value().id; + op.guid = serverIt.value().guid; + op.hash = serverIt.value().hash; + op.lastModifiedSecs = serverIt.value().lastModifiedSecs; + pendingDownloads.append(op); + } + } + + 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); + } + + 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) { + return; + } + if (!pendingUploads.isEmpty()) { + processNextUpload(); + } else if (!pendingDownloads.isEmpty()) { + sendDownload(pendingDownloads.first()); + } else { + finishSync(); + } +} + +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); + pend->setExtraData(op.relativePath); + 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 QString relativePath = extraData.toString(); + if (response.response_code() != Response::RespOk) { + ++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(); + ++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(); + for (int i = 0; i < pendingDownloads.size(); ++i) { + if (pendingDownloads.at(i).relativePath == relativePath) { + pendingDownloads.removeAt(i); + break; + } + } + if (response.response_code() != Response::RespOk) { + ++skippedCount; + processNext(); + return; + } + + const Response_DeckDownload &download = response.GetExtension(Response_DeckDownload::ext); + DeckList deck; + if (!deck.loadFromString_Native(QString::fromStdString(download.deck()))) { + ++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 (!download.guid().empty()) { + record.guid = QString::fromStdString(download.guid()); + } + if (!download.hash().empty()) { + record.baseHash = QString::fromStdString(download.hash()); + } else { + record.baseHash = deck.getDeckHash(); + } + record.lastSyncSecs = download.has_last_modified() ? static_cast(download.last_modified()) + : QDateTime::currentDateTime().toSecsSinceEpoch(); + record.deleted = false; + sidecar.set(relativePath, record); + sidecar.save(); + ++downloadedCount; + processNext(); +} + +void DeckSyncManager::failSync(const QString &error) +{ + syncing = false; + emit syncFinished(uploadedCount, downloadedCount, unchangedCount, skippedCount, error); +} + +void DeckSyncManager::finishSync() +{ + syncing = false; + 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..99f2040fe --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -0,0 +1,133 @@ +/** + * @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_profile.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class DeckList; +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. + * No watcher, no auto triggers, no conflict UI — those arrive in later layers. + */ +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 setProfile(const DeckSyncProfile &_profile); + void setDeckPath(const QString &path); + void setColorIdentityProvider(ColorIdentityProvider provider); + [[nodiscard]] bool isSyncing() const; + +public slots: + void syncNow(); + +signals: + void syncStarted(); + void statusMessage(const QString &message); + void syncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error); + +private slots: + void onListFinished(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); + +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; + }; + + void failSync(const QString &error); + void finishSync(); + 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); + static QString parentDir(const QString &path); + QString uniqueLocalPath(const QString &relativePath) const; + + AbstractClient *client = nullptr; + DeckSyncProfile profile; + QString deckPath; + ColorIdentityProvider colorIdentityProvider; + bool syncing = false; + + QString sidecarPath; + QMap localFiles; + QSet ensuredDirs; + QList pendingUploads; + QList pendingDownloads; + int uploadedCount = 0; + int downloadedCount = 0; + int unchangedCount = 0; + int skippedCount = 0; +}; + +#endif // DECK_SYNC_MANAGER_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 3dfdc956b..90cd24290 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.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" @@ -29,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -177,6 +179,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightVbox->addWidget(shareBar); rightVbox->addWidget(serverDirView); rightVbox->addLayout(rightToolBarLayout); + syncStatusLabel = new QLabel(this); + syncStatusLabel->setWordWrap(true); + rightVbox->addWidget(syncStatusLabel); rightGroupBox = new QGroupBox; rightGroupBox->setLayout(rightVbox); @@ -228,6 +233,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); @@ -239,11 +248,22 @@ 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()); }); + connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage); + connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished); + if (currentUserInfo != nullptr) { + syncUsername = QString::fromStdString(currentUserInfo->name()); + } + retranslateUi(); QWidget *mainWidget = new QWidget(this); @@ -272,6 +292,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(); @@ -299,6 +321,7 @@ QString TabDeckStorage::getTargetPath() const void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo) { + syncUsername = QString::fromStdString(userInfo.name()); setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered); } @@ -316,6 +339,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status) void TabDeckStorage::setRemoteEnabled(bool enabled) { + remoteEnabled = enabled; aUpload->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); @@ -323,6 +347,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aPublishDeck->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); + aSyncNow->setEnabled(enabled && !syncManager->isSyncing()); if (enabled) { serverDirView->refreshTree(); @@ -526,6 +551,61 @@ void TabDeckStorage::actOpenDecksFolder() QtUtils::openPathInFileManager(dir); } +void TabDeckStorage::actSyncNow() +{ + DeckSyncProfile profile(client->serverName(), client->serverPort(), syncUsername); + syncManager->setProfile(profile); + syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + aSyncNow->setEnabled(false); + syncManager->syncNow(); +} + +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..f24f07ac7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -25,9 +25,11 @@ 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 +52,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 +133,10 @@ private slots: void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); + void actSyncNow(); + 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; From ad1cb8f1333cb9fcfdc57b2c5c37a1ac094ce534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 07:18:43 +0200 Subject: [PATCH 2/8] Fix ZeldaZach review for PR 7493: match uploads by sidecar remoteId/guid, validate server paths --- .../interface/deck_sync/deck_sync_manager.cpp | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 9fe76445b..941126e48 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -135,6 +135,9 @@ void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &fo { for (const auto &item : folder.items()) { const QString name = QString::fromStdString(item.name()); + if (name.isEmpty() || name == "." || name == ".." || name.contains('/') || name.contains('\\')) { + continue; + } if (item.has_folder()) { const QString path = prefix.isEmpty() ? name : prefix + "/" + name; dirs.insert(path); @@ -173,8 +176,37 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont sidecar.load(); const qint64 nowSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); + QMap serverPathById; + QMap serverPathByGuid; + for (auto sit = serverFiles.begin(); sit != serverFiles.end(); ++sit) { + serverPathById.insert(sit.value().id, sit.key()); + if (!sit.value().guid.isEmpty()) { + serverPathByGuid.insert(sit.value().guid, sit.key()); + } + } + QSet matchedServerPaths; + for (auto it = localFiles.begin(); it != localFiles.end(); ++it) { - const QString serverPath = serverPathFor(it.key()); + const DeckSyncRecord sidecarRecord = sidecar.get(it.key()); + QString serverPath; + if (sidecarRecord.remoteId != -1 && serverPathById.contains(sidecarRecord.remoteId)) { + serverPath = serverPathById.value(sidecarRecord.remoteId); + } else if (!sidecarRecord.guid.isEmpty() && serverPathByGuid.contains(sidecarRecord.guid)) { + serverPath = serverPathByGuid.value(sidecarRecord.guid); + } else { + serverPath = serverPathFor(it.key()); + if (!serverFiles.contains(serverPath)) { + UploadOp op; + op.relativePath = it.key(); + op.serverPath = serverPath; + op.deckId = -1; + op.guid = sidecar.contains(it.key()) && !sidecar.get(it.key()).guid.isEmpty() + ? sidecar.get(it.key()).guid + : QUuid::createUuid().toString(QUuid::WithoutBraces); + pendingUploads.append(op); + continue; + } + } auto serverIt = serverFiles.find(serverPath); if (serverIt == serverFiles.end()) { UploadOp op; @@ -187,6 +219,7 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont pendingUploads.append(op); continue; } + matchedServerPaths.insert(serverIt.key()); if (it.value().hash == serverIt.value().hash) { DeckSyncRecord record = sidecar.get(it.key()); if (record.guid.isEmpty()) { @@ -222,6 +255,9 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont } for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) { + if (matchedServerPaths.contains(it.key())) { + continue; + } const QString wantedPath = it.key().endsWith(".cod", Qt::CaseInsensitive) ? it.key() : it.key() + ".cod"; if (localFiles.contains(wantedPath) || QFileInfo(deckPath + "/" + wantedPath).exists()) { continue; @@ -455,7 +491,13 @@ void DeckSyncManager::onDownloadFinished(const Response &response, return; } - const QString absolutePath = deckPath + "/" + relativePath; + const QString cleanDeckPath = QDir::cleanPath(deckPath); + const QString absolutePath = QDir::cleanPath(deckPath + "/" + relativePath); + if (absolutePath != cleanDeckPath && !absolutePath.startsWith(cleanDeckPath + "/")) { + ++skippedCount; + processNext(); + return; + } if (localFiles.contains(relativePath)) { const QString backupPath = absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); From e1a9f7766d79871a096d7d17b96912fc8dec9f04 Mon Sep 17 00:00:00 2001 From: BruebachL Date: Tue, 13 Oct 2026 11:45:29 +0200 Subject: [PATCH 3/8] [DeckSync] Move SyncSummary into DeckSyncManager per review --- cockatrice/src/interface/deck_sync/deck_sync_manager.h | 9 +++++++++ .../src/interface/widgets/tabs/tab_deck_storage.h | 10 ++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 99f2040fe..7f759c313 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -44,6 +44,15 @@ public: /** @brief Computes the color identity string uploaded alongside a deck. May be empty. */ using ColorIdentityProvider = std::function; + /** @brief Outcome counts for a finished sync run; mirrors syncFinished() parameters. */ + struct SyncSummary + { + int uploaded = 0; + int downloaded = 0; + int unchanged = 0; + int skipped = 0; + }; + explicit DeckSyncManager(QObject *parent = nullptr); void setClient(AbstractClient *_client); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index f24f07ac7..bd53d5073 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -11,6 +11,7 @@ #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "tab.h" +#include "../deck_sync/deck_sync_manager.h" #include #include #include @@ -29,7 +30,6 @@ class QLabel; class CommandContainer; class Response; class ShareBarWidget; -class DeckSyncManager; class QAction; class QModelIndex; class TabSupervisor; @@ -64,13 +64,7 @@ private: Finished, Failed }; - struct SyncSummary - { - int uploaded = 0; - int downloaded = 0; - int unchanged = 0; - int skipped = 0; - }; + using SyncSummary = DeckSyncManager::SyncSummary; SyncStatusType syncStatusType = SyncStatusType::Idle; QString syncStatusDetail; SyncSummary lastSyncSummary; From 8a541531dab7ab4554eac6ee61e75610f72ad217 Mon Sep 17 00:00:00 2001 From: BruebachL Date: Tue, 13 Oct 2026 11:45:44 +0200 Subject: [PATCH 4/8] [DeckSync] Prefer ctor params for set-once client/provider per review --- cockatrice/src/interface/deck_sync/deck_sync_manager.cpp | 5 +++++ cockatrice/src/interface/deck_sync/deck_sync_manager.h | 1 + cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp | 6 ++---- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 941126e48..464849eec 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -42,6 +42,11 @@ DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent) { } +DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider provider, QObject *parent) + : QObject(parent), client(_client), colorIdentityProvider(std::move(provider)) +{ +} + void DeckSyncManager::setClient(AbstractClient *_client) { client = _client; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 7f759c313..2bf12f72f 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -54,6 +54,7 @@ public: }; explicit DeckSyncManager(QObject *parent = nullptr); + DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr); void setClient(AbstractClient *_client); void setProfile(const DeckSyncProfile &_profile); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 90cd24290..74154f3da 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -254,10 +254,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); - syncManager = new DeckSyncManager(this); - syncManager->setClient(client); - syncManager->setColorIdentityProvider( - [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }); + syncManager = new DeckSyncManager( + client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this); connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage); connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished); if (currentUserInfo != nullptr) { From 0757d3d8674f151650f6ec4ed876e2d2da196439 Mon Sep 17 00:00:00 2001 From: BruebachL Date: Tue, 13 Oct 2026 11:45:54 +0200 Subject: [PATCH 5/8] [DeckSync] Use designated initializer for ServerFile per review --- .../src/interface/deck_sync/deck_sync_manager.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 464849eec..49be7fac0 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -150,11 +150,12 @@ void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &fo } 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; + ServerFile entry{ + .id = item.id(), + .guid = QString::fromStdString(file.guid()), + .hash = QString::fromStdString(file.hash()), + .lastModifiedSecs = file.has_last_modified() ? static_cast(file.last_modified()) : 0, + }; files.insert(path, entry); } } From c0a62a24864345aeaa4db147ed9cb6033b907c25 Mon Sep 17 00:00:00 2001 From: BruebachL Date: Tue, 13 Oct 2026 11:50:40 +0200 Subject: [PATCH 6/8] [Format] Fix include order in tab_deck_storage.h --- cockatrice/src/interface/widgets/tabs/tab_deck_storage.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index bd53d5073..ade10e951 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -8,10 +8,10 @@ #ifndef TAB_DECK_STORAGE_H #define TAB_DECK_STORAGE_H +#include "../deck_sync/deck_sync_manager.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "tab.h" -#include "../deck_sync/deck_sync_manager.h" #include #include #include From ad0da98a86576ebc8a55b2161f14ef2b0e463df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 12:41:00 +0100 Subject: [PATCH 7/8] [DeckSync] Pass the sync summary through the signal and tidy the manager API - Emit SyncSummary from syncFinished() instead of four positional ints, and build it from a single currentSummary() helper, so the counts cannot drift between the status message and the signal. - Use a designated initializer for LocalFile, like the other records. - Drop setClient() and setColorIdentityProvider(): both fields are already constructor parameters and the setters had no callers. setProfile() and setDeckPath() stay, since the tab re-reads them before every run. - Fix the include paths in tab_deck_storage.h and the narrowing conversion of the tree item id, so the branch builds again. --- .../interface/deck_sync/deck_sync_manager.cpp | 47 +++++++++---------- .../interface/deck_sync/deck_sync_manager.h | 7 ++- .../widgets/tabs/tab_deck_storage.cpp | 4 +- .../interface/widgets/tabs/tab_deck_storage.h | 6 +-- 4 files changed, 31 insertions(+), 33 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 49be7fac0..0ff162ee1 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -47,11 +47,6 @@ DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider { } -void DeckSyncManager::setClient(AbstractClient *_client) -{ - client = _client; -} - void DeckSyncManager::setProfile(const DeckSyncProfile &_profile) { profile = _profile; @@ -62,11 +57,6 @@ void DeckSyncManager::setDeckPath(const QString &path) deckPath = path; } -void DeckSyncManager::setColorIdentityProvider(ColorIdentityProvider provider) -{ - colorIdentityProvider = std::move(provider); -} - bool DeckSyncManager::isSyncing() const { return syncing; @@ -78,12 +68,12 @@ void DeckSyncManager::syncNow() return; } if (client == nullptr || !profile.isValid() || deckPath.isEmpty()) { - emit syncFinished(0, 0, 0, 0, tr("Not connected to a server.")); + emit syncFinished(SyncSummary{}, tr("Not connected to a server.")); return; } QDir deckDir(deckPath); if (!deckDir.exists()) { - emit syncFinished(0, 0, 0, 0, tr("Deck folder not found.")); + emit syncFinished(SyncSummary{}, tr("Deck folder not found.")); return; } @@ -120,11 +110,9 @@ void DeckSyncManager::syncNow() ++skippedCount; continue; } - LocalFile file; - file.absolutePath = absolutePath; - file.hash = loaded->deckList.getDeckHash(); - file.modified = QFileInfo(absolutePath).lastModified(); - localFiles.insert(relativePath, file); + localFiles.insert(relativePath, LocalFile{.absolutePath = absolutePath, + .hash = loaded->deckList.getDeckHash(), + .modified = QFileInfo(absolutePath).lastModified()}); } Command_DeckList cmd; @@ -151,7 +139,7 @@ void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &fo const QString path = prefix.isEmpty() ? name : prefix + "/" + name; const ServerInfo_DeckStorage_File &file = item.file(); ServerFile entry{ - .id = item.id(), + .id = static_cast(item.id()), .guid = QString::fromStdString(file.guid()), .hash = QString::fromStdString(file.hash()), .lastModifiedSecs = file.has_last_modified() ? static_cast(file.last_modified()) : 0, @@ -543,16 +531,27 @@ void DeckSyncManager::onDownloadFinished(const Response &response, void DeckSyncManager::failSync(const QString &error) { syncing = false; - emit syncFinished(uploadedCount, downloadedCount, unchangedCount, skippedCount, error); + emit syncFinished(currentSummary(), error); } void DeckSyncManager::finishSync() { syncing = false; + const SyncSummary summary = currentSummary(); 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()); + .arg(summary.uploaded) + .arg(summary.downloaded) + .arg(summary.unchanged) + .arg(summary.skipped)); + emit syncFinished(summary, QString()); +} + +DeckSyncManager::SyncSummary DeckSyncManager::currentSummary() const +{ + return SyncSummary{ + .uploaded = uploadedCount, + .downloaded = downloadedCount, + .unchanged = unchangedCount, + .skipped = skippedCount, + }; } diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 2bf12f72f..8ecbaf74c 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -44,7 +44,7 @@ public: /** @brief Computes the color identity string uploaded alongside a deck. May be empty. */ using ColorIdentityProvider = std::function; - /** @brief Outcome counts for a finished sync run; mirrors syncFinished() parameters. */ + /** @brief Outcome counts for a finished sync run, carried by syncFinished(). */ struct SyncSummary { int uploaded = 0; @@ -56,10 +56,8 @@ public: explicit DeckSyncManager(QObject *parent = nullptr); DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr); - void setClient(AbstractClient *_client); void setProfile(const DeckSyncProfile &_profile); void setDeckPath(const QString &path); - void setColorIdentityProvider(ColorIdentityProvider provider); [[nodiscard]] bool isSyncing() const; public slots: @@ -68,7 +66,7 @@ public slots: signals: void syncStarted(); void statusMessage(const QString &message); - void syncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error); + void syncFinished(const SyncSummary &summary, const QString &error); private slots: void onListFinished(const Response &response, const CommandContainer &commandContainer); @@ -111,6 +109,7 @@ private: void failSync(const QString &error); void finishSync(); + [[nodiscard]] SyncSummary currentSummary() const; void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder, const QString &prefix, QMap &files, diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 74154f3da..2ca46d4f4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -565,7 +565,7 @@ void TabDeckStorage::onSyncStatusMessage(const QString &message) renderSyncStatus(); } -void TabDeckStorage::onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error) +void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &error) { aSyncNow->setEnabled(remoteEnabled); if (!error.isEmpty()) { @@ -574,7 +574,7 @@ void TabDeckStorage::onSyncFinished(int uploaded, int downloaded, int unchanged, } else { syncStatusType = SyncStatusType::Finished; syncStatusDetail.clear(); - lastSyncSummary = {uploaded, downloaded, unchanged, skipped}; + lastSyncSummary = summary; } renderSyncStatus(); if (error.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index ade10e951..c7456877e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -8,8 +8,8 @@ #ifndef TAB_DECK_STORAGE_H #define TAB_DECK_STORAGE_H -#include "../deck_sync/deck_sync_manager.h" -#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" +#include "../../deck_sync/deck_sync_manager.h" +#include "../server/remote/remote_decklist_tree_widget.h" #include "tab.h" #include @@ -129,7 +129,7 @@ private slots: void actSyncNow(); void onSyncStatusMessage(const QString &message); - void onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error); + void onSyncFinished(const SyncSummary &summary, const QString &error); public: TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo); From 2916fa45afdaf05580e45f6633b5295ec0c4674e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 12:43:02 +0100 Subject: [PATCH 8/8] [DeckSync] Match server rows by identity and hash the whole deck Sync keyed local files to server rows by path, but the server names a row after the deck, not after the file, so any deck whose file name and deck name differ uploaded again on every run. A second pass then brought the server copy back as an extra local file. - Match by the sidecar's row id, then guid, then content, and only then by the server path, so renaming either side no longer duplicates a deck. - Key the server tree by row id: the server allows several rows with the same name in one folder, and keying by path silently dropped all but one. - Stop discarding rows whose name contains '/' ("U/W Control"). They stay matchable, and only the download file name is sanitised. - Build the download path from the folder plus a sanitised row name, including every folder segment, so a hostile server name cannot escape the deck folder. - Download a colliding server row under a unique name and count it, instead of skipping it without a trace. - Hash the whole deck instead of the card hash: SHA-1 over the native deck string covers name, printings, banner, tokens and sideboard plans. The client and the server hash the exact bytes they exchange, so both agree. Adds deck_content_hash_test for the client/server hashing invariant and for the name-only change the card hash cannot see. --- .../interface/deck_sync/deck_sync_manager.cpp | 195 +++++++++++------- .../interface/deck_sync/deck_sync_manager.h | 16 +- .../libcockatrice/deck_list/deck_list.cpp | 11 + .../libcockatrice/deck_list/deck_list.h | 10 + servatrice/src/serversocketinterface.cpp | 4 +- tests/CMakeLists.txt | 9 + tests/deck_content_hash_test.cpp | 51 +++++ 7 files changed, 215 insertions(+), 81 deletions(-) create mode 100644 tests/deck_content_hash_test.cpp diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 0ff162ee1..dd4dd6f69 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,35 @@ QString serverPathFor(const QString &relativePath) } return path; } + +// Rows are named after the deck, not after the file, so a row name may contain +// characters a file name cannot ("U/W Control"). Downloads therefore rebuild the +// local path from the folder plus a sanitised row name, and never from the raw +// server-supplied path. +QString sanitizedSegment(const QString &segment) +{ + QString safe = segment; + safe.replace('/', '-'); + safe.replace('\\', '-'); + safe = safe.trimmed(); + if (safe.isEmpty() || safe == QStringLiteral(".") || safe == QStringLiteral("..")) { + return QStringLiteral("deck"); + } + return safe; +} + +QString localPathFor(const QString &dir, const QString &name) +{ + // Every segment is server-supplied, so each one is sanitised before it can + // become part of a local path. + QStringList segments; + for (const QString &segment : dir.split('/', Qt::SkipEmptyParts)) { + segments << sanitizedSegment(segment); + } + segments << sanitizedSegment(name); + const QString path = segments.join('/'); + return path.endsWith(".cod", Qt::CaseInsensitive) ? path : path + QStringLiteral(".cod"); +} } // namespace DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent) @@ -111,7 +141,7 @@ void DeckSyncManager::syncNow() continue; } localFiles.insert(relativePath, LocalFile{.absolutePath = absolutePath, - .hash = loaded->deckList.getDeckHash(), + .hash = loaded->deckList.getContentHash(), .modified = QFileInfo(absolutePath).lastModified()}); } @@ -123,28 +153,30 @@ void DeckSyncManager::syncNow() void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &folder, const QString &prefix, - QMap &files, + QMap &files, QSet &dirs) { for (const auto &item : folder.items()) { const QString name = QString::fromStdString(item.name()); - if (name.isEmpty() || name == "." || name == ".." || name.contains('/') || name.contains('\\')) { - continue; - } + const QString path = prefix.isEmpty() ? name : prefix + "/" + 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(); + const int id = static_cast(item.id()); ServerFile entry{ - .id = static_cast(item.id()), + .id = id, + .path = path, + .dir = prefix, + .name = name, .guid = QString::fromStdString(file.guid()), .hash = QString::fromStdString(file.hash()), .lastModifiedSecs = file.has_last_modified() ? static_cast(file.last_modified()) : 0, }; - files.insert(path, entry); + // Keyed by id: the server allows several rows with the same name in a + // folder, and keying by path silently dropped all but one of them. + files.insert(id, entry); } } } @@ -159,7 +191,7 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont return; } - QMap serverFiles; + QMap serverFiles; QSet serverDirs; const Response_DeckList &list = response.GetExtension(Response_DeckList::ext); collectServerFiles(list.root(), QString(), serverFiles, serverDirs); @@ -169,57 +201,78 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont DeckSyncSidecar sidecar(sidecarPath); sidecar.load(); const qint64 nowSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); + // Reuse the sidecar's guid so the server keeps recognizing the row, and mint + // one only for a deck that has never been synced. + auto guidFor = [&sidecar](const QString &relativePath) { + const DeckSyncRecord record = sidecar.get(relativePath); + return record.guid.isEmpty() ? QUuid::createUuid().toString(QUuid::WithoutBraces) : record.guid; + }; - QMap serverPathById; - QMap serverPathByGuid; + QMap serverIdByPath; + QMap serverIdByGuid; for (auto sit = serverFiles.begin(); sit != serverFiles.end(); ++sit) { - serverPathById.insert(sit.value().id, sit.key()); + serverIdByPath.insert(sit.value().path, sit.key()); if (!sit.value().guid.isEmpty()) { - serverPathByGuid.insert(sit.value().guid, sit.key()); + serverIdByGuid.insert(sit.value().guid, sit.key()); } } - QSet matchedServerPaths; + QSet matchedServerIds; + + // A local file without a usable sidecar record still usually has a server + // counterpart: rows are named after the deck, so compare content rather than + // file names, and only upload when no unmatched row has the same content. + auto unmatchedByHash = [&serverFiles, &matchedServerIds](const QString &hash) { + if (hash.isEmpty()) { + return -1; + } + for (auto sit = serverFiles.begin(); sit != serverFiles.end(); ++sit) { + if (!matchedServerIds.contains(sit.key()) && sit.value().hash == hash) { + return sit.key(); + } + } + return -1; + }; for (auto it = localFiles.begin(); it != localFiles.end(); ++it) { const DeckSyncRecord sidecarRecord = sidecar.get(it.key()); - QString serverPath; - if (sidecarRecord.remoteId != -1 && serverPathById.contains(sidecarRecord.remoteId)) { - serverPath = serverPathById.value(sidecarRecord.remoteId); - } else if (!sidecarRecord.guid.isEmpty() && serverPathByGuid.contains(sidecarRecord.guid)) { - serverPath = serverPathByGuid.value(sidecarRecord.guid); + int serverId = -1; + if (sidecarRecord.remoteId != -1 && serverFiles.contains(sidecarRecord.remoteId)) { + serverId = sidecarRecord.remoteId; + } else if (!sidecarRecord.guid.isEmpty() && serverIdByGuid.contains(sidecarRecord.guid)) { + serverId = serverIdByGuid.value(sidecarRecord.guid); } else { - serverPath = serverPathFor(it.key()); - if (!serverFiles.contains(serverPath)) { - UploadOp op; - op.relativePath = it.key(); - op.serverPath = serverPath; - op.deckId = -1; - op.guid = sidecar.contains(it.key()) && !sidecar.get(it.key()).guid.isEmpty() - ? sidecar.get(it.key()).guid - : QUuid::createUuid().toString(QUuid::WithoutBraces); - pendingUploads.append(op); - continue; + // Content first: a row with the same deck is unambiguous, while a + // matching file name may belong to a different deck entirely. + serverId = unmatchedByHash(it.value().hash); + if (serverId == -1) { + const QString wantedPath = serverPathFor(it.key()); + if (serverIdByPath.contains(wantedPath)) { + const int pathId = serverIdByPath.value(wantedPath); + if (!matchedServerIds.contains(pathId)) { + serverId = pathId; + } + } } } - auto serverIt = serverFiles.find(serverPath); - if (serverIt == serverFiles.end()) { - UploadOp op; - op.relativePath = it.key(); - op.serverPath = serverPath; - op.deckId = -1; - op.guid = sidecar.contains(it.key()) && !sidecar.get(it.key()).guid.isEmpty() - ? sidecar.get(it.key()).guid - : QUuid::createUuid().toString(QUuid::WithoutBraces); - pendingUploads.append(op); + + if (serverId == -1) { + const QString serverPath = serverPathFor(it.key()); + pendingUploads.append(UploadOp{.relativePath = it.key(), + .serverPath = serverPath, + .serverDir = parentDir(serverPath), + .deckId = -1, + .guid = guidFor(it.key())}); continue; } - matchedServerPaths.insert(serverIt.key()); - if (it.value().hash == serverIt.value().hash) { + + const ServerFile &server = serverFiles.value(serverId); + matchedServerIds.insert(serverId); + if (it.value().hash == server.hash) { DeckSyncRecord record = sidecar.get(it.key()); if (record.guid.isEmpty()) { - record.guid = serverIt.value().guid; + record.guid = server.guid; } - record.remoteId = serverIt.value().id; + record.remoteId = server.id; record.baseHash = it.value().hash; record.lastSyncSecs = nowSecs; record.deleted = false; @@ -230,44 +283,36 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont const qint64 localSecs = it.value().modified.toSecsSinceEpoch(); // A server row without last_modified predates sync: the local file wins. // Otherwise strictly newer wins; ties go to the server. - if (serverIt.value().lastModifiedSecs == 0 || localSecs > serverIt.value().lastModifiedSecs) { - UploadOp op; - op.relativePath = it.key(); - op.serverPath = serverPath; - op.deckId = serverIt.value().id; - op.guid = serverIt.value().guid; - pendingUploads.append(op); + if (server.lastModifiedSecs == 0 || localSecs > server.lastModifiedSecs) { + pendingUploads.append(UploadOp{.relativePath = it.key(), + .serverPath = server.path, + .serverDir = server.dir, + .deckId = server.id, + .guid = server.guid}); } else { - DownloadOp op; - op.relativePath = it.key(); - op.deckId = serverIt.value().id; - op.guid = serverIt.value().guid; - op.hash = serverIt.value().hash; - op.lastModifiedSecs = serverIt.value().lastModifiedSecs; - pendingDownloads.append(op); + pendingDownloads.append(DownloadOp{.relativePath = it.key(), + .deckId = server.id, + .guid = server.guid, + .hash = server.hash, + .lastModifiedSecs = server.lastModifiedSecs}); } } for (auto it = serverFiles.begin(); it != serverFiles.end(); ++it) { - if (matchedServerPaths.contains(it.key())) { - continue; - } - const QString wantedPath = it.key().endsWith(".cod", Qt::CaseInsensitive) ? it.key() : it.key() + ".cod"; - if (localFiles.contains(wantedPath) || QFileInfo(deckPath + "/" + wantedPath).exists()) { + if (matchedServerIds.contains(it.key())) { continue; } + const QString wantedPath = localPathFor(it.value().dir, it.value().name); 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); + pendingDownloads.append(DownloadOp{.relativePath = relativePath, + .deckId = it.value().id, + .guid = it.value().guid, + .hash = it.value().hash, + .lastModifiedSecs = it.value().lastModifiedSecs}); } sidecar.save(); @@ -297,7 +342,7 @@ void DeckSyncManager::processNext() void DeckSyncManager::processNextUpload() { const UploadOp op = pendingUploads.first(); - const QString dir = parentDir(op.serverPath); + const QString dir = op.serverDir; if (!ensuredDirs.contains(dir)) { sendMkDirFor(dir); return; @@ -370,7 +415,7 @@ void DeckSyncManager::sendUpload(const UploadOp &op) Command_DeckUpload cmd; if (op.deckId == -1) { - cmd.set_path(parentDir(op.serverPath).toStdString()); + cmd.set_path(op.serverDir.toStdString()); } else { cmd.set_deck_id(op.deckId); } @@ -416,7 +461,7 @@ void DeckSyncManager::onUploadFinished(const Response &response, } else { record.lastSyncSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); } - record.remoteId = file.id(); + record.remoteId = static_cast(file.id()); } else if (localFiles.contains(relativePath)) { record.baseHash = localFiles.value(relativePath).hash; record.lastSyncSecs = QDateTime::currentDateTime().toSecsSinceEpoch(); @@ -517,7 +562,7 @@ void DeckSyncManager::onDownloadFinished(const Response &response, if (!download.hash().empty()) { record.baseHash = QString::fromStdString(download.hash()); } else { - record.baseHash = deck.getDeckHash(); + record.baseHash = deck.getContentHash(); } record.lastSyncSecs = download.has_last_modified() ? static_cast(download.last_modified()) : QDateTime::currentDateTime().toSecsSinceEpoch(); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 8ecbaf74c..c891cbb58 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -30,9 +30,11 @@ class ServerInfo_DeckStorage_Folder; * 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. + * (ties go to the server). A local file is matched to its server row by the + * sidecar's row id, then guid, then the server path, and finally by content, so + * renaming either side does not duplicate a deck. Before a download overwrites a + * locally modified file, the local content is copied to a .sync-backup file next + * to it. * * Ownership: fire-and-forget per run, driven by PendingCommand round trips. * No watcher, no auto triggers, no conflict UI — those arrive in later layers. @@ -81,6 +83,9 @@ private: struct ServerFile { int id = -1; + QString path; ///< Server-side address: folder prefix plus row name. + QString dir; ///< Folder prefix, empty in the root. + QString name; ///< Row name exactly as the server stores it. QString guid; QString hash; qint64 lastModifiedSecs = 0; @@ -94,7 +99,8 @@ private: struct UploadOp { QString relativePath; - QString serverPath; + QString serverPath; ///< Target address of a brand-new row. + QString serverDir; ///< Folder that must exist before the upload. int deckId = -1; QString guid; }; @@ -112,7 +118,7 @@ private: [[nodiscard]] SyncSummary currentSummary() const; void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder, const QString &prefix, - QMap &files, + QMap &files, QSet &dirs); void processNext(); void processNextUpload(); diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 821b53dff..6f1a358f2 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -9,6 +9,7 @@ #include "tree/inner_deck_list_node.h" #include +#include #include #include #include @@ -453,6 +454,16 @@ void DeckList::refreshDeckHash() cachedDeckHash = QString(); } +QString DeckList::contentHash(const QString &nativeDeck) +{ + return QString::fromLatin1(QCryptographicHash::hash(nativeDeck.toUtf8(), QCryptographicHash::Sha1).toHex()); +} + +QString DeckList::getContentHash() const +{ + return contentHash(writeToString_Native()); +} + /** * Calls a given function on each card in the deck. */ diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 6796e9c33..9a8b54c24 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -289,6 +289,16 @@ public: /** @name Deck identity */ ///@{ QString getDeckHash() const; + /** + * @brief SHA-1 of a native deck string, covering everything the card hash + * ignores (name, printings, banner, tokens, comments, sideboard plans). + * + * Deck-collection sync hashes the exact string it sends or receives, so both + * peers compare the same bytes. + */ + static QString contentHash(const QString &nativeDeck); + /** @brief contentHash() of this deck's native serialisation. */ + QString getContentHash() const; void refreshDeckHash(); ///@} diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index e21b3b10f..f9e602f82 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -1114,7 +1114,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); const QString tagsJson = serializeDeckTags(deck.getTags()); const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); - const QString deckHash = deck.getDeckHash(); + // Hash the exact bytes the client sent: both peers must compare the same + // string, not each side's re-serialisation of it. + const QString deckHash = DeckList::contentHash(deckStr); QString guid = cmd.has_guid() ? nameFromStdString(cmd.guid()).left(36) : QString(); if (QUuid::fromString(guid).isNull()) { guid = QUuid::createUuid().toString(QUuid::WithoutBraces); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 32ee9b7f1..5163db067 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -416,6 +416,15 @@ cockatrice_add_test( libcockatrice_deck_list libcockatrice_utility ) +cockatrice_add_test( + NAME + deck_content_hash_test + SOURCES + deck_content_hash_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_utility +) cockatrice_add_test( NAME deck_sync_profile_test diff --git a/tests/deck_content_hash_test.cpp b/tests/deck_content_hash_test.cpp new file mode 100644 index 000000000..192abb007 --- /dev/null +++ b/tests/deck_content_hash_test.cpp @@ -0,0 +1,51 @@ +#include "gtest/gtest.h" +#include +#include + +namespace +{ +QString deckWithName(const QString &name) +{ + return QStringLiteral(R"()" + R"(%1)" + R"()" + R"()" + R"()") + .arg(name); +} +} // namespace + +TEST(DeckContentHashTest, MatchesNativeSerialisation) +{ + DeckList deck; + ASSERT_TRUE(deck.loadFromString_Native(deckWithName(QStringLiteral("Burn")))); + EXPECT_EQ(deck.getContentHash(), DeckList::contentHash(deck.writeToString_Native())); +} + +TEST(DeckContentHashTest, IsStableAcrossLoads) +{ + DeckList first; + DeckList second; + ASSERT_TRUE(first.loadFromString_Native(deckWithName(QStringLiteral("Burn")))); + ASSERT_TRUE(second.loadFromString_Native(deckWithName(QStringLiteral("Burn")))); + EXPECT_EQ(first.getContentHash(), second.getContentHash()); +} + +TEST(DeckContentHashTest, CoversDeckName) +{ + DeckList burn; + DeckList renamed; + ASSERT_TRUE(burn.loadFromString_Native(deckWithName(QStringLiteral("Burn")))); + ASSERT_TRUE(renamed.loadFromString_Native(deckWithName(QStringLiteral("Burn v2")))); + + // The card hash only looks at card names and counts, so it cannot tell the + // two decks apart; the content hash must. + EXPECT_EQ(burn.getDeckHash(), renamed.getDeckHash()); + EXPECT_NE(burn.getContentHash(), renamed.getContentHash()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}