diff --git a/.gitignore b/.gitignore index 53baecf8..1f63de72 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ compile_commands.json # QtCreator local machine specific files for imported projects *creator.user* .DS_Store +/build diff --git a/logformat/AdiFormat.cpp b/logformat/AdiFormat.cpp index 8d0ca30d..a2128636 100644 --- a/logformat/AdiFormat.cpp +++ b/logformat/AdiFormat.cpp @@ -492,6 +492,14 @@ void AdiFormat::contactFields2SQLRecord(QMap &contact, QSqlRe time_on = time_off; } + /* Records that have a date but no time (e.g. LoTW DXCC credit exports) would + * produce an invalid QDateTime, making them impossible to match later. + * Default to midnight UTC so the record remains usable. */ + if ( !time_on.isValid() && date_on.isValid() ) + { + time_on = QTime(0, 0, 0); + } + QDateTime start_time(date_on, time_on, QTimeZone::utc()); QDateTime end_time(date_off, time_off, QTimeZone::utc()); diff --git a/logformat/LogFormat.cpp b/logformat/LogFormat.cpp index 2e045dab..a200f7a9 100644 --- a/logformat/LogFormat.cpp +++ b/logformat/LogFormat.cpp @@ -773,6 +773,136 @@ unsigned long LogFormat::runImport(QTextStream& importLogStream, #undef RECORDIDX +void LogFormat::runQSOCreditImport(QSLFrom /*fromService*/) +{ + FCT_IDENTIFICATION; + + auto reportFormatter = [&](const QDateTime &qsoTime, + const QString &callsign, + const QString &mode, + const QStringList addInfo = QStringList()) + { + return QString("%0; %1; %2%3 %4").arg(qsoTime.isValid() ? qsoTime.toString(locale.formatDateShortWithYYYY()) : "-", + callsign, + mode, + (addInfo.size() > 0 ) ? ";" : "", + addInfo.join(", ")); + }; + + QSLMergeStat stats = {QStringList(), QStringList(), QStringList(), QStringList(), 0}; + this->importStart(); + + QSqlTableModel model; + model.setTable("contacts"); + QSqlRecord QSLRecord = model.record(0); + + while ( true ) + { + QSLRecord.clearValues(); + + if ( !this->importNext(QSLRecord) ) break; + + stats.qsosDownloaded++; + + if ( stats.qsosDownloaded % 100 == 0 ) + { + emit importPosition(stream.pos()); + } + + const QVariant &call = QSLRecord.value("callsign"); + const QVariant &band = QSLRecord.value("band"); + const QVariant &start_time = QSLRecord.value("start_time"); + const QVariant &satName = QSLRecord.value("sat_name"); + + /* require at minimum: callsign, band, and a valid date */ + if ( !start_time.toDateTime().isValid() + || call.toString().isEmpty() + || band.toString().isEmpty() ) + { + qWarning() << "DXCC credit import: missing start_time, callsign, or band"; + qCDebug(runtime) << QSLRecord; + stats.errorQSLs.append(reportFormatter(start_time.toDateTime(), call.toString(), "")); + continue; + } + + /* Match on callsign + band + date + must already have a QSL confirmed. + * Mode is intentionally omitted because DXCC credit records may use generic + * mode group names that do not precisely match the logged mode. + * A ±1 day tolerance is applied because the credit file may record the QSO + * date in the operator's local time zone while the DB stores UTC, causing a + * one-day discrepancy for QSOs made near midnight. */ + const QString matchFilter = QString( + "callsign=upper('%1') AND upper(band)=upper('%2') AND " + "COALESCE(sat_name, '') = upper('%3') AND " + "ABS(JULIANDAY(date(start_time)) - JULIANDAY(date('%4'))) <= 1 AND " + "(qsl_rcvd = 'Y' OR lotw_qsl_rcvd = 'Y')" + ).arg(call.toString(), + band.toString(), + satName.toString(), + start_time.toDateTime().toTimeZone(QTimeZone::utc()).toString("yyyy-MM-dd hh:mm:ss")); + + model.setFilter(matchFilter); + model.select(); + + if ( model.rowCount() < 1 ) + { + stats.unmatchedQSLs.append(reportFormatter(start_time.toDateTime(), call.toString(), "")); + continue; + } + + qCDebug(runtime) << "DXCC credit: found" << model.rowCount() << "match(es) for" + << call.toString() << band.toString() << start_time.toString(); + + /* Update every matching contact — multiple QSOs on the same date/band are possible. */ + for ( int row = 0; row < model.rowCount(); ++row ) + { + QSqlRecord originalRecord = model.record(row); + + QStringList updatedFields; + bool callUpdate = false; + + auto conditionUpdate = [&](const QString &contactKey, + const QString &qslKey) + { + if ( !QSLRecord.value(qslKey).toString().isEmpty() + && originalRecord.value(contactKey).toString().isEmpty() ) + { + qCDebug(runtime) << "Updating:" << contactKey + << "to" << QSLRecord.value(qslKey).toString(); + updatedFields.append(contactKey + "(" + QSLRecord.value(qslKey).toString() + ")"); + originalRecord.setValue(contactKey, QSLRecord.value(qslKey)); + return true; + } + return false; + }; + + callUpdate |= conditionUpdate("credit_granted", "credit_granted"); + callUpdate |= conditionUpdate("credit_submitted", "credit_submitted"); + + if ( callUpdate ) + { + if ( !model.setRecord(row, originalRecord) ) + { + qWarning() << "Cannot update a Contact record - " << model.lastError(); + qCDebug(runtime) << originalRecord; + } + stats.updatedQSOs.append(reportFormatter(start_time.toDateTime(), call.toString(), "", updatedFields)); + } + } + + if ( !model.submitAll() ) + { + qWarning() << "Cannot commit changes to Contact Table - " << model.lastError(); + } + } + + emit importPosition(stream.pos()); + + this->importEnd(); + + emit QSLMergeFinished(stats); +} + void LogFormat::runQSLImport(QSLFrom fromService) { FCT_IDENTIFICATION; diff --git a/logformat/LogFormat.h b/logformat/LogFormat.h index eda94ad6..11db57da 100644 --- a/logformat/LogFormat.h +++ b/logformat/LogFormat.h @@ -34,6 +34,7 @@ class LogFormat : public QObject { enum QSLFrom { LOTW, EQSL, + LOTW_DXCC, UNKNOW }; @@ -57,6 +58,7 @@ class LogFormat : public QObject { unsigned long *warnings, unsigned long *errors); void runQSLImport(QSLFrom fromService); + void runQSOCreditImport(QSLFrom fromService); long runExport(); long runExport(const QList&); void setDefaults(QMap& defaults); diff --git a/service/lotw/Lotw.cpp b/service/lotw/Lotw.cpp index c3a29a27..8e121a3d 100644 --- a/service/lotw/Lotw.cpp +++ b/service/lotw/Lotw.cpp @@ -434,21 +434,29 @@ void LotwQSLDownloader::receiveQSL(const QDate &start_date, bool qso_since, cons qCDebug(function_parameters) << start_date << " " << qso_since; QList> params; - params.append(qMakePair(QString("qso_query"), QString("1"))); - params.append(qMakePair(QString("qso_qsldetail"), QString("yes"))); - params.append(qMakePair(QString("qso_owncall"), station_callsign)); - const QString &start = start_date.toString("yyyy-MM-dd"); - - if (qso_since) + if ( LotwDXCCCredits ) { - params.append(qMakePair(QString("qso_qsl"), QString("no"))); - params.append(qMakePair(QString("qso_qsorxsince"), start)); + params.append(qMakePair(QString("ac_acct"), QString("1"))); } else { - params.append(qMakePair(QString("qso_qsl"), QString("yes"))); - params.append(qMakePair(QString("qso_qslsince"), start)); + params.append(qMakePair(QString("qso_query"), QString("1"))); + params.append(qMakePair(QString("qso_qsldetail"), QString("yes"))); + params.append(qMakePair(QString("qso_owncall"), station_callsign)); + + const QString &start = start_date.toString("yyyy-MM-dd"); + + if (qso_since) + { + params.append(qMakePair(QString("qso_qsl"), QString("no"))); + params.append(qMakePair(QString("qso_qsorxsince"), start)); + } + else + { + params.append(qMakePair(QString("qso_qsl"), QString("yes"))); + params.append(qMakePair(QString("qso_qslsince"), start)); + } } get(params); @@ -540,7 +548,10 @@ void LotwQSLDownloader::processReply(QNetworkReply *reply) emit receiveQSLComplete(stats); }); - adi.runQSLImport(adi.LOTW); + if ( LotwDXCCCredits ) + adi.runQSOCreditImport(adi.LOTW_DXCC); + else + adi.runQSLImport(adi.LOTW); tempFile.close(); @@ -559,7 +570,7 @@ void LotwQSLDownloader::get(QList> params) query.addQueryItem("login", username.toUtf8().toPercentEncoding()); query.addQueryItem("password", password.toUtf8().toPercentEncoding()); - QUrl url(ADIF_API); + QUrl url(LotwDXCCCredits ? DXCC_CREDIT_API : ADIF_API); url.setQuery(query); qCDebug(runtime) << Data::safeQueryString(query); diff --git a/service/lotw/Lotw.h b/service/lotw/Lotw.h index 801e6f7f..1928360c 100644 --- a/service/lotw/Lotw.h +++ b/service/lotw/Lotw.h @@ -95,6 +95,8 @@ class LotwQSLDownloader : public GenericQSLDownloader, private LotwBase explicit LotwQSLDownloader(QObject *parent = nullptr); virtual ~LotwQSLDownloader(); + bool LotwDXCCCredits = false; + virtual void receiveQSL(const QDate &, bool, const QString &) override; public slots: @@ -103,6 +105,7 @@ public slots: private: QNetworkReply *currentReply; const QString ADIF_API = "https://lotw.arrl.org/lotwuser/lotwreport.adi"; + const QString DXCC_CREDIT_API = "https://lotw.arrl.org/lotwuser/logbook/qslcards.php"; virtual void processReply(QNetworkReply* reply) override; void get(QList> params); diff --git a/ui/DownloadQSLDialog.cpp b/ui/DownloadQSLDialog.cpp index d3873739..1aadff0e 100644 --- a/ui/DownloadQSLDialog.cpp +++ b/ui/DownloadQSLDialog.cpp @@ -25,6 +25,7 @@ DownloadQSLDialog::DownloadQSLDialog(QWidget *parent) "FROM contacts ORDER BY station_callsign", "", ui->lotwMyCallsignCombo)); ui->lotwDateEdit->setDisplayFormat(locale.formatDateShortWithYYYY()); ui->eqslDateEdit->setDisplayFormat(locale.formatDateShortWithYYYY()); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("&Download")); const StationProfile &profile = StationProfilesManager::instance()->getCurProfile1(); @@ -44,6 +45,9 @@ DownloadQSLDialog::DownloadQSLDialog(QWidget *parent) ui->lotwGroupBox->setChecked(false); ui->lotwGroupBox->setEnabled(false); ui->lotwGroupBox->setToolTip(tr("LoTW is not configured properly.

