mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-07 18:43:26 -08:00
[Client] Manual deck collection sync with Sync Now action
This commit is contained in:
parent
fa4c5766eb
commit
3bed5ae5f5
5 changed files with 753 additions and 0 deletions
|
|
@ -28,6 +28,7 @@ set(cockatrice_SOURCES
|
||||||
src/interface/deck_loader/deck_loader.cpp
|
src/interface/deck_loader/deck_loader.cpp
|
||||||
src/interface/deck_sync/deck_sync_profile.cpp
|
src/interface/deck_sync/deck_sync_profile.cpp
|
||||||
src/interface/deck_sync/deck_sync_sidecar.cpp
|
src/interface/deck_sync/deck_sync_sidecar.cpp
|
||||||
|
src/interface/deck_sync/deck_sync_manager.cpp
|
||||||
src/interface/widgets/dialogs/dlg_connect.cpp
|
src/interface/widgets/dialogs/dlg_connect.cpp
|
||||||
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
|
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
|
||||||
src/interface/widgets/dialogs/dlg_create_game.cpp
|
src/interface/widgets/dialogs/dlg_create_game.cpp
|
||||||
|
|
|
||||||
510
cockatrice/src/interface/deck_sync/deck_sync_manager.cpp
Normal file
510
cockatrice/src/interface/deck_sync/deck_sync_manager.cpp
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
#include "deck_sync_manager.h"
|
||||||
|
|
||||||
|
#include "../deck_loader/deck_file_format.h"
|
||||||
|
#include "../deck_loader/deck_loader.h"
|
||||||
|
#include "deck_sync_sidecar.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QUuid>
|
||||||
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||||
|
#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_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_upload.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pending_command.h>
|
||||||
|
#include <libcockatrice/utility/string_limits.h>
|
||||||
|
|
||||||
|
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<LoadedDeck> 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<QString, ServerFile> &files,
|
||||||
|
QSet<QString> &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<qint64>(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<QString, ServerFile> serverFiles;
|
||||||
|
QSet<QString> 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<LoadedDeck> 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<qint64>(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<qint64>(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());
|
||||||
|
}
|
||||||
133
cockatrice/src/interface/deck_sync/deck_sync_manager.h
Normal file
133
cockatrice/src/interface/deck_sync/deck_sync_manager.h
Normal file
|
|
@ -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 <QDateTime>
|
||||||
|
#include <QList>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
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<QString(const DeckList &)>;
|
||||||
|
|
||||||
|
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<QString, ServerFile> &files,
|
||||||
|
QSet<QString> &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<QString, LocalFile> localFiles;
|
||||||
|
QSet<QString> ensuredDirs;
|
||||||
|
QList<UploadOp> pendingUploads;
|
||||||
|
QList<DownloadOp> pendingDownloads;
|
||||||
|
int uploadedCount = 0;
|
||||||
|
int downloadedCount = 0;
|
||||||
|
int unchangedCount = 0;
|
||||||
|
int skippedCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_MANAGER_H
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../deck_loader/deck_loader.h"
|
#include "../../deck_loader/deck_loader.h"
|
||||||
|
#include "../../deck_sync/deck_sync_manager.h"
|
||||||
#include "../../pixel_map_generator.h"
|
#include "../../pixel_map_generator.h"
|
||||||
#include "../cards/additional_info/deck_color_identity.h"
|
#include "../cards/additional_info/deck_color_identity.h"
|
||||||
#include "../deck_share/deck_share_utils.h"
|
#include "../deck_share/deck_share_utils.h"
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
#include <QItemSelection>
|
#include <QItemSelection>
|
||||||
#include <QItemSelectionModel>
|
#include <QItemSelectionModel>
|
||||||
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QModelIndex>
|
#include <QModelIndex>
|
||||||
|
|
@ -177,6 +179,9 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
rightVbox->addWidget(shareBar);
|
rightVbox->addWidget(shareBar);
|
||||||
rightVbox->addWidget(serverDirView);
|
rightVbox->addWidget(serverDirView);
|
||||||
rightVbox->addLayout(rightToolBarLayout);
|
rightVbox->addLayout(rightToolBarLayout);
|
||||||
|
syncStatusLabel = new QLabel(this);
|
||||||
|
syncStatusLabel->setWordWrap(true);
|
||||||
|
rightVbox->addWidget(syncStatusLabel);
|
||||||
rightGroupBox = new QGroupBox;
|
rightGroupBox = new QGroupBox;
|
||||||
rightGroupBox->setLayout(rightVbox);
|
rightGroupBox->setLayout(rightVbox);
|
||||||
|
|
||||||
|
|
@ -228,6 +233,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
|
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
|
||||||
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
|
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
|
||||||
|
|
||||||
|
aSyncNow = new QAction(this);
|
||||||
|
aSyncNow->setIcon(qApp->style()->standardIcon(QStyle::SP_BrowserReload));
|
||||||
|
connect(aSyncNow, &QAction::triggered, this, &TabDeckStorage::actSyncNow);
|
||||||
|
|
||||||
// Add actions to toolbars
|
// Add actions to toolbars
|
||||||
leftToolBar->addAction(aOpenLocalDeck);
|
leftToolBar->addAction(aOpenLocalDeck);
|
||||||
leftToolBar->addAction(aRenameLocal);
|
leftToolBar->addAction(aRenameLocal);
|
||||||
|
|
@ -239,11 +248,22 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
||||||
|
|
||||||
rightToolBar->addAction(aOpenRemoteDeck);
|
rightToolBar->addAction(aOpenRemoteDeck);
|
||||||
rightToolBar->addAction(aDownload);
|
rightToolBar->addAction(aDownload);
|
||||||
|
rightToolBar->addAction(aSyncNow);
|
||||||
rightToolBar->addAction(aShareDecks);
|
rightToolBar->addAction(aShareDecks);
|
||||||
rightToolBar->addAction(aPublishDeck);
|
rightToolBar->addAction(aPublishDeck);
|
||||||
rightToolBar->addAction(aNewFolder);
|
rightToolBar->addAction(aNewFolder);
|
||||||
rightToolBar->addAction(aDeleteRemoteDeck);
|
rightToolBar->addAction(aDeleteRemoteDeck);
|
||||||
|
|
||||||
|
syncManager = new DeckSyncManager(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();
|
retranslateUi();
|
||||||
|
|
||||||
QWidget *mainWidget = new QWidget(this);
|
QWidget *mainWidget = new QWidget(this);
|
||||||
|
|
@ -272,6 +292,8 @@ void TabDeckStorage::retranslateUi()
|
||||||
aShareDecks->setText(tr("Share decks"));
|
aShareDecks->setText(tr("Share decks"));
|
||||||
aPublishDeck->setText(tr("Publish/unpublish deck"));
|
aPublishDeck->setText(tr("Publish/unpublish deck"));
|
||||||
aOpenDecksFolder->setText(tr("Open decks folder"));
|
aOpenDecksFolder->setText(tr("Open decks folder"));
|
||||||
|
aSyncNow->setText(tr("Sync now"));
|
||||||
|
renderSyncStatus();
|
||||||
shareBar->retranslateUi();
|
shareBar->retranslateUi();
|
||||||
if (shareBar->isVisible()) {
|
if (shareBar->isVisible()) {
|
||||||
onServerSelectionChanged();
|
onServerSelectionChanged();
|
||||||
|
|
@ -299,6 +321,7 @@ QString TabDeckStorage::getTargetPath() const
|
||||||
|
|
||||||
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
|
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
|
||||||
{
|
{
|
||||||
|
syncUsername = QString::fromStdString(userInfo.name());
|
||||||
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
|
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,6 +339,7 @@ void TabDeckStorage::handleConnectionChanged(ClientStatus status)
|
||||||
|
|
||||||
void TabDeckStorage::setRemoteEnabled(bool enabled)
|
void TabDeckStorage::setRemoteEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
|
remoteEnabled = enabled;
|
||||||
aUpload->setEnabled(enabled);
|
aUpload->setEnabled(enabled);
|
||||||
aOpenRemoteDeck->setEnabled(enabled);
|
aOpenRemoteDeck->setEnabled(enabled);
|
||||||
aDownload->setEnabled(enabled);
|
aDownload->setEnabled(enabled);
|
||||||
|
|
@ -323,6 +347,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
|
||||||
aPublishDeck->setEnabled(enabled);
|
aPublishDeck->setEnabled(enabled);
|
||||||
aNewFolder->setEnabled(enabled);
|
aNewFolder->setEnabled(enabled);
|
||||||
aDeleteRemoteDeck->setEnabled(enabled);
|
aDeleteRemoteDeck->setEnabled(enabled);
|
||||||
|
aSyncNow->setEnabled(enabled && !syncManager->isSyncing());
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
serverDirView->refreshTree();
|
serverDirView->refreshTree();
|
||||||
|
|
@ -526,6 +551,61 @@ void TabDeckStorage::actOpenDecksFolder()
|
||||||
QtUtils::openPathInFileManager(dir);
|
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)
|
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
|
||||||
{
|
{
|
||||||
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {
|
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ class QFileSystemModel;
|
||||||
class QToolBar;
|
class QToolBar;
|
||||||
class QGroupBox;
|
class QGroupBox;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
|
class QLabel;
|
||||||
class CommandContainer;
|
class CommandContainer;
|
||||||
class Response;
|
class Response;
|
||||||
class ShareBarWidget;
|
class ShareBarWidget;
|
||||||
|
class DeckSyncManager;
|
||||||
class QAction;
|
class QAction;
|
||||||
class QModelIndex;
|
class QModelIndex;
|
||||||
class TabSupervisor;
|
class TabSupervisor;
|
||||||
|
|
@ -50,6 +52,29 @@ private:
|
||||||
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
|
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
|
||||||
QAction *aOpenDecksFolder;
|
QAction *aOpenDecksFolder;
|
||||||
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
|
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
|
||||||
|
QAction *aSyncNow;
|
||||||
|
QLabel *syncStatusLabel;
|
||||||
|
DeckSyncManager *syncManager;
|
||||||
|
QString syncUsername;
|
||||||
|
bool remoteEnabled = false;
|
||||||
|
enum class SyncStatusType
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Working,
|
||||||
|
Finished,
|
||||||
|
Failed
|
||||||
|
};
|
||||||
|
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;
|
bool visibilityRefreshStarted = false;
|
||||||
QTimer *visibilityRefreshTimer;
|
QTimer *visibilityRefreshTimer;
|
||||||
QStringList visibilityFailures;
|
QStringList visibilityFailures;
|
||||||
|
|
@ -108,6 +133,10 @@ private slots:
|
||||||
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
|
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
|
||||||
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
||||||
|
|
||||||
|
void actSyncNow();
|
||||||
|
void onSyncStatusMessage(const QString &message);
|
||||||
|
void onSyncFinished(int uploaded, int downloaded, int unchanged, int skipped, const QString &error);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
|
TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo);
|
||||||
void retranslateUi() override;
|
void retranslateUi() override;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue