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