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