Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions QLog.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -242,6 +243,7 @@ SOURCES += \
HEADERS += \
awards/AwardDefinition.h \
awards/AwardDXCC.h \
awards/AwardDXMarathon.h \
awards/AwardGridsquare.h \
awards/AwardIOTA.h \
awards/AwardITU.h \
Expand Down
161 changes: 161 additions & 0 deletions awards/AwardDXMarathon.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#include "AwardDXMarathon.h"

#include <QComboBox>
#include <QDate>
#include <QDesktopServices>
#include <QFile>
#include <QFileDialog>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QSpinBox>
#include <QSqlError>
#include <QSqlQuery>
#include <QSqlQueryModel>
#include <QTableView>
#include <QTextStream>
#include <QUrl>
#include <QVBoxLayout>

#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<int>::of(&QComboBox::currentIndexChanged), widget, [this]{ refresh(); });
QObject::connect(m_profileMatch, QOverload<int>::of(&QComboBox::currentIndexChanged), widget, [this]{ refresh(); });
QObject::connect(m_year, QOverload<int>::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 &params)
{
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<QSqlRecord> 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;
}
38 changes: 38 additions & 0 deletions awards/AwardDXMarathon.h
Original file line number Diff line number Diff line change
@@ -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 &params) 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
64 changes: 58 additions & 6 deletions core/AlertEvaluator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <QDate>

MODULE_IDENTIFICATION("qlog.ui.alertevaluator");

Expand Down Expand Up @@ -125,6 +128,7 @@ AlertRule::AlertRule(QObject *parent) :
sota(false),
iota(false),
wwff(false),
dxMarathon(false),
ruleValid(false)
{
FCT_IDENTIFICATION;
Expand All @@ -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();
Expand All @@ -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() )
{
Expand All @@ -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") )
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 =
Expand All @@ -281,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 != "*" )
{
Expand Down Expand Up @@ -332,6 +341,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 =
Expand All @@ -343,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 != "*" )
{
Expand Down Expand Up @@ -410,6 +422,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) + "; "
Expand All @@ -420,3 +433,42 @@ 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 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 %2), "
"EXISTS(SELECT 1 FROM contacts WHERE %1 "
"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);
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 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")
<< "entity" << dxcc << "worked" << entityWorked
<< "zone" << cqz << "worked" << zoneWorked
<< "new" << isNew;
return isNew;
}
2 changes: 2 additions & 0 deletions core/AlertEvaluator.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ class AlertRule : public QObject
bool sota;
bool iota;
bool wwff;
bool dxMarathon;

private:
bool ruleValid;
QRegularExpression callsignRE;
QRegularExpression commentRE;
QSet<QString> dxMemberSet;
bool isDXMarathonNew(int dxcc, int cqz, const QString &band, const QString &callsign) const;

};

Expand Down
Loading