mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
[Client] DeckSyncProfile + sidecar + DeckDiff with tests
This commit is contained in:
parent
11fdfb4610
commit
be072ffd68
12 changed files with 742 additions and 0 deletions
|
|
@ -26,6 +26,8 @@ set(cockatrice_SOURCES
|
|||
src/client/settings/shortcuts_settings.cpp
|
||||
src/interface/deck_loader/card_node_function.cpp
|
||||
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/widgets/dialogs/dlg_connect.cpp
|
||||
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
|
||||
src/interface/widgets/dialogs/dlg_create_game.cpp
|
||||
|
|
|
|||
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#include "deck_sync_profile.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
|
||||
DeckSyncProfile::DeckSyncProfile(const QString &_host, int _port, const QString &_username)
|
||||
: host(_host), port(_port), username(_username)
|
||||
{
|
||||
}
|
||||
|
||||
bool DeckSyncProfile::isValid() const
|
||||
{
|
||||
return !host.isEmpty() && port > 0 && !username.isEmpty();
|
||||
}
|
||||
|
||||
QString DeckSyncProfile::key() const
|
||||
{
|
||||
if (!isValid()) {
|
||||
return {};
|
||||
}
|
||||
// NUL separators keep the triple injective: neither IPv6 hosts nor
|
||||
// usernames containing ':' can collide two profiles into one file.
|
||||
// Lowercasing is safe on both parts: DNS names are case-insensitive, and
|
||||
// Servatrice usernames are unique under a case-insensitive collation
|
||||
// (cockatrice_users uses utf8mb4_unicode_ci), so both spellings are one account.
|
||||
QByteArray blob = host.trimmed().toLower().toUtf8();
|
||||
blob.append('\0');
|
||||
blob.append(QByteArray::number(port));
|
||||
blob.append('\0');
|
||||
blob.append(username.trimmed().toLower().toUtf8());
|
||||
return QCryptographicHash::hash(blob, QCryptographicHash::Sha1).toHex();
|
||||
}
|
||||
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @file deck_sync_profile.h
|
||||
* @brief Identity of one sync target (server + user) for deck collection sync.
|
||||
*/
|
||||
|
||||
#ifndef DECK_SYNC_PROFILE_H
|
||||
#define DECK_SYNC_PROFILE_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
/**
|
||||
* @class DeckSyncProfile
|
||||
* @brief Identifies which server and login a sync sidecar belongs to.
|
||||
*
|
||||
* A local deck folder can sync against several servers. Every server login
|
||||
* gets its own profile, and every profile gets its own sidecar file, so
|
||||
* switching servers never mixes up remote deck ids.
|
||||
*/
|
||||
class DeckSyncProfile
|
||||
{
|
||||
public:
|
||||
QString host; ///< Server hostname as entered by the user.
|
||||
int port = 0; ///< Server port.
|
||||
QString username; ///< Login name on that server.
|
||||
|
||||
DeckSyncProfile() = default;
|
||||
DeckSyncProfile(const QString &_host, int _port, const QString &_username);
|
||||
|
||||
/** @brief False when any of host, port or username is missing. */
|
||||
[[nodiscard]] bool isValid() const;
|
||||
/**
|
||||
* @brief Stable file-system-safe key for this profile.
|
||||
*
|
||||
* SHA1 hex over the NUL-separated lowercased "host", port and "username"
|
||||
* triple, so the same server reached with different hostname
|
||||
* capitalisation maps to one file, while IPv6 hosts and usernames
|
||||
* containing ':' can never collide two profiles. Empty for invalid
|
||||
* profiles, so no two half-configured profiles share a file.
|
||||
*/
|
||||
[[nodiscard]] QString key() const;
|
||||
};
|
||||
|
||||
#endif // DECK_SYNC_PROFILE_H
|
||||
98
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
98
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#include "deck_sync_sidecar.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int SIDECAR_FORMAT_VERSION = 1;
|
||||
} // namespace
|
||||
|
||||
DeckSyncSidecar::DeckSyncSidecar(const QString &_filePath) : filePath(_filePath)
|
||||
{
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::load()
|
||||
{
|
||||
recordMap.clear();
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.exists()) {
|
||||
return true;
|
||||
}
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||
if (!document.isObject() || !document.object().value("records").isObject()) {
|
||||
return false;
|
||||
}
|
||||
const QJsonObject records = document.object().value("records").toObject();
|
||||
for (auto it = records.begin(); it != records.end(); ++it) {
|
||||
if (!it.value().isObject()) {
|
||||
continue;
|
||||
}
|
||||
const QJsonObject entry = it.value().toObject();
|
||||
DeckSyncRecord record;
|
||||
record.guid = entry.value("guid").toString();
|
||||
record.remoteId = entry.value("remoteId").toInt(-1);
|
||||
record.baseHash = entry.value("baseHash").toString();
|
||||
record.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble());
|
||||
record.deleted = entry.value("deleted").toBool(false);
|
||||
recordMap.insert(it.key(), record);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::save() const
|
||||
{
|
||||
QJsonObject records;
|
||||
for (auto it = recordMap.begin(); it != recordMap.end(); ++it) {
|
||||
QJsonObject entry;
|
||||
entry.insert("guid", it.value().guid);
|
||||
entry.insert("remoteId", it.value().remoteId);
|
||||
entry.insert("baseHash", it.value().baseHash);
|
||||
entry.insert("lastSync", static_cast<double>(it.value().lastSyncSecs));
|
||||
entry.insert("deleted", it.value().deleted);
|
||||
records.insert(it.key(), entry);
|
||||
}
|
||||
QJsonObject root;
|
||||
root.insert("version", SIDECAR_FORMAT_VERSION);
|
||||
root.insert("records", records);
|
||||
|
||||
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
return false;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeckSyncSidecar::contains(const QString &relativePath) const
|
||||
{
|
||||
return recordMap.contains(relativePath);
|
||||
}
|
||||
|
||||
DeckSyncRecord DeckSyncSidecar::get(const QString &relativePath) const
|
||||
{
|
||||
return recordMap.value(relativePath, DeckSyncRecord());
|
||||
}
|
||||
|
||||
void DeckSyncSidecar::set(const QString &relativePath, const DeckSyncRecord &record)
|
||||
{
|
||||
recordMap.insert(relativePath, record);
|
||||
}
|
||||
|
||||
void DeckSyncSidecar::remove(const QString &relativePath)
|
||||
{
|
||||
recordMap.remove(relativePath);
|
||||
}
|
||||
|
||||
QMap<QString, DeckSyncRecord> DeckSyncSidecar::records() const
|
||||
{
|
||||
return recordMap;
|
||||
}
|
||||
52
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
52
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @file deck_sync_sidecar.h
|
||||
* @brief Per-profile local bookkeeping for deck collection sync.
|
||||
*/
|
||||
|
||||
#ifndef DECK_SYNC_SIDECAR_H
|
||||
#define DECK_SYNC_SIDECAR_H
|
||||
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
|
||||
/** @brief Sync bookkeeping for one local deck file. */
|
||||
struct DeckSyncRecord
|
||||
{
|
||||
QString guid; ///< Stable deck identity shared with the server.
|
||||
int remoteId = -1; ///< Server row id, -1 when never uploaded.
|
||||
QString baseHash; ///< Deck hash at the last successful sync.
|
||||
qint64 lastSyncSecs = 0; ///< Last sync time, seconds since Unix epoch.
|
||||
bool deleted = false; ///< Local tombstone waiting to be pushed.
|
||||
};
|
||||
|
||||
/**
|
||||
* @class DeckSyncSidecar
|
||||
* @brief Loads and stores sync records as JSON next to the deck folder.
|
||||
*
|
||||
* One sidecar file per DeckSyncProfile (named by profile key), mapping paths
|
||||
* relative to the deck folder to their last-synced state. Comparing the live
|
||||
* file hash against baseHash tells clean apart from locally modified.
|
||||
*/
|
||||
class DeckSyncSidecar
|
||||
{
|
||||
public:
|
||||
QString filePath; ///< Full path of the sidecar JSON file.
|
||||
|
||||
explicit DeckSyncSidecar(const QString &_filePath);
|
||||
|
||||
/** @brief Loads records; a missing file loads as empty and returns true. */
|
||||
bool load();
|
||||
/** @brief Stores records, creating parent folders as needed. */
|
||||
bool save() const;
|
||||
[[nodiscard]] bool contains(const QString &relativePath) const;
|
||||
/** @brief Returns the record, or a default record when unknown. */
|
||||
[[nodiscard]] DeckSyncRecord get(const QString &relativePath) const;
|
||||
void set(const QString &relativePath, const DeckSyncRecord &record);
|
||||
void remove(const QString &relativePath);
|
||||
[[nodiscard]] QMap<QString, DeckSyncRecord> records() const;
|
||||
|
||||
private:
|
||||
QMap<QString, DeckSyncRecord> recordMap;
|
||||
};
|
||||
|
||||
#endif // DECK_SYNC_SIDECAR_H
|
||||
Loading…
Add table
Add a link
Reference in a new issue