1 /* SPDX-FileCopyrightText: 2020 Tobias Leupold <tobias.leupold@gmx.de>
2 
3    SPDX-License-Identifier: GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
4 */
5 
6 // Local includes
7 #include "ElevationEngine.h"
8 #include "Settings.h"
9 #include "Coordinates.h"
10 
11 // KDE includes
12 #include <KLocalizedString>
13 
14 // Qt includes
15 #include <QNetworkAccessManager>
16 #include <QNetworkReply>
17 #include <QDebug>
18 #include <QJsonDocument>
19 #include <QJsonObject>
20 #include <QJsonArray>
21 #include <QTimer>
22 
23 // C++ includes
24 #include <functional>
25 
26 // opentopodata.org query restrictions
27 static constexpr int s_maximumLocations = 100;
28 static constexpr int s_msToNextRequest = 1000;
29 
ElevationEngine(QObject * parent,Settings * settings)30 ElevationEngine::ElevationEngine(QObject *parent, Settings *settings)
31     : QObject(parent),
32       m_settings(settings)
33 {
34     m_manager = new QNetworkAccessManager(this);
35     connect(m_manager, &QNetworkAccessManager::finished, this, &ElevationEngine::processReply);
36 
37     m_requestTimer = new QTimer(this);
38     m_requestTimer->setSingleShot(true);
39     m_requestTimer->setInterval(s_msToNextRequest);
40     connect(m_requestTimer, &QTimer::timeout, this, &ElevationEngine::processNextRequest);
41 }
42 
request(ElevationEngine::Target target,const QVector<QString> & ids,const QVector<Coordinates> & coordinates)43 void ElevationEngine::request(ElevationEngine::Target target, const QVector<QString> &ids,
44                               const QVector<Coordinates> &coordinates)
45 {
46     // Check if we want to lookup different coordinates
47     bool identicalCoordinates = true;
48     if (coordinates.count() > 1) {
49         const auto &firstCoordinates = coordinates.first();
50         for (int i = 1; i < coordinates.count(); i++) {
51             if (coordinates.at(i) != firstCoordinates) {
52                 identicalCoordinates = false;
53                 break;
54             }
55         }
56     }
57 
58     if (identicalCoordinates) {
59         // Add a request for one location
60         m_queuedTargets.append(target);
61         m_queuedIds.append(ids);
62         const auto &firstCoordinates = coordinates.first();
63         m_queuedLocations.append(QStringLiteral("%1,%2").arg(
64                                                 QString::number(firstCoordinates.lat()),
65                                                 QString::number(firstCoordinates.lon())));
66     } else {
67         // Create clusters of locations with at most s_maximumLocations locations per cluster
68 
69         QStringList locations;
70         for (const auto &singleCoordinate : coordinates) {
71             locations.append(QStringLiteral("%1,%2").arg(QString::number(singleCoordinate.lat()),
72                                                          QString::number(singleCoordinate.lon())));
73         }
74 
75         // Group all requested coordinates to groups with at most s_maximumLocations entries
76         int start = 0;
77         while (start < ids.count()) {
78             m_queuedTargets.append(target);
79             m_queuedIds.append(ids.mid(start, s_maximumLocations));
80             m_queuedLocations.append(
81                 locations.mid(start, s_maximumLocations).join(QLatin1String("|")));
82             start += s_maximumLocations;
83         }
84     }
85 
86     processNextRequest();
87 }
88 
processNextRequest()89 void ElevationEngine::processNextRequest()
90 {
91     if (m_queuedTargets.isEmpty()) {
92         // Nothing to do.
93         // This happens because m_requestTimer always calls this after being finished.
94         return;
95     }
96 
97     if (m_requestTimer->isActive()) {
98         // Pending request, we can't currently post another one.
99         // m_requestTimer will call this again after having waited for s_msToNextRequest.
100         return;
101     }
102 
103     auto *reply = m_manager->get(QNetworkRequest(QUrl(
104         QStringLiteral("https://api.opentopodata.org/v1/%1?locations=%2").arg(
105                        m_settings->elevationDataset(), m_queuedLocations.takeFirst()))));
106     m_requests.insert(reply, { m_queuedTargets.takeFirst(), m_queuedIds.takeFirst() });
107     QTimer::singleShot(3000, this, std::bind(&ElevationEngine::cleanUpRequest, this, reply));
108 
109     // Block the next requst (checking)
110     m_requestTimer->start();
111 }
112 
removeRequest(QNetworkReply * request)113 void ElevationEngine::removeRequest(QNetworkReply *request)
114 {
115     m_requests.remove(request);
116     request->deleteLater();
117 }
118 
cleanUpRequest(QNetworkReply * request)119 void ElevationEngine::cleanUpRequest(QNetworkReply *request)
120 {
121     if (m_requests.contains(request)) {
122         request->abort();
123         emit lookupFailed(i18n("The request timed out"));
124         m_requests.remove(request);
125     }
126 }
127 
processReply(QNetworkReply * request)128 void ElevationEngine::processReply(QNetworkReply *request)
129 {
130     if (! request->isOpen()) {
131         // This happens if the request has been aborted by the cleanup timer
132         return;
133     }
134 
135     const auto [ target, ids ] = m_requests.value(request);
136     removeRequest(request);
137 
138     const auto requestData = request->readAll();
139     QJsonParseError error;
140     const auto json = QJsonDocument::fromJson(requestData, &error);
141     if (error.error != QJsonParseError::NoError || ! json.isObject()) {
142         emit lookupFailed(i18n("Could not parse the server's response: Failed to create a JSON "
143                                "document.</p>"
144                                "<p>The error's description was: %1</p>"
145                                "<p>The literal response was:</p>"
146                                "<p><kbd>%2</kbd>", error.errorString(),
147                                QString::fromLocal8Bit(requestData)));
148         return;
149     }
150 
151     const auto object = json.object();
152     const auto statusValue = object.value(QStringLiteral("status"));
153     if (statusValue.isUndefined()) {
154         emit lookupFailed(i18n("Could not parse the server's response: Could not read the status "
155                                "value"));
156     }
157 
158     const auto statusString = statusValue.toString();
159     if (statusString != QStringLiteral("OK")) {
160         const auto errorValue = object.value(QStringLiteral("error"));
161         const auto errorString = errorValue.isUndefined()
162             ? i18n("Could not read error description") : errorValue.toString();
163         emit lookupFailed(i18nc("A server error status followed by the error description",
164                                 "%1: %2", statusString, errorString));
165         return;
166     }
167 
168     const auto resultsValue = object.value(QStringLiteral("results"));
169     if (! resultsValue.isArray()) {
170         emit lookupFailed(i18n("Could not parse the server's response: Could not read the results "
171                                "array"));
172         return;
173     }
174 
175     const auto resultsArray = resultsValue.toArray();
176     bool allPresent = true;
177     QVector<double> elevations;
178     for (const auto result : resultsArray)  {
179         const auto elevation = result.toObject().value(QStringLiteral("elevation"));
180         if (elevation.isUndefined()) {
181             emit lookupFailed(i18n("Could not parse the server's response: Could not read the "
182                                    "elevation value"));
183             return;
184         } else if (elevation.isNull()) {
185             allPresent = false;
186         }
187         elevations.append(elevation.toDouble());
188     }
189 
190     const int originalElevationsCount = elevations.count();
191     if (ids.count() > 1 && originalElevationsCount == 1) {
192         // Same coordinates requested multiple times
193         const auto coordinates = elevations.first();
194         for (int i = 0; i < ids.count() - 1; i++) {
195             elevations.append(coordinates);
196         }
197     }
198 
199     emit elevationProcessed(target, ids, elevations);
200 
201     if (! allPresent) {
202         emit notAllPresent(ids.count(), originalElevationsCount);
203     }
204 }
205