diff --git a/QLog.pro b/QLog.pro index c904102c..dc8448e8 100644 --- a/QLog.pro +++ b/QLog.pro @@ -11,7 +11,7 @@ greaterThan(QT_MAJOR_VERSION, 5): QT += widgets TARGET = qlog TEMPLATE = app VERSION = 0.45.0 - +LIBS += -lz DEFINES += VERSION=\\\"$$VERSION\\\" # Define paths to HAMLIB. Leave empty if system libraries should be used @@ -88,6 +88,7 @@ SOURCES += \ data/SerialPort.cpp \ data/StationProfile.cpp \ data/UpdatableSQLRecord.cpp \ + data/recomputedxccdialog.cpp \ logformat/AdiFormat.cpp \ logformat/AdxFormat.cpp \ logformat/CSVFormat.cpp \ @@ -229,6 +230,7 @@ HEADERS += \ data/WWFFEntity.h \ data/WWVSpot.h \ data/WsjtxEntry.h \ + data/recomputedxccdialog.h \ logformat/AdiFormat.h \ logformat/AdxFormat.h \ logformat/CSVFormat.h \ @@ -476,8 +478,8 @@ macx: { INSTALLS += target } - INCLUDEPATH += /usr/local/include /opt/homebrew/include - LIBS += -L/usr/local/lib -L/opt/homebrew/lib -lhamlib -lsqlite3 + INCLUDEPATH += /usr/local/include /opt/homebrew/include /opt/local/include + LIBS += -L/usr/local/lib -L/opt/homebrew/lib -lhamlib -lsqlite3 -L/opt/local/lib equals(QT_MAJOR_VERSION, 6): LIBS += -lqt6keychain equals(QT_MAJOR_VERSION, 5): LIBS += -lqt5keychain DISTFILES += diff --git a/core/LOVDownloader.cpp b/core/LOVDownloader.cpp index 4311d200..685499be 100644 --- a/core/LOVDownloader.cpp +++ b/core/LOVDownloader.cpp @@ -16,6 +16,7 @@ #include "LOVDownloader.h" #include "debug.h" +#include MODULE_IDENTIFICATION("qlog.core.lovdownloader"); @@ -192,7 +193,9 @@ void LOVDownloader::parseData(const SourceDefinition &sourceDef, QTextStream &da case MEMBERSHIPCONTENTLIST: parseMembershipContent(sourceDef, data); break; - + case CLUBLOGCTY: + parseClubLogCTY(sourceDef, data); + break; default: qWarning() << "Unssorted type to download" << sourceDef.type << sourceDef.fileName; } @@ -1081,6 +1084,11 @@ void LOVDownloader::processReply(QNetworkReply *reply) QFile file(dir.filePath(sourceDef.fileName)); file.open(QIODevice::WriteOnly); + if (sourceType == CLUBLOGCTY) + { + QByteArray maybeXml = gunzip(data); + if (!maybeXml.isEmpty()) data = maybeXml; + } file.write(data); file.flush(); file.close(); @@ -1098,3 +1106,261 @@ void LOVDownloader::processReply(QNetworkReply *reply) emit finished(false); } } + + +QByteArray LOVDownloader::gunzip(const QByteArray &in) { + if (in.isEmpty()) return {}; + z_stream strm{}; + strm.next_in = reinterpret_cast(const_cast(in.data())); + strm.avail_in = in.size(); + // 16 + MAX_WBITS tells zlib to parse gzip header/footer + if (inflateInit2(&strm, 16 + MAX_WBITS) != Z_OK) return {}; + QByteArray out; + char buf[8192]; + int ret = Z_OK; + while (ret == Z_OK) { + strm.next_out = reinterpret_cast(buf); + strm.avail_out = sizeof(buf); + ret = inflate(&strm, Z_NO_FLUSH); + if (ret == Z_OK || ret == Z_STREAM_END) { + out.append(buf, sizeof(buf) - strm.avail_out); + } + } + inflateEnd(&strm); + return out; +} + +#include +#include +#include + +void LOVDownloader::parseClubLogCTY(const SourceDefinition &sourceDef, QTextStream &stream) +{ + FCT_IDENTIFICATION; + + if (sourceDef.type != CLUBLOGCTY) + { + return; + } + + // Read whole text (it’s XML); QXmlStreamReader can also take QIODevice, but we + // already have a QTextStream here. + const QString xmlText = stream.readAll(); + QXmlStreamReader xml(xmlText); + + // Clean all five tables inside one transaction + QSqlDatabase::database().transaction(); + auto rollback = [&](){ + qCWarning(runtime) << "ClubLog CTY import failed - rollback"; + QSqlDatabase::database().rollback(); + }; + + if (!deleteTable("clublog_zone_exceptions") + || !deleteTable("clublog_invalid_ops") + || !deleteTable("clublog_exceptions") + || !deleteTable("clublog_prefixes") + || !deleteTable("clublog_entities")) { + rollback(); + return; + } + + QSqlQuery insEntity, insPrefix, insExc, insInv, insZone; + + // prepared statements + if (!insEntity.prepare( + "INSERT INTO clublog_entities(adif,name,prefix,deleted,cqz,cont,lon,lat,start,\"end\",whitelist,whitelist_start,whitelist_end)" + "VALUES(:adif,:name,:prefix,:deleted,:cqz,:cont,:lon,:lat,:start,:end,:whitelist,:whitelist_start,:whitelist_end)")) + { qWarning() << insEntity.lastError(); rollback(); return; } + + if (!insPrefix.prepare( + "INSERT INTO clublog_prefixes(record,call,entity,adif,cqz,cont,lon,lat,start,\"end\")" + "VALUES(:record,:call,:entity,:adif,:cqz,:cont,:lon,:lat,:start,:end)")) + { qWarning() << insPrefix.lastError(); rollback(); return; } + + if (!insExc.prepare( + "INSERT INTO clublog_exceptions(record,call,entity,adif,cqz,cont,lon,lat,start,\"end\")" + "VALUES(:record,:call,:entity,:adif,:cqz,:cont,:lon,:lat,:start,:end)")) + { qWarning() << insExc.lastError(); rollback(); return; } + + if (!insInv.prepare( + "INSERT INTO clublog_invalid_ops(record,call,start,\"end\")" + "VALUES(:record,:call,:start,:end)")) + { qWarning() << insInv.lastError(); rollback(); return; } + + if (!insZone.prepare( + "INSERT INTO clublog_zone_exceptions(record,call,zone,start,\"end\")" + "VALUES(:record,:call,:zone,:start,:end)")) + { qWarning() << insZone.lastError(); rollback(); return; } + + auto readText = [&](QXmlStreamReader &x)->QString { return x.readElementText().trimmed(); }; + auto readDate = [&](const QString &s)->QString { return s; }; // store ISO8601 text as-is + + // Cursor down to + while (!xml.atEnd() && !(xml.isStartElement() && xml.name() == QStringLiteral("clublog"))) xml.readNext(); + + if (xml.atEnd()) { + qWarning() << "ClubLog: not found"; + rollback(); return; + } + + // Iterate children of + while (!xml.atEnd()) { + xml.readNext(); + + if (!xml.isStartElement()) continue; + + const QString top = xml.name().toString(); + + if (top == "entities") { + // ...... + while (!(xml.isEndElement() && xml.name() == QStringLiteral("entities"))) { + xml.readNext(); + if (xml.isStartElement() && xml.name() == QStringLiteral("entity")) { + // parse one entity + int adif = 0; QString name, prefix, cont; bool deleted=false; + int cqz = 0; QString start, end, whitelist_start, whitelist_end; bool whitelist=false; + double lon=0.0, lat=0.0; + + while (!(xml.isEndElement() && xml.name() == QStringLiteral("entity"))) { + xml.readNext(); + if (!xml.isStartElement()) continue; + const QString tag = xml.name().toString(); + if (tag=="adif") adif = readText(xml).toInt(); + else if (tag=="name") name = readText(xml); + else if (tag=="prefix") prefix = readText(xml); + else if (tag=="deleted") deleted = (readText(xml).compare("true", Qt::CaseInsensitive)==0); + else if (tag=="cqz") cqz = readText(xml).toInt(); + else if (tag=="cont") cont = readText(xml); + else if (tag=="long") lon = readText(xml).toDouble(); + else if (tag=="lat") lat = readText(xml).toDouble(); + else if (tag=="start") start = readDate(readText(xml)); + else if (tag=="end") end = readDate(readText(xml)); + else if (tag=="whitelist") whitelist = (readText(xml).compare("true", Qt::CaseInsensitive)==0); + else if (tag=="whitelist_start") whitelist_start = readDate(readText(xml)); + else if (tag=="whitelist_end") whitelist_end = readDate(readText(xml)); + else xml.skipCurrentElement(); + } + + insEntity.bindValue(":adif", adif); + insEntity.bindValue(":name", name); + insEntity.bindValue(":prefix", prefix); + insEntity.bindValue(":deleted", deleted ? 1 : 0); + insEntity.bindValue(":cqz", cqz ? cqz : 0); + insEntity.bindValue(":cont", cont.isEmpty()? "" : cont); + insEntity.bindValue(":lon", lon); + insEntity.bindValue(":lat", lat); + insEntity.bindValue(":start", start.isEmpty()? "" : start); + insEntity.bindValue(":end", end.isEmpty()? "" : end); + insEntity.bindValue(":whitelist", whitelist?1:0); + insEntity.bindValue(":whitelist_start", whitelist_start.isEmpty()? "":whitelist_start); + insEntity.bindValue(":whitelist_end", whitelist_end.isEmpty()? "":whitelist_end); + + if (!insEntity.exec()) { qWarning() << insEntity.lastError(); rollback(); return; } + } + } + } + else if (top == "prefixes" || top == "exceptions") { + // these two share the same internal structure: ... + bool isPrefix = (top=="prefixes"); + QSqlQuery &ins = isPrefix ? insPrefix : insExc; + + while (!(xml.isEndElement() && xml.name() == top)) { + xml.readNext(); + if (xml.isStartElement() && (xml.name()==QStringLiteral("prefix") || xml.name()==QStringLiteral("exception"))) { + bool ok=false; + quint32 rec = xml.attributes().value("record").toUInt(&ok); + QString call, entity, cont, start, end; + int adif=0, cqz=0; double lon=0.0, lat=0.0; + + while (!(xml.isEndElement() && (xml.name()==QStringLiteral("prefix") || xml.name()==QStringLiteral("exception")))) { + xml.readNext(); + if (!xml.isStartElement()) continue; + const QString tag = xml.name().toString(); + if (tag=="call") call = readText(xml); + else if (tag=="entity") entity = readText(xml); + else if (tag=="adif") adif = readText(xml).toInt(); + else if (tag=="cqz") cqz = readText(xml).toInt(); + else if (tag=="cont") cont = readText(xml); + else if (tag=="long") lon = readText(xml).toDouble(); + else if (tag=="lat") lat = readText(xml).toDouble(); + else if (tag=="start") start = readDate(readText(xml)); + else if (tag=="end") end = readDate(readText(xml)); + else xml.skipCurrentElement(); + } + + ins.bindValue(":record", rec); + ins.bindValue(":call", call); + ins.bindValue(":entity", entity); + ins.bindValue(":adif", adif); + ins.bindValue(":cqz", cqz?cqz:0); + ins.bindValue(":cont", cont.isEmpty()? "" : cont); + ins.bindValue(":lon", lon); + ins.bindValue(":lat", lat); + ins.bindValue(":start", start.isEmpty()? "" : start); + ins.bindValue(":end", end.isEmpty()? "" : end); + + if (!ins.exec()) { qWarning() << ins.lastError(); rollback(); return; } + } + } + } + else if (top == "invalid_operations") { + while (!(xml.isEndElement() && xml.name()==QStringLiteral("invalid_operations"))) { + xml.readNext(); + if (xml.isStartElement() && xml.name()==QStringLiteral("invalid")) { + bool ok=false; quint32 rec = xml.attributes().value("record").toUInt(&ok); + QString call, start, end; + while (!(xml.isEndElement() && xml.name()==QStringLiteral("invalid"))) { + xml.readNext(); + if (!xml.isStartElement()) continue; + const QString tag = xml.name().toString(); + if (tag=="call") call = readText(xml); + else if (tag=="start") start = readDate(readText(xml)); + else if (tag=="end") end = readDate(readText(xml)); + else xml.skipCurrentElement(); + } + insInv.bindValue(":record", rec); + insInv.bindValue(":call", call); + insInv.bindValue(":start", start); + insInv.bindValue(":end", end); + if (!insInv.exec()) { qWarning() << insInv.lastError(); rollback(); return; } + } + } + } + else if (top == "zone_exceptions") { + while (!(xml.isEndElement() && xml.name()==QStringLiteral("zone_exceptions"))) { + xml.readNext(); + if (xml.isStartElement() && xml.name()==QStringLiteral("zone_exception")) { + bool ok=false; quint32 rec = xml.attributes().value("record").toUInt(&ok); + QString call, start, end; int zone=0; + while (!(xml.isEndElement() && xml.name()==QStringLiteral("zone_exception"))) { + xml.readNext(); + if (!xml.isStartElement()) continue; + const QString tag = xml.name().toString(); + if (tag=="call") call = readText(xml); + else if (tag=="zone") zone = readText(xml).toInt(); + else if (tag=="start") start = readDate(readText(xml)); + else if (tag=="end") end = readDate(readText(xml)); + else xml.skipCurrentElement(); + } + insZone.bindValue(":record", rec); + insZone.bindValue(":call", call); + insZone.bindValue(":zone", zone); + insZone.bindValue(":start", start); + insZone.bindValue(":end", end); + if (!insZone.exec()) { qWarning() << insZone.lastError(); rollback(); return; } + } + } + } + else { + xml.skipCurrentElement(); + } + } + + if (xml.hasError()) { + qWarning() << "ClubLog XML error:" << xml.errorString(); + rollback(); return; + } + + QSqlDatabase::database().commit(); + qCDebug(runtime) << "ClubLog CTY import finished."; +} diff --git a/core/LOVDownloader.h b/core/LOVDownloader.h index 15a632cf..dccf6539 100644 --- a/core/LOVDownloader.h +++ b/core/LOVDownloader.h @@ -19,7 +19,8 @@ class LOVDownloader : public QObject IOTALIST = 4, POTADIRECTORY = 5, MEMBERSHIPCONTENTLIST = 6, - UNDEF = 7 + CLUBLOGCTY = 7, + UNDEF = 8 }; public: @@ -103,6 +104,12 @@ public slots: "content.csv", "LOV/last_membershipcontent_update", "membership_directory", + 7)}, + {CLUBLOGCTY, SourceDefinition(CLUBLOGCTY, + "https://cdn.clublog.org/cty.php?api=a1a2215eea4990661a8ce55873a33c4ef290b49d", + "clublog_cty.xml", + "LOV/last_clublogcty_update", + "clublog_entities", 7)} }; @@ -126,6 +133,8 @@ public slots: void parseIOTA(const SourceDefinition &sourceDef, QTextStream& data); void parsePOTA(const SourceDefinition &sourceDef, QTextStream& data); void parseMembershipContent(const SourceDefinition &sourceDef, QTextStream& data); + static QByteArray gunzip(const QByteArray &in); + void parseClubLogCTY(const SourceDefinition &sourceDef, QTextStream &data); private slots: void processReply(QNetworkReply*); diff --git a/core/Migration.cpp b/core/Migration.cpp index 00efaf54..c57ea8a5 100644 --- a/core/Migration.cpp +++ b/core/Migration.cpp @@ -354,14 +354,14 @@ bool Migration::updateExternalResource() connect(&progress, &QProgressDialog::canceled, &downloader, &LOVDownloader::abortRequest); - updateExternalResourceProgress(progress, downloader, LOVDownloader::CTY, "(1/7)"); - updateExternalResourceProgress(progress, downloader, LOVDownloader::SATLIST, "(2/7)"); - updateExternalResourceProgress(progress, downloader, LOVDownloader::SOTASUMMITS, "(3/7)"); - updateExternalResourceProgress(progress, downloader, LOVDownloader::WWFFDIRECTORY, "(4/7)"); - updateExternalResourceProgress(progress, downloader, LOVDownloader::IOTALIST, "(5/7)"); - updateExternalResourceProgress(progress, downloader, LOVDownloader::POTADIRECTORY, "(6/7)"); - updateExternalResourceProgress(progress, downloader, LOVDownloader::MEMBERSHIPCONTENTLIST, "(7/7)"); - + updateExternalResourceProgress(progress, downloader, LOVDownloader::CTY, "(1/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::SATLIST, "(2/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::SOTASUMMITS, "(3/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::WWFFDIRECTORY, "(4/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::IOTALIST, "(5/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::POTADIRECTORY, "(6/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::MEMBERSHIPCONTENTLIST, "(7/8)"); + updateExternalResourceProgress(progress, downloader, LOVDownloader::CLUBLOGCTY, "8/8"); return true; } @@ -398,7 +398,9 @@ void Migration::updateExternalResourceProgress(QProgressDialog& progress, case LOVDownloader::SourceType::MEMBERSHIPCONTENTLIST: stringInfo = tr("Membership Directory Records"); break; - + case LOVDownloader::SourceType::CLUBLOGCTY: + stringInfo = tr("Clublog CTY.XML"); + break; default: stringInfo = tr("List of Values"); } diff --git a/core/Migration.h b/core/Migration.h index 2addb2eb..652dc3ea 100644 --- a/core/Migration.h +++ b/core/Migration.h @@ -42,7 +42,7 @@ class Migration : public QObject QString fixIntlField(QSqlQuery &query, const QString &columName, const QString &columnNameIntl); bool refreshUploadStatusTrigger(); - static const int latestVersion = 34; + static const int latestVersion = 35; }; #endif // QLOG_CORE_MIGRATION_H diff --git a/data/Data.cpp b/data/Data.cpp index b85e0c1d..1fca0b02 100644 --- a/data/Data.cpp +++ b/data/Data.cpp @@ -1092,6 +1092,176 @@ DxccEntity Data::lookupDxcc(const QString &callsign) return dxccRet; } +// Data.cpp +// Data.cpp +DxccEntity Data::lookupCallsign(const QString& callsign, const QDateTime& date) +{ + const QDateTime useDate = date.isValid() ? date : QDateTime::currentDateTimeUtc(); + const QString dateIso = useDate.toUTC().toString(Qt::ISODate); + + QString lookupPrefix = callsign; // use the callsign with optional prefix as default to find the dxcc + const Callsign parsedCallsign(callsign); // use Callsign to split the callsign into its parts + + if ( parsedCallsign.isValid() ) + { + QString suffix = parsedCallsign.getSuffix(); + if ( suffix.length() == 1 ) // some countries add single numbers as suffix to designate a call area, e.g. /4 + { + bool isNumber = false; + (void)suffix.toInt(&isNumber); + if ( isNumber ) + { + lookupPrefix = parsedCallsign.getBasePrefix() + suffix; // use the call prefix and the number from the suffix to find the dxcc + } + } + else if ( suffix.length() > 1 + && !parsedCallsign.secondarySpecialSuffixes.contains(suffix) ) // if there is more than one character and it is not one of the special suffixes, we definitely have a call prefix as suffix + { + lookupPrefix = suffix + "/" + parsedCallsign.getBase(); + } + } + + DxccEntity e; // empty = no match / invalid + QSqlQuery q; + e.dxcc = 0; + e.ituz = 0; + e.cqz = 0; + e.tz = 0; + + if(callsign.endsWith("/MM")) return e; + + // 1) Invalid ops ⇒ treat as no result + q.prepare( + "SELECT 1 FROM clublog_invalid_ops " + "WHERE UPPER(call)=UPPER(?) " + "AND (start IS '' OR start <= ?) " + "AND (end IS '' OR end >= ?) " + "LIMIT 1"); + q.addBindValue(lookupPrefix); + q.addBindValue(dateIso); + q.addBindValue(dateIso); + if (q.exec() && q.next()) + { + qDebug() << "Invalid Operation" << callsign; + return e; + } + + auto applyZoneOverride = [&](DxccEntity& out){ + QSqlQuery qz; + qz.prepare( + "SELECT zone FROM clublog_zone_exceptions " + "WHERE UPPER(call)=UPPER(?) " + "AND (start IS '' OR start <= ?) " + "AND (end IS '' OR end >= ?) " + "ORDER BY record DESC LIMIT 1"); + qz.addBindValue(lookupPrefix); + qz.addBindValue(dateIso); + qz.addBindValue(dateIso); + if (qz.exec() && qz.next()) + out.cqz = qz.value(0).toInt(); + }; + + // 2) Exact exceptions (join dxcc_entities to get ituz/tz) + { + QSqlQuery qe; + qe.prepare( + "SELECT " + " e.name, e.prefix, e.adif, " + " COALESCE(x.cont, e.cont), " + " COALESCE(x.cqz , e.cqz ), " + " COALESCE(x.lat , e.lat ), " + " COALESCE(x.lon, e.lon), " + " de.ituz, de.tz " + "FROM clublog_exceptions x " + "JOIN clublog_entities e ON e.adif = x.adif " + "LEFT JOIN dxcc_entities de ON de.id = e.adif " + "WHERE UPPER(x.call)=UPPER(?) " + "AND (x.start IS '' OR x.start <= ?) " + "AND (x.end IS '' OR x.end >= ?) " + "ORDER BY x.record DESC LIMIT 1"); + qe.addBindValue(lookupPrefix); + qe.addBindValue(dateIso); + qe.addBindValue(dateIso); + + if (qe.exec() && qe.next()) { + DxccEntity out; + out.country = qe.value(0).toString(); + out.prefix = qe.value(1).toString(); + out.dxcc = qe.value(2).toInt(); + out.cont = qe.value(3).toString(); + out.cqz = qe.value(4).toInt(); + out.latlon[0] = qe.value(5).toDouble(); + out.latlon[1] = qe.value(6).toDouble(); + out.ituz = qe.value(7).isNull() ? 0 : qe.value(7).toInt(); + out.tz = qe.value(8).isNull() ? 0.0f : static_cast(qe.value(8).toDouble()); + out.flag = flags.value(qe.value(2).toInt()); + applyZoneOverride(out); + return out; + } + } + + // 3) Longest prefix (join dxcc_entities to get ituz/tz) + DxccEntity best{}; + bool bestFound = false; + + { + QSqlQuery qp; + qp.prepare( + "SELECT " + " e.name, e.prefix, e.adif, e.cont, e.cqz, e.lat, e.lon, " + " de.ituz, de.tz " + "FROM clublog_prefixes p " + "JOIN clublog_entities e ON e.adif = p.adif " + "LEFT JOIN dxcc_entities de ON de.id = e.adif " + "WHERE p.call = ? " + "AND (p.start IS '' OR p.start <= ?) " + "AND (p.end IS '' OR p.end >= ?) " + "ORDER BY p.record DESC " + "LIMIT 1"); + + // Grow the probe 1 char at a time; keep the last hit as the "longest". + for (int i = 1; i <= lookupPrefix.size(); ++i) { + const QString probe = lookupPrefix.left(i); + + qp.bindValue(0, probe); + qp.bindValue(1, dateIso); + qp.bindValue(2, dateIso); + + if (qp.exec() && qp.next()) { + DxccEntity cand; + cand.country = qp.value(0).toString(); + cand.prefix = qp.value(1).toString(); // the matched prefix (== probe) + cand.dxcc = qp.value(2).toInt(); + cand.cont = qp.value(3).toString(); + cand.cqz = qp.value(4).toInt(); + cand.latlon[0] = qp.value(5).toDouble(); + cand.latlon[1] = qp.value(6).toDouble(); + cand.ituz = qp.value(7).isNull() ? 0 : qp.value(7).toInt(); + cand.tz = qp.value(8).isNull() ? 0.0f : static_cast(qp.value(8).toDouble()); + cand.flag = flags.value(cand.dxcc); + + best = cand; // overwrite with the longer match + bestFound = true; + } else { + // Clear results so we can reuse the prepared statement cleanly + qp.finish(); + } + + } + } + + if (bestFound) { + applyZoneOverride(best); // your existing override hook + return best; + } + + + + // 4) No match + return e; +} + + DxccEntity Data::lookupDxccID(const int dxccID) { FCT_IDENTIFICATION; diff --git a/data/Data.h b/data/Data.h index a50f14f8..0d0d2b5d 100644 --- a/data/Data.h +++ b/data/Data.h @@ -160,6 +160,7 @@ class Data : public QObject QStringList potaIDList() { return potaRefID.keys();} QString getIANATimeZone(double, double); QStringList sigIDList(); + DxccEntity lookupCallsign(const QString& callsign, const QDateTime& date); signals: diff --git a/data/recomputedxccdialog.cpp b/data/recomputedxccdialog.cpp new file mode 100644 index 00000000..1ed3c3d3 --- /dev/null +++ b/data/recomputedxccdialog.cpp @@ -0,0 +1,220 @@ +// RecomputeDxccDialog.cpp +#include "data/recomputedxccdialog.h" +#include "Data.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.data.antprofile"); + +static QDateTime parseIsoDateTimeUtc(const QString &s) +{ + FCT_IDENTIFICATION; + +#if QT_VERSION >= QT_VERSION_CHECK(5, 8, 0) + QDateTime dt = QDateTime::fromString(s, Qt::ISODateWithMs); + if (!dt.isValid()) + dt = QDateTime::fromString(s, Qt::ISODate); +#else + QDateTime dt = QDateTime::fromString(s, Qt::ISODate); +#endif + return dt.toUTC(); +} + +RecomputeDxccDialog::RecomputeDxccDialog(QWidget *parent) + : QDialog(parent) +{ + FCT_IDENTIFICATION; + + setWindowTitle("Recompute DXCC"); + resize(720, 420); + + auto *v = new QVBoxLayout(this); + + onlyMissingCheck_ = new QCheckBox(tr("Only records with empty DXCC"), this); + onlyMissingCheck_->setToolTip(tr("If checked, only contacts where DXCC is NULL or 0 will be processed.")); + onlyMissingCheck_->setChecked(false); // default off (process all) + v->addWidget(onlyMissingCheck_); + + m_bar = new QProgressBar(this); + m_bar->setRange(0, 0); + v->addWidget(m_bar); + + m_stats = new QLabel("Processed: 0 | Updated: 0", this); + v->addWidget(m_stats); + + m_log = new QTextEdit(this); + m_log->setReadOnly(true); + v->addWidget(m_log, 1); + + auto* btns = new QHBoxLayout(); + m_startBtn = new QPushButton(tr("Start"), this); + m_cancelBtn = new QPushButton(tr("Close"), this); + btns->addStretch(); + btns->addWidget(m_startBtn); + btns->addWidget(m_cancelBtn); + + connect(m_startBtn, &QPushButton::clicked, this, &RecomputeDxccDialog::onStart); + connect(m_cancelBtn, &QPushButton::clicked, this, &RecomputeDxccDialog::onCancel); + + v->addLayout(btns); + + // Fire the job in a thread so UI stays alive + // QTimer::singleShot(0, this, &RecomputeDxccDialog::runJob); +} + +RecomputeDxccDialog::~RecomputeDxccDialog() +{ + FCT_IDENTIFICATION; + + m_cancel.store(true); +} + +void RecomputeDxccDialog::onCancel() +{ + FCT_IDENTIFICATION; + + m_cancel.store(true); + m_cancelBtn->setEnabled(false); + accept(); +} + +void RecomputeDxccDialog::onStart() +{ + FCT_IDENTIFICATION; + + m_startBtn->setEnabled(false); + onlyMissingCheck_->setEnabled(false); + m_log->clear(); + + QTimer::singleShot(0, this, &RecomputeDxccDialog::runJob); +} + +void RecomputeDxccDialog::logLine(const QString &line) +{ + FCT_IDENTIFICATION; + + m_log->append(line); +} + +void RecomputeDxccDialog::runJob() +{ + FCT_IDENTIFICATION; + + stopRequested = false; + m_log->clear(); + + // nice header in the log + m_log->append(QStringLiteral("=== Recompute DXCC started @ %1 ===") + .arg(QDateTime::currentDateTime().toString(Qt::ISODate))); + + QSqlQuery countQuery; + QString countSql = "SELECT COUNT(*) FROM contacts"; + if (onlyMissingCheck_->isChecked()) { + countSql += " WHERE dxcc = 0"; // per your request + } + if (!countQuery.exec(countSql) || !countQuery.next()) { + m_log->append("Failed to count contacts: " + countQuery.lastError().text()); + m_stats->setText("Failed."); + return; + } + int total = countQuery.value(0).toInt(); + if (total == 0) { + m_stats->setText("No contacts found to process."); + m_log->append("No contacts match the current filter. Nothing to do."); + return; + } + + m_bar->setRange(0, total); + m_bar->setValue(0); + + QString sql = "SELECT id, callsign, start_time, dxcc, country FROM contacts"; + if (onlyMissingCheck_->isChecked()) { + sql += " WHERE dxcc = 0 or dxcc is null "; // per your request + } + sql += " ORDER BY id"; // keeps UI updates smoother/predictable + + QSqlQuery q; + if (!q.exec(sql)) { + m_log->append("Failed to query contacts: " + q.lastError().text()); + m_stats->setText("Failed."); + return; + } + + int processed = 0, updated = 0; + + while (q.next() && !stopRequested) { + const int id = q.value(0).toInt(); + const QString callsign = q.value(1).toString(); + const QString startIso = q.value(2).toString(); + const int oldDxcc = q.value(3).toInt(); + const QString oldCtry = q.value(4).toString(); + + // robust ISO parsing + normalize to UTC + QDateTime dt = QDateTime::fromString(startIso, Qt::ISODateWithMs); + if (!dt.isValid()) dt = QDateTime::fromString(startIso, Qt::ISODate); + if (!dt.isValid()) dt = QDateTime::fromString(startIso, "yyyy-MM-dd'T'HH:mm:sszzz"); + if (!dt.isValid()) dt = QDateTime::currentDateTimeUtc(); + dt = dt.toUTC(); + + const DxccEntity ent = Data::instance()->lookupCallsign(callsign, dt); + + // update only if something actually changed and we have a meaningful dxcc + const bool changed = (ent.dxcc != 0) && (ent.dxcc != oldDxcc); + if (changed) { + QSqlQuery u; + u.prepare("UPDATE contacts SET dxcc = ?, country = ?, cont = ?, cqz = ?, ituz = ?, pfx = ? WHERE id = ?"); + u.addBindValue(ent.dxcc); + u.addBindValue(ent.country); + u.addBindValue(ent.cont); + u.addBindValue(ent.cqz); + u.addBindValue(ent.ituz); + u.addBindValue(ent.prefix); + u.addBindValue(id); + + if (!u.exec()) { + m_log->append(QString("Failed to update %1 (id=%2): %3") + .arg(callsign).arg(id).arg(u.lastError().text())); + } else { + ++updated; + m_log->append(QString("Updated %1 (id=%2) @ %3 %4 -> %5 (DXCC %6)") + .arg(callsign) + .arg(id) + .arg(dt.toString(Qt::ISODate)) + .arg(oldCtry.isEmpty() ? QStringLiteral("") : oldCtry) + .arg(ent.country) + .arg(ent.dxcc)); + } + } + + ++processed; + m_bar->setValue(processed); + m_stats->setText(QString("Processed %1/%2, Updated %3") + .arg(processed).arg(total).arg(updated)); + QCoreApplication::processEvents(); + } + + // final status + m_stats->setText(QString("Done. Processed %1, Updated %2").arg(processed).arg(updated)); + + // append a summary block to the log (no popup) + m_log->append(QString()); + m_log->append(QStringLiteral("=== Summary ===")); + m_log->append(QString("Processed: %1").arg(processed)); + m_log->append(QString("Updated: %1").arg(updated)); + m_log->append(QStringLiteral("Filter: %1") + .arg(onlyMissingCheck_->isChecked() + ? QStringLiteral("Only records with DXCC = 0") + : QStringLiteral("All records"))); + m_log->append(QStringLiteral("Finished @ %1") + .arg(QDateTime::currentDateTime().toString(Qt::ISODate))); +} diff --git a/data/recomputedxccdialog.h b/data/recomputedxccdialog.h new file mode 100644 index 00000000..8a802c86 --- /dev/null +++ b/data/recomputedxccdialog.h @@ -0,0 +1,37 @@ +// RecomputeDxccDialog.h +#pragma once +#include +#include +#include +#include + +class QProgressBar; +class QLabel; +class QTextEdit; +class QPushButton; +class QThread; + +class RecomputeDxccDialog : public QDialog +{ + Q_OBJECT +public: + explicit RecomputeDxccDialog(QWidget *parent = nullptr); + ~RecomputeDxccDialog(); + +private slots: + void onCancel(); + void onStart(); + void runJob(); + +private: + void logLine(const QString &line); + + QProgressBar *m_bar; + QLabel *m_stats; + QTextEdit *m_log; + QPushButton *m_cancelBtn; + QPushButton *m_startBtn; + QCheckBox* onlyMissingCheck_ = nullptr; + bool stopRequested; + std::atomic m_cancel{false}; +}; diff --git a/logformat/LogFormat.cpp b/logformat/LogFormat.cpp index 9222f0cb..4a871a7c 100644 --- a/logformat/LogFormat.cpp +++ b/logformat/LogFormat.cpp @@ -499,7 +499,7 @@ unsigned long LogFormat::runImport(QTextStream& importLogStream, if ( recordDXCCId != 0 || updateDxcc ) { - const DxccEntity &entity = ( updateDxcc ) ? Data::instance()->lookupDxcc(call.toString()) + const DxccEntity &entity = ( updateDxcc ) ? Data::instance()->lookupCallsign(call.toString(),start_time) : Data::instance()->lookupDxccID(recordDXCCId); if ( entity.dxcc == 0 ) // DXCC not found diff --git a/res/res.qrc b/res/res.qrc index f961a7da..2bd2a3c5 100644 --- a/res/res.qrc +++ b/res/res.qrc @@ -47,5 +47,6 @@ sql/migration_032.sql sql/migration_033.sql sql/migration_034.sql + sql/migration_035.sql diff --git a/res/sql/migration_035.sql b/res/sql/migration_035.sql new file mode 100644 index 00000000..143b4bab --- /dev/null +++ b/res/sql/migration_035.sql @@ -0,0 +1,61 @@ +CREATE TABLE IF NOT EXISTS clublog_entities ( + adif INTEGER PRIMARY KEY, + name TEXT NOT NULL, + prefix TEXT NOT NULL, + deleted INTEGER NOT NULL, + cqz INTEGER, + cont TEXT, + lon REAL, + lat REAL, + start TEXT, + "end" TEXT, + whitelist INTEGER, + whitelist_start TEXT, + whitelist_end TEXT +); + +CREATE TABLE IF NOT EXISTS clublog_exceptions ( + record INTEGER PRIMARY KEY, + call TEXT NOT NULL, + entity TEXT NOT NULL, + adif INTEGER NOT NULL, + cqz INTEGER, + cont TEXT, + lon REAL, + lat REAL, + start TEXT, + "end" TEXT +); +CREATE INDEX IF NOT EXISTS idx_exceptions_call ON clublog_exceptions(call); + +CREATE TABLE IF NOT EXISTS clublog_prefixes ( + record INTEGER PRIMARY KEY, + call TEXT NOT NULL, + entity TEXT NOT NULL, + adif INTEGER NOT NULL, + cqz INTEGER, + cont TEXT, + lon REAL, + lat REAL, + start TEXT, + end TEXT +); +CREATE INDEX IF NOT EXISTS idx_prefixes_call ON clublog_prefixes(call); + +CREATE TABLE IF NOT EXISTS clublog_invalid_ops ( + record INTEGER PRIMARY KEY, + call TEXT NOT NULL, + start TEXT, + end TEXT +); +CREATE INDEX IF NOT EXISTS idx_invalid_call ON clublog_invalid_ops(call); + +CREATE TABLE IF NOT EXISTS clublog_zone_exceptions ( + record INTEGER PRIMARY KEY, + call TEXT NOT NULL, + zone INTEGER NOT NULL, + start TEXT, + end TEXT +); +CREATE INDEX IF NOT EXISTS idx_zone_call ON clublog_zone_exceptions(call); + diff --git a/ui/AwardsDialog.cpp b/ui/AwardsDialog.cpp index 2ca00bb4..b565638c 100644 --- a/ui/AwardsDialog.cpp +++ b/ui/AwardsDialog.cpp @@ -150,8 +150,8 @@ void AwardsDialog::refreshTable(int) setNotWorkedEnabled(true); const QString &entitySelected = getSelectedEntity(); headersColumns = "translate_to_locale(d.name) col1, d.prefix col2 "; - sqlPartDetailTable = " FROM (SELECT id, name, prefix FROM dxcc_entities " - " UNION SELECT DISTINCT dxcc, dxcc, '" + tr("Unknown") + "' as prefix FROM source_contacts a LEFT JOIN dxcc_entities b ON a.dxcc = b.id WHERE b.id IS NULL) d " + sqlPartDetailTable = " FROM (SELECT adif as id, name, prefix FROM clublog_entities " + " UNION SELECT DISTINCT dxcc, dxcc, '" + tr("Unknown") + "' as prefix FROM source_contacts a LEFT JOIN clublog_entities b ON a.dxcc = b.adif WHERE b.adif IS NULL) d " " LEFT OUTER JOIN source_contacts c ON d.id = c.dxcc" " LEFT OUTER JOIN modes m on c.mode = m.name" " WHERE (c.id is NULL or c.my_dxcc = '" + entitySelected + "') "; diff --git a/ui/MainWindow.cpp b/ui/MainWindow.cpp index 57ba9c4b..43a62c25 100644 --- a/ui/MainWindow.cpp +++ b/ui/MainWindow.cpp @@ -30,6 +30,7 @@ #include "ui/DownloadQSLDialog.h" #include "ui/UploadQSODialog.h" #include "core/LogParam.h" +#include "data/recomputedxccdialog.h" MODULE_IDENTIFICATION("qlog.ui.mainwindow"); @@ -1549,6 +1550,14 @@ void MainWindow::showAwards() dialog.exec(); } +void MainWindow::recomputeDxcc() +{ + FCT_IDENTIFICATION; + + RecomputeDxccDialog dlg(this); + dlg.exec(); +} + void MainWindow::showAbout() { FCT_IDENTIFICATION; diff --git a/ui/MainWindow.h b/ui/MainWindow.h index 63c5819e..0ce31781 100644 --- a/ui/MainWindow.h +++ b/ui/MainWindow.h @@ -55,6 +55,7 @@ private slots: void importLog(); void exportLog(); void showAwards(); + void recomputeDxcc(); void showAbout(); void showWikiHelp(); void showMailingList(); diff --git a/ui/MainWindow.ui b/ui/MainWindow.ui index 4d0f129e..4aede16f 100644 --- a/ui/MainWindow.ui +++ b/ui/MainWindow.ui @@ -7,14 +7,14 @@ 0 0 913 - 558 + 580 - QMainWindow::AllowNestedDocks|QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks|QMainWindow::GroupedDragging + QMainWindow::DockOption::AllowNestedDocks|QMainWindow::DockOption::AllowTabbedDocks|QMainWindow::DockOption::AnimatedDocks|QMainWindow::DockOption::GroupedDragging @@ -40,7 +40,7 @@ - Qt::ClickFocus + Qt::FocusPolicy::ClickFocus @@ -52,7 +52,7 @@ 0 0 913 - 23 + 42 @@ -75,6 +75,7 @@ + @@ -173,7 +174,7 @@ false - Qt::ToolButtonIconOnly + Qt::ToolButtonStyle::ToolButtonIconOnly false @@ -211,7 +212,7 @@ - Qt::ClickFocus + Qt::FocusPolicy::ClickFocus @@ -307,8 +308,7 @@ - - .. + Quit @@ -320,7 +320,7 @@ Ctrl+Q - QAction::QuitRole + QAction::MenuRole::QuitRole true @@ -328,20 +328,18 @@ - - .. + &Settings - QAction::PreferencesRole + QAction::MenuRole::PreferencesRole - - .. + New QSO - Clear @@ -358,8 +356,7 @@ - - .. + &Import @@ -367,8 +364,7 @@ - - .. + &Export @@ -387,20 +383,18 @@ - - .. + &About - QAction::AboutRole + QAction::MenuRole::AboutRole - - .. + New QSO - Save @@ -435,8 +429,7 @@ - - .. + S&tatistics @@ -491,8 +484,7 @@ - - .. + &Awards @@ -526,8 +518,7 @@ - - .. + &Wiki @@ -599,10 +590,10 @@ Ctrl+F - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -619,10 +610,10 @@ Ctrl+M - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -636,10 +627,10 @@ Ctrl+PgDown - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -653,10 +644,10 @@ Ctrl+PgUp - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -670,10 +661,10 @@ Alt+Return - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -687,10 +678,10 @@ Alt+Up - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -704,10 +695,10 @@ Alt+Down - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -721,10 +712,10 @@ Alt+Right - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -738,10 +729,10 @@ Alt+Left - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut - QAction::NoRole + QAction::MenuRole::NoRole true @@ -755,13 +746,13 @@ Alt+\ - Qt::ApplicationShortcut + Qt::ShortcutContext::ApplicationShortcut false - QAction::NoRole + QAction::MenuRole::NoRole true @@ -853,8 +844,7 @@ - - .. + Upload @@ -883,6 +873,11 @@ true + + + Recompute DXCC + + @@ -1614,6 +1609,22 @@ + + actionRecompute_DXCC + triggered() + MainWindow + recomputeDxcc() + + + -1 + -1 + + + 456 + 289 + + + settingsChanged() @@ -1628,6 +1639,7 @@ rotConnect() QSOFilterSetting() showAwards() + recomputeDxcc() alertRuleSetting() showAlerts() clearAlerts() diff --git a/ui/NewContactWidget.cpp b/ui/NewContactWidget.cpp index 866c0605..df0fe04a 100644 --- a/ui/NewContactWidget.cpp +++ b/ui/NewContactWidget.cpp @@ -524,7 +524,13 @@ void NewContactWidget::setDxccInfo(const QString &callsign) qCDebug(function_parameters) << callsign; - setDxccInfo(Data::instance()->lookupDxcc(callsign.toUpper())); + if(isManualEnterMode) + { + QDateTime qsoDt = QDateTime(ui->dateEdit->date(),ui->timeOnEdit->time()); + setDxccInfo(Data::instance()->lookupCallsign(callsign.toUpper(), qsoDt.toUTC())); + } + else + setDxccInfo(Data::instance()->lookupDxcc(callsign.toUpper())); } void NewContactWidget::useFieldsFromPrevQSO(const QString &callsign, const QString &grid)