File indexing completed on 2024-04-28 15:39:05

0001 // SPDX-FileCopyrightText: 2020-2022 Tobias Leupold <tl at stonemx dot de>
0002 //
0003 // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0004 
0005 // Local includes
0006 #include "ElevationEngine.h"
0007 #include "Settings.h"
0008 #include "Coordinates.h"
0009 
0010 // KDE includes
0011 #include <KLocalizedString>
0012 
0013 // Qt includes
0014 #include <QNetworkAccessManager>
0015 #include <QNetworkReply>
0016 #include <QDebug>
0017 #include <QJsonDocument>
0018 #include <QJsonObject>
0019 #include <QJsonArray>
0020 #include <QTimer>
0021 
0022 // C++ includes
0023 #include <functional>
0024 
0025 // opentopodata.org query restrictions
0026 static constexpr int s_maximumLocations = 100;
0027 static constexpr int s_msToNextRequest = 1000;
0028 
0029 ElevationEngine::ElevationEngine(QObject *parent, Settings *settings)
0030     : QObject(parent),
0031       m_settings(settings)
0032 {
0033     m_manager = new QNetworkAccessManager(this);
0034     connect(m_manager, &QNetworkAccessManager::finished, this, &ElevationEngine::processReply);
0035 
0036     m_requestTimer = new QTimer(this);
0037     m_requestTimer->setSingleShot(true);
0038     m_requestTimer->setInterval(s_msToNextRequest);
0039     connect(m_requestTimer, &QTimer::timeout, this, &ElevationEngine::processNextRequest);
0040 }
0041 
0042 void ElevationEngine::request(ElevationEngine::Target target, const QVector<QString> &ids,
0043                               const QVector<Coordinates> &coordinates)
0044 {
0045     // Check if we want to lookup different coordinates
0046     bool identicalCoordinates = true;
0047     if (coordinates.count() > 1) {
0048         const auto &firstCoordinates = coordinates.first();
0049         for (int i = 1; i < coordinates.count(); i++) {
0050             if (coordinates.at(i) != firstCoordinates) {
0051                 identicalCoordinates = false;
0052                 break;
0053             }
0054         }
0055     }
0056 
0057     if (identicalCoordinates) {
0058         // Add a request for one location
0059         m_queuedTargets.append(target);
0060         m_queuedIds.append(ids);
0061         const auto &firstCoordinates = coordinates.first();
0062         m_queuedLocations.append(QStringLiteral("%1,%2").arg(
0063                                                 QString::number(firstCoordinates.lat()),
0064                                                 QString::number(firstCoordinates.lon())));
0065     } else {
0066         // Create clusters of locations with at most s_maximumLocations locations per cluster
0067 
0068         QStringList locations;
0069         for (const auto &singleCoordinate : coordinates) {
0070             locations.append(QStringLiteral("%1,%2").arg(QString::number(singleCoordinate.lat()),
0071                                                          QString::number(singleCoordinate.lon())));
0072         }
0073 
0074         // Group all requested coordinates to groups with at most s_maximumLocations entries
0075         int start = 0;
0076         while (start < ids.count()) {
0077             m_queuedTargets.append(target);
0078             m_queuedIds.append(ids.mid(start, s_maximumLocations));
0079             m_queuedLocations.append(
0080                 locations.mid(start, s_maximumLocations).join(QLatin1String("|")));
0081             start += s_maximumLocations;
0082         }
0083     }
0084 
0085     processNextRequest();
0086 }
0087 
0088 void ElevationEngine::processNextRequest()
0089 {
0090     if (m_queuedTargets.isEmpty()) {
0091         // Nothing to do.
0092         // This happens because m_requestTimer always calls this after being finished.
0093         return;
0094     }
0095 
0096     if (m_requestTimer->isActive()) {
0097         // Pending request, we can't currently post another one.
0098         // m_requestTimer will call this again after having waited for s_msToNextRequest.
0099         return;
0100     }
0101 
0102     auto *reply = m_manager->get(QNetworkRequest(QUrl(
0103         QStringLiteral("https://api.opentopodata.org/v1/%1?locations=%2").arg(
0104                        m_settings->elevationDataset(), m_queuedLocations.takeFirst()))));
0105     m_requests.insert(reply, { m_queuedTargets.takeFirst(), m_queuedIds.takeFirst() });
0106     QTimer::singleShot(3000, this, std::bind(&ElevationEngine::cleanUpRequest, this, reply));
0107 
0108     // Block the next request (checking)
0109     m_requestTimer->start();
0110 }
0111 
0112 void ElevationEngine::removeRequest(QNetworkReply *request)
0113 {
0114     m_requests.remove(request);
0115     request->deleteLater();
0116 }
0117 
0118 void ElevationEngine::cleanUpRequest(QNetworkReply *request)
0119 {
0120     if (m_requests.contains(request)) {
0121         request->abort();
0122         Q_EMIT lookupFailed(i18n("The request timed out"));
0123         m_requests.remove(request);
0124     }
0125 }
0126 
0127 void ElevationEngine::processReply(QNetworkReply *request)
0128 {
0129     if (! request->isOpen()) {
0130         // This happens if the request has been aborted by the cleanup timer
0131         return;
0132     }
0133 
0134     const auto [ target, ids ] = m_requests.value(request);
0135     removeRequest(request);
0136 
0137     const auto requestData = request->readAll();
0138     QJsonParseError error;
0139     const auto json = QJsonDocument::fromJson(requestData, &error);
0140     if (error.error != QJsonParseError::NoError || ! json.isObject()) {
0141         Q_EMIT lookupFailed(i18n("Could not parse the server's response: Failed to create a JSON "
0142                                  "document.</p>"
0143                                  "<p>The error's description was: %1</p>"
0144                                  "<p>The literal response was:</p>"
0145                                  "<p><kbd>%2</kbd>", error.errorString(),
0146                                  QString::fromLocal8Bit(requestData)));
0147         return;
0148     }
0149 
0150     const auto object = json.object();
0151     const auto statusValue = object.value(QStringLiteral("status"));
0152     if (statusValue.isUndefined()) {
0153         Q_EMIT lookupFailed(i18n("Could not parse the server's response: Could not read the status "
0154                                  "value"));
0155     }
0156 
0157     const auto statusString = statusValue.toString();
0158     if (statusString != QStringLiteral("OK")) {
0159         const auto errorValue = object.value(QStringLiteral("error"));
0160         const auto errorString = errorValue.isUndefined()
0161             ? i18n("Could not read error description") : errorValue.toString();
0162         Q_EMIT lookupFailed(i18nc("A server error status followed by the error description",
0163                                   "%1: %2", statusString, errorString));
0164         return;
0165     }
0166 
0167     const auto resultsValue = object.value(QStringLiteral("results"));
0168     if (! resultsValue.isArray()) {
0169         Q_EMIT lookupFailed(i18n("Could not parse the server's response: Could not read the "
0170                                  "results array"));
0171         return;
0172     }
0173 
0174     const auto resultsArray = resultsValue.toArray();
0175     bool allPresent = true;
0176     QVector<double> elevations;
0177     for (const auto &result : resultsArray)  {
0178         const auto elevation = result.toObject().value(QStringLiteral("elevation"));
0179         if (elevation.isUndefined()) {
0180             Q_EMIT lookupFailed(i18n("Could not parse the server's response: Could not read the "
0181                                      "elevation value"));
0182             return;
0183         } else if (elevation.isNull()) {
0184             allPresent = false;
0185         }
0186         elevations.append(elevation.toDouble());
0187     }
0188 
0189     const int originalElevationsCount = elevations.count();
0190     if (ids.count() > 1 && originalElevationsCount == 1) {
0191         // Same coordinates requested multiple times
0192         const auto coordinates = elevations.first();
0193         for (int i = 0; i < ids.count() - 1; i++) {
0194             elevations.append(coordinates);
0195         }
0196     }
0197 
0198     Q_EMIT elevationProcessed(target, ids, elevations);
0199 
0200     if (! allPresent) {
0201         Q_EMIT notAllPresent(ids.count(), originalElevationsCount);
0202     }
0203 }