diff --git a/QLog.pro b/QLog.pro index 508cdc1f..9308abf6 100644 --- a/QLog.pro +++ b/QLog.pro @@ -84,6 +84,7 @@ SOURCES += \ core/FldigiTCPServer.cpp \ core/LOVDownloader.cpp \ core/LogDatabase.cpp \ + core/ContactSync.cpp \ core/LogLocale.cpp \ core/LogParam.cpp \ core/MembershipQE.cpp \ @@ -173,6 +174,7 @@ SOURCES += \ ui/AlertWidget.cpp \ ui/AwardsDialog.cpp \ ui/DXCCSubmissionDialog.cpp \ + ui/SyncDialog.cpp \ ui/BandmapWidget.cpp \ ui/CWConsoleWidget.cpp \ ui/ChatWidget.cpp \ @@ -260,6 +262,7 @@ HEADERS += \ core/FldigiTCPServer.h \ core/LOVDownloader.h \ core/LogDatabase.h \ + core/ContactSync.h \ core/LogLocale.h \ core/LogParam.h \ core/MembershipQE.h \ @@ -367,6 +370,7 @@ HEADERS += \ ui/AlertWidget.h \ ui/AwardsDialog.h \ ui/DXCCSubmissionDialog.h \ + ui/SyncDialog.h \ ui/BandmapWidget.h \ ui/CWConsoleWidget.h \ ui/ChatWidget.h \ @@ -437,6 +441,7 @@ FORMS += \ ui/AlertWidget.ui \ ui/AwardsDialog.ui \ ui/DXCCSubmissionDialog.ui \ + ui/SyncDialog.ui \ ui/BandmapWidget.ui \ ui/CWConsoleWidget.ui \ ui/ChatWidget.ui \ diff --git a/core/ContactSync.cpp b/core/ContactSync.cpp new file mode 100644 index 00000000..4807973a --- /dev/null +++ b/core/ContactSync.cpp @@ -0,0 +1,1130 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ContactSync.h" +#include "LogParam.h" +#include "debug.h" + +MODULE_IDENTIFICATION("qlog.core.contactsync"); + +namespace { + +constexpr const char *SUBDIR_ROOT = "qlog-sync"; +constexpr const char *SUBDIR_NODES = "nodes"; + +QString currentTimestamp() +{ + return QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyy-MM-ddTHH:mm:ss.zzzZ")); +} + +QString journalFileName(int seq) +{ + return QStringLiteral("journal-%1.jsonl").arg(seq, 4, 10, QChar('0')); +} + +} // namespace + +ContactSync *ContactSync::instance() +{ + static ContactSync inst; + return &inst; +} + +ContactSync::ContactSync(QObject *parent) + : QObject(parent), + timer(new QTimer(this)), + currentSeq(1), + currentBytes(0), + busyFlag(0) +{ + timer->setInterval(FLUSH_INTERVAL_MS); + connect(timer, &QTimer::timeout, this, &ContactSync::onTick); +} + +// +// ---- main-thread getters/setters (use the default DB connection) ---- +// + +bool ContactSync::isEnabled() const +{ + return LogParam::getSyncEnabled(); +} + +QString ContactSync::folder() const +{ + return LogParam::getSyncFolder(); +} + +QString ContactSync::selfNodeId() const +{ + QSqlQuery q; + if ( !q.exec("SELECT value FROM sync_runtime WHERE key = 'self_node_id'") ) + return LogParam::getLogID(); + if ( q.next() ) + return q.value(0).toString(); + return LogParam::getLogID(); +} + +bool ContactSync::setSelfNodeId(const QString &nodeId, QString *errorOut) +{ + const QString trimmed = nodeId.trimmed(); + if ( trimmed.isEmpty() ) + { + if ( errorOut ) *errorOut = tr("Node ID cannot be empty"); + return false; + } + + QSqlQuery q; + if ( !q.prepare("INSERT OR REPLACE INTO sync_runtime (key, value) VALUES ('self_node_id', :v)") ) + { + if ( errorOut ) *errorOut = q.lastError().text(); + return false; + } + q.bindValue(":v", trimmed); + if ( !q.exec() ) + { + if ( errorOut ) *errorOut = q.lastError().text(); + return false; + } + return true; +} + +QDateTime ContactSync::lastFlushTime() const +{ + const QString stored = LogParam::getSyncLastFlush(); + if ( stored.isEmpty() ) + return QDateTime(); + return QDateTime::fromString(stored, Qt::ISODateWithMs); +} + +QDateTime ContactSync::lastPullTime() const +{ + const QString stored = LogParam::getSyncLastPull(); + if ( stored.isEmpty() ) + return QDateTime(); + return QDateTime::fromString(stored, Qt::ISODateWithMs); +} + +ContactSync::DuplicatePolicy ContactSync::duplicatePolicy() const +{ + return static_cast(LogParam::getSyncDuplicatePolicy()); +} + +void ContactSync::setDuplicatePolicy(DuplicatePolicy policy) +{ + LogParam::setSyncDuplicatePolicy(static_cast(policy)); +} + +bool ContactSync::setEnabled(bool enabled, QString *errorOut) +{ + if ( enabled ) + { + snapshotForCycle(); + if ( !ensureFolderLayout(errorOut) ) + return false; + } + LogParam::setSyncEnabled(enabled); + if ( enabled ) + start(); + else + stop(); + return true; +} + +bool ContactSync::setFolder(const QString &path, QString *errorOut) +{ + LogParam::setSyncFolder(path); + if ( isEnabled() ) + { + snapshotForCycle(); + return ensureFolderLayout(errorOut); + } + return true; +} + +void ContactSync::start() +{ + if ( !isEnabled() ) + return; + if ( !timer->isActive() ) + timer->start(); +} + +void ContactSync::stop() +{ + timer->stop(); +} + +int ContactSync::pendingChangesCount() const +{ + if ( !isEnabled() ) + return 0; + + const QString self = selfNodeId(); + if ( self.isEmpty() ) + return 0; + + const QString upsertWm = LogParam::getSyncUpsertWatermark(); + const QString deleteWm = LogParam::getSyncDeleteWatermark(); + + int total = 0; + + QString sql1 = QStringLiteral("SELECT COUNT(*) FROM contacts " + "WHERE origin_node = :node " + " AND updated_at IS NOT NULL"); + if ( !upsertWm.isEmpty() ) + sql1 += QStringLiteral(" AND updated_at > :wm"); + + QSqlQuery q1; + if ( q1.prepare(sql1) ) + { + q1.bindValue(":node", self); + if ( !upsertWm.isEmpty() ) + q1.bindValue(":wm", upsertWm); + if ( q1.exec() && q1.next() ) + total += q1.value(0).toInt(); + } + + QString sql2 = QStringLiteral("SELECT COUNT(*) FROM contacts_tombstones " + "WHERE origin_node = :node"); + if ( !deleteWm.isEmpty() ) + sql2 += QStringLiteral(" AND deleted_at > :wm"); + + QSqlQuery q2; + if ( q2.prepare(sql2) ) + { + q2.bindValue(":node", self); + if ( !deleteWm.isEmpty() ) + q2.bindValue(":wm", deleteWm); + if ( q2.exec() && q2.next() ) + total += q2.value(0).toInt(); + } + + return total; +} + +// +// ---- folder layout / journal file helpers (no DB) ---- +// + +void ContactSync::snapshotForCycle() +{ + cycleFolder = folder(); // LogParam — main-thread safe + cycleSelf = selfNodeId(); // sync_runtime via default conn — main-thread safe +} + +QString ContactSync::rootDir() const +{ + if ( cycleFolder.isEmpty() ) + return QString(); + return QDir(cycleFolder).filePath(SUBDIR_ROOT); +} + +QString ContactSync::nodeDir() const +{ + const QString root = rootDir(); + if ( root.isEmpty() || cycleSelf.isEmpty() ) + return QString(); + return QDir(root).filePath(QStringLiteral("%1/%2").arg(SUBDIR_NODES, cycleSelf)); +} + +QString ContactSync::manifestPath() const +{ + const QString root = rootDir(); + return root.isEmpty() ? QString() : QDir(root).filePath(QStringLiteral("manifest.json")); +} + +QString ContactSync::headPath() const +{ + const QString nd = nodeDir(); + return nd.isEmpty() ? QString() : QDir(nd).filePath(QStringLiteral("HEAD")); +} + +QString ContactSync::journalPath(int seq) const +{ + const QString nd = nodeDir(); + return nd.isEmpty() ? QString() : QDir(nd).filePath(journalFileName(seq)); +} + +QString ContactSync::currentJournalPath() const +{ + return journalPath(currentSeq); +} + +bool ContactSync::ensureFolderLayout(QString *errorOut) +{ + FCT_IDENTIFICATION; + + if ( cycleFolder.isEmpty() ) + { + if ( errorOut ) *errorOut = tr("Sync folder is not set"); + return false; + } + if ( cycleSelf.isEmpty() ) + { + if ( errorOut ) *errorOut = tr("Log ID is not set"); + return false; + } + + QDir dir; + if ( !dir.mkpath(nodeDir()) ) + { + if ( errorOut ) *errorOut = tr("Cannot create sync folder: %1").arg(nodeDir()); + return false; + } + + if ( !writeManifestIfMissing() ) + { + if ( errorOut ) *errorOut = tr("Cannot write manifest"); + return false; + } + + return loadHead(); +} + +bool ContactSync::writeManifestIfMissing() +{ + const QString path = manifestPath(); + if ( path.isEmpty() ) + return false; + if ( QFile::exists(path) ) + return true; + + QJsonObject obj; + obj["version"] = JOURNAL_FORMAT_VERSION; + obj["created_at"] = currentTimestamp(); + + QSaveFile f(path); + if ( !f.open(QIODevice::WriteOnly | QIODevice::Truncate) ) + return false; + f.write(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + return f.commit(); +} + +bool ContactSync::loadHead() +{ + const QString path = headPath(); + if ( path.isEmpty() ) + return false; + + if ( !QFile::exists(path) ) + { + currentSeq = 1; + currentBytes = 0; + QFile create(currentJournalPath()); + if ( !create.open(QIODevice::WriteOnly | QIODevice::Append) ) + return false; + create.close(); + return writeHead(); + } + + QFile f(path); + if ( !f.open(QIODevice::ReadOnly) ) + return false; + const QJsonObject obj = QJsonDocument::fromJson(f.readAll()).object(); + const QString fileName = obj.value("file").toString(); + currentSeq = 1; + if ( fileName.startsWith("journal-") && fileName.endsWith(".jsonl") ) + { + const QString seqStr = fileName.mid(8, fileName.size() - 8 - 6); + bool ok = false; + const int parsed = seqStr.toInt(&ok); + if ( ok && parsed >= 1 ) + currentSeq = parsed; + } + currentBytes = QFileInfo(currentJournalPath()).size(); + return true; +} + +bool ContactSync::writeHead() +{ + const QString path = headPath(); + if ( path.isEmpty() ) + return false; + + QJsonObject obj; + obj["file"] = journalFileName(currentSeq); + obj["bytes"] = static_cast(currentBytes); + obj["updated_at"] = currentTimestamp(); + + QSaveFile f(path); + if ( !f.open(QIODevice::WriteOnly | QIODevice::Truncate) ) + return false; + f.write(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + return f.commit(); +} + +bool ContactSync::rotateIfNeeded() +{ + if ( currentBytes < MAX_JOURNAL_BYTES ) + return true; + currentSeq += 1; + currentBytes = 0; + return true; +} + +bool ContactSync::appendRecord(const QByteArray &line, QString *errorOut) +{ + if ( !rotateIfNeeded() ) + { + if ( errorOut ) *errorOut = tr("Cannot rotate journal"); + return false; + } + + QFile f(currentJournalPath()); + if ( !f.open(QIODevice::WriteOnly | QIODevice::Append) ) + { + if ( errorOut ) *errorOut = tr("Cannot open journal: %1").arg(f.errorString()); + return false; + } + QByteArray payload = line; + payload.append('\n'); + if ( f.write(payload) != payload.size() ) + { + if ( errorOut ) *errorOut = tr("Short write to journal"); + return false; + } + currentBytes += payload.size(); + return true; +} + +// +// ---- worker-side DB helpers (use the passed-in cloned connection) ---- +// +// Everything below takes `QSqlDatabase &db` and uses `QSqlQuery q(db)`. For +// now the dispatchers call these synchronously with the default connection, +// but the signature is set up so a future move back to a worker thread can +// pass in a per-call cloned connection without changing the call sites. +// + +QSet ContactSync::contactColumnsOnDb(QSqlDatabase &db) const +{ + // Cache is per-process; the schema can't change at runtime. + static QSet cache; + static bool loaded = false; + if ( !loaded ) + { + QSqlQuery q(db); + if ( q.exec(QStringLiteral("PRAGMA table_info(contacts)")) ) + { + while ( q.next() ) + cache.insert(q.value(QStringLiteral("name")).toString()); + loaded = !cache.isEmpty(); + } + } + return cache; +} + +bool ContactSync::flushOnDb(QSqlDatabase &db, int *upsertsOut, int *deletesOut, + QString *newUpsertWatermark, + QString *newDeleteWatermark, QString *errorOut) +{ + // Read self and current watermarks from the worker's connection. The + // worker never writes to log_param; new watermark values come back to + // the main thread via the out-params below, and the queued lambda + // commits them through LogParam (so its cache stays coherent). + + QString self; + { + QSqlQuery q(db); + if ( q.exec(QStringLiteral("SELECT value FROM sync_runtime " + "WHERE key = 'self_node_id'")) && q.next() ) + self = q.value(0).toString(); + } + + auto readParam = [&db](const QString &name) -> QString + { + QSqlQuery q(db); + if ( !q.prepare(QStringLiteral("SELECT value FROM log_param WHERE name = :n")) ) + return QString(); + q.bindValue(QStringLiteral(":n"), name); + if ( !q.exec() || !q.next() ) return QString(); + return q.value(0).toString(); + }; + + const QString upsertWm = readParam(QStringLiteral("sync/last_journaled_updated_at")); + const QString deleteWm = readParam(QStringLiteral("sync/last_journaled_deleted_at")); + + // ---- upserts ---- + int upserts = 0; + QString maxUpsert = upsertWm; + { + QString sql = QStringLiteral("SELECT * FROM contacts " + "WHERE origin_node = :node " + " AND updated_at IS NOT NULL"); + if ( !upsertWm.isEmpty() ) + sql += QStringLiteral(" AND updated_at > :wm"); + sql += QStringLiteral(" ORDER BY updated_at ASC, id ASC"); + + QSqlQuery q(db); + if ( !q.prepare(sql) ) + { + if ( errorOut ) *errorOut = q.lastError().text(); + return false; + } + q.bindValue(":node", self); + if ( !upsertWm.isEmpty() ) + q.bindValue(":wm", upsertWm); + if ( !q.exec() ) + { + if ( errorOut ) *errorOut = q.lastError().text(); + return false; + } + + while ( q.next() ) + { + const QSqlRecord rec = q.record(); + QJsonObject payload; + for ( int i = 0; i < rec.count(); ++i ) + { + const QString name = rec.fieldName(i); + if ( name == QLatin1String("id") ) + continue; + const QVariant v = rec.value(i); + if ( v.isNull() ) + continue; + payload.insert(name, QJsonValue::fromVariant(v)); + } + + QJsonObject record; + record["v"] = JOURNAL_FORMAT_VERSION; + record["op"] = QStringLiteral("upsert"); + record["qso_uuid"] = rec.value("qso_uuid").toString(); + record["updated_at"] = rec.value("updated_at").toString(); + record["node"] = self; + record["payload"] = payload; + + const QByteArray line = QJsonDocument(record).toJson(QJsonDocument::Compact); + if ( !appendRecord(line, errorOut) ) + return false; + + const QString updatedAt = rec.value("updated_at").toString(); + if ( updatedAt > maxUpsert ) + maxUpsert = updatedAt; + ++upserts; + } + } + + // ---- deletes ---- + int deletes = 0; + QString maxDelete = deleteWm; + { + QString sql = QStringLiteral("SELECT qso_uuid, deleted_at, origin_node " + "FROM contacts_tombstones " + "WHERE origin_node = :node"); + if ( !deleteWm.isEmpty() ) + sql += QStringLiteral(" AND deleted_at > :wm"); + sql += QStringLiteral(" ORDER BY deleted_at ASC"); + + QSqlQuery q(db); + if ( !q.prepare(sql) ) + { + if ( errorOut ) *errorOut = q.lastError().text(); + return false; + } + q.bindValue(":node", self); + if ( !deleteWm.isEmpty() ) + q.bindValue(":wm", deleteWm); + if ( !q.exec() ) + { + if ( errorOut ) *errorOut = q.lastError().text(); + return false; + } + + while ( q.next() ) + { + QJsonObject record; + record["v"] = JOURNAL_FORMAT_VERSION; + record["op"] = QStringLiteral("delete"); + record["qso_uuid"] = q.value("qso_uuid").toString(); + record["updated_at"] = q.value("deleted_at").toString(); + record["node"] = self; + + const QByteArray line = QJsonDocument(record).toJson(QJsonDocument::Compact); + if ( !appendRecord(line, errorOut) ) + return false; + + const QString deletedAt = q.value("deleted_at").toString(); + if ( deletedAt > maxDelete ) + maxDelete = deletedAt; + ++deletes; + } + } + + if ( !writeHead() ) + { + if ( errorOut ) *errorOut = tr("Cannot update HEAD"); + return false; + } + + if ( upsertsOut ) *upsertsOut = upserts; + if ( deletesOut ) *deletesOut = deletes; + if ( newUpsertWatermark && upserts > 0 ) *newUpsertWatermark = maxUpsert; + if ( newDeleteWatermark && deletes > 0 ) *newDeleteWatermark = maxDelete; + + return true; +} + +bool ContactSync::applyUpsertOnDb(QSqlDatabase &db, const QJsonObject &rec, + QString *errorOut) +{ + const QString qsoUuid = rec.value("qso_uuid").toString(); + const QString updatedAt = rec.value("updated_at").toString(); + const QJsonObject payload = rec.value("payload").toObject(); + + // Read duplicate policy from the worker's own connection so we never + // touch LogParam from a background thread. + int policyValue = 1; + { + QSqlQuery q(db); + if ( q.prepare(QStringLiteral("SELECT value FROM log_param " + "WHERE name = 'sync/duplicate_policy'")) + && q.exec() && q.next() ) + { + policyValue = q.value(0).toInt(); + } + } + const DuplicatePolicy policy = static_cast(policyValue); + + // LWW vs existing local row by qso_uuid. + { + QSqlQuery check(db); + check.prepare("SELECT updated_at FROM contacts WHERE qso_uuid = :u"); + check.bindValue(":u", qsoUuid); + if ( !check.exec() ) + { + if ( errorOut ) *errorOut = check.lastError().text(); + return false; + } + if ( check.next() ) + { + const QString local = check.value(0).toString(); + if ( !local.isEmpty() && local >= updatedAt ) + return true; + + QStringList sets; + QVariantMap binds; + const QSet cols = contactColumnsOnDb(db); + for ( auto it = payload.constBegin(); it != payload.constEnd(); ++it ) + { + const QString &col = it.key(); + if ( col == QLatin1String("id") || col == QLatin1String("qso_uuid") ) + continue; + if ( !cols.contains(col) ) + continue; + sets << col + " = :" + col; + binds.insert(col, it.value().toVariant()); + } + QSqlQuery upd(db); + const QString sql = "UPDATE contacts SET " + sets.join(", ") + + " WHERE qso_uuid = :where_uuid"; + if ( !upd.prepare(sql) ) + { + if ( errorOut ) *errorOut = upd.lastError().text(); + return false; + } + for ( auto it = binds.constBegin(); it != binds.constEnd(); ++it ) + upd.bindValue(":" + it.key(), it.value()); + upd.bindValue(":where_uuid", qsoUuid); + if ( !upd.exec() ) + { + if ( errorOut ) *errorOut = upd.lastError().text(); + return false; + } + return true; + } + } + + // Tombstone wins over older incoming insert. + { + QSqlQuery tomb(db); + tomb.prepare("SELECT deleted_at FROM contacts_tombstones WHERE qso_uuid = :u"); + tomb.bindValue(":u", qsoUuid); + if ( tomb.exec() && tomb.next() ) + { + const QString deletedAt = tomb.value(0).toString(); + if ( !deletedAt.isEmpty() && deletedAt >= updatedAt ) + return true; + } + } + + // Fingerprint-based duplicate detection (callsign + mode + band + + // sat_name + start_time ±30 min). Mirrors the ADIF import dupe query. + { + const QString fpCall = payload.value("callsign").toString(); + const QString fpMode = payload.value("mode").toString(); + const QString fpBand = payload.value("band").toString(); + const QString fpSat = payload.value("sat_name").toString(); + const QString fpStart = payload.value("start_time").toString(); + + if ( !fpCall.isEmpty() && !fpMode.isEmpty() && !fpBand.isEmpty() + && !fpStart.isEmpty() ) + { + QSqlQuery dup(db); + if ( dup.prepare("SELECT qso_uuid FROM contacts " + "WHERE callsign = upper(:callsign) " + " AND upper(mode) = upper(:mode) " + " AND upper(band) = upper(:band) " + " AND COALESCE(sat_name, '') = COALESCE(:sat_name, '') " + " AND ABS(JULIANDAY(start_time) - " + " JULIANDAY(datetime(:startdate))) * 24 * 60 < 30 " + "LIMIT 1") ) + { + dup.bindValue(":callsign", fpCall); + dup.bindValue(":mode", fpMode); + dup.bindValue(":band", fpBand); + dup.bindValue(":sat_name", fpSat.isEmpty() ? QVariant() : QVariant(fpSat)); + dup.bindValue(":startdate", fpStart); + + if ( dup.exec() && dup.next() ) + { + const QString localUuid = dup.value(0).toString(); + + if ( policy == DuplicatePolicy::Skip ) + { + qCInfo(runtime) << "Sync dup-skip:" << fpCall + << "remote uuid" << qsoUuid + << "local uuid" << localUuid; + return true; + } + + if ( qsoUuid < localUuid ) + { + QSqlQuery adopt(db); + adopt.prepare("UPDATE contacts SET qso_uuid = :new " + "WHERE qso_uuid = :old"); + adopt.bindValue(":new", qsoUuid); + adopt.bindValue(":old", localUuid); + if ( !adopt.exec() ) + { + if ( errorOut ) *errorOut = adopt.lastError().text(); + return false; + } + qCInfo(runtime) << "Sync dup-merge: local" << localUuid + << "→ remote" << qsoUuid; + } + return true; + } + } + } + } + + // Fresh INSERT with all sync columns pre-set so the AFTER INSERT auto-fill + // trigger's WHEN-guard skips and preserves the remote node id and ts. + QStringList cols; + QStringList placeholders; + QVariantMap binds; + const QSet known = contactColumnsOnDb(db); + for ( auto it = payload.constBegin(); it != payload.constEnd(); ++it ) + { + const QString &col = it.key(); + if ( col == QLatin1String("id") ) + continue; + if ( !known.contains(col) ) + continue; + cols << col; + placeholders << ":" + col; + binds.insert(col, it.value().toVariant()); + } + + QSqlQuery ins(db); + const QString sql = "INSERT INTO contacts (" + cols.join(", ") + ") " + "VALUES (" + placeholders.join(", ") + ")"; + if ( !ins.prepare(sql) ) + { + if ( errorOut ) *errorOut = ins.lastError().text(); + return false; + } + for ( auto it = binds.constBegin(); it != binds.constEnd(); ++it ) + ins.bindValue(":" + it.key(), it.value()); + if ( !ins.exec() ) + { + if ( errorOut ) *errorOut = ins.lastError().text(); + return false; + } + return true; +} + +bool ContactSync::applyDeleteOnDb(QSqlDatabase &db, const QString &qsoUuid, + const QString &deletedAt, + const QString &origin, QString *errorOut) +{ + QSqlQuery check(db); + check.prepare("SELECT updated_at FROM contacts WHERE qso_uuid = :u"); + check.bindValue(":u", qsoUuid); + if ( !check.exec() ) + { + if ( errorOut ) *errorOut = check.lastError().text(); + return false; + } + if ( check.next() ) + { + const QString local = check.value(0).toString(); + if ( !local.isEmpty() && local > deletedAt ) + return true; + QSqlQuery del(db); + del.prepare("DELETE FROM contacts WHERE qso_uuid = :u"); + del.bindValue(":u", qsoUuid); + if ( !del.exec() ) + { + if ( errorOut ) *errorOut = del.lastError().text(); + return false; + } + return true; + } + + QSqlQuery tomb(db); + tomb.prepare("INSERT OR REPLACE INTO contacts_tombstones " + "(qso_uuid, deleted_at, origin_node) VALUES (:u, :d, :o)"); + tomb.bindValue(":u", qsoUuid); + tomb.bindValue(":d", deletedAt); + tomb.bindValue(":o", origin); + if ( !tomb.exec() ) + { + if ( errorOut ) *errorOut = tomb.lastError().text(); + return false; + } + return true; +} + +bool ContactSync::applyJournalLineOnDb(QSqlDatabase &db, const QByteArray &line, + QString *errorOut) +{ + QJsonParseError parseErr; + const QJsonDocument doc = QJsonDocument::fromJson(line, &parseErr); + if ( parseErr.error != QJsonParseError::NoError ) + { + if ( errorOut ) *errorOut = parseErr.errorString(); + return false; + } + const QJsonObject rec = doc.object(); + + if ( rec.value("v").toInt() != JOURNAL_FORMAT_VERSION ) + { + if ( errorOut ) *errorOut = tr("unknown record version"); + return false; + } + + const QString op = rec.value("op").toString(); + const QString qsoUuid = rec.value("qso_uuid").toString(); + const QString updatedAt = rec.value("updated_at").toString(); + const QString origin = rec.value("node").toString(); + + if ( qsoUuid.isEmpty() ) + { + if ( errorOut ) *errorOut = tr("missing qso_uuid"); + return false; + } + + if ( op == QLatin1String("upsert") ) + return applyUpsertOnDb(db, rec, errorOut); + + if ( op == QLatin1String("delete") ) + return applyDeleteOnDb(db, qsoUuid, updatedAt, origin, errorOut); + + if ( errorOut ) *errorOut = tr("unknown op: %1").arg(op); + return false; +} + +bool ContactSync::pullPeerOnDb(QSqlDatabase &db, const QString &peerNode, + const QString &peerDir, int *appliedOut, + QString *errorOut) +{ + QString lastFile; + qint64 lastOffset = 0; + { + QSqlQuery q(db); + if ( q.prepare("SELECT last_file, last_offset FROM sync_peers WHERE node = :n") ) + { + q.bindValue(":n", peerNode); + if ( q.exec() && q.next() ) + { + lastFile = q.value(0).toString(); + lastOffset = q.value(1).toLongLong(); + } + } + } + + QDir peerD(peerDir); + const QStringList journals = peerD.entryList(QStringList() << "journal-*.jsonl", + QDir::Files, QDir::Name); + + int applied = 0; + QString finalFile = lastFile; + qint64 finalOffset = lastOffset; + + for ( const QString &name : journals ) + { + if ( !lastFile.isEmpty() && name < lastFile ) + continue; + + QFile f(peerD.filePath(name)); + if ( !f.open(QIODevice::ReadOnly) ) + continue; + + const qint64 startAt = (name == lastFile) ? lastOffset : 0; + if ( !f.seek(startAt) ) + { + f.close(); + continue; + } + + while ( !f.atEnd() ) + { + const QByteArray line = f.readLine(); + if ( line.trimmed().isEmpty() ) + break; + + QString err; + if ( applyJournalLineOnDb(db, line, &err) ) + ++applied; + else + qCWarning(runtime) << "Skipping record from" << peerNode << ":" << err; + + finalFile = name; + finalOffset = f.pos(); + } + f.close(); + } + + QSqlQuery upd(db); + if ( upd.prepare("INSERT OR REPLACE INTO sync_peers " + "(node, last_file, last_offset, last_seen_at) " + "VALUES (:n, :f, :o, :t)") ) + { + upd.bindValue(":n", peerNode); + upd.bindValue(":f", finalFile); + upd.bindValue(":o", static_cast(finalOffset)); + upd.bindValue(":t", QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + if ( !upd.exec() && errorOut ) + *errorOut = upd.lastError().text(); + } + + if ( appliedOut ) *appliedOut = applied; + return true; +} + +bool ContactSync::pullOnDb(QSqlDatabase &db, int *appliedOut, QString *errorOut) +{ + QString self; + { + QSqlQuery q(db); + if ( q.exec("SELECT value FROM sync_runtime WHERE key = 'self_node_id'") + && q.next() ) + self = q.value(0).toString(); + } + + const QString nodesPath = QDir(rootDir()).filePath(SUBDIR_NODES); + QDir nodesDir(nodesPath); + if ( !nodesDir.exists() ) + { + if ( appliedOut ) *appliedOut = 0; + return true; + } + + int totalApplied = 0; + QStringList errors; + + const QStringList peers = nodesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for ( const QString &peer : peers ) + { + if ( peer == self ) + continue; + int applied = 0; + QString err; + if ( !pullPeerOnDb(db, peer, nodesDir.filePath(peer), &applied, &err) ) + errors << QString("%1: %2").arg(peer, err); + totalApplied += applied; + } + + if ( appliedOut ) *appliedOut = totalApplied; + if ( !errors.isEmpty() ) + { + if ( errorOut ) *errorOut = errors.join(QStringLiteral("; ")); + return false; + } + return true; +} + +// +// ---- main-thread dispatchers ---- +// + +void ContactSync::setBusy(bool busy) +{ + emit busyChanged(busy); +} + +void ContactSync::runFlushCycle() +{ + // NOTE: Temporarily reverted to synchronous-on-main-thread to isolate + // whether the worker thread / connection cloning was the root cause of + // the "SET - Cannot exec an insert parameter statement" errors. If this + // version runs cleanly we'll know it's a threading issue. + + if ( !isEnabled() ) + return; + if ( !busyFlag.testAndSetAcquire(0, 1) ) + return; + setBusy(true); + + snapshotForCycle(); + + QString err; + if ( !ensureFolderLayout(&err) ) + { + busyFlag.storeRelease(0); + setBusy(false); + emit flushFailed(err); + return; + } + + QSqlDatabase db = QSqlDatabase::database(); + int upserts = 0, deletes = 0; + QString newUpsertWm, newDeleteWm; + const bool ok = flushOnDb(db, &upserts, &deletes, + &newUpsertWm, &newDeleteWm, &err); + + if ( ok ) + { + if ( !newUpsertWm.isEmpty() && !LogParam::setSyncUpsertWatermark(newUpsertWm) ) + qCWarning(runtime) << "ContactSync: setSyncUpsertWatermark failed"; + if ( !newDeleteWm.isEmpty() && !LogParam::setSyncDeleteWatermark(newDeleteWm) ) + qCWarning(runtime) << "ContactSync: setSyncDeleteWatermark failed"; + lastFlush = QDateTime::currentDateTimeUtc(); + if ( !LogParam::setSyncLastFlush(lastFlush.toString(Qt::ISODateWithMs)) ) + qCWarning(runtime) << "ContactSync: setSyncLastFlush failed"; + emit flushed(upserts, deletes); + } + else + { + emit flushFailed(err); + } + busyFlag.storeRelease(0); + setBusy(false); +} + +void ContactSync::runPullCycle() +{ + if ( !isEnabled() ) + return; + if ( !busyFlag.testAndSetAcquire(0, 1) ) + return; + setBusy(true); + + snapshotForCycle(); + + QString err; + if ( !ensureFolderLayout(&err) ) + { + busyFlag.storeRelease(0); + setBusy(false); + emit pullFailed(err); + return; + } + + QSqlDatabase db = QSqlDatabase::database(); + int applied = 0; + const bool ok = pullOnDb(db, &applied, &err); + + if ( ok ) + { + if ( !LogParam::setSyncLastPull( + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)) ) + qCWarning(runtime) << "ContactSync: setSyncLastPull failed"; + emit pulled(applied); + } + else + { + emit pullFailed(err); + } + busyFlag.storeRelease(0); + setBusy(false); +} + +void ContactSync::runFullCycle() +{ + if ( !isEnabled() ) + return; + if ( !busyFlag.testAndSetAcquire(0, 1) ) + return; + setBusy(true); + + snapshotForCycle(); + + QString preErr; + if ( !ensureFolderLayout(&preErr) ) + { + busyFlag.storeRelease(0); + setBusy(false); + emit flushFailed(preErr); + return; + } + + QSqlDatabase db = QSqlDatabase::database(); + int upserts = 0, deletes = 0, applied = 0; + QString newUpsertWm, newDeleteWm; + QString flushErr, pullErr; + + const bool flushOk = flushOnDb(db, &upserts, &deletes, + &newUpsertWm, &newDeleteWm, &flushErr); + const bool pullOk = pullOnDb(db, &applied, &pullErr); + + if ( flushOk ) + { + if ( !newUpsertWm.isEmpty() && !LogParam::setSyncUpsertWatermark(newUpsertWm) ) + qCWarning(runtime) << "ContactSync: setSyncUpsertWatermark failed"; + if ( !newDeleteWm.isEmpty() && !LogParam::setSyncDeleteWatermark(newDeleteWm) ) + qCWarning(runtime) << "ContactSync: setSyncDeleteWatermark failed"; + lastFlush = QDateTime::currentDateTimeUtc(); + if ( !LogParam::setSyncLastFlush(lastFlush.toString(Qt::ISODateWithMs)) ) + qCWarning(runtime) << "ContactSync: setSyncLastFlush failed"; + emit flushed(upserts, deletes); + } + else + { + emit flushFailed(flushErr); + } + + if ( pullOk ) + { + if ( !LogParam::setSyncLastPull( + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)) ) + qCWarning(runtime) << "ContactSync: setSyncLastPull failed"; + emit pulled(applied); + } + else + { + emit pullFailed(pullErr); + } + + busyFlag.storeRelease(0); + setBusy(false); +} + +void ContactSync::requestFlush() +{ + runFlushCycle(); +} + +void ContactSync::requestPull() +{ + runPullCycle(); +} + +void ContactSync::onTick() +{ + runFullCycle(); +} diff --git a/core/ContactSync.h b/core/ContactSync.h new file mode 100644 index 00000000..bd25d841 --- /dev/null +++ b/core/ContactSync.h @@ -0,0 +1,133 @@ +#ifndef QLOG_CORE_CONTACTSYNC_H +#define QLOG_CORE_CONTACTSYNC_H + +#include +#include +#include +#include +#include + +class QTimer; +class QJsonObject; +class QSqlDatabase; + +class ContactSync : public QObject +{ + Q_OBJECT + +public: + // How to handle a remote upsert whose qso_uuid is unknown locally but + // whose fingerprint (callsign + mode + band + sat_name + start_time + // ±30 min) matches an existing local row. + enum class DuplicatePolicy { + Skip = 0, // log dupe and leave both rows independent + Merge = 1 // converge on the lexicographically-smaller UUID, then + // let LWW reconcile fields (default) + }; + + static ContactSync *instance(); + + bool isEnabled() const; + QString folder() const; + QString selfNodeId() const; + QDateTime lastFlushTime() const; + QDateTime lastPullTime() const; + + bool setEnabled(bool enabled, QString *errorOut = nullptr); + bool setFolder(const QString &path, QString *errorOut = nullptr); + bool setSelfNodeId(const QString &nodeId, QString *errorOut = nullptr); + + DuplicatePolicy duplicatePolicy() const; + void setDuplicatePolicy(DuplicatePolicy policy); + + int pendingChangesCount() const; + + void start(); + void stop(); + +public slots: + // Run a flush cycle. Called by both the periodic timer and the local- + // change signals (contactAdded/contactUpdated/Deleted). If a cycle is + // already running, the call coalesces (the in-flight cycle picks up + // everything above the watermark anyway). + void requestFlush(); + void requestPull(); + +signals: + void flushed(int upsertCount, int deleteCount); + void flushFailed(const QString &message); + void pulled(int appliedCount); + void pullFailed(const QString &message); + void busyChanged(bool busy); + +private slots: + void onTick(); + +private: + explicit ContactSync(QObject *parent = nullptr); + + // ---- folder helpers (no DB) ---- + QString rootDir() const; + QString nodeDir() const; + QString manifestPath() const; + QString headPath() const; + QString journalPath(int seq) const; + QString currentJournalPath() const; + + // ---- worker-side: every method takes the cloned connection it should + // use. They never touch the default (main-thread) connection. + bool ensureFolderLayout(QString *errorOut); + bool loadHead(); + bool writeHead(); + bool writeManifestIfMissing(); + bool appendRecord(const QByteArray &line, QString *errorOut); + bool rotateIfNeeded(); + + bool flushOnDb(QSqlDatabase &db, int *upsertsOut, int *deletesOut, + QString *newUpsertWatermark, QString *newDeleteWatermark, + QString *errorOut); + + bool pullOnDb(QSqlDatabase &db, int *appliedOut, QString *errorOut); + bool pullPeerOnDb(QSqlDatabase &db, const QString &peerNode, + const QString &peerDir, int *appliedOut, + QString *errorOut); + bool applyJournalLineOnDb(QSqlDatabase &db, const QByteArray &line, + QString *errorOut); + bool applyUpsertOnDb(QSqlDatabase &db, const QJsonObject &record, + QString *errorOut); + bool applyDeleteOnDb(QSqlDatabase &db, const QString &qsoUuid, + const QString &deletedAt, const QString &origin, + QString *errorOut); + + QSet contactColumnsOnDb(QSqlDatabase &db) const; + + // Dispatch helpers — open a per-call cloned SQLite connection, run the + // work, post results back to this object via queued signals. + void runFlushCycle(); + void runPullCycle(); + void runFullCycle(); // flush then pull (timer tick) + + void setBusy(bool busy); + + // Cache folder + self for the duration of one flush/pull cycle. Avoids + // re-querying sync_runtime on every path-helper call. Also positions the + // class for a future move back to a worker thread, where these would + // need to be captured on the main thread before dispatch (the path + // helpers must never call LogParam or QSqlDatabase::database() from a + // background thread). + void snapshotForCycle(); + + QTimer *timer; + int currentSeq; + qint64 currentBytes; + QDateTime lastFlush; + QAtomicInt busyFlag; // 0 = idle, 1 = cycle running + QString cycleFolder; // snapshot of folder() for this cycle + QString cycleSelf; // snapshot of selfNodeId() for this cycle + + static constexpr qint64 MAX_JOURNAL_BYTES = 4 * 1024 * 1024; + static constexpr int FLUSH_INTERVAL_MS = 30000; + static constexpr int JOURNAL_FORMAT_VERSION = 1; +}; + +#endif // QLOG_CORE_CONTACTSYNC_H diff --git a/core/LogParam.cpp b/core/LogParam.cpp index c0466503..492b00b7 100644 --- a/core/LogParam.cpp +++ b/core/LogParam.cpp @@ -50,6 +50,21 @@ QString LogParam::getLogID() return getParam("logid").toString(); } +bool LogParam::setSyncEnabled(bool enabled) { return setParam("sync/enabled", enabled); } +bool LogParam::getSyncEnabled() { return getParam("sync/enabled", false).toBool(); } +bool LogParam::setSyncFolder(const QString &path) { return setParam("sync/folder", path); } +QString LogParam::getSyncFolder() { return getParam("sync/folder").toString(); } +bool LogParam::setSyncLastFlush(const QString &iso) { return setParam("sync/last_flush", iso); } +QString LogParam::getSyncLastFlush() { return getParam("sync/last_flush").toString(); } +bool LogParam::setSyncLastPull(const QString &iso) { return setParam("sync/last_pull", iso); } +QString LogParam::getSyncLastPull() { return getParam("sync/last_pull").toString(); } +bool LogParam::setSyncDuplicatePolicy(int v) { return setParam("sync/duplicate_policy", v); } +int LogParam::getSyncDuplicatePolicy() { return getParam("sync/duplicate_policy", 1).toInt(); } +bool LogParam::setSyncUpsertWatermark(const QString &iso) { return setParam("sync/last_journaled_updated_at", iso); } +QString LogParam::getSyncUpsertWatermark() { return getParam("sync/last_journaled_updated_at").toString(); } +bool LogParam::setSyncDeleteWatermark(const QString &iso) { return setParam("sync/last_journaled_deleted_at", iso); } +QString LogParam::getSyncDeleteWatermark() { return getParam("sync/last_journaled_deleted_at").toString(); } + bool LogParam::setContestSeqnoType(const QVariant &data) { return setParam("contest/seqnotype", data); diff --git a/core/LogParam.h b/core/LogParam.h index cd811083..a9c91691 100644 --- a/core/LogParam.h +++ b/core/LogParam.h @@ -30,6 +30,24 @@ class LogParam : public QObject static bool setLogID(const QString &id); static QString getLogID(); + /********* + * Contact Sync + ********/ + static bool setSyncEnabled(bool enabled); + static bool getSyncEnabled(); + static bool setSyncFolder(const QString &path); + static QString getSyncFolder(); + static bool setSyncLastFlush(const QString &iso); + static QString getSyncLastFlush(); + static bool setSyncLastPull(const QString &iso); + static QString getSyncLastPull(); + static bool setSyncDuplicatePolicy(int value); + static int getSyncDuplicatePolicy(); + static bool setSyncUpsertWatermark(const QString &iso); + static QString getSyncUpsertWatermark(); + static bool setSyncDeleteWatermark(const QString &iso); + static QString getSyncDeleteWatermark(); + /********* * Contest ********/ diff --git a/core/Migration.cpp b/core/Migration.cpp index 0c2efc64..5ab4dda7 100644 --- a/core/Migration.cpp +++ b/core/Migration.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "core/Migration.h" #include "debug.h" #include "data/Data.h" @@ -329,6 +330,9 @@ bool DBSchemaMigration::functionMigration(int version) case 35: ret = removeSettings2DB(); break; + case 39: + ret = setupContactSync(); + break; default: ret = true; } @@ -628,6 +632,146 @@ bool DBSchemaMigration::createTriggers() return true; } +bool DBSchemaMigration::setupContactSync() +{ + FCT_IDENTIFICATION; + + // Seed sync_runtime with a human-readable node id (hostname, falling + // back to the existing log GUID). The triggers below read the current + // value at fire time, so the user can later change it via the Sync + // dialog without rebuilding any trigger. + QString defaultNodeId = QHostInfo::localHostName().trimmed(); + if ( defaultNodeId.isEmpty() ) + defaultNodeId = LogParam::getLogID(); + + QSqlQuery seed; + if ( !seed.prepare("INSERT OR REPLACE INTO sync_runtime (key, value) " + "VALUES ('self_node_id', :v)") ) + { + qWarning() << "Cannot prepare sync_runtime seed" << seed.lastError(); + return false; + } + seed.bindValue(":v", defaultNodeId); + if ( !seed.exec() ) + { + qWarning() << "Cannot seed sync_runtime" << seed.lastError(); + return false; + } + + // SQL fragments reused below. uuidExpr generates a v4-shaped UUID purely + // in SQLite so each row gets a distinct value in a single UPDATE. + static const QString uuidExpr = + "lower(hex(randomblob(4))) || '-' || " + "lower(hex(randomblob(2))) || '-4' || " + "substr(lower(hex(randomblob(2))), 2) || '-' || " + "substr('89ab', abs(random()) % 4 + 1, 1) || " + "substr(lower(hex(randomblob(2))), 2) || '-' || " + "lower(hex(randomblob(6)))"; + + static const QString nodeSelect = + "(SELECT value FROM sync_runtime WHERE key = 'self_node_id')"; + + // Backfill sync metadata for existing contacts. Done before any sync + // trigger exists, so no AFTER UPDATE bumper fires during the mass write. + QSqlQuery backfill; + if ( !backfill.prepare(QStringLiteral( + "UPDATE contacts SET " + " qso_uuid = COALESCE(qso_uuid, ") + uuidExpr + + QStringLiteral("), " + " updated_at = COALESCE(updated_at, end_time, start_time, " + " strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), " + " origin_node = COALESCE(origin_node, :node)")) ) + { + qWarning() << "Cannot prepare contacts backfill" << backfill.lastError(); + return false; + } + backfill.bindValue(":node", defaultNodeId); + if ( !backfill.exec() ) + { + qWarning() << "Cannot backfill contacts" << backfill.lastError(); + return false; + } + + QSqlQuery q; + + // AFTER INSERT: auto-fill qso_uuid / updated_at / origin_node when the + // caller didn't pre-set them. WHEN guard skips the inner UPDATE when all + // three are already set, so a future sync-apply path won't cascade into + // the AFTER UPDATE bumper. Built via concatenation (not arg) so % in the + // strftime format reaches SQLite verbatim. + const QString insertTrigger = + QStringLiteral("CREATE TRIGGER contacts_sync_insert " + "AFTER INSERT ON contacts " + "FOR EACH ROW " + "WHEN NEW.qso_uuid IS NULL " + " OR NEW.updated_at IS NULL " + " OR NEW.origin_node IS NULL " + "BEGIN " + " UPDATE contacts " + " SET qso_uuid = COALESCE(NEW.qso_uuid, ") + uuidExpr + + QStringLiteral("), " + " updated_at = COALESCE(NEW.updated_at, " + " strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), " + " origin_node = COALESCE(NEW.origin_node, ") + nodeSelect + + QStringLiteral(") " + " WHERE id = NEW.id; " + "END"); + + if ( !q.exec(insertTrigger) ) + { + qWarning() << "Cannot create contacts_sync_insert" << q.lastError(); + return false; + } + + // AFTER UPDATE: bump updated_at and re-stamp origin_node on every local + // edit, unless the caller already set updated_at explicitly (the sync + // apply path). origin_node has to follow the editor: a row pulled from + // a peer keeps that peer's id, so a later local edit would otherwise + // stay invisible to the writer's "origin_node = self" filter and never + // get journaled out. The IS-distinct-from check on NEW vs OLD also + // prevents the inner UPDATE from re-triggering itself. + if ( !q.exec(QStringLiteral("CREATE TRIGGER contacts_sync_update " + "AFTER UPDATE ON contacts " + "FOR EACH ROW " + "WHEN NEW.updated_at IS OLD.updated_at " + "BEGIN " + " UPDATE contacts " + " SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), " + " origin_node = ") + nodeSelect + + QStringLiteral(" " + " WHERE id = NEW.id; " + "END")) ) + { + qWarning() << "Cannot create contacts_sync_update" << q.lastError(); + return false; + } + + // BEFORE DELETE: record a tombstone so the sync layer can propagate the + // delete to peers. Origin is this install's node id (the deleter), not + // the row's original creator. + const QString tombTrigger = + QStringLiteral("CREATE TRIGGER contacts_record_tombstone " + "BEFORE DELETE ON contacts " + "FOR EACH ROW " + "WHEN OLD.qso_uuid IS NOT NULL " + "BEGIN " + " INSERT OR REPLACE INTO contacts_tombstones " + " (qso_uuid, deleted_at, origin_node) " + " VALUES " + " (OLD.qso_uuid, " + " strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ") + nodeSelect + + QStringLiteral("); " + "END"); + + if ( !q.exec(tombTrigger) ) + { + qWarning() << "Cannot create contacts_record_tombstone" << q.lastError(); + return false; + } + + return true; +} + bool DBSchemaMigration::importQSLCards2DB() { FCT_IDENTIFICATION; @@ -1204,7 +1348,10 @@ bool DBSchemaMigration::refreshUploadStatusTrigger() " 'hamlogeu_qso_upload_date', " " 'hamlogeu_qso_upload_status', " " 'hamqth_qso_upload_date', " - " 'hamqth_qso_upload_status')"); + " 'hamqth_qso_upload_status', " + " 'qso_uuid', " + " 'updated_at', " + " 'origin_node')"); if ( !query.exec() ) { diff --git a/core/Migration.h b/core/Migration.h index c789550c..a808e41f 100644 --- a/core/Migration.h +++ b/core/Migration.h @@ -14,7 +14,7 @@ class DBSchemaMigration : public QObject bool run(bool force = false); static bool backupAllQSOsToADX(bool force = false); - static constexpr int latestVersion = 38; + static constexpr int latestVersion = 39; private: bool functionMigration(int version); @@ -33,6 +33,7 @@ class DBSchemaMigration : public QObject bool insertUUID(); bool fillMyDXCC(); bool createTriggers(); + bool setupContactSync(); bool importQSLCards2DB(); bool fillCQITUZStationProfiles(); bool resetConfigs(); diff --git a/res/res.qrc b/res/res.qrc index eb5f8db0..8ca4af25 100644 --- a/res/res.qrc +++ b/res/res.qrc @@ -51,5 +51,6 @@ sql/migration_036.sql sql/migration_037.sql sql/migration_038.sql + sql/migration_039.sql diff --git a/res/sql/migration_039.sql b/res/sql/migration_039.sql new file mode 100644 index 00000000..692a6614 --- /dev/null +++ b/res/sql/migration_039.sql @@ -0,0 +1,27 @@ +ALTER TABLE contacts ADD COLUMN qso_uuid TEXT; +ALTER TABLE contacts ADD COLUMN updated_at TEXT; +ALTER TABLE contacts ADD COLUMN origin_node TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_qso_uuid ON contacts(qso_uuid); +CREATE INDEX IF NOT EXISTS idx_contacts_updated_at ON contacts(updated_at); + +CREATE TABLE IF NOT EXISTS contacts_tombstones ( + qso_uuid TEXT PRIMARY KEY, + deleted_at TEXT NOT NULL, + origin_node TEXT +); + +CREATE INDEX IF NOT EXISTS idx_contacts_tombstones_deleted_at + ON contacts_tombstones(deleted_at); + +CREATE TABLE IF NOT EXISTS sync_runtime ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS sync_peers ( + node TEXT PRIMARY KEY, + last_file TEXT, + last_offset INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT +); diff --git a/ui/MainWindow.cpp b/ui/MainWindow.cpp index 271bd6a0..4d1b0cae 100644 --- a/ui/MainWindow.cpp +++ b/ui/MainWindow.cpp @@ -23,6 +23,8 @@ #include "ui/QSOFilterDialog.h" #include "ui/AwardsDialog.h" #include "ui/DXCCSubmissionDialog.h" +#include "ui/SyncDialog.h" +#include "core/ContactSync.h" #include "core/PropConditions.h" #include "data/MainLayoutProfile.h" #include "ui/EditActivitiesDialog.h" @@ -468,6 +470,44 @@ MainWindow::MainWindow(QWidget* parent) : //restoreConnectionStates(); setupActivitiesMenu(); + + // Journal local changes within milliseconds — these signals only fire from + // UI-driven write paths (manual log entry, edit, delete). The 30 s timer + // in ContactSync still catches ADIF imports and digital-mode auto-logs. + // + // We can't use Qt::QueuedConnection here: contactUpdated carries a + // QSqlRecord& and contactDeleted a const QSqlRecord&, and Qt's queue + // can't copy reference arguments without an explicit metatype. Instead + // we accept the signal directly and schedule the flush onto the next + // event-loop iteration via QTimer::singleShot(0). That gives us the + // same "fire after submitAll() commits" semantics — which matters + // because contactUpdated / contactDeleted are emitted from QSqlTable + // Model::beforeUpdate / beforeDelete, before the SQL is actually sent. + ContactSync *sync = ContactSync::instance(); + auto deferredFlush = [sync]() + { + QTimer::singleShot(0, sync, &ContactSync::requestFlush); + }; + connect(ui->newContactWidget, &NewContactWidget::contactAdded, + sync, [deferredFlush](QSqlRecord) { deferredFlush(); }); + connect(ui->logbookWidget, &LogbookWidget::contactUpdated, + sync, [deferredFlush](QSqlRecord&) { deferredFlush(); }); + connect(ui->logbookWidget, &LogbookWidget::contactDeleted, + sync, [deferredFlush](const QSqlRecord&) { deferredFlush(); }); + + // When a pull applies remote inserts / updates / deletes to the + // contacts table, the LogbookWidget's QSqlTableModel doesn't auto- + // refresh — it only sees changes that go through its own setData / + // submitAll. Hook ContactSync::pulled so any non-zero apply triggers + // a refresh of the logbook view. + connect(sync, &ContactSync::pulled, + this, [this](int applied) + { + if ( applied > 0 ) + ui->logbookWidget->updateTable(); + }); + + sync->start(); } void MainWindow::closeEvent(QCloseEvent* event) @@ -1920,6 +1960,14 @@ void MainWindow::showDXCCSubmission() dialog.exec(); } +void MainWindow::showSyncDialog() +{ + FCT_IDENTIFICATION; + + SyncDialog dialog(this); + dialog.exec(); +} + void MainWindow::showAbout() { FCT_IDENTIFICATION; diff --git a/ui/MainWindow.h b/ui/MainWindow.h index 4cfd796a..e5c8e90e 100644 --- a/ui/MainWindow.h +++ b/ui/MainWindow.h @@ -60,6 +60,7 @@ private slots: void exportLog(); void showAwards(); void showDXCCSubmission(); + void showSyncDialog(); void showAbout(); void showWhatsNew(); void showWikiHelp(); diff --git a/ui/MainWindow.ui b/ui/MainWindow.ui index e75c9479..d3c2c552 100644 --- a/ui/MainWindow.ui +++ b/ui/MainWindow.ui @@ -82,6 +82,8 @@ + + @@ -617,6 +619,17 @@ QAction::NoRole + + + Contact &Sync… + + + Configure contact sync between QLog installs + + + QAction::NoRole + + Edit Rules @@ -1579,6 +1592,22 @@ + + actionContactSync + triggered() + MainWindow + showSyncDialog() + + + -1 + -1 + + + 456 + 298 + + + actionAwards triggered() @@ -1994,6 +2023,7 @@ QSOFilterSetting() showAwards() showDXCCSubmission() + showSyncDialog() alertRuleSetting() showAlerts() clearAlerts() diff --git a/ui/SyncDialog.cpp b/ui/SyncDialog.cpp new file mode 100644 index 00000000..fb4acff0 --- /dev/null +++ b/ui/SyncDialog.cpp @@ -0,0 +1,167 @@ +#include +#include + +#include "SyncDialog.h" +#include "ui_SyncDialog.h" +#include "core/ContactSync.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.ui.syncdialog"); + +SyncDialog::SyncDialog(QWidget *parent) + : QDialog(parent), + ui(new Ui::SyncDialog) +{ + ui->setupUi(this); + + ContactSync *sync = ContactSync::instance(); + + ui->enabledCheckBox->setChecked(sync->isEnabled()); + ui->folderLineEdit->setText(sync->folder()); + + ui->nodeIdLineEdit->setText(sync->selfNodeId()); + + // The combo's index matches DuplicatePolicy's integer value: + // 0 = Skip, 1 = Merge. The .ui file lists Merge first so we map. + const int policyValue = static_cast(sync->duplicatePolicy()); + ui->dupePolicyComboBox->setCurrentIndex(policyValue == 0 ? 1 : 0); + + refreshStatus(); + applyEnabledUiState(); + + connect(ui->enabledCheckBox, &QCheckBox::toggled, this, &SyncDialog::onEnabledToggled); + connect(ui->browseButton, &QPushButton::clicked, this, &SyncDialog::onBrowseClicked); + connect(ui->folderLineEdit, &QLineEdit::editingFinished, this, &SyncDialog::onFolderEdited); + connect(ui->saveNodeIdButton, &QPushButton::clicked, this, &SyncDialog::onSaveNodeIdClicked); + connect(ui->dupePolicyComboBox, QOverload::of(&QComboBox::currentIndexChanged), + this, &SyncDialog::onDupePolicyChanged); + connect(ui->flushNowButton, &QPushButton::clicked, this, &SyncDialog::onFlushNowClicked); + connect(ui->pullNowButton, &QPushButton::clicked, this, &SyncDialog::onPullNowClicked); + connect(sync, &ContactSync::flushed, this, [this](int, int) { refreshStatus(); }); + connect(sync, &ContactSync::pulled, this, [this](int) { refreshStatus(); }); + connect(sync, &ContactSync::flushFailed, this, [this](const QString &m) + { + QMessageBox::warning(this, tr("Sync"), m); + }); + connect(sync, &ContactSync::pullFailed, this, [this](const QString &m) + { + QMessageBox::warning(this, tr("Sync"), m); + }); + connect(sync, &ContactSync::busyChanged, this, [this](bool busy) + { + // Disable buttons while a cycle is running; re-enable when it ends. + ui->flushNowButton->setEnabled(!busy && ui->enabledCheckBox->isChecked() + && !ui->folderLineEdit->text().trimmed().isEmpty()); + ui->pullNowButton->setEnabled(!busy && ui->enabledCheckBox->isChecked() + && !ui->folderLineEdit->text().trimmed().isEmpty()); + }); +} + +SyncDialog::~SyncDialog() +{ + delete ui; +} + +void SyncDialog::applyEnabledUiState() +{ + const bool on = ui->enabledCheckBox->isChecked(); + const bool ready = on && !ui->folderLineEdit->text().trimmed().isEmpty(); + ui->flushNowButton->setEnabled(ready); + ui->pullNowButton->setEnabled(ready); +} + +void SyncDialog::onEnabledToggled(bool checked) +{ + FCT_IDENTIFICATION; + + QString err; + if ( !ContactSync::instance()->setEnabled(checked, &err) ) + { + QMessageBox::warning(this, tr("Sync"), err); + // Revert checkbox without re-triggering the slot + ui->enabledCheckBox->blockSignals(true); + ui->enabledCheckBox->setChecked(false); + ui->enabledCheckBox->blockSignals(false); + } + applyEnabledUiState(); + refreshStatus(); +} + +void SyncDialog::onBrowseClicked() +{ + const QString chosen = QFileDialog::getExistingDirectory(this, + tr("Choose Sync Folder"), + ui->folderLineEdit->text()); + if ( chosen.isEmpty() ) + return; + ui->folderLineEdit->setText(chosen); + onFolderEdited(); +} + +void SyncDialog::onFolderEdited() +{ + FCT_IDENTIFICATION; + + QString err; + if ( !ContactSync::instance()->setFolder(ui->folderLineEdit->text().trimmed(), &err) ) + QMessageBox::warning(this, tr("Sync"), err); + applyEnabledUiState(); + refreshStatus(); +} + +void SyncDialog::onSaveNodeIdClicked() +{ + FCT_IDENTIFICATION; + + QString err; + if ( !ContactSync::instance()->setSelfNodeId(ui->nodeIdLineEdit->text(), &err) ) + { + QMessageBox::warning(this, tr("Sync"), err); + ui->nodeIdLineEdit->setText(ContactSync::instance()->selfNodeId()); + return; + } + refreshStatus(); +} + +void SyncDialog::onDupePolicyChanged(int index) +{ + FCT_IDENTIFICATION; + + // Combo: 0 = Merge, 1 = Skip; enum: 0 = Skip, 1 = Merge. + const auto policy = (index == 0) ? ContactSync::DuplicatePolicy::Merge + : ContactSync::DuplicatePolicy::Skip; + ContactSync::instance()->setDuplicatePolicy(policy); +} + +void SyncDialog::onFlushNowClicked() +{ + FCT_IDENTIFICATION; + + // Fire-and-forget: the cycle runs on a worker thread and posts back via + // flushed / flushFailed / busyChanged signals connected in the ctor. + ContactSync::instance()->requestFlush(); +} + +void SyncDialog::onPullNowClicked() +{ + FCT_IDENTIFICATION; + + ContactSync::instance()->requestPull(); +} + +void SyncDialog::refreshStatus() +{ + ContactSync *sync = ContactSync::instance(); + + const QDateTime lastFlush = sync->lastFlushTime(); + ui->lastFlushValueLabel->setText(lastFlush.isValid() + ? lastFlush.toLocalTime().toString(Qt::ISODate) + : tr("never")); + + const QDateTime lastPull = sync->lastPullTime(); + ui->lastPullValueLabel->setText(lastPull.isValid() + ? lastPull.toLocalTime().toString(Qt::ISODate) + : tr("never")); + + ui->pendingValueLabel->setText(QString::number(sync->pendingChangesCount())); +} diff --git a/ui/SyncDialog.h b/ui/SyncDialog.h new file mode 100644 index 00000000..15ae9844 --- /dev/null +++ b/ui/SyncDialog.h @@ -0,0 +1,34 @@ +#ifndef QLOG_UI_SYNCDIALOG_H +#define QLOG_UI_SYNCDIALOG_H + +#include + +namespace Ui { +class SyncDialog; +} + +class SyncDialog : public QDialog +{ + Q_OBJECT + +public: + explicit SyncDialog(QWidget *parent = nullptr); + ~SyncDialog(); + +private slots: + void onEnabledToggled(bool checked); + void onBrowseClicked(); + void onFolderEdited(); + void onSaveNodeIdClicked(); + void onDupePolicyChanged(int index); + void onFlushNowClicked(); + void onPullNowClicked(); + void refreshStatus(); + +private: + void applyEnabledUiState(); + + Ui::SyncDialog *ui; +}; + +#endif // QLOG_UI_SYNCDIALOG_H diff --git a/ui/SyncDialog.ui b/ui/SyncDialog.ui new file mode 100644 index 00000000..0108d992 --- /dev/null +++ b/ui/SyncDialog.ui @@ -0,0 +1,219 @@ + + + SyncDialog + + + + 0 + 0 + 560 + 340 + + + + Contact Sync + + + + + + Settings + + + + + + Enable contact sync + + + + + + + Sync folder: + + + + + + + + + e.g. ~/Dropbox/QLogSync + + + + + + + Browse… + + + + + + + + + Duplicate handling: + + + + + + + How to handle a remote QSO that matches an existing local QSO by callsign, mode, band, sat, and start time (±30 min) but has a different UUID — typically because both peers logged the QSO before sync was set up. + + + + Merge (converge on shared UUID) + + + + + Skip (keep both rows independent) + + + + + + + + Node ID: + + + + + + + + + hostname or any unique label + + + + + + + Save + + + + + + + + + + + + Status + + + + + + Last flush: + + + + + + + never + + + + + + + Last pull: + + + + + + + never + + + + + + + Pending changes: + + + + + + + 0 + + + + + + + + + Flush Now + + + + + + + Pull Now + + + + + + + + + + + + Qt::Vertical + + + + 20 + 20 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + buttonBox + rejected() + SyncDialog + reject() + + + 0 + 0 + + + 0 + 0 + + + + +