File indexing completed on 2024-05-12 04:42:41

0001 /*
0002     SPDX-FileCopyrightText: 2024 Jonah Brüchert <jbb@kaidan.im>
0003     SPDX-FileCopyrightText: 2024 Stefan Vesovic <asphyxia@spline.de>
0004 
0005     SPDX-License-Identifier: LGPL-2.0-or-later
0006 */
0007 
0008 #include "srbijavozbackend.h"
0009 
0010 #include "datatypes/location.h"
0011 #include "datatypes/stopover.h"
0012 #include "locationrequest.h"
0013 #include "logging.h"
0014 #include "localbackendutils.h"
0015 #include "locationreply.h"
0016 #include "locationrequest.h"
0017 #include "journeyreply.h"
0018 
0019 #include <QFile>
0020 #include <QJsonDocument>
0021 #include <QJsonArray>
0022 #include <QNetworkAccessManager>
0023 #include <QNetworkRequest>
0024 #include <QNetworkReply>
0025 #include <QUrlQuery>
0026 
0027 #include <memory>
0028 
0029 using namespace Qt::Literals::StringLiterals;
0030 
0031 using namespace KPublicTransport;
0032 
0033 SrbijavozBackend::SrbijavozBackend() = default;
0034 
0035 AbstractBackend::Capabilities SrbijavozBackend::capabilities() const
0036 {
0037     return Secure;
0038 }
0039 
0040 bool SrbijavozBackend::needsLocationQuery(const Location &loc, QueryType type) const
0041 {
0042     Q_UNUSED(type);
0043     return loc.identifier(identifierName()).isEmpty();
0044 }
0045 
0046 bool SrbijavozBackend::queryJourney(const JourneyRequest &request, JourneyReply *reply, QNetworkAccessManager *nam) const
0047 {
0048     // Ignore requests for which we don't have the needed identifier
0049     if (request.from().identifier(identifierName()).isEmpty() || request.to().identifier(identifierName()).isEmpty()) {
0050         return false;
0051     }
0052 
0053     if (m_stationsById.empty()) {
0054         if (!m_fetchStationsTask) {
0055             auto mutThis = const_cast<SrbijavozBackend *>(this);
0056             mutThis->m_fetchStationsTask = mutThis->downloadStationData(reply, nam);
0057         }
0058 
0059         QObject::connect(m_fetchStationsTask, &AbstractAsyncTask::finished, reply, [=, this]() {
0060             queryJourney(request, reply, nam);
0061         });
0062 
0063         return true;
0064     }
0065 
0066     QUrl url(u"https://webapi1.srbvoz.rs/ekarta/api/listavozova/ListaVozova_Web"_s);
0067 
0068     QUrlQuery query;
0069     query.addQueryItem(u"stanicaod"_s, request.from().identifier(identifierName()));
0070     query.addQueryItem(u"stanicado"_s, request.to().identifier(identifierName()));
0071     query.addQueryItem(u"datum"_s, request.dateTime().date().toString(QStringLiteral("MM-dd-yyyy")));
0072     query.addQueryItem(u"brojputnika"_s, QString::number(1));
0073     query.addQueryItem(u"razred"_s, QString::number(2));
0074 
0075     url.setQuery(query);
0076 
0077     auto netReply = nam->get(QNetworkRequest(url));
0078     QObject::connect(netReply, &QNetworkReply::finished, reply, [=, this] {
0079         auto bytes = netReply->readAll();
0080         auto connections = QJsonDocument::fromJson(bytes).array();
0081 
0082         std::vector<Journey> journeys;
0083         for (auto train : connections) {
0084             int startId = train[u"odsifra"].toInt();
0085             int endId = train[u"dosifra"].toInt();
0086             QString departureTimeString = train[u"vremep"].toString();
0087             QString arrivalTimeString = train[u"vremed"].toString();
0088             int trainNumber = train[u"brvoz"].toInt();
0089 
0090             QString notes;
0091             if (preferredLanguage() == u"sr") {
0092                 notes = train[u"napomena"].toString().trimmed();
0093             } else {
0094                 notes = train[u"napomenaE"].toString().trimmed();
0095             }
0096 
0097             auto departureTime = parseDateTime(departureTimeString, request.dateTime().date());
0098             auto arrivalTime = parseDateTime(arrivalTimeString, request.dateTime().date(), departureTime);
0099 
0100             const auto stops = train[u"etTrasaVoza"].toArray();
0101 
0102             if (!LocalBackendUtils::isInSelectedTimeframe(departureTime, arrivalTime, request)) {
0103                 continue;
0104             }
0105 
0106             Line line;
0107             line.setName(QString::number(trainNumber));
0108             line.setMode(Line::Train);
0109 
0110             Route route;
0111             route.setLine(line);
0112 
0113             if (!stops.empty()) {
0114                 if (auto destinationId = stops.last()[u"sifrA_STANICE"].toInt(); m_stationsById.contains(destinationId)) {
0115                     route.setDestination(stationToLocation(*m_stationsById.at(destinationId)));
0116                 }
0117             }
0118 
0119             Journey journey;
0120 
0121             JourneySection journeySection;
0122             journeySection.setFrom(stationToLocation(*m_stationsById.at(startId)));
0123             journeySection.setTo(stationToLocation(*m_stationsById.at(endId)));
0124             journeySection.setScheduledDepartureTime(departureTime);
0125             journeySection.setScheduledArrivalTime(arrivalTime);
0126             journeySection.setMode(JourneySection::PublicTransport);
0127 
0128             if (!notes.isEmpty()) {
0129                 journeySection.setNotes(QList<QString>{notes});
0130             }
0131 
0132             std::vector<Stopover> stopovers;
0133 
0134             bool inRoute = false;
0135             QDateTime mostRecentTime = departureTime;
0136             for (const auto &stopover : stops) {
0137                 int stationId = stopover[u"sifrA_STANICE"].toInt();
0138 
0139                 if (stationId == endId) {
0140                     inRoute = false;
0141                 }
0142 
0143                 if (inRoute) {
0144                     Stopover stop;
0145 
0146                     auto arrivalTime = parseDateTime(stopover[u"vremE_DOLASKA"].toString(), request.dateTime().date(), mostRecentTime);
0147                     mostRecentTime = arrivalTime;
0148                     auto departureTime = parseDateTime(stopover[u"vremE_POLASKA"].toString(), request.dateTime().date(), mostRecentTime);
0149                     mostRecentTime = departureTime;
0150 
0151                     stop.setScheduledArrivalTime(arrivalTime);
0152                     stop.setScheduledDepartureTime(arrivalTime);
0153                     stop.setStopPoint(stationToLocation(*m_stationsById.at(stationId)));
0154                     stopovers.push_back(std::move(stop));
0155                 }
0156 
0157                 if (stationId == startId) {
0158                     inRoute = true;
0159                 }
0160             }
0161 
0162             journeySection.setIntermediateStops(std::move(stopovers));
0163             journeySection.setRoute(route);
0164 
0165             journey.setSections({journeySection});
0166 
0167             journeys.push_back(std::move(journey));
0168         }
0169 
0170         bool isEmpty = journeys.empty();
0171 
0172         addResult(reply, this, std::move(journeys));
0173 
0174         if (!isEmpty) {
0175             Attribution osmAttribution;
0176             osmAttribution.setLicense(QStringLiteral("ODbL"));
0177             osmAttribution.setLicenseUrl(QUrl(QStringLiteral("https://opendatacommons.org/licenses/odbl/")));
0178             osmAttribution.setName(QStringLiteral("OpenStreetMap®"));
0179             osmAttribution.setUrl(QUrl(QStringLiteral("https://www.openstreetmap.org")));
0180 
0181             Attribution attribution;
0182             attribution.setName(QStringLiteral("Srbija Voz"));
0183             attribution.setUrl(QUrl(QStringLiteral("https://srbijavoz.rs")));
0184 
0185             addAttributions(reply, {attribution, osmAttribution});
0186         }
0187     });
0188 
0189     QObject::connect(netReply, &QNetworkReply::errorOccurred, reply, [=, this]() {
0190         addError(reply, Reply::NetworkError, netReply->errorString());
0191     });
0192 
0193     return true;
0194 }
0195 
0196 bool SrbijavozBackend::queryLocation(const LocationRequest &request, LocationReply *reply, QNetworkAccessManager *nam) const
0197 {
0198     if (m_stationsById.empty()) {
0199         if (!m_fetchStationsTask) {
0200             auto mutThis = const_cast<SrbijavozBackend *>(this);
0201             mutThis->m_fetchStationsTask = mutThis->downloadStationData(reply, nam);
0202         }
0203 
0204         QObject::connect(m_fetchStationsTask, &AbstractAsyncTask::finished, reply, [=, this]() {
0205             queryLocation(request, reply, nam);
0206         });
0207 
0208         return true;
0209     }
0210 
0211     std::vector<Location> locations;
0212 
0213     const auto searchableName = makeSearchableName(request.name());
0214     for (const auto &[name, station] : m_stationsBySearchName) {
0215         if (name.contains(searchableName)) {
0216             auto location = stationToLocation(*station);
0217 
0218             // Skip stations that we don't have an identifier for
0219             // Those would be coming from OSM data when a station is either not used by Srb Voz,
0220             // or not correctly matched
0221             if (!location.identifier(identifierName()).isEmpty()) {
0222                 locations.push_back(std::move(location));
0223             }
0224         }
0225     }
0226 
0227     addResult(reply, std::move(locations));
0228 
0229     return false;
0230 }
0231 
0232 void SrbijavozBackend::loadAuxData()
0233 {
0234     QFile file(QStringLiteral(":/org.kde.kpublictransport/networks/stations/me-rs.json"));
0235     if (!file.open(QFile::ReadOnly)) {
0236         qCWarning(Log) << file.errorString();
0237         qFatal("The bundled station data of KPublicTransport can not be read. This is a bug.");
0238     }
0239 
0240     const auto stationsJson = QJsonDocument::fromJson(file.readAll()).array();
0241     std::unordered_map<int, std::shared_ptr<SrbStation>> stationsById;
0242     std::unordered_map<QString, std::shared_ptr<SrbStation>> stationsBySearchName;
0243     for (const auto &stationJson : stationsJson) {
0244         auto stationJsonObject = stationJson.toObject();
0245 
0246         // OSM name tags in different languages.
0247         // They will be tried from first to last.
0248         std::vector<QString> keyPrecedence = {
0249             u"name:" % preferredLanguage(),
0250             u"int_name"_s,
0251             u"name:en"_s,
0252             u"alt_name:en"_s,
0253             u"name:sr-Latn"_s,
0254             u"name"_s,
0255             u"alt_name"_s,
0256             u"name:sr"_s
0257         };
0258 
0259         auto findName = [=]() {
0260             for (const auto &key : keyPrecedence) {
0261                 if (stationJsonObject.contains(key)) {
0262                     if (auto name = stationJsonObject[key].toString(); !name.isEmpty()) {
0263                         return name;
0264                     }
0265                 }
0266             }
0267 
0268             return QString();
0269         };
0270 
0271         auto stationName = findName();
0272 
0273         SrbStation station {
0274             .name = stationName,
0275             .longitude = float(stationJson[u"latitude"].toDouble()),
0276             .latitude = float(stationJson[u"longitude"].toDouble()),
0277             .id = -1
0278         };
0279 
0280         auto sharedStation = std::make_shared<SrbStation>(std::move(station));
0281 
0282         // Add search links for all considered languages
0283         for (const auto &key : keyPrecedence) {
0284             if (stationJsonObject.contains(key)) {
0285                 stationsBySearchName.insert({makeSearchableName(stationJsonObject[key].toString()), sharedStation});
0286             }
0287         }
0288     }
0289 
0290     m_stationsBySearchName = std::move(stationsBySearchName);
0291 }
0292 
0293 void SrbijavozBackend::applyStationQuirks()
0294 {
0295     auto addMapping = [&](const QString &from, QString to) {
0296         to = makeSearchableName(to);
0297         if (!m_stationsBySearchName.contains(to)) {
0298             qCWarning(Log) << "srbijavoz: Error in manual mapping from" << from << "to" << to;
0299             return;
0300         }
0301 
0302         auto station = m_stationsBySearchName.at(to);
0303         m_stationsBySearchName.insert({makeSearchableName(from), station});
0304     };
0305 
0306     // Stations of which we can't easily normalize the spelling
0307     addMapping(u"Kos Mitrovica Sever"_s, u"Kosovska Mitrovica Sever"_s);
0308     addMapping(u"Ban.milosevo Polje"_s, u"Banatsko Milosevo"_s);
0309     addMapping(u"Subotica Predgrade"_s, u"Subotica predgrađe"_s);
0310     addMapping(u"Skenderovo"_s, u"Skenderevo"_s);
0311     addMapping(u"Gugalj"_s, u"Гугаљ"_s);
0312     addMapping(u"Donje Jerinje"_s, u"Jarinjë"_s);
0313     addMapping(u"Jerina"_s, u"Jarinjë"_s);
0314     addMapping(u"Brvenik"_s, u"Brevnik"_s);
0315     addMapping(u"Palanka"_s, u"Smederevska Palanka"_s);
0316     addMapping(u"Petrovac-glozan"_s, u"Bački Petrovac - Gložan"_s);
0317     addMapping(u"Osipaonica Stajali."_s, u"Osipaonica staјalište"_s);
0318     addMapping(u"Pancevo Gl.stanica"_s, u"Pančevo glavna"_s);
0319     addMapping(u"Resnik Kragujev."_s, u"Resnik Kragujevački"_s);
0320     addMapping(u"Karlovacki Vinograd"_s, u"Karlovački vinogradi"_s);
0321 
0322     // That one station that I'm not sure enough to add it to OSM
0323     m_stationsBySearchName.insert({makeSearchableName(u"Subotica Javna Skl."_s), std::make_shared<SrbStation>(SrbStation {
0324         .name = u"Subotica Javna Skladista"_s,
0325         .longitude = 19.696104,
0326         .latitude = 46.094215,
0327         .id = -1
0328     })});
0329 }
0330 
0331 AsyncTask<void> *SrbijavozBackend::downloadStationData(Reply *reply, QNetworkAccessManager *nam)
0332 {
0333     loadAuxData();
0334     applyStationQuirks();
0335 
0336     auto *task = new AsyncTask<void>(reply);
0337 
0338     const QUrl url(u"https://webapi1.srbvoz.rs/ekarta/api/stanica"_s);
0339     auto netReply = nam->get(QNetworkRequest(url));
0340     QObject::connect(netReply, &QNetworkReply::finished, reply, [=, this] {
0341         auto bytes = netReply->readAll();
0342 
0343         auto stationsJson = QJsonDocument::fromJson(bytes).array();
0344         for (auto stationJson : stationsJson) {
0345             QString stationName = stationJson[u"naziv"].toString();
0346             uint32_t stationId = stationJson[u"sifra"].toInt();
0347 
0348             QString searchName = makeSearchableName(stationName);
0349             if (!m_stationsBySearchName.contains(searchName)) {
0350                 qCWarning(Log) << "Missing station data for" << stationName << ".";
0351                 qCWarning(Log) << "To fix this, look for the station on OpenStreetMap,"
0352                                << "fix its properties and regenerate the data in lib/networks/stations/";
0353                 qCWarning(Log) << "Usually, the issue is a name mismatch. If the name used by Srbijavoz can not be added"
0354                                   "to OSM, you can add a mapping in the backend.";
0355 
0356                 SrbStation station;
0357                 station.id = int(stationId);
0358                 station.name = stationName;
0359 
0360                 auto sharedStation = std::make_shared<SrbStation>(std::move(station));
0361                 m_stationsBySearchName.insert({searchName, sharedStation});
0362                 m_stationsById.insert({station.id, sharedStation});
0363 
0364                 continue;
0365             }
0366 
0367             auto &station = m_stationsBySearchName.at(searchName);
0368             station->id = int(stationId);
0369 
0370             m_stationsById.insert({int(stationId), station});
0371         }
0372 
0373         task->reportFinished();
0374     });
0375 
0376     return task;
0377 }
0378 
0379 Location SrbijavozBackend::stationToLocation(const SrbStation &station) const
0380 {
0381     Location loc;
0382     if (station.latitude != NAN && station.longitude != NAN) {
0383         loc.setLatitude(station.latitude);
0384         loc.setLongitude(station.longitude);
0385     } else {
0386         // So we have at least some knowledge of the position
0387         loc.setCountry(u"Serbia"_s);
0388     }
0389     loc.setName(station.name);
0390     loc.setType(Location::Stop);
0391 
0392     if (station.id != -1) {
0393         loc.setIdentifier(identifierName(), QString::number(station.id));
0394     }
0395 
0396     return loc;
0397 }
0398 
0399 QString SrbijavozBackend::makeSearchableName(QString name) const
0400 {
0401     auto out = LocalBackendUtils::makeSearchableName(
0402         name.replace(QRegularExpression(QStringLiteral("station|halt")), QString()));
0403 
0404     auto normalizeEnd = [&](QStringView end, QStringView normalizedEnd) {
0405         if (out.endsWith(end)) {
0406             out = out.mid(0, out.size() - end.size()) % normalizedEnd;
0407         }
0408     };
0409 
0410     normalizeEnd(u"ce", u"ca");
0411     normalizeEnd(u"ci", u"c");
0412     normalizeEnd(u"je", u"ja");
0413     normalizeEnd(u".", u"");
0414 
0415     return out;
0416 }
0417 
0418 QString SrbijavozBackend::identifierName() const
0419 {
0420     return u"srbvozid"_s;
0421 }
0422 
0423 QDateTime SrbijavozBackend::parseDateTime(const QString &timeString, const QDate &date, const QDateTime &knownPreviousTime) const
0424 {
0425     auto time = QTime::fromString(timeString.trimmed());
0426 
0427     auto dateTime = date.startOfDay();
0428     dateTime.setTime(time);
0429     dateTime.setTimeZone(QTimeZone("Europe/Belgrade"));
0430 
0431     if (!knownPreviousTime.isNull() && dateTime < knownPreviousTime) {
0432         dateTime.setDate(dateTime.date().addDays(1));
0433     }
0434 
0435     return dateTime;
0436 }