From 0154332ad05672f8859369bd61fb2b94117ab33f Mon Sep 17 00:00:00 2001 From: Ron6519 Date: Wed, 20 May 2026 07:23:58 +0200 Subject: [PATCH] QRZCallEU: Add QRZCALL.EU callbook and QSO upload Add QRZCALL.EU as a callbook lookup provider and a QSO upload target, mirroring the QRZ.com provider in service/qrzcom. A single QRZCALL.EU Personal Access Token (pat_...) authenticates both; it is entered on a new Sync & QSL -> QRZCALL.EU sub-tab. Adds DB migration 039 with the qrzcalleu_qso_upload_status and qrzcalleu_qso_upload_date columns on contacts, and extends the update_contacts_upload_status trigger so an edited uploaded QSO flips back to 'M' for the next manual re-upload. Upload supports both the manual UploadQSODialog and an optional "Immediately Upload" toggle (mirrors ClubLog's real-time path, insert + update; QRZCALL.EU has no DELETE action so delete is not wired). Duplicate INSERTs are treated as success. QLog test suite passes 2420/2420, including migration_001..039. --- QLog.pro | 2 + core/CallbookManager.cpp | 5 + core/LogParam.cpp | 10 + core/LogParam.h | 2 + core/Migration.cpp | 3 + core/Migration.h | 2 +- res/res.qrc | 1 + res/sql/migration_039.sql | 3 + service/qrzcalleu/QRZCallEU.cpp | 508 ++++++++++++++++++++++++++++++++ service/qrzcalleu/QRZCallEU.h | 94 ++++++ ui/MainWindow.cpp | 14 +- ui/MainWindow.h | 2 + ui/SettingsDialog.cpp | 34 +++ ui/SettingsDialog.ui | 91 ++++++ ui/UploadQSODialog.cpp | 19 +- ui/UploadQSODialog.h | 1 + ui/UploadQSODialog.ui | 22 ++ 17 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 res/sql/migration_039.sql create mode 100644 service/qrzcalleu/QRZCallEU.cpp create mode 100644 service/qrzcalleu/QRZCallEU.h diff --git a/QLog.pro b/QLog.pro index 508cdc1f..1df7246e 100644 --- a/QLog.pro +++ b/QLog.pro @@ -167,6 +167,7 @@ SOURCES += \ service/lotw/Lotw.cpp \ service/potaapp/PotaApp.cpp \ service/qrzcom/QRZ.cpp \ + service/qrzcalleu/QRZCallEU.cpp \ ui/ActivityEditor.cpp \ ui/AlertRuleDetail.cpp \ ui/AlertSettingDialog.cpp \ @@ -361,6 +362,7 @@ HEADERS += \ service/lotw/Lotw.h \ service/potaapp/PotaApp.h \ service/qrzcom/QRZ.h \ + service/qrzcalleu/QRZCallEU.h \ ui/ActivityEditor.h \ ui/AlertRuleDetail.h \ ui/AlertSettingDialog.h \ diff --git a/core/CallbookManager.cpp b/core/CallbookManager.cpp index 14e30aee..49e5461f 100644 --- a/core/CallbookManager.cpp +++ b/core/CallbookManager.cpp @@ -3,6 +3,7 @@ #include "core/debug.h" #include "service/hamqth/HamQTH.h" #include "service/qrzcom/QRZ.h" +#include "service/qrzcalleu/QRZCallEU.h" #include "data/Callsign.h" #include "LogParam.h" @@ -70,6 +71,10 @@ GenericCallbook *CallbookManager::createCallbook(const QString &callbookID) { ret = new QRZCallbook(this); } + else if ( callbookID == QRZCallEUCallbook::CALLBOOK_NAME ) + { + ret = new QRZCallEUCallbook(this); + } if ( ret ) { diff --git a/core/LogParam.cpp b/core/LogParam.cpp index c0466503..0837c957 100644 --- a/core/LogParam.cpp +++ b/core/LogParam.cpp @@ -417,6 +417,16 @@ void LogParam::setClublogUploadImmediatelyEnabled(bool state) setParam("services/clublog/logbook/uploadimmediately", state); } +bool LogParam::getQRZCallEUUploadImmediatelyEnabled() +{ + return getParam("services/qrzcalleu/logbook/uploadimmediately", false).toBool(); +} + +void LogParam::setQRZCallEUUploadImmediatelyEnabled(bool state) +{ + setParam("services/qrzcalleu/logbook/uploadimmediately", state); +} + QString LogParam::getEQSLLogbookUsername() { return getParam("services/eqsl/logbook/username").toString().trimmed(); diff --git a/core/LogParam.h b/core/LogParam.h index cd811083..726e9e6e 100644 --- a/core/LogParam.h +++ b/core/LogParam.h @@ -131,6 +131,8 @@ class LogParam : public QObject static void setClublogLogbookReqEmail(const QString& email); static bool getClublogUploadImmediatelyEnabled(); static void setClublogUploadImmediatelyEnabled(bool state); + static bool getQRZCallEUUploadImmediatelyEnabled(); + static void setQRZCallEUUploadImmediatelyEnabled(bool state); /********* * eQSL diff --git a/core/Migration.cpp b/core/Migration.cpp index 0c2efc64..ab12fbe9 100644 --- a/core/Migration.cpp +++ b/core/Migration.cpp @@ -1201,6 +1201,8 @@ bool DBSchemaMigration::refreshUploadStatusTrigger() " 'hrdlog_qso_upload_status', " " 'qrzcom_qso_upload_date', " " 'qrzcom_qso_upload_status', " + " 'qrzcalleu_qso_upload_date', " + " 'qrzcalleu_qso_upload_status', " " 'hamlogeu_qso_upload_date', " " 'hamlogeu_qso_upload_status', " " 'hamqth_qso_upload_date', " @@ -1246,6 +1248,7 @@ bool DBSchemaMigration::refreshUploadStatusTrigger() " UPDATE contacts " " SET hrdlog_qso_upload_status = CASE WHEN old.hrdlog_qso_upload_status = 'Y' AND (%3) THEN 'M' ELSE old.hrdlog_qso_upload_status END, " " qrzcom_qso_upload_status = CASE WHEN old.qrzcom_qso_upload_status = 'Y' THEN 'M' ELSE old.qrzcom_qso_upload_status END , " + " qrzcalleu_qso_upload_status = CASE WHEN old.qrzcalleu_qso_upload_status = 'Y' THEN 'M' ELSE old.qrzcalleu_qso_upload_status END , " " hamlogeu_qso_upload_status = CASE WHEN old.hamlogeu_qso_upload_status = 'Y' THEN 'M' ELSE old.hamlogeu_qso_upload_status END , " " hamqth_qso_upload_status = CASE WHEN old.hamqth_qso_upload_status = 'Y' THEN 'M' ELSE old.hamqth_qso_upload_status END , " " clublog_qso_upload_status = CASE WHEN old.clublog_qso_upload_status = 'Y' AND (%4) THEN 'M' ELSE old.clublog_qso_upload_status END " diff --git a/core/Migration.h b/core/Migration.h index c789550c..d4b4ec72 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); 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..0a60d0b2 --- /dev/null +++ b/res/sql/migration_039.sql @@ -0,0 +1,3 @@ +ALTER TABLE contacts ADD qrzcalleu_qso_upload_date TEXT; +ALTER TABLE contacts ADD qrzcalleu_qso_upload_status CHECK(qrzcalleu_qso_upload_status IN ('N', 'Y', 'M')) DEFAULT 'N'; +UPDATE contacts SET qrzcalleu_qso_upload_status = 'N' WHERE qrzcalleu_qso_upload_status IS NULL; diff --git a/service/qrzcalleu/QRZCallEU.cpp b/service/qrzcalleu/QRZCallEU.cpp new file mode 100644 index 00000000..c3c195ef --- /dev/null +++ b/service/qrzcalleu/QRZCallEU.cpp @@ -0,0 +1,508 @@ +#include +#include +#include +#include +#include +#include +#include +#include "QRZCallEU.h" +#include "core/debug.h" +#include "core/CredentialStore.h" +#include "core/LogParam.h" +#include "data/Callsign.h" +#include "data/Data.h" + +// QRZCALL.EU exposes QRZ-compatible endpoints: +// callbook lookup - https://api.qrzcall.eu/v1/pub/callsign_xml.php +// QSO upload - https://api.qrzcall.eu/v1/pub/logbook_api.php +// A single Personal Access Token (pat_...) authenticates both. + +MODULE_IDENTIFICATION("qlog.core.qrzcalleu"); + +const QString QRZCallEUBase::SECURE_STORAGE_API_KEY = "QRZCALLEU"; +const QString QRZCallEUBase::CONFIG_USERNAME_API_CONST = "qrzcalleuapi"; +const QString QRZCallEUCallbook::CALLBOOK_NAME = "qrzcalleu"; + +REGISTRATION_SECURE_SERVICE(QRZCallEUBase); + +const QString QRZCallEUBase::getPAT() +{ + FCT_IDENTIFICATION; + + return getPassword(SECURE_STORAGE_API_KEY, getUsername()); +} + +void QRZCallEUBase::savePAT(const QString &newPAT) +{ + FCT_IDENTIFICATION; + + deletePassword(SECURE_STORAGE_API_KEY, getUsername()); + + if ( newPAT.isEmpty() ) + return; + + savePassword(SECURE_STORAGE_API_KEY, getUsername(), newPAT); +} + +bool QRZCallEUBase::isUploadImmediatelyEnabled() +{ + FCT_IDENTIFICATION; + + return LogParam::getQRZCallEUUploadImmediatelyEnabled(); +} + +void QRZCallEUBase::saveUploadImmediatelyConfig(bool value) +{ + FCT_IDENTIFICATION; + + LogParam::setQRZCallEUUploadImmediatelyEnabled(value); +} + +void QRZCallEUBase::registerCredentials() +{ + CredentialRegistry::instance().add(SECURE_STORAGE_API_KEY, []() + { + return QList + { + { SECURE_STORAGE_API_KEY, [](){ return getUsername(); } } + }; + }); +} + +/**********/ +/* Lookup */ +/**********/ + +QRZCallEUCallbook::QRZCallEUCallbook(QObject *parent) : + GenericCallbook(parent), + QRZCallEUBase(), + currentReply(nullptr) +{ + FCT_IDENTIFICATION; +} + +QRZCallEUCallbook::~QRZCallEUCallbook() +{ + FCT_IDENTIFICATION; + + if ( currentReply ) + currentReply->abort(); +} + +QString QRZCallEUCallbook::getDisplayName() +{ + FCT_IDENTIFICATION; + + return QString(tr("QRZCALL.EU")); +} + +void QRZCallEUCallbook::queryCallsign(const QString &callsign) +{ + FCT_IDENTIFICATION; + + qCDebug(function_parameters) << callsign; + + const QString &pat = getPAT(); + + if ( pat.isEmpty() ) + { + qCDebug(runtime) << "Empty QRZCALL.EU token"; + emit callsignNotFound(callsign); + return; + } + + const Callsign qCall(callsign); + + QUrlQuery params; + // QRZCALL.EU, like QRZ.com, looks up the base callsign best. + params.addQueryItem("callsign", (qCall.isValid()) ? qCall.getBase() + : callsign); + + QUrl url(API_URL); + url.setQuery(params); + + if ( currentReply ) + qCWarning(runtime) << "processing a new request but the previous one hasn't been completed yet !!!"; + + QNetworkRequest request(url); + request.setRawHeader("Authorization", QByteArray("Bearer ") + pat.toUtf8()); + request.setRawHeader("User-Agent", QString("QLog/%1").arg(VERSION).toUtf8()); + + qCDebug(runtime) << url.toString(QUrl::RemoveQuery); + + currentReply = getNetworkAccessManager()->get(request); + currentReply->setProperty("queryCallsign", QVariant(callsign)); +} + +void QRZCallEUCallbook::abortQuery() +{ + FCT_IDENTIFICATION; + + if ( currentReply ) + { + currentReply->abort(); + currentReply = nullptr; + } +} + +void QRZCallEUCallbook::processReply(QNetworkReply *reply) +{ + FCT_IDENTIFICATION; + + /* always process one request per class */ + currentReply = nullptr; + + const int replyStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QString &queryCallsign = reply->property("queryCallsign").toString(); + + if ( reply->error() != QNetworkReply::NoError + && reply->error() != QNetworkReply::ContentAccessDenied + && reply->error() != QNetworkReply::ContentNotFoundError + && reply->error() != QNetworkReply::AuthenticationRequiredError ) + { + qCDebug(runtime) << "QRZCALL.EU error" << reply->errorString(); + qCDebug(runtime) << "HTTP Status Code" << replyStatusCode; + + if ( reply->error() != QNetworkReply::OperationCanceledError ) + { + emit lookupError(reply->errorString()); + reply->deleteLater(); + } + return; + } + + /* An expired/invalid token is reported by QRZCALL.EU as HTTP 401/403. */ + if ( replyStatusCode == 401 || replyStatusCode == 403 ) + { + qCDebug(runtime) << "QRZCALL.EU authentication failed - HTTP" << replyStatusCode; + emit loginFailed(); + emit lookupError(tr("QRZCALL.EU token is invalid or the subscription has expired")); + reply->deleteLater(); + return; + } + + const QByteArray &response = reply->readAll(); + qCDebug(runtime) << response; + + QXmlStreamReader xml(response); + CallbookResponseData responseData; + + while ( !xml.atEnd() && !xml.hasError() ) + { + const QXmlStreamReader::TokenType token = xml.readNext(); + + if ( token != QXmlStreamReader::StartElement ) + continue; + + const QString elementName = xml.name().toString(); + + if ( elementName == "Error" ) + { + const QString &errorString = xml.readElementText(); + + if ( replyStatusCode == 404 + || errorString.contains("not found", Qt::CaseInsensitive) ) + { + emit callsignNotFound(queryCallsign); + } + else + { + qCInfo(runtime) << "QRZCALL.EU Error -" << errorString; + emit lookupError(errorString); + } + continue; + } + + if (elementName == "call") responseData.call = decodeHtmlEntities(xml.readElementText().toUpper()); + else if (elementName == "fname") responseData.fname = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "name") responseData.lname = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "nickname") responseData.nick = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "born") responseData.born = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "addr1") responseData.addr1 = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "attn") responseData.qth = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "state") responseData.us_state = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "zip") responseData.zipcode = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "county") responseData.county = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "country") responseData.country = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "dxcc") responseData.dxcc = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "lat") responseData.latitude = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "lon") responseData.longitude = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "ituzone") responseData.ituz = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "cqzone") responseData.cqz = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "grid") responseData.gridsquare = decodeHtmlEntities(xml.readElementText().toUpper()); + else if (elementName == "iota") responseData.iota = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "efdate") responseData.lic_year = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "qslmgr") responseData.qsl_via = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "email") responseData.email = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "url") responseData.url = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "image") responseData.image_url = decodeHtmlEntities(xml.readElementText()); + else if (elementName == "lotw") responseData.lotw = decodeHtmlEntities(xml.readElementText().toUpper()); + else if (elementName == "eqsl") responseData.eqsl = decodeHtmlEntities(xml.readElementText().toUpper()); + else if (elementName == "mqsl") responseData.pqsl = decodeHtmlEntities(xml.readElementText().toUpper()); + } + + if ( !responseData.call.isEmpty() ) + emit callsignResult(responseData); + + reply->deleteLater(); +} + +/**********/ +/* Upload */ +/**********/ + +QRZCallEUUploader::QRZCallEUUploader(QObject *parent) : + GenericQSOUploader(QStringList(), parent), + QRZCallEUBase(), + currentReply(nullptr), + cancelUpload(false) +{ + FCT_IDENTIFICATION; + + // Used by the real-time ("Immediately Upload") path to mark a QSO as + // uploaded directly in the database after a successful upload. + if ( !query_updateRT.prepare("UPDATE contacts " + "SET qrzcalleu_qso_upload_status = 'Y', " + " qrzcalleu_qso_upload_date = strftime('%Y-%m-%d', DATETIME('now', 'utc')) " + "WHERE id = :id") ) + qCWarning(runtime) << "Cannot prepare the real-time upload-status update"; +} + +QRZCallEUUploader::~QRZCallEUUploader() +{ + FCT_IDENTIFICATION; + + if ( currentReply ) + { + currentReply->abort(); + currentReply->deleteLater(); + } +} + +void QRZCallEUUploader::uploadContact(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + QByteArray data = generateADIF({record}); + cancelUpload = false; + + actionInsert(getPAT(), data, "REPLACE"); + currentReply->setProperty("contactID", record.value("id")); +} + +void QRZCallEUUploader::uploadQSOList(const QList &qsos, const QVariantMap &) +{ + FCT_IDENTIFICATION; + + if ( qsos.isEmpty() ) + { + /* Nothing to do */ + emit uploadFinished(); + return; + } + + cancelUpload = false; + queuedContacts4Upload = qsos; + uploadContact(queuedContacts4Upload.first()); + queuedContacts4Upload.removeFirst(); +} + +void QRZCallEUUploader::sendRealtimeUpload(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + QByteArray data = generateADIF({record}); + cancelUpload = false; + + actionInsert(getPAT(), data, "REPLACE"); + currentReply->setProperty("contactID", record.value("id")); + // Tag this reply as real-time so processReply() writes the upload status + // straight into the database (no UploadQSODialog is involved here). + currentReply->setProperty("messageType", QVariant("realtimeInsert")); +} + +void QRZCallEUUploader::insertQSOImmediately(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + if ( !isUploadImmediatelyEnabled() ) + return; + + if ( getPAT().isEmpty() ) + { + qCDebug(runtime) << "QRZCALL.EU token not set - skipping real-time upload"; + return; + } + + sendRealtimeUpload(record); +} + +void QRZCallEUUploader::updateQSOImmediately(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + if ( !isUploadImmediatelyEnabled() ) + return; + + // Only re-upload QSOs that were already sent to QRZCALL.EU; an edit of a + // never-uploaded QSO has nothing to update there. + const QString &uploadStatus = record.value("qrzcalleu_qso_upload_status").toString(); + + if ( uploadStatus.isEmpty() || uploadStatus == "N" ) + { + qCDebug(runtime) << "QSO not previously uploaded to QRZCALL.EU - nothing to update"; + return; + } + + if ( getPAT().isEmpty() ) + return; + + sendRealtimeUpload(record); +} + +void QRZCallEUUploader::actionInsert(const QString &pat, QByteArray &data, const QString &insertPolicy) +{ + FCT_IDENTIFICATION; + + QUrlQuery params; + params.addQueryItem("KEY", pat); + params.addQueryItem("ACTION", "INSERT"); + params.addQueryItem("OPTION", insertPolicy); + params.addQueryItem("ADIF", data.trimmed().toPercentEncoding()); + + QUrl url(API_LOGBOOK_URL); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + request.setRawHeader("User-Agent", QString("QLog/%1").arg(VERSION).toUtf8()); + + qCDebug(runtime) << Data::safeQueryString(params); + + if ( currentReply ) + qCWarning(runtime) << "processing a new request but the previous one hasn't been completed yet !!!"; + + currentReply = getNetworkAccessManager()->post(request, params.query(QUrl::FullyEncoded).toUtf8()); + currentReply->setProperty("messageType", QVariant("actionsInsert")); +} + +void QRZCallEUUploader::abortRequest() +{ + FCT_IDENTIFICATION; + + cancelUpload = true; + + if ( currentReply ) + { + currentReply->abort(); + currentReply = nullptr; + } +} + +void QRZCallEUUploader::processReply(QNetworkReply *reply) +{ + FCT_IDENTIFICATION; + + /* always process one request per class */ + currentReply = nullptr; + + const int replyStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if ( reply->error() != QNetworkReply::NoError + || replyStatusCode < 200 + || replyStatusCode >= 300 ) + { + qCDebug(runtime) << "QRZCALL.EU error" << reply->errorString(); + qCDebug(runtime) << "HTTP Status Code" << replyStatusCode; + + if ( reply->error() != QNetworkReply::OperationCanceledError ) + { + emit uploadError(reply->errorString()); + reply->deleteLater(); + } + + cancelUpload = true; + return; + } + + const QString &messageType = reply->property("messageType").toString(); + + // "actionsInsert" - QSO from the manual UploadQSODialog queue + // "realtimeInsert" - QSO uploaded immediately on log/edit ("Immediately Upload") + if ( messageType == "actionsInsert" || messageType == "realtimeInsert" ) + { + const QString replyString(reply->readAll()); + qCDebug(runtime) << replyString; + + const QMap &data = parseActionResponse(replyString); + const QString &status = data.value("RESULT", "FAIL"); + const QString &reason = data.value("REASON", QString()); + const qulonglong contactID = reply->property("contactID").toULongLong(); + + // RESULT=OK - QSO inserted + // RESULT=REPLACE - QSO already present, updated in place + // RESULT=FAIL with a "duplicate" reason - QSO already present; treat + // as success so re-uploads do not block the queue. + const bool uploadOK = ( status == "OK" ) + || ( status == "REPLACE" ) + || ( status == "FAIL" && reason.contains("duplicate", Qt::CaseInsensitive) ); + + if ( uploadOK ) + { + qCDebug(runtime) << "Confirmed Upload for QSO Id" << contactID; + + // Real-time path: write the upload status straight to the DB. + // The manual path leaves that to UploadQSODialog. + if ( messageType == "realtimeInsert" ) + { + query_updateRT.bindValue(":id", contactID); + if ( !query_updateRT.exec() ) + qCWarning(runtime) << "Cannot update real-time upload status" << query_updateRT.lastError(); + } + + emit uploadedQSO(contactID); + + if ( messageType == "actionsInsert" ) + { + if ( queuedContacts4Upload.isEmpty() ) + { + cancelUpload = false; + emit uploadFinished(); + } + else if ( !cancelUpload ) + { + uploadContact(queuedContacts4Upload.first()); + queuedContacts4Upload.removeFirst(); + } + } + } + else + { + emit uploadError(reason.isEmpty() ? tr("General Error") : reason); + cancelUpload = false; + } + } + + reply->deleteLater(); +} + +QMap QRZCallEUUploader::parseActionResponse(const QString &responseString) const +{ + FCT_IDENTIFICATION; + + qCDebug(function_parameters) << responseString; + + QMap data; + const QStringList &parsedResponse = responseString.split("&"); + + for ( const QString ¶m : parsedResponse ) + { + const QStringList parsedParams = param.split("="); + + if ( parsedParams.count() == 1 ) + data[parsedParams.at(0)] = QString(); + else if ( parsedParams.count() >= 2 ) + data[parsedParams.at(0)] = QUrl::fromPercentEncoding(parsedParams.at(1).toUtf8()); + } + + return data; +} diff --git a/service/qrzcalleu/QRZCallEU.h b/service/qrzcalleu/QRZCallEU.h new file mode 100644 index 00000000..8535d5bd --- /dev/null +++ b/service/qrzcalleu/QRZCallEU.h @@ -0,0 +1,94 @@ +#ifndef QLOG_SERVICE_QRZCALLEU_QRZCALLEU_H +#define QLOG_SERVICE_QRZCALLEU_QRZCALLEU_H + +#include +#include +#include +#include +#include "service/GenericCallbook.h" +#include "service/GenericQSOUploader.h" +#include "core/CredentialStore.h" + +class QNetworkAccessManager; +class QNetworkReply; + +// QRZCALL.EU uses a single Personal Access Token (pat_...) for both the +// callbook lookup and the QSO upload. The token is stored once in the +// credential store under a fixed internal username. +class QRZCallEUBase : public SecureServiceBase +{ +protected: + const static QString SECURE_STORAGE_API_KEY; + const static QString CONFIG_USERNAME_API_CONST; + +public: + explicit QRZCallEUBase() {}; + virtual ~QRZCallEUBase() {}; + + DECLARE_SECURE_SERVICE(QRZCallEUBase); + + static QString getUsername() {return CONFIG_USERNAME_API_CONST;} + static const QString getPAT(); + static void savePAT(const QString &newPAT); + static bool isUploadImmediatelyEnabled(); + static void saveUploadImmediatelyConfig(bool value); +}; + +class QRZCallEUCallbook : public GenericCallbook, private QRZCallEUBase +{ + Q_OBJECT + +public: + const static QString CALLBOOK_NAME; + + explicit QRZCallEUCallbook(QObject *parent = nullptr); + virtual ~QRZCallEUCallbook(); + + QString getDisplayName() override; + +public slots: + virtual void queryCallsign(const QString &callsign) override; + virtual void abortQuery() override; + +protected: + virtual void processReply(QNetworkReply *reply) override; + +private: + QNetworkReply *currentReply; + const QString API_URL = "https://api.qrzcall.eu/v1/pub/callsign_xml.php"; +}; + +class QRZCallEUUploader : public GenericQSOUploader, private QRZCallEUBase +{ + Q_OBJECT + +public: + explicit QRZCallEUUploader(QObject *parent = nullptr); + virtual ~QRZCallEUUploader(); + + void uploadContact(const QSqlRecord &record); + virtual void uploadQSOList(const QList &qsos, const QVariantMap &addlParams) override; + +public slots: + virtual void abortRequest() override; + // Real-time upload ("Immediately Upload"): triggered when a QSO is logged + // or edited. A no-op unless QRZCallEUBase::isUploadImmediatelyEnabled(). + void insertQSOImmediately(const QSqlRecord &record); + void updateQSOImmediately(const QSqlRecord &record); + +protected: + virtual void processReply(QNetworkReply *reply) override; + +private: + QNetworkReply *currentReply; + QList queuedContacts4Upload; + bool cancelUpload; + QSqlQuery query_updateRT; + const QString API_LOGBOOK_URL = "https://api.qrzcall.eu/v1/pub/logbook_api.php"; + + void actionInsert(const QString &pat, QByteArray &data, const QString &insertPolicy); + void sendRealtimeUpload(const QSqlRecord &record); + QMap parseActionResponse(const QString &) const; +}; + +#endif // QLOG_SERVICE_QRZCALLEU_QRZCALLEU_H diff --git a/ui/MainWindow.cpp b/ui/MainWindow.cpp index 271bd6a0..90d759b8 100644 --- a/ui/MainWindow.cpp +++ b/ui/MainWindow.cpp @@ -59,7 +59,8 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent), ui(new Ui::MainWindow), stats(new StatisticsWidget), - clublogRT(new ClubLogUploader(this)) + clublogRT(new ClubLogUploader(this)), + qrzcalleuRT(new QRZCallEUUploader(this)) { FCT_IDENTIFICATION; @@ -339,6 +340,7 @@ MainWindow::MainWindow(QWidget* parent) : connect(ui->logbookWidget, &LogbookWidget::logbookUpdated, stats, &StatisticsWidget::refreshWidget); connect(ui->logbookWidget, &LogbookWidget::contactUpdated, &networknotification, &NetworkNotification::QSOUpdated); connect(ui->logbookWidget, &LogbookWidget::clublogContactUpdated, clublogRT, &ClubLogUploader::updateQSOImmediately); + connect(ui->logbookWidget, &LogbookWidget::contactUpdated, qrzcalleuRT, &QRZCallEUUploader::updateQSOImmediately); connect(ui->logbookWidget, &LogbookWidget::contactDeleted, &networknotification, &NetworkNotification::QSODeleted); connect(ui->logbookWidget, &LogbookWidget::contactDeleted, ui->bandmapWidget, &BandmapWidget::updateSpotsDupeWhenQSODeleted); connect(ui->logbookWidget, &LogbookWidget::deletedEntities, ui->bandmapWidget, &BandmapWidget::updateSpotsDxccStatusWhenQSODeleted); @@ -360,6 +362,7 @@ MainWindow::MainWindow(QWidget* parent) : connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->wsjtxWidget, &WsjtxWidget::updateSpotsStatusWhenQSOAdded); connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->dxWidget, &DxWidget::setLastQSO); connect(ui->newContactWidget, &NewContactWidget::contactAdded, clublogRT, &ClubLogUploader::insertQSOImmediately); + connect(ui->newContactWidget, &NewContactWidget::contactAdded, qrzcalleuRT, &QRZCallEUUploader::insertQSOImmediately); connect(ui->newContactWidget, &NewContactWidget::contestStarted, this, &MainWindow::startContest); connect(ui->newContactWidget, &NewContactWidget::newTarget, ui->mapWidget, &MapWidget::setTarget); connect(ui->newContactWidget, &NewContactWidget::newTarget, ui->onlineMapWidget, &OnlineMapWidget::setTarget); @@ -425,6 +428,14 @@ MainWindow::MainWindow(QWidget* parent) : connect(clublogRT, &ClubLogUploader::uploadedQSO, ui->logbookWidget, &LogbookWidget::updateTable); + connect(qrzcalleuRT, &QRZCallEUUploader::uploadError, this, [this](const QString &msg) + { + qCInfo(runtime) << "QRZCALL.EU RT Upload Error: " << msg; + QMessageBox::warning(this, tr("QRZCALL.EU Immediately Upload Error"), msg); + }); + + connect(qrzcalleuRT, &QRZCallEUUploader::uploadedQSO, ui->logbookWidget, &LogbookWidget::updateTable); + if ( StationProfilesManager::instance()->profileNameList().isEmpty() ) firstRun = true; else @@ -2106,6 +2117,7 @@ MainWindow::~MainWindow() locatorLabel->deleteLater(); QSqlDatabase::database().close(); clublogRT->deleteLater(); + qrzcalleuRT->deleteLater(); if ( wsjtx ) wsjtx->deleteLater(); diff --git a/ui/MainWindow.h b/ui/MainWindow.h index 4cfd796a..81938137 100644 --- a/ui/MainWindow.h +++ b/ui/MainWindow.h @@ -9,6 +9,7 @@ #include "core/AlertEvaluator.h" #include "core/PropConditions.h" #include "service/clublog/ClubLog.h" +#include "service/qrzcalleu/QRZCallEU.h" namespace Ui { class MainWindow; @@ -117,6 +118,7 @@ private slots: PropConditions *conditions; bool isFusionStyle; ClubLogUploader* clublogRT; + QRZCallEUUploader* qrzcalleuRT; WsjtxUDPReceiver* wsjtx; QActionGroup *seqGroup; QActionGroup *dupeGroup; diff --git a/ui/SettingsDialog.cpp b/ui/SettingsDialog.cpp index 54956235..0692a901 100644 --- a/ui/SettingsDialog.cpp +++ b/ui/SettingsDialog.cpp @@ -12,6 +12,7 @@ #include "models/RotTypeModel.h" #include "service/GenericCallbook.h" #include "service/qrzcom/QRZ.h" +#include "service/qrzcalleu/QRZCallEU.h" #include "service/hamqth/HamQTH.h" #include "service/lotw/Lotw.h" #include "service/clublog/ClubLog.h" @@ -314,6 +315,15 @@ SettingsDialog::SettingsDialog(MainWindow *parent) : ui->primaryCallbookCombo->addItem(tr("Disabled"), QVariant(GenericCallbook::CALLBOOK_NAME)); ui->primaryCallbookCombo->addItem(tr("HamQTH"), QVariant(HamQTHCallbook::CALLBOOK_NAME)); ui->primaryCallbookCombo->addItem(tr("QRZ.com"), QVariant(QRZCallbook::CALLBOOK_NAME)); + ui->primaryCallbookCombo->addItem(tr("QRZCALL.EU"), QVariant(QRZCallEUCallbook::CALLBOOK_NAME)); + + // QRZCALL.EU "Immediately Upload" requires a token to be set. + connect(ui->qrzcalleuPatEdit, &QLineEdit::textChanged, this, [this](const QString &token) + { + ui->qrzcalleuUploadImmediatelyCheckbox->setEnabled(!token.isEmpty()); + if ( token.isEmpty() ) + ui->qrzcalleuUploadImmediatelyCheckbox->setChecked(false); + }); populateFlowControlCombo(ui->rigFlowControlSelect); populateParityCombo(ui->rigParitySelect); @@ -2088,6 +2098,7 @@ void SettingsDialog::primaryCallbookChanged(int index) ui->secondaryCallbookCombo->clear(); ui->secondaryCallbookCombo->addItem(tr("Disabled"), QVariant(GenericCallbook::CALLBOOK_NAME)); ui->secondaryCallbookCombo->addItem(tr("QRZ.com"), QVariant(QRZCallbook::CALLBOOK_NAME)); + ui->secondaryCallbookCombo->addItem(tr("QRZCALL.EU"), QVariant(QRZCallEUCallbook::CALLBOOK_NAME)); } else if ( primaryCallbookSelection == QRZCallbook::CALLBOOK_NAME ) { @@ -2095,6 +2106,15 @@ void SettingsDialog::primaryCallbookChanged(int index) ui->secondaryCallbookCombo->clear(); ui->secondaryCallbookCombo->addItem(tr("Disabled"), QVariant(GenericCallbook::CALLBOOK_NAME)); ui->secondaryCallbookCombo->addItem(tr("HamQTH"), QVariant(HamQTHCallbook::CALLBOOK_NAME)); + ui->secondaryCallbookCombo->addItem(tr("QRZCALL.EU"), QVariant(QRZCallEUCallbook::CALLBOOK_NAME)); + } + else if ( primaryCallbookSelection == QRZCallEUCallbook::CALLBOOK_NAME ) + { + ui->secondaryCallbookCombo->setEnabled(true); + ui->secondaryCallbookCombo->clear(); + ui->secondaryCallbookCombo->addItem(tr("Disabled"), QVariant(GenericCallbook::CALLBOOK_NAME)); + ui->secondaryCallbookCombo->addItem(tr("HamQTH"), QVariant(HamQTHCallbook::CALLBOOK_NAME)); + ui->secondaryCallbookCombo->addItem(tr("QRZ.com"), QVariant(QRZCallbook::CALLBOOK_NAME)); } } @@ -2361,6 +2381,14 @@ void SettingsDialog::readSettings() ui->qrzApiKeyEdit->setText(QRZBase::getLogbookAPIKey(QRZBase::getInternalAPIUsername())); generateQRZAPICallsignTable(); + /***************/ + /* QRZCALL.EU */ + /***************/ + ui->qrzcalleuPatEdit->setText(QRZCallEUBase::getPAT()); + ui->qrzcalleuUploadImmediatelyCheckbox->setEnabled(!QRZCallEUBase::getPAT().isEmpty()); + ui->qrzcalleuUploadImmediatelyCheckbox->setChecked(QRZCallEUBase::isUploadImmediatelyEnabled() + && !QRZCallEUBase::getPAT().isEmpty()); + /***********/ /* Wavelog */ /***********/ @@ -2485,6 +2513,12 @@ void SettingsDialog::writeSettings() QRZBase::saveLogbookAPIKey(ui->qrzApiKeyEdit->text(), QRZBase::getInternalAPIUsername()); saveQRZAPICallsignTable(); + /***************/ + /* QRZCALL.EU */ + /***************/ + QRZCallEUBase::savePAT(ui->qrzcalleuPatEdit->text()); + QRZCallEUBase::saveUploadImmediatelyConfig(ui->qrzcalleuUploadImmediatelyCheckbox->isChecked()); + /***********/ /* Wavelog */ /***********/ diff --git a/ui/SettingsDialog.ui b/ui/SettingsDialog.ui index 816f298e..43b06a86 100644 --- a/ui/SettingsDialog.ui +++ b/ui/SettingsDialog.ui @@ -3401,6 +3401,25 @@ + + + + QRZCALL.EU + + + + + + <b>Notice:</b> QRZCALL.EU uses a single Personal Access Token for both callbook lookup and QSO upload. Enter it on the <b>Sync & QSL</b> tab (<b>QRZCALL.EU</b> page). A Data or Extra subscription is required. + + + true + + + + + + @@ -4001,6 +4020,76 @@ + + + QRZCALL.EU + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + API Token + + + + + + + QLineEdit::Password + + + pat_… + + + + + + + Immediately Upload + + + + + + + false + + + QSOs are uploaded to QRZCALL.EU immediately when logged or edited + + + + + + + + + + This QRZCALL.EU Personal Access Token is used for QSO upload and for callbook lookup. Generate it at qrzcall.eu (My Profile, Account, API Tokens). A Data or Extra subscription is required. + + + true + + + + + Others @@ -5057,6 +5146,8 @@ qrzCallsignApiKeyTableView qrzCallsignApiKeyAddButton qrzCallsignApiKeyDelButton + qrzcalleuPatEdit + qrzcalleuUploadImmediatelyCheckbox wavelogAddQSOEndpointEdit wavelogApiKeyEdit dxccConfirmedByLotwCheckBox diff --git a/ui/UploadQSODialog.cpp b/ui/UploadQSODialog.cpp index 9c4f2754..a5905fd0 100644 --- a/ui/UploadQSODialog.cpp +++ b/ui/UploadQSODialog.cpp @@ -11,6 +11,7 @@ #include "service/eqsl/Eqsl.h" #include "service/lotw/Lotw.h" #include "service/qrzcom/QRZ.h" +#include "service/qrzcalleu/QRZCallEU.h" #include "service/hrdlog/HRDLog.h" #include "service/cloudlog/Cloudlog.h" @@ -80,6 +81,15 @@ UploadQSODialog::UploadQSODialog(QWidget *parent) : ui->wavelogNumberLabel, !CloudlogBase::getLogbookAPIKey().isEmpty())); + onlineServices.insert(QRZCALLEUID, UploadTask(QRZCALLEUID, + tr("QRZCALL.EU"), + new QRZCallEUUploader(this), + "qrzcalleu_qso_upload_status", + "qrzcalleu_qso_upload_date", + ui->qrzcalleuCheckbox, + ui->qrzcalleuNumberLabel, + !QRZCallEUBase::getPAT().isEmpty())); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("&Upload")); ui->myCallsignCombo->blockSignals(true); @@ -195,6 +205,7 @@ void UploadQSODialog::loadDialogState() ui->hrdlogCheckbox->setChecked(ui->hrdlogCheckbox->isEnabled() && LogParam::getUploadServiceState("hrdlog")); ui->qrzCheckbox->setChecked(ui->qrzCheckbox->isEnabled() && LogParam::getUploadServiceState("qrzcom")); ui->wavelogCheckbox->setChecked(ui->wavelogCheckbox->isEnabled() && LogParam::getUploadServiceState("wavelog")); + ui->qrzcalleuCheckbox->setChecked(ui->qrzcalleuCheckbox->isEnabled() && LogParam::getUploadServiceState("qrzcalleu")); int index = ui->myCallsignCombo->findText(profile.callsign); @@ -232,6 +243,7 @@ void UploadQSODialog::saveDialogState() LogParam::setUploadServiceState("hrdlog", ui->hrdlogCheckbox->isChecked()); LogParam::setUploadServiceState("qrzcom", ui->qrzCheckbox->isChecked()); LogParam::setUploadServiceState("wavelog", ui->wavelogCheckbox->isChecked()); + LogParam::setUploadServiceState("qrzcalleu", ui->qrzcalleuCheckbox->isChecked()); LogParam::setUploadQSOLastCall(ui->myCallsignCombo->currentText()); LogParam::setUploadeqslQSLComment(ui->eqslQSLComment->isChecked()); LogParam::setUploadeqslQSLMessage(ui->eqslQSLMessage->isChecked()); @@ -336,7 +348,8 @@ void UploadQSODialog::processNextUploader() dialog->done(QDialog::Accepted); if ( currentTask.getServiceID() != HRDLOGID && currentTask.getServiceID() != QRZCOMID - && currentTask.getServiceID() != WAVELOGID ) + && currentTask.getServiceID() != WAVELOGID + && currentTask.getServiceID() != QRZCALLEUID ) { const QString statusField = currentTask.getDBUploadStatusFieldName(); @@ -414,7 +427,8 @@ void UploadQSODialog::processNextUploader() // set progress bar range for services that do not support the batch upload if ( currentTask.getServiceID() == HRDLOGID || currentTask.getServiceID() == QRZCOMID - || currentTask.getServiceID() == WAVELOGID ) + || currentTask.getServiceID() == WAVELOGID + || currentTask.getServiceID() == QRZCALLEUID ) dialog->setRange(0, list.size()); uploader->uploadQSOList(list, uploadConfig); @@ -639,6 +653,7 @@ void UploadQSODialog::executeQuery() case CLUBLOGID: forUploadMarked = lotwUploadStatus || rec->value("status_" + QString::number(taskID)).toBool(); break; // depends on the LoTW Receive field case HRDLOGID: forUploadMarked = eqslUploadStatus || lotwUploadStatus || rec->value("status_" + QString::number(taskID)).toBool(); break; //depends on EQSL and LoTW fields case QRZCOMID: forUploadMarked = (! serviceNames.empty()) || rec->value("status_" + QString::number(taskID)).toBool(); break; // all fields are sent + case QRZCALLEUID: forUploadMarked = (! serviceNames.empty()) || rec->value("status_" + QString::number(taskID)).toBool(); break; // all fields are sent case WAVELOGID: forUploadMarked = rec->value("status_" + QString::number(taskID)).toBool(); break; default: forUploadMarked = false; } diff --git a/ui/UploadQSODialog.h b/ui/UploadQSODialog.h index 538344fc..39075ec1 100644 --- a/ui/UploadQSODialog.h +++ b/ui/UploadQSODialog.h @@ -50,6 +50,7 @@ private slots: HRDLOGID = 4, // depends on EQSL and LoTW fields QRZCOMID = 5, // all fields are sent WAVELOGID = 6, // all fiedls are sent + QRZCALLEUID = 7, // all fields are sent }; class UploadTask diff --git a/ui/UploadQSODialog.ui b/ui/UploadQSODialog.ui index bf4d4214..0a3440c2 100644 --- a/ui/UploadQSODialog.ui +++ b/ui/UploadQSODialog.ui @@ -200,6 +200,27 @@ + + + + + + QRZCALL.EU + + + uploadButtonGroup + + + + + + + + + + + + @@ -669,6 +690,7 @@ lotwCheckbox clublogCheckbox wavelogCheckbox + qrzcalleuCheckbox myStationProfileCheckbox myStationProfileCombo myCallsignCheckbox