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 }