File indexing completed on 2025-01-12 05:01:50

0001 /*
0002     SPDX-FileCopyrightText: 2007-2009, 2019 Shawn Starr <shawn.starr@rogers.com>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 /* Ion for NOAA's National Weather Service XML data */
0008 
0009 #include "ion_noaa.h"
0010 
0011 #include "ion_noaadebug.h"
0012 
0013 #include <KIO/TransferJob>
0014 #include <KLocalizedString>
0015 #include <KUnitConversion/Converter>
0016 
0017 #include <QJsonArray>
0018 #include <QJsonDocument>
0019 #include <QJsonObject>
0020 #include <QLocale>
0021 #include <QTimeZone>
0022 
0023 using namespace Qt::StringLiterals;
0024 
0025 WeatherData::WeatherData()
0026     : stationLatitude(qQNaN())
0027     , stationLongitude(qQNaN())
0028     , temperature_F(qQNaN())
0029     , temperature_C(qQNaN())
0030     , humidity(qQNaN())
0031     , windSpeed(qQNaN())
0032     , windGust(qQNaN())
0033     , pressure(qQNaN())
0034     , dewpoint_F(qQNaN())
0035     , dewpoint_C(qQNaN())
0036     , heatindex_F(qQNaN())
0037     , heatindex_C(qQNaN())
0038     , windchill_F(qQNaN())
0039     , windchill_C(qQNaN())
0040     , visibility(qQNaN())
0041 {
0042 }
0043 
0044 QMap<QString, IonInterface::WindDirections> NOAAIon::setupWindIconMappings() const
0045 {
0046     return QMap<QString, WindDirections>{
0047         {QStringLiteral("north"), N},
0048         {QStringLiteral("northeast"), NE},
0049         {QStringLiteral("south"), S},
0050         {QStringLiteral("southwest"), SW},
0051         {QStringLiteral("east"), E},
0052         {QStringLiteral("southeast"), SE},
0053         {QStringLiteral("west"), W},
0054         {QStringLiteral("northwest"), NW},
0055         {QStringLiteral("calm"), VR},
0056     };
0057 }
0058 
0059 QMap<QString, IonInterface::ConditionIcons> NOAAIon::setupConditionIconMappings() const
0060 {
0061     QMap<QString, ConditionIcons> conditionList;
0062     return conditionList;
0063 }
0064 
0065 QMap<QString, IonInterface::ConditionIcons> const &NOAAIon::conditionIcons() const
0066 {
0067     static QMap<QString, ConditionIcons> const condval = setupConditionIconMappings();
0068     return condval;
0069 }
0070 
0071 QMap<QString, IonInterface::WindDirections> const &NOAAIon::windIcons() const
0072 {
0073     static QMap<QString, WindDirections> const wval = setupWindIconMappings();
0074     return wval;
0075 }
0076 
0077 // ctor, dtor
0078 NOAAIon::NOAAIon(QObject *parent)
0079     : IonInterface(parent)
0080 {
0081     // Get the real city XML URL so we can parse this
0082     getXMLSetup();
0083 }
0084 
0085 void NOAAIon::reset()
0086 {
0087     m_sourcesToReset = sources();
0088     getXMLSetup();
0089 }
0090 
0091 NOAAIon::~NOAAIon()
0092 {
0093     // seems necessary to avoid crash
0094     removeAllSources();
0095 }
0096 
0097 QStringList NOAAIon::validate(const QString &source) const
0098 {
0099     QStringList placeList;
0100     QString station;
0101     QString sourceNormalized = source.toUpper();
0102 
0103     QHash<QString, NOAAIon::XMLMapInfo>::const_iterator it = m_places.constBegin();
0104     // If the source name might look like a station ID, check these too and return the name
0105     bool checkState = source.count() == 2;
0106 
0107     while (it != m_places.constEnd()) {
0108         if (checkState) {
0109             if (it.value().stateName == source) {
0110                 placeList.append(QStringLiteral("place|").append(it.key()));
0111             }
0112         } else if (it.key().toUpper().contains(sourceNormalized)) {
0113             placeList.append(QStringLiteral("place|").append(it.key()));
0114         } else if (it.value().stationID == sourceNormalized) {
0115             station = QStringLiteral("place|").append(it.key());
0116         }
0117 
0118         ++it;
0119     }
0120 
0121     placeList.sort();
0122     if (!station.isEmpty()) {
0123         placeList.prepend(station);
0124     }
0125 
0126     return placeList;
0127 }
0128 
0129 bool NOAAIon::updateIonSource(const QString &source)
0130 {
0131     // We expect the applet to send the source in the following tokenization:
0132     // ionname:validate:place_name - Triggers validation of place
0133     // ionname:weather:place_name - Triggers receiving weather of place
0134 
0135     QStringList sourceAction = source.split(QLatin1Char('|'));
0136 
0137     // Guard: if the size of array is not 2 then we have bad data, return an error
0138     if (sourceAction.size() < 2) {
0139         setData(source, QStringLiteral("validate"), QStringLiteral("noaa|malformed"));
0140         return true;
0141     }
0142 
0143     if (sourceAction[1] == QLatin1String("validate") && sourceAction.size() > 2) {
0144         QStringList result = validate(sourceAction[2]);
0145 
0146         if (result.size() == 1) {
0147             setData(source, QStringLiteral("validate"), QStringLiteral("noaa|valid|single|").append(result.join(QLatin1Char('|'))));
0148             return true;
0149         }
0150         if (result.size() > 1) {
0151             setData(source, QStringLiteral("validate"), QStringLiteral("noaa|valid|multiple|").append(result.join(QLatin1Char('|'))));
0152             return true;
0153         }
0154         // result.size() == 0
0155         setData(source, QStringLiteral("validate"), QStringLiteral("noaa|invalid|single|").append(sourceAction[2]));
0156         return true;
0157     }
0158 
0159     if (sourceAction[1] == QLatin1String("weather") && sourceAction.size() > 2) {
0160         getXMLData(source);
0161         return true;
0162     }
0163 
0164     setData(source, QStringLiteral("validate"), QStringLiteral("noaa|malformed"));
0165     return true;
0166 }
0167 
0168 KJob *NOAAIon::apiRequestJob(const QUrl &url, const QString &source)
0169 {
0170     KIO::TransferJob *getJob = KIO::get(url, KIO::Reload, KIO::HideProgressInfo);
0171 
0172     m_jobData.insert(getJob, QByteArray());
0173     if (!source.isEmpty()) {
0174         m_jobList.insert(getJob, source);
0175     }
0176 
0177     qCDebug(IONENGINE_NOAA) << "Requesting URL:" << url;
0178 
0179     connect(getJob, &KIO::TransferJob::data, this, [this](KIO::Job *job, const QByteArray &data) {
0180         if (data.isEmpty() || !m_jobData.contains(job)) {
0181             return;
0182         }
0183         m_jobData[job].append(data);
0184     });
0185 
0186     return getJob;
0187 }
0188 
0189 // Parses city list and gets the correct city based on ID number
0190 void NOAAIon::getXMLSetup()
0191 {
0192     auto getJob = apiRequestJob(QUrl(QStringLiteral("https://www.weather.gov/data/current_obs/index.xml")), {});
0193     connect(getJob, &KJob::result, this, &NOAAIon::setup_slotJobFinished);
0194 }
0195 
0196 void NOAAIon::setup_slotJobFinished(KJob *job)
0197 {
0198     QXmlStreamReader reader = QXmlStreamReader(m_jobData.value(job));
0199     if (!reader.atEnd()) {
0200         const bool success = readXMLSetup(reader);
0201         setInitialized(success);
0202     }
0203 
0204     m_jobData.remove(job);
0205 
0206     for (const QString &source : std::as_const(m_sourcesToReset)) {
0207         updateSourceEvent(source);
0208     }
0209 }
0210 
0211 // Gets specific city XML data
0212 void NOAAIon::getXMLData(const QString &source)
0213 {
0214     for (const QString &fetching : std::as_const(m_jobList)) {
0215         if (fetching == source) {
0216             // already getting this source and awaiting the data
0217             return;
0218         }
0219     }
0220 
0221     QString dataKey = source;
0222     dataKey.remove(QStringLiteral("noaa|weather|"));
0223     const QUrl url(m_places[dataKey].XMLurl);
0224 
0225     // If this is empty we have no valid data, send out an error and abort.
0226     if (url.url().isEmpty()) {
0227         setData(source, QStringLiteral("validate"), QStringLiteral("noaa|malformed"));
0228         return;
0229     }
0230 
0231     auto getJob = apiRequestJob(url, source);
0232     connect(getJob, &KJob::result, this, &NOAAIon::slotJobFinished);
0233 }
0234 
0235 void NOAAIon::slotJobFinished(KJob *job)
0236 {
0237     // Dual use method, if we're fetching location data to parse we need to do this first
0238     const QString source(m_jobList.value(job));
0239     removeAllData(source);
0240 
0241     QXmlStreamReader reader = QXmlStreamReader(m_jobData.value(job));
0242     readXMLData(source, reader);
0243 
0244     // Now that we have the longitude and latitude, fetch the seven day forecast
0245     // and the alerts
0246     getForecast(source);
0247     getAlerts(source);
0248 
0249     m_jobList.remove(job);
0250     m_jobData.remove(job);
0251 }
0252 
0253 void NOAAIon::parseFloat(float &value, const QString &string)
0254 {
0255     bool ok = false;
0256     const float result = string.toFloat(&ok);
0257     if (ok) {
0258         value = result;
0259     }
0260 }
0261 
0262 void NOAAIon::parseFloat(float &value, QXmlStreamReader &xml)
0263 {
0264     bool ok = false;
0265     const float result = xml.readElementText().toFloat(&ok);
0266     if (ok) {
0267         value = result;
0268     }
0269 }
0270 
0271 void NOAAIon::parseDouble(double &value, QXmlStreamReader &xml)
0272 {
0273     bool ok = false;
0274     const double result = xml.readElementText().toDouble(&ok);
0275     if (ok) {
0276         value = result;
0277     }
0278 }
0279 
0280 void NOAAIon::parseStationID(QXmlStreamReader &xml)
0281 {
0282     QString state;
0283     QString stationName;
0284     QString stationID;
0285     QString xmlurl;
0286 
0287     while (!xml.atEnd()) {
0288         xml.readNext();
0289 
0290         const auto elementName = xml.name();
0291 
0292         if (xml.isEndElement() && elementName == QLatin1String("station")) {
0293             if (!xmlurl.isEmpty()) {
0294                 NOAAIon::XMLMapInfo info;
0295                 info.stateName = state;
0296                 info.stationName = stationName;
0297                 info.stationID = stationID;
0298                 info.XMLurl = xmlurl;
0299 
0300                 QString tmp = stationName + QLatin1String(", ") + state; // Build the key name.
0301                 m_places[tmp] = info;
0302             }
0303             break;
0304         }
0305 
0306         if (xml.isStartElement()) {
0307             if (elementName == QLatin1String("station_id")) {
0308                 stationID = xml.readElementText();
0309             } else if (elementName == QLatin1String("state")) {
0310                 state = xml.readElementText();
0311             } else if (elementName == QLatin1String("station_name")) {
0312                 stationName = xml.readElementText();
0313             } else if (elementName == QLatin1String("xml_url")) {
0314                 xmlurl = xml.readElementText().replace(QStringLiteral("http://"), QStringLiteral("http://www."));
0315             } else {
0316                 parseUnknownElement(xml);
0317             }
0318         }
0319     }
0320 }
0321 
0322 void NOAAIon::parseStationList(QXmlStreamReader &xml)
0323 {
0324     while (!xml.atEnd()) {
0325         xml.readNext();
0326 
0327         if (xml.isEndElement()) {
0328             break;
0329         }
0330 
0331         if (xml.isStartElement()) {
0332             if (xml.name() == QLatin1String("station")) {
0333                 parseStationID(xml);
0334             } else {
0335                 parseUnknownElement(xml);
0336             }
0337         }
0338     }
0339 }
0340 
0341 // Parse the city list and store into a QMap
0342 bool NOAAIon::readXMLSetup(QXmlStreamReader &xml)
0343 {
0344     bool success = false;
0345     while (!xml.atEnd()) {
0346         xml.readNext();
0347 
0348         if (xml.isStartElement()) {
0349             if (xml.name() == QLatin1String("wx_station_index")) {
0350                 parseStationList(xml);
0351                 success = true;
0352             }
0353         }
0354     }
0355     return (!xml.error() && success);
0356 }
0357 
0358 void NOAAIon::parseWeatherSite(WeatherData &data, QXmlStreamReader &xml)
0359 {
0360     data.temperature_C = qQNaN();
0361     data.temperature_F = qQNaN();
0362     data.dewpoint_C = qQNaN();
0363     data.dewpoint_F = qQNaN();
0364     data.weather = QStringLiteral("N/A");
0365     data.stationID = i18n("N/A");
0366     data.pressure = qQNaN();
0367     data.visibility = qQNaN();
0368     data.humidity = qQNaN();
0369     data.windSpeed = qQNaN();
0370     data.windGust = qQNaN();
0371     data.windchill_F = qQNaN();
0372     data.windchill_C = qQNaN();
0373     data.heatindex_F = qQNaN();
0374     data.heatindex_C = qQNaN();
0375 
0376     while (!xml.atEnd()) {
0377         xml.readNext();
0378 
0379         const auto elementName = xml.name();
0380 
0381         if (xml.isStartElement()) {
0382             if (elementName == QLatin1String("location")) {
0383                 data.locationName = xml.readElementText();
0384             } else if (elementName == QLatin1String("station_id")) {
0385                 data.stationID = xml.readElementText();
0386             } else if (elementName == QLatin1String("latitude")) {
0387                 parseDouble(data.stationLatitude, xml);
0388             } else if (elementName == QLatin1String("longitude")) {
0389                 parseDouble(data.stationLongitude, xml);
0390             } else if (elementName == QLatin1String("observation_time_rfc822")) {
0391                 data.observationDateTime = QDateTime::fromString(xml.readElementText(), Qt::RFC2822Date);
0392             } else if (elementName == QLatin1String("observation_time")) {
0393                 data.observationTime = xml.readElementText();
0394                 QStringList tmpDateStr = data.observationTime.split(QLatin1Char(' '));
0395                 data.observationTime = QStringLiteral("%1 %2").arg(tmpDateStr[6], tmpDateStr[7]);
0396             } else if (elementName == QLatin1String("weather")) {
0397                 const QString weather = xml.readElementText();
0398                 data.weather = (weather.isEmpty() || weather == QLatin1String("NA")) ? QStringLiteral("N/A") : weather;
0399                 // Pick which icon set depending on period of day
0400             } else if (elementName == QLatin1String("temp_f")) {
0401                 parseFloat(data.temperature_F, xml);
0402             } else if (elementName == QLatin1String("temp_c")) {
0403                 parseFloat(data.temperature_C, xml);
0404             } else if (elementName == QLatin1String("relative_humidity")) {
0405                 parseFloat(data.humidity, xml);
0406             } else if (elementName == QLatin1String("wind_dir")) {
0407                 data.windDirection = xml.readElementText();
0408             } else if (elementName == QLatin1String("wind_mph")) {
0409                 const QString windSpeed = xml.readElementText();
0410                 if (windSpeed == QLatin1String("NA")) {
0411                     data.windSpeed = 0.0;
0412                 } else {
0413                     parseFloat(data.windSpeed, windSpeed);
0414                 }
0415             } else if (elementName == QLatin1String("wind_gust_mph")) {
0416                 const QString windGust = xml.readElementText();
0417                 if (windGust == QLatin1String("NA") || windGust == QLatin1String("N/A")) {
0418                     data.windGust = 0.0;
0419                 } else {
0420                     parseFloat(data.windGust, windGust);
0421                 }
0422             } else if (elementName == QLatin1String("pressure_in")) {
0423                 parseFloat(data.pressure, xml);
0424             } else if (elementName == QLatin1String("dewpoint_f")) {
0425                 parseFloat(data.dewpoint_F, xml);
0426             } else if (elementName == QLatin1String("dewpoint_c")) {
0427                 parseFloat(data.dewpoint_C, xml);
0428             } else if (elementName == QLatin1String("heat_index_f")) {
0429                 parseFloat(data.heatindex_F, xml);
0430             } else if (elementName == QLatin1String("heat_index_c")) {
0431                 parseFloat(data.heatindex_C, xml);
0432             } else if (elementName == QLatin1String("windchill_f")) {
0433                 parseFloat(data.windchill_F, xml);
0434             } else if (elementName == QLatin1String("windchill_c")) {
0435                 parseFloat(data.windchill_C, xml);
0436             } else if (elementName == QLatin1String("visibility_mi")) {
0437                 parseFloat(data.visibility, xml);
0438             } else {
0439                 parseUnknownElement(xml);
0440             }
0441         }
0442     }
0443 }
0444 
0445 // Parse Weather data main loop, from here we have to descend into each tag pair
0446 bool NOAAIon::readXMLData(const QString &source, QXmlStreamReader &xml)
0447 {
0448     WeatherData data;
0449     data.isForecastsDataPending = true;
0450 
0451     while (!xml.atEnd()) {
0452         xml.readNext();
0453 
0454         if (xml.isEndElement()) {
0455             break;
0456         }
0457 
0458         if (xml.isStartElement()) {
0459             if (xml.name() == QLatin1String("current_observation")) {
0460                 parseWeatherSite(data, xml);
0461             } else {
0462                 parseUnknownElement(xml);
0463             }
0464         }
0465     }
0466 
0467     bool solarDataSourceNeedsConnect = false;
0468     Plasma5Support::DataEngine *timeEngine = dataEngine(QStringLiteral("time"));
0469     if (timeEngine) {
0470         const bool canCalculateElevation = (data.observationDateTime.isValid() && (!qIsNaN(data.stationLatitude) && !qIsNaN(data.stationLongitude)));
0471         if (canCalculateElevation) {
0472             data.solarDataTimeEngineSourceName = QStringLiteral("%1|Solar|Latitude=%2|Longitude=%3|DateTime=%4")
0473                                                      .arg(QString::fromUtf8(data.observationDateTime.timeZone().id()))
0474                                                      .arg(data.stationLatitude)
0475                                                      .arg(data.stationLongitude)
0476                                                      .arg(data.observationDateTime.toString(Qt::ISODate));
0477             solarDataSourceNeedsConnect = true;
0478         }
0479 
0480         // check any previous data
0481         const auto it = m_weatherData.constFind(source);
0482         if (it != m_weatherData.constEnd()) {
0483             const QString &oldSolarDataTimeEngineSource = it.value().solarDataTimeEngineSourceName;
0484 
0485             if (oldSolarDataTimeEngineSource == data.solarDataTimeEngineSourceName) {
0486                 // can reuse elevation source (if any), copy over data
0487                 data.isNight = it.value().isNight;
0488                 solarDataSourceNeedsConnect = false;
0489             } else if (!oldSolarDataTimeEngineSource.isEmpty()) {
0490                 // drop old elevation source
0491                 timeEngine->disconnectSource(oldSolarDataTimeEngineSource, this);
0492             }
0493         }
0494     }
0495 
0496     m_weatherData[source] = data;
0497 
0498     // connect only after m_weatherData has the data, so the instant data push handling can see it
0499     if (solarDataSourceNeedsConnect) {
0500         data.isSolarDataPending = true;
0501         timeEngine->connectSource(data.solarDataTimeEngineSourceName, this);
0502     }
0503 
0504     return !xml.error();
0505 }
0506 
0507 // handle when no XML tag is found
0508 void NOAAIon::parseUnknownElement(QXmlStreamReader &xml) const
0509 {
0510     while (!xml.atEnd()) {
0511         xml.readNext();
0512 
0513         if (xml.isEndElement()) {
0514             break;
0515         }
0516 
0517         if (xml.isStartElement()) {
0518             parseUnknownElement(xml);
0519         }
0520     }
0521 }
0522 
0523 void NOAAIon::updateWeather(const QString &source)
0524 {
0525     const WeatherData &weatherData = m_weatherData[source];
0526 
0527     if (weatherData.isForecastsDataPending || weatherData.isSolarDataPending) {
0528         return;
0529     }
0530 
0531     Plasma5Support::DataEngine::Data data;
0532 
0533     data.insert(QStringLiteral("Place"), weatherData.locationName);
0534     data.insert(QStringLiteral("Station"), weatherData.stationID);
0535 
0536     const bool stationCoordValid = (!qIsNaN(weatherData.stationLatitude) && !qIsNaN(weatherData.stationLongitude));
0537 
0538     if (stationCoordValid) {
0539         data.insert(QStringLiteral("Latitude"), weatherData.stationLatitude);
0540         data.insert(QStringLiteral("Longitude"), weatherData.stationLongitude);
0541     }
0542 
0543     // Real weather - Current conditions
0544     if (weatherData.observationDateTime.isValid()) {
0545         data.insert(QStringLiteral("Observation Timestamp"), weatherData.observationDateTime);
0546     }
0547 
0548     data.insert(QStringLiteral("Observation Period"), weatherData.observationTime);
0549 
0550     const QString conditionI18n = weatherData.weather == QLatin1String("N/A") ? i18n("N/A") : i18nc("weather condition", weatherData.weather.toUtf8().data());
0551 
0552     data.insert(QStringLiteral("Current Conditions"), conditionI18n);
0553     qCDebug(IONENGINE_NOAA) << "i18n condition string: " << qPrintable(conditionI18n);
0554 
0555     const QString weather = weatherData.weather.toLower();
0556     ConditionIcons condition = getConditionIcon(weather, !weatherData.isNight);
0557     data.insert(QStringLiteral("Condition Icon"), getWeatherIcon(condition));
0558 
0559     if (!qIsNaN(weatherData.temperature_F)) {
0560         data.insert(QStringLiteral("Temperature"), weatherData.temperature_F);
0561     }
0562 
0563     // Used for all temperatures
0564     data.insert(QStringLiteral("Temperature Unit"), KUnitConversion::Fahrenheit);
0565 
0566     if (!qIsNaN(weatherData.windchill_F)) {
0567         data.insert(QStringLiteral("Windchill"), weatherData.windchill_F);
0568     }
0569 
0570     if (!qIsNaN(weatherData.heatindex_F)) {
0571         data.insert(QStringLiteral("Heat Index"), weatherData.heatindex_F);
0572     }
0573 
0574     if (!qIsNaN(weatherData.dewpoint_F)) {
0575         data.insert(QStringLiteral("Dewpoint"), weatherData.dewpoint_F);
0576     }
0577 
0578     if (!qIsNaN(weatherData.pressure)) {
0579         data.insert(QStringLiteral("Pressure"), weatherData.pressure);
0580         data.insert(QStringLiteral("Pressure Unit"), KUnitConversion::InchesOfMercury);
0581     }
0582 
0583     if (!qIsNaN(weatherData.visibility)) {
0584         data.insert(QStringLiteral("Visibility"), weatherData.visibility);
0585         data.insert(QStringLiteral("Visibility Unit"), KUnitConversion::Mile);
0586     }
0587 
0588     if (!qIsNaN(weatherData.humidity)) {
0589         data.insert(QStringLiteral("Humidity"), weatherData.humidity);
0590         data.insert(QStringLiteral("Humidity Unit"), KUnitConversion::Percent);
0591     }
0592 
0593     if (!qIsNaN(weatherData.windSpeed)) {
0594         data.insert(QStringLiteral("Wind Speed"), weatherData.windSpeed);
0595     }
0596 
0597     if (!qIsNaN(weatherData.windSpeed) || !qIsNaN(weatherData.windGust)) {
0598         data.insert(QStringLiteral("Wind Speed Unit"), KUnitConversion::MilePerHour);
0599     }
0600 
0601     if (!qIsNaN(weatherData.windGust)) {
0602         data.insert(QStringLiteral("Wind Gust"), weatherData.windGust);
0603     }
0604 
0605     if (!qIsNaN(weatherData.windSpeed) && static_cast<int>(weatherData.windSpeed) == 0) {
0606         data.insert(QStringLiteral("Wind Direction"), QStringLiteral("VR")); // Variable/calm
0607     } else if (!weatherData.windDirection.isEmpty()) {
0608         data.insert(QStringLiteral("Wind Direction"), getWindDirectionIcon(windIcons(), weatherData.windDirection.toLower()));
0609     }
0610 
0611     // Set number of forecasts per day/night supported
0612     data.insert(QStringLiteral("Total Weather Days"), weatherData.forecasts.size());
0613 
0614     int i = 0;
0615     for (const WeatherData::Forecast &forecast : weatherData.forecasts) {
0616         ConditionIcons icon = getConditionIcon(forecast.summary.toLower(), true);
0617         QString iconName = getWeatherIcon(icon);
0618 
0619         /* Sometimes the forecast for the later days is unavailable, if so skip remianing days
0620          * since their forecast data is probably unavailable.
0621          */
0622         if (forecast.low.isEmpty() || forecast.high.isEmpty()) {
0623             break;
0624         }
0625 
0626         // Get the short day name for the forecast
0627         data.insert(QStringLiteral("Short Forecast Day %1").arg(i),
0628                     QStringLiteral("%1|%2|%3|%4|%5|%6")
0629                         .arg(forecast.day, iconName, i18nc("weather forecast", forecast.summary.toUtf8().data()), forecast.high, forecast.low)
0630                         .arg(forecast.precipitation));
0631         ++i;
0632     }
0633 
0634     data.insert(u"Total Warnings Issued"_s, weatherData.alerts.size());
0635     int alertNum = 0;
0636     for (const WeatherData::Alert &alert : weatherData.alerts) {
0637         // TODO: Add a Headline parameter to the engine and the applet
0638         data.insert(u"Warning Description %1"_s.arg(alertNum), u"<p><b>%1</b></p>%2"_s.arg(alert.headline, alert.description));
0639         data.insert(u"Warning Timestamp %1"_s.arg(alertNum), QLocale().toString(alert.startTime, QLocale::ShortFormat));
0640         data.insert(u"Warning Priority %1"_s.arg(alertNum), alert.priority);
0641         ++alertNum;
0642     }
0643 
0644     data.insert(QStringLiteral("Credit"), i18nc("credit line, keep string short)", "Data from NOAA National\302\240Weather\302\240Service"));
0645 
0646     setData(source, data);
0647 }
0648 
0649 /**
0650  * Determine the condition icon based on the list of possible NOAA weather conditions as defined at
0651  * <https://www.weather.gov/xml/current_obs/weather.php> and
0652  * <https://graphical.weather.gov/xml/mdl/XML/Design/MDL_XML_Design.htm#_Toc141760782>
0653  * Since the number of NOAA weather conditions need to be fitted into the narowly defined groups in IonInterface::ConditionIcons, we
0654  * try to group the NOAA conditions as best as we can based on their priorities/severity.
0655  * TODO: summaries "Hot" & "Cold" have no proper matching entry in ConditionIcons, consider extending it
0656  */
0657 IonInterface::ConditionIcons NOAAIon::getConditionIcon(const QString &weather, bool isDayTime) const
0658 {
0659     IonInterface::ConditionIcons result;
0660     // Consider any type of storm, tornado or funnel to be a thunderstorm.
0661     if (weather.contains(QLatin1String("thunderstorm")) || weather.contains(QLatin1String("funnel")) || weather.contains(QLatin1String("tornado"))
0662         || weather.contains(QLatin1String("storm")) || weather.contains(QLatin1String("tstms"))) {
0663         if (weather.contains(QLatin1String("vicinity")) || weather.contains(QLatin1String("chance"))) {
0664             result = isDayTime ? IonInterface::ChanceThunderstormDay : IonInterface::ChanceThunderstormNight;
0665         } else {
0666             result = IonInterface::Thunderstorm;
0667         }
0668 
0669     } else if (weather.contains(QLatin1String("pellets")) || weather.contains(QLatin1String("crystals")) || weather.contains(QLatin1String("hail"))) {
0670         result = IonInterface::Hail;
0671 
0672     } else if (((weather.contains(QLatin1String("rain")) || weather.contains(QLatin1String("drizzle")) || weather.contains(QLatin1String("showers")))
0673                 && weather.contains(QLatin1String("snow")))
0674                || weather.contains(QLatin1String("wintry mix"))) {
0675         result = IonInterface::RainSnow;
0676 
0677     } else if (weather.contains(QLatin1String("flurries"))) {
0678         result = IonInterface::Flurries;
0679 
0680     } else if (weather.contains(QLatin1String("snow")) && weather.contains(QLatin1String("light"))) {
0681         result = IonInterface::LightSnow;
0682 
0683     } else if (weather.contains(QLatin1String("snow"))) {
0684         if (weather.contains(QLatin1String("vicinity")) || weather.contains(QLatin1String("chance"))) {
0685             result = isDayTime ? IonInterface::ChanceSnowDay : IonInterface::ChanceSnowNight;
0686         } else {
0687             result = IonInterface::Snow;
0688         }
0689 
0690     } else if (weather.contains(QLatin1String("freezing rain"))) {
0691         result = IonInterface::FreezingRain;
0692 
0693     } else if (weather.contains(QLatin1String("freezing drizzle"))) {
0694         result = IonInterface::FreezingDrizzle;
0695 
0696     } else if (weather.contains(QLatin1String("cold"))) {
0697         // temperature condition has not hint about air ingredients, so let's assume chance of snow
0698         result = isDayTime ? IonInterface::ChanceSnowDay : IonInterface::ChanceSnowNight;
0699 
0700     } else if (weather.contains(QLatin1String("showers"))) {
0701         if (weather.contains(QLatin1String("vicinity")) || weather.contains(QLatin1String("chance"))) {
0702             result = isDayTime ? IonInterface::ChanceShowersDay : IonInterface::ChanceShowersNight;
0703         } else {
0704             result = IonInterface::Showers;
0705         }
0706     } else if (weather.contains(QLatin1String("light rain")) || weather.contains(QLatin1String("drizzle"))) {
0707         result = IonInterface::LightRain;
0708 
0709     } else if (weather.contains(QLatin1String("rain"))) {
0710         result = IonInterface::Rain;
0711 
0712     } else if (weather.contains(QLatin1String("few clouds")) || weather.contains(QLatin1String("mostly sunny"))
0713                || weather.contains(QLatin1String("mostly clear")) || weather.contains(QLatin1String("increasing clouds"))
0714                || weather.contains(QLatin1String("becoming cloudy")) || weather.contains(QLatin1String("clearing"))
0715                || weather.contains(QLatin1String("decreasing clouds")) || weather.contains(QLatin1String("becoming sunny"))) {
0716         if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) {
0717             result = isDayTime ? IonInterface::FewCloudsWindyDay : IonInterface::FewCloudsWindyNight;
0718         } else {
0719             result = isDayTime ? IonInterface::FewCloudsDay : IonInterface::FewCloudsNight;
0720         }
0721 
0722     } else if (weather.contains(QLatin1String("partly cloudy")) || weather.contains(QLatin1String("partly sunny"))
0723                || weather.contains(QLatin1String("partly clear"))) {
0724         if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) {
0725             result = isDayTime ? IonInterface::PartlyCloudyWindyDay : IonInterface::PartlyCloudyWindyNight;
0726         } else {
0727             result = isDayTime ? IonInterface::PartlyCloudyDay : IonInterface::PartlyCloudyNight;
0728         }
0729 
0730     } else if (weather.contains(QLatin1String("overcast")) || weather.contains(QLatin1String("cloudy"))) {
0731         if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) {
0732             result = IonInterface::OvercastWindy;
0733         } else {
0734             result = IonInterface::Overcast;
0735         }
0736 
0737     } else if (weather.contains(QLatin1String("haze")) || weather.contains(QLatin1String("smoke")) || weather.contains(QLatin1String("dust"))
0738                || weather.contains(QLatin1String("sand"))) {
0739         result = IonInterface::Haze;
0740 
0741     } else if (weather.contains(QLatin1String("fair")) || weather.contains(QLatin1String("clear")) || weather.contains(QLatin1String("sunny"))) {
0742         if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) {
0743             result = isDayTime ? IonInterface::ClearWindyDay : IonInterface::ClearWindyNight;
0744         } else {
0745             result = isDayTime ? IonInterface::ClearDay : IonInterface::ClearNight;
0746         }
0747 
0748     } else if (weather.contains(QLatin1String("fog"))) {
0749         result = IonInterface::Mist;
0750 
0751     } else if (weather.contains(QLatin1String("hot"))) {
0752         // temperature condition has not hint about air ingredients, so let's assume the sky is clear when it is hot
0753         if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) {
0754             result = isDayTime ? IonInterface::ClearWindyDay : IonInterface::ClearWindyNight;
0755         } else {
0756             result = isDayTime ? IonInterface::ClearDay : IonInterface::ClearNight;
0757         }
0758 
0759     } else if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) {
0760         // Assume a clear sky when it's windy but no clouds have been mentioned
0761         result = isDayTime ? IonInterface::ClearWindyDay : IonInterface::ClearWindyNight;
0762     } else {
0763         result = IonInterface::NotAvailable;
0764     }
0765 
0766     return result;
0767 }
0768 
0769 void NOAAIon::getForecast(const QString &source)
0770 {
0771     const double lat = m_weatherData[source].stationLatitude;
0772     const double lon = m_weatherData[source].stationLongitude;
0773     if (qIsNaN(lat) || qIsNaN(lon)) {
0774         return;
0775     }
0776 
0777     /* Assuming that we have the latitude and longitude data at this point, get the 7-day
0778      * forecast.
0779      */
0780     const QUrl url(QLatin1String("https://graphical.weather.gov/xml/sample_products/browser_interface/"
0781                                  "ndfdBrowserClientByDay.php?lat=")
0782                    + QString::number(lat) + QLatin1String("&lon=") + QString::number(lon) + QLatin1String("&format=24+hourly&numDays=7"));
0783 
0784     auto getJob = apiRequestJob(url, source);
0785     connect(getJob, &KJob::result, this, &NOAAIon::forecast_slotJobFinished);
0786 }
0787 
0788 void NOAAIon::forecast_slotJobFinished(KJob *job)
0789 {
0790     QXmlStreamReader reader = QXmlStreamReader(m_jobData.value(job));
0791     const QString source = m_jobList.value(job);
0792 
0793     if (!reader.atEnd()) {
0794         readForecast(source, reader);
0795         updateWeather(source);
0796     }
0797 
0798     m_jobList.remove(job);
0799     m_jobData.remove(job);
0800 
0801     if (m_sourcesToReset.contains(source)) {
0802         m_sourcesToReset.removeAll(source);
0803 
0804         // so the weather engine updates it's data
0805         forceImmediateUpdateOfAllVisualizations();
0806 
0807         // update the clients of our engine
0808         Q_EMIT forceUpdate(this, source);
0809     }
0810 }
0811 
0812 void NOAAIon::readForecast(const QString &source, QXmlStreamReader &xml)
0813 {
0814     WeatherData &weatherData = m_weatherData[source];
0815     QList<WeatherData::Forecast> &forecasts = weatherData.forecasts;
0816 
0817     // Clear the current forecasts
0818     forecasts.clear();
0819 
0820     while (!xml.atEnd()) {
0821         xml.readNext();
0822 
0823         if (xml.isStartElement()) {
0824             /* Read all reported days from <time-layout>. We check for existence of a specific
0825              * <layout-key> which indicates the separate day listings.  The schema defines it to be
0826              * the first item before the day listings.
0827              */
0828             if (xml.name() == QLatin1String("layout-key") && xml.readElementText() == QLatin1String("k-p24h-n7-1")) {
0829                 // Read days until we get to end of parent (<time-layout>)tag
0830                 while (!(xml.isEndElement() && xml.name() == QLatin1String("time-layout"))) {
0831                     xml.readNext();
0832 
0833                     if (xml.name() == QLatin1String("start-valid-time")) {
0834                         QString data = xml.readElementText();
0835                         QDateTime date = QDateTime::fromString(data, Qt::ISODate);
0836 
0837                         WeatherData::Forecast forecast;
0838                         forecast.day = QLocale().toString(date.date().day());
0839                         forecasts.append(forecast);
0840                         // qCDebug(IONENGINE_NOAA) << forecast.day;
0841                     }
0842                 }
0843 
0844             } else if (xml.name() == QLatin1String("temperature") && xml.attributes().value(QStringLiteral("type")) == QLatin1String("maximum")) {
0845                 // Read max temps until we get to end tag
0846                 int i = 0;
0847                 while (!(xml.isEndElement() && xml.name() == QLatin1String("temperature")) && i < forecasts.count()) {
0848                     xml.readNext();
0849 
0850                     if (xml.name() == QLatin1String("value")) {
0851                         forecasts[i].high = xml.readElementText();
0852                         // qCDebug(IONENGINE_NOAA) << forecasts[i].high;
0853                         i++;
0854                     }
0855                 }
0856             } else if (xml.name() == QLatin1String("temperature") && xml.attributes().value(QStringLiteral("type")) == QLatin1String("minimum")) {
0857                 // Read min temps until we get to end tag
0858                 int i = 0;
0859                 while (!(xml.isEndElement() && xml.name() == QLatin1String("temperature")) && i < forecasts.count()) {
0860                     xml.readNext();
0861 
0862                     if (xml.name() == QLatin1String("value")) {
0863                         forecasts[i].low = xml.readElementText();
0864                         // qCDebug(IONENGINE_NOAA) << forecasts[i].low;
0865                         i++;
0866                     }
0867                 }
0868             } else if (xml.name() == QLatin1String("probability-of-precipitation")) {
0869                 // Precipitation is usually provided in 12-hour periods instead of 24.
0870                 int periodHours = xml.attributes().value(QStringLiteral("type")).split(QLatin1Char(' '))[0].toInt();
0871                 if (!periodHours) {
0872                     periodHours = 24;
0873                 }
0874 
0875                 int i = 0;
0876                 int hours = 0;
0877                 while (!(xml.isEndElement() && xml.name() == QLatin1String("weather")) && i < forecasts.count()) {
0878                     xml.readNext();
0879                     if (xml.name() != QLatin1String("value")) {
0880                         continue;
0881                     }
0882 
0883                     const int probability = xml.readElementText().toInt();
0884                     forecasts[i].precipitation = qMax(probability, forecasts[i].precipitation);
0885                     hours += periodHours;
0886 
0887                     if (hours >= 24) {
0888                         hours = 0;
0889                         i++;
0890                     }
0891                 }
0892             } else if (xml.name() == QLatin1String("weather")) {
0893                 // Read weather conditions until we get to end tag
0894                 int i = 0;
0895                 while (!(xml.isEndElement() && xml.name() == QLatin1String("weather")) && i < forecasts.count()) {
0896                     xml.readNext();
0897 
0898                     if (xml.name() == QLatin1String("weather-conditions") && xml.isStartElement()) {
0899                         QString summary = xml.attributes().value(QStringLiteral("weather-summary")).toString();
0900                         forecasts[i].summary = summary;
0901                         // qCDebug(IONENGINE_NOAA) << forecasts[i].summary;
0902                         qCDebug(IONENGINE_NOAA) << "i18n summary string: " << i18nc("weather forecast", forecasts[i].summary.toUtf8().data());
0903                         i++;
0904                     }
0905                 }
0906             }
0907         }
0908     }
0909 
0910     weatherData.isForecastsDataPending = false;
0911 }
0912 
0913 void NOAAIon::getCountyID(const QString &source)
0914 {
0915     const double lat = m_weatherData[source].stationLatitude;
0916     const double lon = m_weatherData[source].stationLongitude;
0917     if (qIsNaN(lat) || qIsNaN(lon)) {
0918         return;
0919     }
0920 
0921     const QUrl url(QStringLiteral("https://api.weather.gov/points/%1,%2").arg(lat).arg(lon));
0922 
0923     auto getJob = apiRequestJob(url, source);
0924     connect(getJob, &KJob::result, this, &NOAAIon::county_slotJobFinished);
0925 }
0926 
0927 void NOAAIon::county_slotJobFinished(KJob *job)
0928 {
0929     const QString source = m_jobList.value(job);
0930 
0931     if (!job->error()) {
0932         QJsonDocument doc = QJsonDocument::fromJson(m_jobData.value(job));
0933         if (!doc.isEmpty()) {
0934             readCountyID(source, doc);
0935         }
0936     } else {
0937         qCWarning(IONENGINE_NOAA) << "Error getting coordinates info" << job->errorText();
0938     }
0939 
0940     m_jobList.remove(job);
0941     m_jobData.remove(job);
0942 }
0943 
0944 void NOAAIon::readCountyID(const QString &source, const QJsonDocument &doc)
0945 {
0946     if (doc.isEmpty()) {
0947         return;
0948     }
0949 
0950     const auto properties = doc[QStringLiteral("properties")];
0951     if (!properties.isObject()) {
0952         return;
0953     }
0954 
0955     const QString countyUrl = properties[QStringLiteral("county")].toString();
0956     const QString countyID = countyUrl.split(QLatin1Char('/')).last();
0957     m_weatherData[source].countyID = countyID;
0958 
0959     getAlerts(source);
0960 }
0961 
0962 void NOAAIon::getAlerts(const QString &source)
0963 {
0964     // We get the alerts by county because it includes all the events.
0965     // Using the forecast zone would miss some of them, and the lat/lon point
0966     // corresponds to the weather station, not necessarily the user location
0967     const QString countyID = m_weatherData[source].countyID;
0968     if (countyID.isEmpty()) {
0969         getCountyID(source);
0970         return;
0971     }
0972 
0973     const QUrl url(QStringLiteral("https://api.weather.gov/alerts/active?zone=%1").arg(countyID));
0974 
0975     auto getJob = apiRequestJob(url, source);
0976     connect(getJob, &KJob::result, this, &NOAAIon::alerts_slotJobFinished);
0977 }
0978 
0979 void NOAAIon::alerts_slotJobFinished(KJob *job)
0980 {
0981     const QString source = m_jobList.value(job);
0982 
0983     if (!job->error()) {
0984         QJsonDocument doc = QJsonDocument::fromJson(m_jobData.value(job));
0985         if (!doc.isEmpty()) {
0986             readAlerts(source, doc);
0987         }
0988     } else {
0989         qCWarning(IONENGINE_NOAA) << "Error getting alerts info" << job->errorText();
0990     }
0991 
0992     m_jobList.remove(job);
0993     m_jobData.remove(job);
0994 }
0995 
0996 // Helpers to parse warnings
0997 int mapSeverity(const QString &severity)
0998 {
0999     if (severity == "Extreme"_L1) {
1000         return 4;
1001     } else if (severity == "Severe"_L1) {
1002         return 3;
1003     } else if (severity == "Moderate"_L1) {
1004         return 2;
1005     } else if (severity == "Minor"_L1) {
1006         return 1;
1007     } else { // severity: "Unknown"
1008         return 0;
1009     }
1010 };
1011 
1012 QString formatAlertDescription(QString description)
1013 {
1014     /* -- Example of an alert's description --
1015     * WHAT...Minor flooding is occurring and minor flooding is forecast.\n
1016     \n
1017     * WHERE...Santee River near Jamestown.\n
1018     \n
1019     * WHEN...Until further notice.\n
1020     \n
1021     * IMPACTS...At 12.0 feet, several dirt logging roads are impassable.\n
1022     \n
1023     * ADDITIONAL DETAILS...\n
1024     - At 930 PM EST Tuesday, the stage was 11.4 feet.\n
1025     - Forecast...The river is expected to rise to a crest of 11.7\n
1026     feet Thursday evening.\n
1027     - Flood stage is 10.0 feet.\n
1028     */
1029     description.replace("* "_L1, "<b>"_L1);
1030     description.replace("..."_L1, ":</b> "_L1);
1031     description.replace("\n\n"_L1, "<br/>"_L1);
1032     description.replace("\n-"_L1, "<br/>-"_L1);
1033     return description;
1034 }
1035 
1036 void NOAAIon::readAlerts(const QString &source, const QJsonDocument &doc)
1037 {
1038     if (doc.isEmpty()) {
1039         return;
1040     }
1041 
1042     auto &alerts = m_weatherData[source].alerts;
1043     alerts.clear();
1044 
1045     const auto features = doc[u"features"_s].toArray();
1046     qCDebug(IONENGINE_NOAA) << u"Received %1 alert/s"_s.arg(features.count());
1047 
1048     for (const auto &alertInfo : features) {
1049         const auto properties = alertInfo[u"properties"_s];
1050         if (!properties.isObject()) {
1051             continue;
1052         }
1053 
1054         auto alert = WeatherData::Alert();
1055         alert.startTime = QDateTime::fromString(properties[u"onset"_s].toString(), Qt::ISODate);
1056         alert.endTime = QDateTime::fromString(properties[u"ends"_s].toString(), Qt::ISODate);
1057         alert.priority = mapSeverity(properties[u"severity"_s].toString());
1058         alert.headline = properties[u"parameters"_s][u"NWSheadline"_s][0].toString();
1059         alert.description = formatAlertDescription(properties[u"description"_s].toString());
1060 
1061         alerts << alert;
1062     }
1063 
1064     // Sort by higher priority and the lower start time
1065     std::sort(alerts.begin(), alerts.end(), [](auto a, auto b) {
1066         if (a.priority != b.priority) {
1067             return a.priority > b.priority;
1068         }
1069         return a.startTime < b.startTime;
1070     });
1071 
1072     updateWeather(source);
1073     forceImmediateUpdateOfAllVisualizations();
1074     Q_EMIT forceUpdate(this, source);
1075 }
1076 
1077 void NOAAIon::dataUpdated(const QString &sourceName, const Plasma5Support::DataEngine::Data &data)
1078 {
1079     const bool isNight = (data.value(QStringLiteral("Corrected Elevation")).toDouble() < 0.0);
1080 
1081     for (auto end = m_weatherData.end(), it = m_weatherData.begin(); it != end; ++it) {
1082         auto &weatherData = it.value();
1083         if (weatherData.solarDataTimeEngineSourceName == sourceName) {
1084             weatherData.isNight = isNight;
1085             weatherData.isSolarDataPending = false;
1086             updateWeather(it.key());
1087         }
1088     }
1089 }
1090 
1091 K_PLUGIN_CLASS_WITH_JSON(NOAAIon, "ion-noaa.json")
1092 
1093 #include "ion_noaa.moc"