From ab7c602c81701cf0adb4077ec271780af8043b87 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:57:36 -0500 Subject: [PATCH 1/2] Add DX Marathon award and alert support Introduce a DX Marathon award UI and alert integration. Adds AwardDXMarathon (awards/AwardDXMarathon.{cpp,h}) with ADIF export and listing, registers it in AwardsDialog and UI. Alert system: new dxMarathon flag in AlertRule (save/load/match/operator), AlertRuleDetail UI checkbox, and alert evaluation logic to detect new entities/zones. Persistence: migration_040.sql adds alert_rules.dx_marathon and Migration::latestVersion bumped to 40. Settings: LogParam getters/setters and SettingsDialog controls to choose profile vs callsign matching. Updated resources (res.qrc) and small UI text tweak. --- QLog.pro | 2 + awards/AwardDXMarathon.cpp | 161 +++++++++++++++++++++++++++++++++++++ awards/AwardDXMarathon.h | 38 +++++++++ core/AlertEvaluator.cpp | 47 ++++++++++- core/AlertEvaluator.h | 2 + core/LogParam.cpp | 10 +++ core/LogParam.h | 2 + core/Migration.h | 2 +- res/res.qrc | 1 + res/sql/migration_040.sql | 1 + ui/AlertRuleDetail.cpp | 2 + ui/AlertRuleDetail.ui | 6 ++ ui/AwardsDialog.cpp | 2 + ui/AwardsDialog.ui | 2 +- ui/SettingsDialog.cpp | 2 + ui/SettingsDialog.ui | 27 +++++++ 16 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 awards/AwardDXMarathon.cpp create mode 100644 awards/AwardDXMarathon.h create mode 100644 res/sql/migration_040.sql diff --git a/QLog.pro b/QLog.pro index d52c63d2..24fa5913 100644 --- a/QLog.pro +++ b/QLog.pro @@ -57,6 +57,7 @@ CONFIG *= link_pkgconfig SOURCES += \ awards/AwardDefinition.cpp \ awards/AwardDXCC.cpp \ + awards/AwardDXMarathon.cpp \ awards/AwardGridsquare.cpp \ awards/AwardIOTA.cpp \ awards/AwardITU.cpp \ @@ -242,6 +243,7 @@ SOURCES += \ HEADERS += \ awards/AwardDefinition.h \ awards/AwardDXCC.h \ + awards/AwardDXMarathon.h \ awards/AwardGridsquare.h \ awards/AwardIOTA.h \ awards/AwardITU.h \ diff --git a/awards/AwardDXMarathon.cpp b/awards/AwardDXMarathon.cpp new file mode 100644 index 00000000..e052f368 --- /dev/null +++ b/awards/AwardDXMarathon.cpp @@ -0,0 +1,161 @@ +#include "AwardDXMarathon.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/debug.h" +#include "data/StationProfile.h" +#include "logformat/AdiFormat.h" + +MODULE_IDENTIFICATION("qlog.awards.dxmarathon"); + +QString AwardDXMarathon::displayName() const +{ + return QCoreApplication::translate("AwardsDialog", "DX Marathon"); +} + +QString AwardDXMarathon::rulesUrl() const +{ + return QStringLiteral("https://www.dxmarathon.com/rules/2026/"); +} + +QWidget *AwardDXMarathon::createWidget(QWidget *parent) +{ + FCT_IDENTIFICATION; + QWidget *widget = new QWidget(parent); + QVBoxLayout *layout = new QVBoxLayout(widget); + QHBoxLayout *controls = new QHBoxLayout; + m_profile = new QComboBox(widget); + for (const QString &name : StationProfilesManager::instance()->profileNameList()) + m_profile->addItem(name, name); + m_profileMatch = new QComboBox(widget); + m_profileMatch->addItem(QCoreApplication::translate("AwardsDialog", "Station Profile"), QStringLiteral("profile")); + m_profileMatch->addItem(QCoreApplication::translate("AwardsDialog", "Callsign only"), QStringLiteral("callsign")); + m_profileMatch->setToolTip(QCoreApplication::translate("AwardsDialog", "Callsign-only matching can combine contacts from different operating locations. Review the exported log before submitting.")); + m_year = new QSpinBox(widget); + m_year->setRange(2006, 2100); + m_year->setValue(QDate::currentDate().year()); + QPushButton *exportButton = new QPushButton(QCoreApplication::translate("AwardsDialog", "Export submission ADIF"), widget); + QPushButton *submitButton = new QPushButton(QCoreApplication::translate("AwardsDialog", "Open submission tool"), widget); + m_score = new QLabel(widget); + controls->addWidget(new QLabel(QCoreApplication::translate("AwardsDialog", "Station Profile"), widget)); + controls->addWidget(m_profile); + controls->addWidget(new QLabel(QCoreApplication::translate("AwardsDialog", "Match"), widget)); + controls->addWidget(m_profileMatch); + controls->addWidget(new QLabel(QCoreApplication::translate("AwardsDialog", "Year"), widget)); + controls->addWidget(m_year); + controls->addWidget(exportButton); + controls->addWidget(submitButton); + controls->addStretch(); + controls->addWidget(m_score); + layout->addLayout(controls); + m_table = new QTableView(widget); + m_model = new QSqlQueryModel(m_table); + m_table->setModel(m_model); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setAlternatingRowColors(true); + m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + layout->addWidget(m_table); + QObject::connect(m_profile, QOverload::of(&QComboBox::currentIndexChanged), widget, [this]{ refresh(); }); + QObject::connect(m_profileMatch, QOverload::of(&QComboBox::currentIndexChanged), widget, [this]{ refresh(); }); + QObject::connect(m_year, QOverload::of(&QSpinBox::valueChanged), widget, [this]{ refresh(); }); + QObject::connect(exportButton, &QPushButton::clicked, widget, [this]{ exportAdif(); }); + QObject::connect(submitButton, &QPushButton::clicked, widget, []{ QDesktopServices::openUrl(QUrl(QStringLiteral("https://entry.dxmarathon.com"))); }); + m_widget = widget; + return widget; +} + +QString AwardDXMarathon::contactPredicate() const +{ + if (!m_profile || m_profile->currentIndex() < 0) + return QStringLiteral("0"); + const StationProfile p = StationProfilesManager::instance()->getProfile(m_profile->currentData().toString()); + const QString callsign = QString(p.callsign).replace("'", "''"); + QString stationMatch = QString("UPPER(station_callsign)=UPPER('%1')").arg(callsign); + if (m_profileMatch->currentData().toString() == QLatin1String("profile")) + stationMatch = QString("EXISTS (SELECT 1 FROM station_profiles WHERE profile_name='%1' AND %2)") + .arg(QString(m_profile->currentData().toString()).replace("'", "''"), p.getContactInnerJoin()); + return QString("%1 " + "AND start_time >= '%2-01-01T00:00:00' AND start_time < '%3-01-01T00:00:00' " + "AND band IN ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m') " + "AND callsign NOT LIKE '%/AM' AND callsign NOT LIKE '%/MM' %4") + .arg(stationMatch).arg(m_year->value()).arg(m_year->value() + 1).arg(m_userFilter); +} + +void AwardDXMarathon::updateData(const AwardFilterParams ¶ms) +{ + m_userFilter = params.userFilterWhereClause; + refresh(); +} + +void AwardDXMarathon::refresh() +{ + FCT_IDENTIFICATION; + if (!m_model) return; + const QString eligible = contactPredicate(); + const QString sql = QString( + "WITH eligible AS (SELECT * FROM contacts WHERE %1), targets(kind,sort_key,item,name) AS (" + " SELECT 'Entity', id, CAST(id AS TEXT), translate_to_locale(name) FROM dxcc_entities_clublog WHERE deleted=0" + " UNION ALL SELECT 'Zone', 1000+n, CAST(n AS TEXT), 'CQ Zone '||n FROM (WITH RECURSIVE z(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM z WHERE n<40) SELECT n FROM z))," + " hits AS (SELECT 'Entity' kind, CAST(dxcc AS TEXT) item, MIN(start_time) first_qso FROM eligible WHERE dxcc>0 GROUP BY dxcc" + " UNION ALL SELECT 'Zone', CAST(cqz AS TEXT), MIN(start_time) FROM eligible WHERE cqz BETWEEN 1 AND 40 GROUP BY cqz)" + " SELECT t.kind, t.item, t.name, CASE WHEN h.item IS NULL THEN 'Not worked' ELSE 'Worked' END status," + " h.first_qso FROM targets t LEFT JOIN hits h ON h.kind=t.kind AND h.item=t.item ORDER BY t.sort_key").arg(eligible); + qCDebug(runtime) << "DX Marathon SQL:" << sql; + m_model->setQuery(sql); + if (m_model->lastError().isValid()) qCWarning(runtime) << "DX Marathon query error:" << m_model->lastError(); + m_model->setHeaderData(0, Qt::Horizontal, QCoreApplication::translate("AwardsDialog", "Type")); + m_model->setHeaderData(1, Qt::Horizontal, QCoreApplication::translate("AwardsDialog", "Number")); + m_model->setHeaderData(2, Qt::Horizontal, QCoreApplication::translate("AwardsDialog", "Entity / Zone")); + m_model->setHeaderData(3, Qt::Horizontal, QCoreApplication::translate("AwardsDialog", "Status")); + m_model->setHeaderData(4, Qt::Horizontal, QCoreApplication::translate("AwardsDialog", "First QSO")); + QSqlQuery count(QString("SELECT COUNT(DISTINCT CASE WHEN dxcc>0 THEN dxcc END), " + "COUNT(DISTINCT CASE WHEN cqz BETWEEN 1 AND 40 THEN cqz END) " + "FROM contacts WHERE %1").arg(eligible)); + if (count.next()) m_score->setText(QCoreApplication::translate("AwardsDialog", "Score: %1 (%2 entities + %3 zones)").arg(count.value(0).toInt()+count.value(1).toInt()).arg(count.value(0).toInt()).arg(count.value(1).toInt())); +} + +void AwardDXMarathon::exportAdif() +{ + FCT_IDENTIFICATION; + const QString fileName = QFileDialog::getSaveFileName(m_widget, QCoreApplication::translate("AwardsDialog", "Export DX Marathon ADIF"), QString("dxmarathon-%1.adi").arg(m_year->value()), QStringLiteral("ADIF (*.adi)")); + if (fileName.isEmpty()) return; + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::critical(m_widget, QObject::tr("QLog Error"), file.errorString()); return; } + QTextStream stream(&file); + AdiFormat format(stream); + QSqlQuery query(QString("SELECT * FROM contacts WHERE %1 ORDER BY start_time").arg(contactPredicate())); + QList records; + while (query.next()) records.append(query.record()); + if (query.lastError().isValid()) { qCWarning(runtime) << "DX Marathon export query error:" << query.lastError(); QMessageBox::critical(m_widget, QObject::tr("QLog Error"), query.lastError().text()); return; } + const long exported = format.runExport(records); + qCDebug(runtime) << "DX Marathon ADIF exported contacts:" << exported << "file:" << fileName; + QMessageBox::information(m_widget, QObject::tr("QLog Info"), QCoreApplication::translate("AwardsDialog", "%1 contacts exported. Upload this ADIF to the DX Marathon submission tool.").arg(exported)); +} + +AwardDefinition::ConditionResult AwardDXMarathon::getConditionSelected(const QModelIndex &index) const +{ + ConditionResult r; + if (!index.isValid() || m_model->data(m_model->index(index.row(), 3)).toString() != QLatin1String("Worked")) return r; + const QString kind = m_model->data(m_model->index(index.row(), 0)).toString(); + const QString item = m_model->data(m_model->index(index.row(), 1)).toString(); + r.filter = QString("(%1 = '%2' AND %3)").arg(kind == QLatin1String("Zone") ? "cqz" : "dxcc", item, contactPredicate()); + r.valid = true; + return r; +} diff --git a/awards/AwardDXMarathon.h b/awards/AwardDXMarathon.h new file mode 100644 index 00000000..a0d7b780 --- /dev/null +++ b/awards/AwardDXMarathon.h @@ -0,0 +1,38 @@ +#ifndef QLOG_AWARDS_AWARDDXMARATHON_H +#define QLOG_AWARDS_AWARDDXMARATHON_H + +#include "AwardDefinition.h" + +class QComboBox; +class QSpinBox; +class QSqlQueryModel; +class QTableView; +class QLabel; + +class AwardDXMarathon : public AwardDefinition +{ +public: + QString key() const override { return QStringLiteral("dxmarathon"); } + QString displayName() const override; + QString rulesUrl() const override; + bool entityInputEnabled() const override { return false; } + bool notWorkedEnabled() const override { return false; } + QWidget *createWidget(QWidget *parent) override; + void updateData(const AwardFilterParams ¶ms) override; + ConditionResult getConditionSelected(const QModelIndex &index) const override; + +private: + void refresh(); + void exportAdif(); + QString contactPredicate() const; + + QComboBox *m_profile = nullptr; + QComboBox *m_profileMatch = nullptr; + QSpinBox *m_year = nullptr; + QLabel *m_score = nullptr; + QTableView *m_table = nullptr; + QSqlQueryModel *m_model = nullptr; + QString m_userFilter; +}; + +#endif diff --git a/core/AlertEvaluator.cpp b/core/AlertEvaluator.cpp index bae611f4..8672ca1e 100644 --- a/core/AlertEvaluator.cpp +++ b/core/AlertEvaluator.cpp @@ -8,6 +8,9 @@ #include "data/WsjtxEntry.h" #include "data/SpotAlert.h" #include "data/BandPlan.h" +#include "data/StationProfile.h" +#include "core/LogParam.h" +#include MODULE_IDENTIFICATION("qlog.ui.alertevaluator"); @@ -125,6 +128,7 @@ AlertRule::AlertRule(QObject *parent) : sota(false), iota(false), wwff(false), + dxMarathon(false), ruleValid(false) { FCT_IDENTIFICATION; @@ -143,12 +147,12 @@ bool AlertRule::save() QSqlQuery insertUpdateStmt; if ( ! insertUpdateStmt.prepare("INSERT INTO alert_rules(rule_name, enabled, source, dx_callsign, dx_country, " - "dx_logstatus, dx_continent, spot_comment, mode, band, spotter_country, spotter_continent, dx_member, ituz, cqz, pota, sota, iota, wwff) " + "dx_logstatus, dx_continent, spot_comment, mode, band, spotter_country, spotter_continent, dx_member, ituz, cqz, pota, sota, iota, wwff, dx_marathon) " " VALUES (:ruleName, :enabled, :source, :dxCallsign, :dxCountry, " - ":dxLogstatus, :dxContinent, :spotComment, :mode, :band, :spotterCountry, :spotterContinent, :dxMember, :ituz, :cqz, :pota, :sota, :iota, :wwff) " + ":dxLogstatus, :dxContinent, :spotComment, :mode, :band, :spotterCountry, :spotterContinent, :dxMember, :ituz, :cqz, :pota, :sota, :iota, :wwff, :dxMarathon) " " ON CONFLICT(rule_name) DO UPDATE SET enabled = :enabled, source = :source, dx_callsign =:dxCallsign, " "dx_country = :dxCountry, dx_logstatus = :dxLogstatus, dx_continent = :dxContinent, spot_comment = :spotComment, " - "mode = :mode, band = :band, spotter_country = :spotterCountry, spotter_continent = :spotterContinent, dx_member = :dxMember, ituz = :ituz, cqz = :cqz, pota = :pota, sota = :sota, iota = :iota, wwff = :wwff " + "mode = :mode, band = :band, spotter_country = :spotterCountry, spotter_continent = :spotterContinent, dx_member = :dxMember, ituz = :ituz, cqz = :cqz, pota = :pota, sota = :sota, iota = :iota, wwff = :wwff, dx_marathon = :dxMarathon " " WHERE rule_name = :ruleName")) { qWarning() << "Cannot prepare insert/update Alert Rule statement" << insertUpdateStmt.lastError(); @@ -174,6 +178,7 @@ bool AlertRule::save() insertUpdateStmt.bindValue(":sota", sota); insertUpdateStmt.bindValue(":iota", iota); insertUpdateStmt.bindValue(":wwff", wwff); + insertUpdateStmt.bindValue(":dxMarathon", dxMarathon); if ( ! insertUpdateStmt.exec() ) { @@ -192,7 +197,7 @@ bool AlertRule::load(const QString &in_ruleName) QSqlQuery query; if ( ! query.prepare("SELECT rule_name, enabled, source, dx_callsign, dx_country, dx_logstatus, " - "dx_continent, spot_comment, mode, band, spotter_country, spotter_continent, dx_member, ituz, cqz, pota, sota, iota, wwff " + "dx_continent, spot_comment, mode, band, spotter_country, spotter_continent, dx_member, ituz, cqz, pota, sota, iota, wwff, dx_marathon " "FROM alert_rules " "WHERE rule_name = :rule") ) { @@ -232,6 +237,7 @@ bool AlertRule::load(const QString &in_ruleName) sota = record.value("sota").toBool(); iota = record.value("iota").toBool(); wwff = record.value("wwff").toBool(); + dxMarathon = record.value("dx_marathon").toBool(); callsignRE.setPattern(dxCallsign); callsignRE.setPatternOptions(QRegularExpression::CaseInsensitiveOption); @@ -270,6 +276,7 @@ bool AlertRule::match(const WsjtxEntry &wsjtx) const if ( dxCountry && dxCountry != wsjtx.dxcc.dxcc ) return fail(); if ( ituz && ituz != wsjtx.dxcc.ituz ) return fail(); if ( cqz && cqz != wsjtx.dxcc.cqz ) return fail(); + if ( dxMarathon && !isDXMarathonNew(wsjtx.dxcc.dxcc, wsjtx.dxcc.cqz, wsjtx.band, wsjtx.callsign) ) return fail(); if ( pota || sota || iota || wwff ) { const bool refMatch = @@ -332,6 +339,7 @@ bool AlertRule::match(const DxSpot &spot) const if ( dxCountry != 0 && dxCountry != spot.dxcc.dxcc ) return fail(); if ( ituz != 0 && ituz != spot.dxcc.ituz ) return fail(); if ( cqz != 0 && cqz != spot.dxcc.cqz ) return fail(); + if ( dxMarathon && !isDXMarathonNew(spot.dxcc.dxcc, spot.dxcc.cqz, spot.band, spot.callsign) ) return fail(); if ( pota || sota || iota || wwff ) { const bool refMatch = @@ -410,6 +418,7 @@ AlertRule::operator QString() const + "SOTA: " + (sota ? "true" : "false") + "; " + "IOTA: " + (iota ? "true" : "false") + "; " + "WWFF: " + (wwff ? "true" : "false") + "; " + + "DX Marathon: " + (dxMarathon ? "true" : "false") + "; " + "dxMember: " + dxMember.join(", ") + "; " + "dxCountry: " + QString::number(dxCountry) + "; " + "dxLogStatusMap: 0b" + QString::number(dxLogStatusMap,2) + "; " @@ -420,3 +429,33 @@ AlertRule::operator QString() const + "spotterContinent: " + spotterContinent + "; " + ")"; } + +bool AlertRule::isDXMarathonNew(int dxcc, int cqz, const QString &spotBand, const QString &callsign) const +{ + static const QStringList eligibleBands = {"160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m"}; + if (dxcc <= 0 || cqz <= 0 || !eligibleBands.contains(spotBand) + || callsign.endsWith("/AM", Qt::CaseInsensitive) || callsign.endsWith("/MM", Qt::CaseInsensitive)) + return false; + const StationProfile profile = StationProfilesManager::instance()->getCurProfile1(); + QSqlQuery query; + const bool callsignOnly = LogParam::getDXMarathonAlertByCallsign(); + const QString stationCondition = callsignOnly + ? QStringLiteral("UPPER(station_callsign)=UPPER(:stationCallsign)") + : QString("EXISTS (SELECT 1 FROM station_profiles WHERE profile_name=:profile AND %1)").arg(profile.getContactInnerJoin()); + const QString sql = QString("SELECT EXISTS(SELECT 1 FROM contacts WHERE %1 " + "AND start_time>=:start AND start_time<:end AND dxcc=:dxcc), " + "EXISTS(SELECT 1 FROM contacts WHERE %1 " + "AND start_time>=:start AND start_time<:end AND cqz=:cqz)").arg(stationCondition); + if (!query.prepare(sql)) { qCWarning(runtime) << "DX Marathon alert prepare error:" << query.lastError(); return false; } + query.bindValue(":stationCallsign", profile.callsign); + query.bindValue(":profile", profile.profileName); + query.bindValue(":start", QString("%1-01-01T00:00:00").arg(QDate::currentDate().year())); + query.bindValue(":end", QString("%1-01-01T00:00:00").arg(QDate::currentDate().year()+1)); + query.bindValue(":dxcc", dxcc); + query.bindValue(":cqz", cqz); + if (!query.exec() || !query.next()) { qCWarning(runtime) << "DX Marathon alert query error:" << query.lastError(); return false; } + const bool isNew = !query.value(0).toBool() || !query.value(1).toBool(); + qCDebug(runtime) << "DX Marathon alert" << callsign << "profile" << profile.profileName + << "match" << (callsignOnly ? "callsign" : "station profile") << "new" << isNew; + return isNew; +} diff --git a/core/AlertEvaluator.h b/core/AlertEvaluator.h index 7b1d73d4..145daeda 100644 --- a/core/AlertEvaluator.h +++ b/core/AlertEvaluator.h @@ -41,12 +41,14 @@ class AlertRule : public QObject bool sota; bool iota; bool wwff; + bool dxMarathon; private: bool ruleValid; QRegularExpression callsignRE; QRegularExpression commentRE; QSet dxMemberSet; + bool isDXMarathonNew(int dxcc, int cqz, const QString &band, const QString &callsign) const; }; diff --git a/core/LogParam.cpp b/core/LogParam.cpp index 2e4421f9..707659ae 100644 --- a/core/LogParam.cpp +++ b/core/LogParam.cpp @@ -153,6 +153,16 @@ bool LogParam::getDxccConfirmedByEqslState() return getParam("others/dxccconfirmedbyeqsl", false).toBool(); } +bool LogParam::getDXMarathonAlertByCallsign() +{ + return getParam("others/dxmarathonalertbycallsign", false).toBool(); +} + +bool LogParam::setDXMarathonAlertByCallsign(bool state) +{ + return setParam("others/dxmarathonalertbycallsign", state); +} + int LogParam::getContestSeqno(const QString &band) { return getParam(( band.isEmpty() ) ? "contest/seqnos/single" diff --git a/core/LogParam.h b/core/LogParam.h index a1f5bacc..8585e002 100644 --- a/core/LogParam.h +++ b/core/LogParam.h @@ -62,6 +62,8 @@ class LogParam : public QObject static bool getDxccConfirmedByPaperState(); static bool setDxccConfirmedByEqslState(bool state); static bool getDxccConfirmedByEqslState(); + static bool getDXMarathonAlertByCallsign(); + static bool setDXMarathonAlertByCallsign(bool state); static int getContestSeqno(const QString &band = QString()); static bool setContestSeqno(int value, const QString &band = QString()); static void removeContestSeqno(); diff --git a/core/Migration.h b/core/Migration.h index d4b4ec72..8cf61414 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 = 39; + static constexpr int latestVersion = 40; private: bool functionMigration(int version); diff --git a/res/res.qrc b/res/res.qrc index 8ca4af25..c4b53a44 100644 --- a/res/res.qrc +++ b/res/res.qrc @@ -52,5 +52,6 @@ sql/migration_037.sql sql/migration_038.sql sql/migration_039.sql + sql/migration_040.sql diff --git a/res/sql/migration_040.sql b/res/sql/migration_040.sql new file mode 100644 index 00000000..1534995d --- /dev/null +++ b/res/sql/migration_040.sql @@ -0,0 +1 @@ +ALTER TABLE alert_rules ADD COLUMN dx_marathon INTEGER DEFAULT 0; diff --git a/ui/AlertRuleDetail.cpp b/ui/AlertRuleDetail.cpp index 10739d67..55307c23 100644 --- a/ui/AlertRuleDetail.cpp +++ b/ui/AlertRuleDetail.cpp @@ -321,6 +321,7 @@ void AlertRuleDetail::save() * WWFF **********/ rule.wwff = ui->wwffCheckbox->isChecked(); + rule.dxMarathon = ui->dxMarathonCheckbox->isChecked(); qCDebug(runtime) << rule; @@ -590,6 +591,7 @@ void AlertRuleDetail::loadRule(const QString &ruleName) * WWFF **********/ ui->wwffCheckbox->setChecked(rule.wwff); + ui->dxMarathonCheckbox->setChecked(rule.dxMarathon); } else qCDebug(runtime) << "Cannot load rule " << ruleName; diff --git a/ui/AlertRuleDetail.ui b/ui/AlertRuleDetail.ui index 2b6b843b..cfe71825 100644 --- a/ui/AlertRuleDetail.ui +++ b/ui/AlertRuleDetail.ui @@ -455,6 +455,12 @@ + + + Alert when the entity or CQ zone is new for the current year and active station profile + DX Marathon + + diff --git a/ui/AwardsDialog.cpp b/ui/AwardsDialog.cpp index c85f95a3..09550ad6 100644 --- a/ui/AwardsDialog.cpp +++ b/ui/AwardsDialog.cpp @@ -29,6 +29,7 @@ #include "awards/AwardUKD.h" #include "awards/AwardWAIP.h" #include "awards/AwardWAAC.h" +#include "awards/AwardDXMarathon.h" MODULE_IDENTIFICATION("qlog.ui.awardsdialog"); @@ -215,6 +216,7 @@ QList AwardsDialog::createAwards() { return { new AwardDXCC(), + new AwardDXMarathon(), new AwardITU(), new AwardWAC(), new AwardWAZ(), diff --git a/ui/AwardsDialog.ui b/ui/AwardsDialog.ui index 4767608f..be88ed7a 100644 --- a/ui/AwardsDialog.ui +++ b/ui/AwardsDialog.ui @@ -69,7 +69,7 @@ - 🌐 Rules + Rules diff --git a/ui/SettingsDialog.cpp b/ui/SettingsDialog.cpp index 9d2cf0b2..9a5178a3 100644 --- a/ui/SettingsDialog.cpp +++ b/ui/SettingsDialog.cpp @@ -2732,6 +2732,7 @@ void SettingsDialog::readSettings() ui->dxccConfirmedByLotwCheckBox->setChecked(LogParam::getDxccConfirmedByLotwState()); ui->dxccConfirmedByPaperCheckBox->setChecked(LogParam::getDxccConfirmedByPaperState()); ui->dxccConfirmedByEqslCheckBox->setChecked(LogParam::getDxccConfirmedByEqslState()); + ui->dxMarathonAlertMatchComboBox->setCurrentIndex(LogParam::getDXMarathonAlertByCallsign() ? 1 : 0); /***************/ /* ON4KST Chat */ @@ -2862,6 +2863,7 @@ void SettingsDialog::writeSettings() LogParam::setDxccConfirmedByLotwState(ui->dxccConfirmedByLotwCheckBox->isChecked()); LogParam::setDxccConfirmedByPaperState(ui->dxccConfirmedByPaperCheckBox->isChecked()); LogParam::setDxccConfirmedByEqslState(ui->dxccConfirmedByEqslCheckBox->isChecked()); + LogParam::setDXMarathonAlertByCallsign(ui->dxMarathonAlertMatchComboBox->currentIndex() == 1); /***************/ /* ON4KST Chat */ diff --git a/ui/SettingsDialog.ui b/ui/SettingsDialog.ui index a5486d68..4650f548 100644 --- a/ui/SettingsDialog.ui +++ b/ui/SettingsDialog.ui @@ -4213,6 +4213,33 @@ + + + + + 0 + 0 + + + + DX Marathon + + + + + Alert Matching + + + + + Callsign matching can combine contacts made from different operating locations. + Station Profile + Callsign + + + + + From 8d6027175ac8af2662bdda9051e636a545c7de72 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:41:44 -0500 Subject: [PATCH 2/2] Handle DX Marathon logic; add StationProfile tests Prevent DX Marathon from being filtered by the normal DXCC log-status: AlertEvaluator now skips the dxLogStatusMap check when dxMarathon is true, adds a marathonEligible SQL clause (allowed bands and excludes /AM and /MM suffixes), and improves debug output showing entity/zone worked state. Fixed a typo in StationProfile (darc_doc -> darc_dok) so contact inner-join uses the correct column. Added unit tests for StationProfile (test pro and tst_stationprofile.cpp) and registered the test in tests.pro. Updated AlertRuleDetail.ui tooltip to clarify DX Marathon ignores DXCC log-status selections. --- core/AlertEvaluator.cpp | 25 +++++++--- data/StationProfile.cpp | 2 +- .../StationProfileTest/StationProfileTest.pro | 14 ++++++ .../StationProfileTest/tst_stationprofile.cpp | 49 +++++++++++++++++++ tests/tests.pro | 3 +- ui/AlertRuleDetail.ui | 2 +- 6 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 tests/StationProfileTest/StationProfileTest.pro create mode 100644 tests/StationProfileTest/tst_stationprofile.cpp diff --git a/core/AlertEvaluator.cpp b/core/AlertEvaluator.cpp index 8672ca1e..d636c63b 100644 --- a/core/AlertEvaluator.cpp +++ b/core/AlertEvaluator.cpp @@ -288,7 +288,9 @@ bool AlertRule::match(const WsjtxEntry &wsjtx) const if ( !refMatch ) return fail(); } - if ( !(wsjtx.status & dxLogStatusMap) ) return fail(); + // DX Marathon has its own annual entity/zone status. Combining it with the + // normal lifetime DXCC status can hide a needed zone in a worked entity. + if ( !dxMarathon && !(wsjtx.status & dxLogStatusMap) ) return fail(); if ( mode != "*" ) { @@ -351,7 +353,9 @@ bool AlertRule::match(const DxSpot &spot) const if ( !refMatch ) return fail(); } - if ( !(spot.status & dxLogStatusMap) ) return fail(); + // See the WSJT-X path above: Marathon need replaces, rather than augments, + // the normal DXCC log-status filter. + if ( !dxMarathon && !(spot.status & dxLogStatusMap) ) return fail(); if ( mode != "*" ) { @@ -442,10 +446,14 @@ bool AlertRule::isDXMarathonNew(int dxcc, int cqz, const QString &spotBand, cons const QString stationCondition = callsignOnly ? QStringLiteral("UPPER(station_callsign)=UPPER(:stationCallsign)") : QString("EXISTS (SELECT 1 FROM station_profiles WHERE profile_name=:profile AND %1)").arg(profile.getContactInnerJoin()); + const QString marathonEligible = QStringLiteral( + "band IN ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m') " + "AND UPPER(callsign) NOT LIKE '%/AM' AND UPPER(callsign) NOT LIKE '%/MM'"); const QString sql = QString("SELECT EXISTS(SELECT 1 FROM contacts WHERE %1 " - "AND start_time>=:start AND start_time<:end AND dxcc=:dxcc), " + "AND start_time>=:start AND start_time<:end AND dxcc=:dxcc AND %2), " "EXISTS(SELECT 1 FROM contacts WHERE %1 " - "AND start_time>=:start AND start_time<:end AND cqz=:cqz)").arg(stationCondition); + "AND start_time>=:start AND start_time<:end AND cqz=:cqz AND %2)") + .arg(stationCondition, marathonEligible); if (!query.prepare(sql)) { qCWarning(runtime) << "DX Marathon alert prepare error:" << query.lastError(); return false; } query.bindValue(":stationCallsign", profile.callsign); query.bindValue(":profile", profile.profileName); @@ -454,8 +462,13 @@ bool AlertRule::isDXMarathonNew(int dxcc, int cqz, const QString &spotBand, cons query.bindValue(":dxcc", dxcc); query.bindValue(":cqz", cqz); if (!query.exec() || !query.next()) { qCWarning(runtime) << "DX Marathon alert query error:" << query.lastError(); return false; } - const bool isNew = !query.value(0).toBool() || !query.value(1).toBool(); + const bool entityWorked = query.value(0).toBool(); + const bool zoneWorked = query.value(1).toBool(); + const bool isNew = !entityWorked || !zoneWorked; qCDebug(runtime) << "DX Marathon alert" << callsign << "profile" << profile.profileName - << "match" << (callsignOnly ? "callsign" : "station profile") << "new" << isNew; + << "match" << (callsignOnly ? "callsign" : "station profile") + << "entity" << dxcc << "worked" << entityWorked + << "zone" << cqz << "worked" << zoneWorked + << "new" << isNew; return isNew; } diff --git a/data/StationProfile.cpp b/data/StationProfile.cpp index 8ab8688e..5ccf017b 100644 --- a/data/StationProfile.cpp +++ b/data/StationProfile.cpp @@ -248,7 +248,7 @@ QString StationProfile::getContactInnerJoin() const // skipping Country - depends on dxcc addIfNoEmpty(county, "my_cnty", "county"); addIfNoEmpty(operatorCallsign, "operator", "operator_callsign"); - addIfNoEmpty(darcDOK, "my_darc_dok", "darc_doc"); + addIfNoEmpty(darcDOK, "my_darc_dok", "darc_dok"); return "(" + ret.join(" AND ") + ")"; } diff --git a/tests/StationProfileTest/StationProfileTest.pro b/tests/StationProfileTest/StationProfileTest.pro new file mode 100644 index 00000000..fa6a8183 --- /dev/null +++ b/tests/StationProfileTest/StationProfileTest.pro @@ -0,0 +1,14 @@ +QT += testlib core sql +CONFIG += console testcase c++11 +TEMPLATE = app +TARGET = tst_stationprofile + +INCLUDEPATH += $$PWD/../.. + +SOURCES += \ + tst_stationprofile.cpp \ + ../../data/StationProfile.cpp + +HEADERS += \ + ../../data/StationProfile.h \ + ../../data/ProfileManager.h diff --git a/tests/StationProfileTest/tst_stationprofile.cpp b/tests/StationProfileTest/tst_stationprofile.cpp new file mode 100644 index 00000000..2f69a872 --- /dev/null +++ b/tests/StationProfileTest/tst_stationprofile.cpp @@ -0,0 +1,49 @@ +#include + +#include "data/StationProfile.h" + +class StationProfileTest : public QObject +{ + Q_OBJECT + +private slots: + void requiredFieldsAreAlwaysMatched(); + void populatedLocationFieldsUseContactColumns(); + void emptyOptionalFieldsAreIgnored(); +}; + +void StationProfileTest::requiredFieldsAreAlwaysMatched() +{ + StationProfile profile; + const QString join = profile.getContactInnerJoin(); + + QVERIFY(join.contains(QStringLiteral("contacts.station_callsign = station_profiles.callsign"))); + QVERIFY(join.contains(QStringLiteral("contacts.my_gridsquare = station_profiles.locator"))); +} + +void StationProfileTest::populatedLocationFieldsUseContactColumns() +{ + StationProfile profile; + profile.county = QStringLiteral("Example Parish"); + profile.darcDOK = QStringLiteral("A01"); + profile.operatorCallsign = QStringLiteral("N0CALL"); + + const QString join = profile.getContactInnerJoin(); + QVERIFY(join.contains(QStringLiteral("contacts.my_cnty = station_profiles.county"))); + QVERIFY(join.contains(QStringLiteral("contacts.my_darc_dok = station_profiles.darc_dok"))); + QVERIFY(join.contains(QStringLiteral("contacts.operator = station_profiles.operator_callsign"))); + QVERIFY(!join.contains(QStringLiteral("darc_doc"))); +} + +void StationProfileTest::emptyOptionalFieldsAreIgnored() +{ + StationProfile profile; + const QString join = profile.getContactInnerJoin(); + + QVERIFY(!join.contains(QStringLiteral("contacts.my_cnty"))); + QVERIFY(!join.contains(QStringLiteral("contacts.my_darc_dok"))); + QVERIFY(!join.contains(QStringLiteral("contacts.operator ="))); +} + +QTEST_APPLESS_MAIN(StationProfileTest) +#include "tst_stationprofile.moc" diff --git a/tests/tests.pro b/tests/tests.pro index 23998a1f..cbd1c562 100644 --- a/tests/tests.pro +++ b/tests/tests.pro @@ -17,4 +17,5 @@ SUBDIRS += CallsignTest \ MigrationTest \ PasswordCipherTest \ QuadKeyCacheTest \ - RigctldManagerTest + RigctldManagerTest \ + StationProfileTest diff --git a/ui/AlertRuleDetail.ui b/ui/AlertRuleDetail.ui index cfe71825..b8c72251 100644 --- a/ui/AlertRuleDetail.ui +++ b/ui/AlertRuleDetail.ui @@ -457,7 +457,7 @@ - Alert when the entity or CQ zone is new for the current year and active station profile + Alert when the entity or CQ zone is new for the current year and active station profile. DXCC Log Status selections do not apply. DX Marathon