Please, use Settings dialog to configure it.

")); + ui->lotwDXCCCheckBox->setChecked(false); + ui->lotwDXCCCheckBox->setEnabled(false); + ui->lotwDXCCCheckBox->setToolTip(tr("LoTW is not configured properly.

Please, use Settings dialog to configure it.

")); } if ( EQSLBase::getUsername().isEmpty() ) @@ -97,6 +101,9 @@ void DownloadQSLDialog::loadDialogState() ui->eqslDateTypeCombo->setCurrentIndex((LogParam::getDownloadQSLServiceLastQSOQSL("eqsl")) ? 0 : 1); ui->eqslQTHProfileEdit->setText(LogParam::getDownloadQSLeQSLLastProfile()); + + /* LoTW DXCC Credits checkbox always starts unchecked — this is a one-off task. */ + ui->lotwDXCCCheckBox->setChecked(false); } void DownloadQSLDialog::saveDialogState() @@ -205,6 +212,15 @@ void DownloadQSLDialog::downloadQSLs() lotw->receiveQSL(ui->lotwDateEdit->date(), !qslSinceActive, ui->lotwMyCallsignCombo->currentText()); }); + if ( ui->lotwDXCCCheckBox->isChecked() ) + downloadQueue.enqueue([=]() + { + LotwQSLDownloader* lotw = new LotwQSLDownloader(this); + lotw->LotwDXCCCredits = true; + prepareDownload(lotw, tr("LoTW DXCC Credits"), false, "lotwdxcc"); + lotw->receiveQSL(QDate(), false, QString()); + }); + if ( downloadQueue.isEmpty() ) { QMessageBox::information(this, tr("QLog Information"), tr("No service selected")); diff --git a/ui/DownloadQSLDialog.ui b/ui/DownloadQSLDialog.ui index 5a553ccb..a6dc23bb 100644 --- a/ui/DownloadQSLDialog.ui +++ b/ui/DownloadQSLDialog.ui @@ -7,7 +7,7 @@ 0 0 403 - 270 + 290 @@ -109,7 +109,14 @@ - + + + + LoTW DXCC Credits + + + + Qt::Horizontal @@ -130,6 +137,7 @@ lotwDateTypeCombo lotwDateEdit lotwMyCallsignCombo + lotwDXCCCheckBox