File indexing completed on 2024-04-28 04:41:45

0001 /*
0002     SPDX-FileCopyrightText: 2021 Volker Krause <vkrause@kde.org>
0003 
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "polygonsimplifier.h"
0008 #include "../lib/geo/geojson_p.h"
0009 
0010 #include <QCommandLineOption>
0011 #include <QCommandLineParser>
0012 #include <QCoreApplication>
0013 #include <QDebug>
0014 #include <QDirIterator>
0015 #include <QJsonArray>
0016 #include <QJsonDocument>
0017 #include <QJsonObject>
0018 #include <QPolygonF>
0019 #include <QProcess>
0020 #include <QRegularExpression>
0021 
0022 #include <iostream>
0023 
0024 // excluded provider ids, mainly for simple disambiguation
0025 static constexpr const char* const excluded_transport_apis[] = {
0026     "db-hafas-query",
0027     "db-busradar-nrw-hafas-mgate",
0028     "db-sbahn-muenchen-hafas-mgate",
0029 };
0030 
0031 // manual config file name mappings, used when our mapping heuristics fail
0032 static constexpr struct {
0033     const char* fromCountry;
0034     const char* fromId;
0035     const char* to;
0036 } transport_api_mapping[] = {
0037     { "au", "transportnsw", "au_nsw" },
0038     { "be", "nmbs-sncb", "be_sncb" },
0039     { "ch", "sbb-cff-ffs", "ch_sbb" },
0040     { "de", "bayernfahrplan", "de_by_bayern" },
0041     { "de", "nahsh", "de_sh_sh" },
0042     { "de", "nasa", "de_st_insa" },
0043     { "de", "saarfahrplan", "de_sl_saarvv" },
0044     { "dk", "rejseplanen", "dk_dsb" },
0045 };
0046 
0047 static bool updateTransportApisRepo(const QString &path)
0048 {
0049     if (!QDir().exists(path)) {
0050         QProcess proc;
0051         proc.setProcessChannelMode(QProcess::ForwardedChannels);
0052         proc.start(QStringLiteral("git"), {QStringLiteral("clone"), QStringLiteral("https://github.com/public-transport/transport-apis.git"), path});
0053         if (!proc.waitForFinished() || proc.exitCode() != 0) {
0054             return false;
0055         }
0056     }
0057 
0058     QProcess proc;
0059     proc.setWorkingDirectory(path);
0060     proc.setProcessChannelMode(QProcess::ForwardedChannels);
0061     proc.start(QStringLiteral("git"), {QStringLiteral("pull")});
0062     return proc.waitForFinished() && proc.exitCode() == 0;
0063 }
0064 
0065 static bool isArrayOfObjects(const QJsonValue &v)
0066 {
0067     const auto a = v.toArray();
0068     return !a.isEmpty() && a.at(0).isObject();
0069 }
0070 
0071 static void mergeJsonObject(QJsonObject &destObj, const QJsonObject &srcObj)
0072 {
0073     for (auto it = srcObj.begin(); it != srcObj.end(); ++it) {
0074         if (it.value().isObject()) {
0075             const auto srcChild = it.value().toObject();
0076             auto destChild = destObj.value(it.key()).toObject();
0077             mergeJsonObject(destChild, srcChild);
0078             destObj.insert(it.key(), destChild);
0079         } else if (isArrayOfObjects(it.value())) {
0080             const auto srcArray = it.value().toArray();
0081             const auto destArray = destObj.value(it.key()).toArray();
0082             QJsonArray outArray;
0083             int i = 0;
0084             for (; i < destArray.size() && i < srcArray.size(); ++i) {
0085                 auto obj = destArray.at(i).toObject();
0086                 mergeJsonObject(obj, srcArray.at(i).toObject());
0087                 outArray.push_back(obj);
0088             }
0089             for (; i < destArray.size(); ++i) {
0090                 outArray.push_back(destArray.at(i));
0091             }
0092             for (; i < srcArray.size(); ++i) {
0093                 outArray.push_back(srcArray.at(i));
0094             }
0095             destObj.insert(it.key(), outArray);
0096         } else {
0097             destObj.insert(it.key(), it.value());
0098         }
0099     }
0100 }
0101 
0102 static void sortJsonArray(QJsonArray &array)
0103 {
0104     QStringList l;
0105     l.reserve(array.size());
0106     std::transform(array.begin(), array.end(), std::back_inserter(l), [](const auto &jval) { return jval.toString(); });
0107     std::sort(l.begin(), l.end());
0108     array = {};
0109     std::transform(l.begin(), l.end(), std::back_inserter(array), [](const auto &s) { return QJsonValue(s); });
0110 }
0111 
0112 static QByteArray postProcessJson(const QByteArray &data)
0113 {
0114     // truncate floating point numbers
0115     auto s = QString::fromUtf8(data);
0116     s = s.replace(QRegularExpression(QStringLiteral(R"((?<=[\[,])(-?\d+.\d{3})\d+(?=[,\]]))")), QStringLiteral("\\1"));
0117     return s.toUtf8();
0118 }
0119 
0120 
0121 class TransportApiMerger
0122 {
0123 public:
0124     explicit TransportApiMerger(const QString &configPath, const QString &configName);
0125     bool applyUpstreamConfig(const QString &apiConfigFile);
0126     inline QStringList geometryFiles() const {
0127         return m_geometryFiles;
0128     }
0129 
0130 private:
0131     void preProcessConfig(QJsonObject &top);
0132     void preProcessCoverage(QJsonObject &obj, const QString &coverageType);
0133 
0134     QString m_configPath;
0135     QString m_configName;
0136     QStringList m_geometryFiles;
0137 };
0138 
0139 TransportApiMerger::TransportApiMerger(const QString &configPath, const QString &configName)
0140     : m_configPath(configPath)
0141     , m_configName(configName)
0142 {
0143 }
0144 
0145 void TransportApiMerger::preProcessCoverage(QJsonObject &obj, const QString &coverageType)
0146 {
0147     // sort country codes
0148     auto regions = obj.take(QLatin1String("region")).toArray();
0149     sortJsonArray(regions);
0150     if (!regions.empty()) {
0151         obj.insert(QLatin1String("region"), regions);
0152     }
0153 
0154     // reduce resolution of the area geometry
0155     using namespace KPublicTransport;
0156     auto polys = GeoJson::readOuterPolygons(obj.take(QLatin1String("area")).toObject());
0157     for (auto &poly : polys) {
0158         const auto originalPolySize = poly.size();
0159         poly = PolygonSimplifier::douglasPeucker(poly, 10'000.0);
0160         if (originalPolySize > poly.size()) {
0161             // only apply offsetting if we actually simplified the polygon
0162             poly = PolygonSimplifier::offset(poly, 10'000.0);
0163         }
0164     }
0165 
0166     // remove polygons fully contained inside another one already (e.g. small enclaves/islands included by the above offset operation now)
0167     for (auto it = polys.begin(); it != polys.end();) {
0168         auto it2 = polys.begin();
0169         for (; it2 != polys.end(); ++it2) {
0170             if (it == it2) {
0171                 continue;
0172             }
0173             if ((*it).subtracted(*it2).isEmpty()) {
0174                 break;
0175             }
0176         }
0177         if (it2 != polys.end()) {
0178             qDebug() << "dropping fully enclosed polygon" << (*it).size();
0179             it = polys.erase(it);
0180         } else {
0181             ++it;
0182         }
0183     }
0184 
0185     if (!polys.empty()) {
0186         // if the polygon is too complex, store it in an external file loaded on demand
0187         if (polys.size() > 1 || polys[0].size() > 10) {
0188             const QString geoJsonFile = m_configName + QLatin1Char('_') + coverageType.chopped(8) + QLatin1String(".geojson");
0189             QFile f(m_configPath + QLatin1String("/geometry/") + geoJsonFile);
0190             if (!f.open(QFile::WriteOnly)) {
0191                 qCritical() << f.errorString() << f.fileName();
0192                 return;
0193             }
0194 
0195             f.write(postProcessJson(QJsonDocument(GeoJson::writePolygons(polys)).toJson(QJsonDocument::Compact)));
0196             obj.insert(QLatin1String("areaFile"), geoJsonFile);
0197             m_geometryFiles.push_back(geoJsonFile);
0198         } else {
0199             obj.insert(QLatin1String("area"), GeoJson::writePolygons(polys));
0200         }
0201     }
0202 }
0203 
0204 void TransportApiMerger::preProcessConfig(QJsonObject &top)
0205 {
0206     // move translated keys to the location ki18n expects them
0207     QJsonObject translatedObj;
0208     for (const auto &key : { "name", "description" }) {
0209         auto v = top.take(QLatin1String(key));
0210 
0211         auto normalizedKey = QString::fromLatin1(key);
0212         normalizedKey[0] = normalizedKey[0].toUpper();
0213         translatedObj.insert(normalizedKey, std::move(v));
0214     }
0215     top.insert(QLatin1String("KPlugin"), std::move(translatedObj));
0216 
0217     // remove excessive Hafas client properties
0218     auto options = top.take(QLatin1String("options")).toObject();
0219     auto client = options.value(QLatin1String("client")).toObject();
0220     client.remove(QLatin1String("os"));
0221     client.remove(QLatin1String("v"));
0222     client.remove(QLatin1String("name"));
0223     if (!client.isEmpty()) {
0224         options.insert(QLatin1String("client"), std::move(client));
0225     }
0226     if (!options.isEmpty()) {
0227         top.insert(QLatin1String("options"), std::move(options));
0228     }
0229 
0230     // coverage data
0231     auto coverage = top.take(QLatin1String("coverage")).toObject();
0232     for (auto it = coverage.begin(); it != coverage.end(); ++it) {
0233         auto cov = it.value().toObject();
0234         preProcessCoverage(cov, it.key());
0235         it.value() = cov;
0236     }
0237     top.insert(QLatin1String("coverage"), coverage);
0238 }
0239 
0240 static void postProcessConfig(QJsonObject &top)
0241 {
0242     // sort languages alphabetically to create stable diffs, the order doesn't matter for us
0243     auto langs = top.take(QLatin1String("supportedLanguages")).toArray();
0244     sortJsonArray(langs);
0245     if (!langs.empty()) {
0246         top.insert(QLatin1String("supportedLanguages"), langs);
0247     }
0248 
0249     // remove inline areas from coverage data
0250     auto coverage = top.take(QLatin1String("coverage")).toObject();
0251     for (auto it = coverage.begin(); it != coverage.end(); ++it) {
0252         auto cov = it.value().toObject();
0253         if (cov.contains(QLatin1String("areaFile")) && cov.contains(QLatin1String("area"))) {
0254             cov.remove(QLatin1String("area"));
0255         }
0256         it.value() = cov;
0257     }
0258     top.insert(QLatin1String("coverage"), coverage);
0259 }
0260 
0261 bool TransportApiMerger::applyUpstreamConfig(const QString &apiConfigFile)
0262 {
0263     const QString kptConfigFile = m_configPath + QLatin1Char('/') + m_configName + QLatin1String(".json");
0264     qDebug() << "merging" << apiConfigFile << kptConfigFile;
0265     QFile inFile(apiConfigFile);
0266     if (!inFile.open(QFile::ReadOnly)) {
0267         std::cerr << qPrintable(inFile.errorString()) << std::endl;
0268         return false;
0269     }
0270     QJsonParseError error;
0271     auto inObj = QJsonDocument::fromJson(inFile.readAll(), &error).object();
0272     if (error.error != QJsonParseError::NoError) {
0273         std::cerr << "JSON parsing error: " << qPrintable(error.errorString()) << ": " << qPrintable(apiConfigFile) << std::endl;
0274         return false;
0275     }
0276     preProcessConfig(inObj);
0277 
0278     QFile outFile(kptConfigFile);
0279     if (!outFile.open(QFile::ReadOnly)) {
0280         std::cerr << qPrintable(outFile.errorString());
0281         return false;
0282     }
0283     auto outObj = QJsonDocument::fromJson(outFile.readAll()).object();
0284     outFile.close();
0285 
0286     mergeJsonObject(outObj, inObj);
0287     postProcessConfig(outObj);
0288 
0289     if (!outFile.open(QFile::WriteOnly | QFile::Truncate)) {
0290         std::cerr << qPrintable(outFile.errorString()) << std::endl;
0291         return false;
0292     }
0293     outFile.write(QJsonDocument(outObj).toJson());
0294     return true;
0295 }
0296 
0297 static void writeGeometryQrcFile(const QString &configPath, const QStringList &geometryFiles)
0298 {
0299     QFile f(configPath + QLatin1String("/geometry/geometry.qrc"));
0300     if (!f.open(QFile::WriteOnly)) {
0301         qCritical() << f.errorString() << f.fileName();
0302         return;
0303     }
0304 
0305     f.write(R"(<!--
0306     SPDX-FileCopyrightText: auto-generated
0307     SPDX-License-Identifier: CC0-1.0
0308 -->
0309 <RCC>
0310     <qresource prefix="/org.kde.kpublictransport/networks/geometry/">
0311 )");
0312     for (const auto &g : geometryFiles) {
0313         f.write("        <file>");
0314         f.write(g.toUtf8());
0315         f.write("</file>\n");
0316     }
0317     f.write(R"(    </qresource>
0318 </RCC>
0319 )");
0320 }
0321 
0322 /** Sync network configurations with upstream transport API repository. */
0323 int main(int argc, char **argv)
0324 {
0325     QCoreApplication::setApplicationName(QStringLiteral("transport-api-snyc"));
0326     QCoreApplication::setOrganizationDomain(QStringLiteral("kde.org"));
0327     QCoreApplication::setOrganizationName(QStringLiteral("KDE"));
0328     QCoreApplication app(argc, argv);
0329 
0330     QCommandLineParser parser;
0331     auto configPathOpt = QCommandLineOption({QStringLiteral("config-path")}, QStringLiteral("location of the network config files"), QStringLiteral("config-path"));
0332     parser.addOption(configPathOpt);
0333     auto transportApiPathOpt = QCommandLineOption({QStringLiteral("transport-apis")}, QStringLiteral("path to the transport-apis repo checkout"), QStringLiteral("transport-apis-repo"));
0334     parser.addOption(transportApiPathOpt);
0335     parser.addHelpOption();
0336     parser.process(app);
0337 
0338     if (!updateTransportApisRepo(parser.value(transportApiPathOpt))) {
0339         return -1;
0340     }
0341 
0342     // match our files and the transport api ones
0343     struct MatchedConfig {
0344         QString config;
0345         std::vector<QString> apiConfigs;
0346     };
0347     std::vector<MatchedConfig> configs;
0348     for (QDirIterator it(parser.value(configPathOpt), QDir::Files); it.hasNext();) {
0349         const auto fileName = it.next();
0350         QFile f(fileName);
0351         if (!fileName.endsWith(QLatin1String(".json"))) {
0352             continue;
0353         }
0354         MatchedConfig c;
0355         c.config = it.fileInfo().baseName();
0356         configs.push_back(std::move(c));
0357     }
0358     for (QDirIterator it(parser.value(transportApiPathOpt) + QLatin1String("/data"), QDir::Files, QDirIterator::Subdirectories); it.hasNext();) {
0359         const auto fileName = it.next();
0360         const auto baseName = it.fileInfo().baseName();
0361         if (!fileName.endsWith(QLatin1String(".json"))
0362          || std::any_of(std::begin(excluded_transport_apis), std::end(excluded_transport_apis), [&baseName](const char* excl) {
0363                 return QLatin1String(excl) == baseName;
0364             })
0365         ) {
0366             continue;
0367         }
0368 
0369         QRegularExpression rx(QStringLiteral("/([a-z]{2})/([a-z-]+?)-(hafas-mgate|hafas-query|[^-]+)\\.json"));
0370         const auto match = rx.match(fileName);
0371         if (!match.hasMatch()) {
0372             return -1;
0373         }
0374 
0375         bool found = false;
0376         for (auto cit = configs.begin(); cit != configs.end(); ++cit) {
0377             if (!(*cit).config.startsWith(match.captured(1) + QLatin1Char('_')) || !(*cit).config.endsWith(QLatin1Char('_') + match.captured(2))) {
0378                 continue;
0379             }
0380             (*cit).apiConfigs.push_back(fileName);
0381             found = true;
0382         }
0383         if (found) {
0384             continue;
0385         }
0386         for (const auto &map : transport_api_mapping) {
0387             if (QLatin1String(map.fromCountry) == match.captured(1) && QLatin1String(map.fromId) == match.captured(2)) {
0388                 auto cit = std::find_if(configs.begin(), configs.end(), [map](const auto &config) {
0389                     return QLatin1String(map.to) == config.config;
0390                 });
0391                 if (cit != configs.end()) {
0392                     (*cit).apiConfigs.push_back(fileName);
0393                 }
0394                 found = true;
0395                 break;
0396             }
0397         }
0398         if (!found) {
0399             qDebug() << "  no match found for" << fileName;
0400         }
0401     }
0402 
0403     // for better readability of the output
0404     std::sort(configs.begin(), configs.end(), [](const auto &lhs, const auto &rhs) {
0405         return lhs.config < rhs.config;
0406     });
0407 
0408     QStringList geometryFiles;
0409     for (const auto &c : configs) {
0410         if (c.apiConfigs.empty()) {
0411             qDebug() << "  " << c.config << "is missing upstream";
0412             continue;
0413         }
0414         if (c.apiConfigs.size() > 1) {
0415             qDebug() << "  " << c.config << "has multiple matches:" << c.apiConfigs;
0416             // TODO pick the best protocol and use that one
0417             continue;
0418         }
0419 
0420         TransportApiMerger merger(parser.value(configPathOpt), c.config);
0421         if (!merger.applyUpstreamConfig(c.apiConfigs[0])) {
0422             return -1;
0423         }
0424         geometryFiles.append(merger.geometryFiles());
0425     }
0426 
0427     std::sort(geometryFiles.begin(), geometryFiles.end());
0428     writeGeometryQrcFile(parser.value(configPathOpt), geometryFiles);
0429 }