swift
aircraftcfgparser.cpp
1 // SPDX-FileCopyrightText: Copyright (C) 2015 swift Project Community / Contributors
2 // SPDX-License-Identifier: GPL-3.0-or-later OR LicenseRef-swift-pilot-client-1
3 
5 
6 #include <atomic>
7 #include <tuple>
8 
9 #include <QDateTime>
10 #include <QDir>
11 #include <QFile>
12 #include <QFileInfo>
13 #include <QFileInfoList>
14 #include <QFlags>
15 #include <QIODevice>
16 #include <QList>
17 #include <QMetaType>
18 #include <QSettings>
19 #include <QStringView>
20 #include <QTextStream>
21 #include <Qt>
22 #include <QtGlobal>
23 
24 #include "config/buildconfig.h"
25 #include "misc/fileutils.h"
26 #include "misc/logmessage.h"
29 #include "misc/statusmessagelist.h"
30 #include "misc/stringutils.h"
31 #include "misc/worker.h"
32 
33 using namespace swift::config;
34 using namespace swift::misc;
35 using namespace swift::misc::simulation;
36 using namespace swift::misc::simulation::fscommon;
37 using namespace swift::misc::network;
38 
39 namespace swift::misc::simulation::fscommon
40 {
41  // response for async. loading
42  using LoaderResponse = std::tuple<CAircraftCfgEntriesList, CAircraftModelList, CStatusMessageList>;
43 
44  CAircraftCfgParser::CAircraftCfgParser(const CSimulatorInfo &simInfo, QObject *parent)
45  : IAircraftModelLoader(simInfo, parent)
46  {}
47 
49  {
50  return new CAircraftCfgParser(simInfo, parent);
51  }
52 
54  {
55  // that should be safe as long as the worker uses deleteLater (which it does)
56  if (m_parserWorker) { m_parserWorker->waitForFinished(); }
57  }
58 
59  void CAircraftCfgParser::startLoadingFromDisk(LoadMode mode, const ModelConsolidationCallback &modelConsolidation,
60  const QStringList &modelDirectories)
61  {
62  static const CStatusMessage statusLoadingOk(this, CStatusMessage::SeverityInfo,
63  u"Aircraft config parser loaded data");
64  static const CStatusMessage statusLoadingError(this, CStatusMessage::SeverityError,
65  u"Aircraft config parser did NOT load data");
66 
67  const CSimulatorInfo simulator = this->getSimulator();
68  const QStringList modelDirs = this->getInitializedModelDirectories(modelDirectories, simulator);
69  const QStringList excludedDirectoryPatterns(
71 
72  if (mode.testFlag(LoadInBackground))
73  {
74  if (m_parserWorker && !m_parserWorker->isFinished()) { return; }
75  emit this->diskLoadingStarted(simulator, mode);
76  m_parserWorker =
77  CWorker::fromTask(this, "CAircraftCfgParser::startLoadingFromDisk",
78  [this, modelDirs, excludedDirectoryPatterns, simulator, modelConsolidation]() {
79  CStatusMessageList msgs;
80  const CAircraftCfgEntriesList aircraftCfgEntriesList =
81  this->performParsing(modelDirs, excludedDirectoryPatterns, msgs);
82  CAircraftModelList models;
83  if (msgs.isSuccess())
84  {
85  models = aircraftCfgEntriesList.toAircraftModelList(simulator, true, msgs);
86  if (modelConsolidation) { modelConsolidation(models, true); }
87  }
88  return std::make_tuple(aircraftCfgEntriesList, models, msgs);
89  });
90  m_parserWorker->thenWithResult<LoaderResponse>(this, [this, simulator](const LoaderResponse &tuple) {
91  m_loadingMessages = std::get<2>(tuple);
93  {
94  m_parsedCfgEntriesList = std::get<0>(tuple);
95  const CAircraftModelList models(std::get<1>(tuple));
96  const bool hasData = !models.isEmpty();
97  if (hasData) { this->setModelsForSimulator(models, this->getSimulator()); }
98  // currently I treat no data as error
99  m_loadingMessages.push_front(hasData ? statusLoadingOk : statusLoadingError);
100  }
102  emit this->loadingFinished(m_loadingMessages, simulator, ParsedData);
103  });
104  }
105  else if (mode == LoadDirectly)
106  {
107  emit this->diskLoadingStarted(simulator, mode);
108 
109  CStatusMessageList msgs;
110  m_parsedCfgEntriesList = this->performParsing(modelDirs, excludedDirectoryPatterns, msgs);
111  const CAircraftModelList models(m_parsedCfgEntriesList.toAircraftModelList(simulator, true, msgs));
112  m_loadingMessages = msgs;
114  const bool hasData = !models.isEmpty();
115  if (hasData) { this->setCachedModels(models, this->getSimulator()); }
116  // currently I treat no data as error
117  emit this->loadingFinished(hasData ? statusLoadingOk : statusLoadingError, simulator, ParsedData);
118  }
119  }
120 
121  bool CAircraftCfgParser::isLoadingFinished() const { return !m_parserWorker || m_parserWorker->isFinished(); }
122 
123  CAircraftCfgEntriesList CAircraftCfgParser::performParsing(const QStringList &directories,
124  const QStringList &excludeDirectories,
125  CStatusMessageList &messages)
126  {
127  CAircraftCfgEntriesList entries;
128  for (const QString &dir : directories)
129  {
130  entries.push_back(this->performParsing(dir, excludeDirectories, messages));
131  }
132  return entries;
133  }
134 
135  CAircraftCfgEntriesList CAircraftCfgParser::performParsing(const QString &directory,
136  const QStringList &excludeDirectories,
137  CStatusMessageList &messages)
138  {
139  //
140  // function has to be threadsafe
141  //
142 
143  if (m_cancelLoading) { return CAircraftCfgEntriesList(); }
144 
145  // excluded?
146  if (CFileUtils::isExcludedDirectory(directory, excludeDirectories) || isExcludedSubDirectory(directory))
147  {
148  const CStatusMessage m = CStatusMessage(this).info(u"Skipping directory '%1' (excluded)") << directory;
149  messages.push_back(m);
150  return CAircraftCfgEntriesList();
151  }
152 
153  // set directory with name filters, get aircraft.cfg and sub directories
154  static const QString NoNameFilter;
155  QDir dir(directory, NoNameFilter, QDir::Name, QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot);
156  // TODO TZ: still have to figure out how msfs2024 handles this
157  // for MSFS2020 we only need aircraft.cfg
158  // MSFS2024 has aircraft.cfg only in communityfolder
159  // a solution for the aircraft from the marketplace may be prepared by ASOBO
160  dir.setNameFilters(fileNameFilters(getSimulator().isMSFS(), getSimulator().isMSFS2024()));
161  if (!dir.exists())
162  {
163  return CAircraftCfgEntriesList(); // can happen if there are shortcuts or linked dirs not available
164  }
165 
166  const QString currentDir = dir.absolutePath();
168  emit this->loadingProgress(this->getSimulator(), QStringLiteral("Parsing '%1'").arg(currentDir), -1);
169 
170  // Dirs last is crucial, since I will break recursion on "aircraft.cfg" level
171  // with T514 this behaviour has been changed
172  const QFileInfoList files =
173  dir.entryInfoList(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot, QDir::DirsLast);
174 
175  // the sim.cfg/aircraft.cfg file should have an *.air file sibling
176  // if not we assume these files can be ignored
177  const QDir dirForAir(directory, CFsDirectories::airFileFilter(), QDir::Name,
178  QDir::Files | QDir::NoDotAndDotDot);
179  const int airFilesCount = dirForAir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot, QDir::DirsLast).size();
180  const bool hasAirFiles = airFilesCount > 0;
181 
182  if (getSimulator().isP3D() && !hasAirFiles)
183  {
184  const CStatusMessage m = CStatusMessage(this).warning(u"No \"air\" files in '%1'") << currentDir;
185  messages.push_back(m);
186  }
187 
188  for (const auto &fileInfo : files)
189  {
190  if (m_cancelLoading) { return CAircraftCfgEntriesList(); }
191  if (fileInfo.isDir())
192  {
193  const QString nextDir = fileInfo.absoluteFilePath();
194  if (currentDir.startsWith(nextDir, Qt::CaseInsensitive)) { continue; } // do not go up
195  if (dir == currentDir) { continue; } // do not recursively call same directory
196 
197  const CAircraftCfgEntriesList subList(performParsing(nextDir, excludeDirectories, messages));
198  if (messages.isSuccess()) { result.push_back(subList); }
199  else
200  {
201  const CStatusMessage m = CStatusMessage(this).warning(u"Parsing failed for '%1'") << nextDir;
202  messages.push_back(m);
203  }
204  }
205  else
206  {
207  // Enforce air files only for P3D
208  if (getSimulator().isP3D() && !hasAirFiles) { continue; }
209 
210  // due to the filter we expect only "aircraft.cfg"/"sim.cfg" here
211  // remark: in a 1st version I have used QSettings to parse to file as ini file
212  // unfortunately some files are malformed which could end up in wrong data
213 
214  const QString fileName = fileInfo.absoluteFilePath(); // full path and name
215  bool fileOk = false;
216  CStatusMessageList fileMsgs;
217  const CAircraftCfgEntriesList fileResults =
218  CAircraftCfgParser::performParsingOfSingleFile(fileName, fileOk, fileMsgs);
219  if (!fileOk)
220  {
221  const CStatusMessage m = CStatusMessage(this).warning(u"Parsing of '%1' failed") << fileName;
222  messages.push_back(fileMsgs);
223  continue;
224  }
225 
226  result.push_back(fileResults);
227 
228  // With T514 we do not skip not anymore
229  // return result; // do not go any deeper in file tree, we found aircraft.cfg
230  }
231  }
232 
233  // all files finished,
234  // normally reached when no aircraft.cfg is found
235  return result;
236  }
237 
239  CStatusMessageList &msgs)
240  {
241  // due to the filter we expect only "aircraft.cfg" files here
242  // remark: in a 1st version I have used QSettings to parse to file as ini file
243  // unfortunately some files are malformed which could end up in wrong data
244 
245  ok = false;
246  const QString fnFixed = CFileUtils::fixWindowsUncPath(fileName);
247  QFile file(fnFixed); // includes path
248  if (!file.open(QFile::ReadOnly | QFile::Text))
249  {
250  const CStatusMessage m =
251  CStatusMessage(static_cast<CAircraftCfgParser *>(nullptr)).warning(u"Unable to read file '%1'")
252  << fnFixed;
253  msgs.push_back(m);
254  return CAircraftCfgEntriesList();
255  }
256 
257  QTextStream in(&file);
258  QList<CAircraftCfgEntries> tempEntries;
259 
260  // parse through the file
261  QString atcType;
262  QString atcModel;
263  QString fltSection("[FLTSIM.0]");
264  static const QString fltSectionStr("[FLTSIM.%1]");
265 
266  int fltsimCounter = 0;
267  FileSection currentSection = Unknown;
268  const bool isRotorcraftPath = fileName.contains("rotorcraft", Qt::CaseInsensitive);
269 
270  while (!in.atEnd())
271  {
272  const QString lineFixed(in.readLine().trimmed());
273  if (lineFixed.isEmpty()) { continue; }
274  if (lineFixed.startsWith("["))
275  {
276  if (lineFixed.startsWith("[GENERAL]", Qt::CaseInsensitive))
277  {
278  currentSection = General;
279  continue;
280  }
281  if (lineFixed.startsWith(fltSection, Qt::CaseInsensitive))
282  {
283  CAircraftCfgEntries e(fileName, fltsimCounter);
284  if (isRotorcraftPath) { e.setRotorcraft(true); }
285  tempEntries.append(e);
286  currentSection = Fltsim;
287  fltSection = fltSectionStr.arg(++fltsimCounter);
288  continue;
289  }
290  currentSection = Unknown;
291  continue;
292  }
293  switch (currentSection)
294  {
295  case General:
296  {
297  if (lineFixed.startsWith("//")) { break; }
298  if (atcType.isEmpty() || atcModel.isEmpty())
299  {
300  const QString c = getFixedIniLineContent(lineFixed);
301  if (lineFixed.startsWith("atc_type", Qt::CaseInsensitive)) { atcType = c; }
302  /*else if (lineFixed.startsWith("atc_model", Qt::CaseInsensitive))
303  {
304  atcModel = c;
305  }*/
306  else if (lineFixed.startsWith("icao_type_designator", Qt::CaseInsensitive)) { atcModel = c; }
307  }
308  }
309  break;
310  case Fltsim:
311  {
312  if (lineFixed.startsWith("//")) { break; }
313  CAircraftCfgEntries &e = tempEntries[tempEntries.size() - 1];
314  if (lineFixed.startsWith("atc_", Qt::CaseInsensitive))
315  {
316  if (lineFixed.startsWith("atc_parking_codes", Qt::CaseInsensitive))
317  {
318  e.setAtcParkingCode(getFixedIniLineContent(lineFixed));
319  }
320  else if (lineFixed.startsWith("atc_airline", Qt::CaseInsensitive))
321  {
322  e.setAtcAirline(getFixedIniLineContent(lineFixed));
323  }
324  else if (lineFixed.startsWith("atc_id_color", Qt::CaseInsensitive))
325  {
326  e.setAtcIdColor(getFixedIniLineContent(lineFixed));
327  }
328  }
329  else if (lineFixed.startsWith("ui_", Qt::CaseInsensitive))
330  {
331  if (lineFixed.startsWith("ui_manufacturer", Qt::CaseInsensitive))
332  {
333  e.setUiManufacturer(getFixedIniLineContent(lineFixed));
334  }
335  else if (lineFixed.startsWith("ui_typerole", Qt::CaseInsensitive))
336  {
337  bool r = getFixedIniLineContent(lineFixed).toLower().contains("rotor");
338  e.setRotorcraft(r);
339  }
340  else if (lineFixed.startsWith("ui_type", Qt::CaseInsensitive))
341  {
342  e.setUiType(getFixedIniLineContent(lineFixed));
343  }
344  else if (lineFixed.startsWith("ui_variation", Qt::CaseInsensitive))
345  {
346  e.setUiVariation(getFixedIniLineContent(lineFixed));
347  }
348  }
349  else if (lineFixed.startsWith("description", Qt::CaseInsensitive))
350  {
351  e.setDescription(getFixedIniLineContent(lineFixed));
352  }
353  else if (lineFixed.startsWith("texture", Qt::CaseInsensitive))
354  {
355  e.setTexture(getFixedIniLineContent(lineFixed));
356  }
357  else if (lineFixed.startsWith("createdBy", Qt::CaseInsensitive))
358  {
359  e.setCreatedBy(getFixedIniLineContent(lineFixed));
360  }
361  else if (lineFixed.startsWith("sim", Qt::CaseInsensitive))
362  {
363  e.setSimName(getFixedIniLineContent(lineFixed));
364  }
365  else if (lineFixed.startsWith("title", Qt::CaseInsensitive))
366  {
367  e.setTitle(getFixedIniLineContent(lineFixed));
368  }
369  }
370  break;
371  default:
372  case Unknown: break;
373  }
374  } // all lines
375  file.close();
376 
377  // store all entries
378  const QFileInfo fileInfo(fnFixed);
379  QDateTime fileTimestamp(fileInfo.lastModified());
380  if (!fileTimestamp.isValid() || fileInfo.birthTime() > fileTimestamp) { fileTimestamp = fileInfo.birthTime(); }
381  Q_ASSERT_X(fileTimestamp.isValid(), Q_FUNC_INFO, "Missing file timestamp");
382 
384  for (const CAircraftCfgEntries &e : std::as_const(tempEntries))
385  {
386  if (e.getTitle().isEmpty())
387  {
388  const CStatusMessage m = CStatusMessage(static_cast<CAircraftCfgParser *>(nullptr))
389  .info(u"FS model in %1, index %2 has no title")
390  << fileName << e.getIndex();
391  msgs.push_back(m);
392  continue;
393  }
394  CAircraftCfgEntries newEntries(e);
395  newEntries.setAtcModel(atcModel);
396  newEntries.setAtcType(atcType);
397  newEntries.setUtcTimestamp(fileTimestamp);
398  result.push_back(newEntries);
399  }
400  ok = true;
401  return result; // do not go any deeper in file tree, we found aircraft.cfg
402  }
403 
404  QString CAircraftCfgParser::fixedStringContent(const QSettings &settings, const QString &key)
405  {
406  return fixedStringContent(settings.value(key));
407  }
408 
409  QString CAircraftCfgParser::fixedStringContent(const QVariant &qv)
410  {
411  if (qv.isNull() || !qv.isValid())
412  {
413  return {}; // normal when there is no settings value
414  }
415  else if (static_cast<QMetaType::Type>(qv.type()) == QMetaType::QStringList)
416  {
417  const QStringList l = qv.toStringList();
418  return l.join(",").trimmed();
419  }
420  else if (static_cast<QMetaType::Type>(qv.type()) == QMetaType::QString) { return qv.toString().trimmed(); }
421  Q_ASSERT(false);
422  return {};
423  }
424 
425  QString CAircraftCfgParser::getFixedIniLineContent(const QString &line)
426  {
427  if (line.isEmpty()) { return {}; }
428 
429  // Remove inline comments starting with ;
430  const int indexComment = line.indexOf(';');
431  QString content = QStringView { line }.left(indexComment - 1).trimmed().toString();
432 
433  const int index = line.indexOf('=');
434  if (index < 0) { return {}; }
435  if (line.length() < index + 1) { return {}; }
436 
437  content = QStringView { content }.mid(index + 1).trimmed().toString();
438 
439  // fix "" strings, some are malformed and just contain " at beginning, not at the end
440  if (hasBalancedQuotes(content, '"'))
441  {
442  // seems to be OK
443  // ex: title=B767-300ER - Condor "Retro Jet"
444 
445  if (content.size() > 2 && content.startsWith('"') && content.endsWith('"'))
446  {
447  // completly in quotes, example title="B767-300ER - Condor Retro Jet"
448  // we assume the quotes shall be removed
449  content.remove(0, 1);
450  content.chop(1);
451  }
452  }
453  else
454  {
455  // UNBALANCED
456 
457  // could be OK, example title=B767-300ER - Condor Retro Jet"
458  // if (content.endsWith('"')) { content.remove(content.size() - 1, 1); }
459 
460  // Unlikely, title="B767-300ER - Condor "Retro Jet
461  if (content.startsWith('"')) { content.remove(0, 1); }
462  }
463 
464  // fix C style linebreaks
465  content.replace("\\n", " ");
466  content.replace("\\t", " ");
467  return content;
468  }
469 
470  // TODO TZ: MSFS2024 currently has aircraft.cfg only in the community folder
471  const QStringList &CAircraftCfgParser::fileNameFilters(bool isMSFS, bool isMSFS2024)
472  {
473  if (CBuildConfig::buildWordSize() == 32 || isMSFS || isMSFS2024)
474  {
475  static const QStringList f({ "aircraft.cfg" });
476  return f;
477  }
478  else
479  {
480  static const QStringList f({ "aircraft.cfg", "sim.cfg" });
481  return f;
482  }
483  }
484 
485  bool CAircraftCfgParser::isExcludedSubDirectory(const QString &checkDirectory)
486  {
487  if (checkDirectory.isEmpty()) { return false; }
488  const QString dir = CFileUtils::lastPathSegment(checkDirectory).toLower();
489  if (dir == u"texture" || dir.startsWith("texture.")) { return true; }
490  if (dir == u"sound" || dir == "soundai") { return true; }
491  if (dir == u"panel") { return true; }
492  if (dir == u"model") { return true; }
493  return false;
494  }
495 } // namespace swift::misc::simulation::fscommon
496 
497 Q_DECLARE_METATYPE(swift::misc::simulation::fscommon::LoaderResponse)
static QString lastPathSegment(const QString &path)
Last path segment a/b/c => c.
Definition: fileutils.cpp:151
static QString fixWindowsUncPath(const QString &filePath)
UNC file paths on Qt start with "/", but UNC file paths only work when they start with "//".
Definition: fileutils.cpp:444
static bool isExcludedDirectory(const QDir &directory, const QStringList &excludeDirectories, Qt::CaseSensitivity cs=osFileNameCaseSensitivity())
Directory to be excluded?
Definition: fileutils.cpp:242
Derived & warning(const char16_t(&format)[N])
Set the severity to warning, providing a format string.
Derived & info(const char16_t(&format)[N])
Set the severity to info, providing a format string.
void push_back(const T &value)
Appends an element at the end of the sequence.
Definition: sequence.h:305
void push_front(const T &value)
Insert as first element.
Definition: sequence.h:308
bool isEmpty() const
Synonym for empty.
Definition: sequence.h:285
Streamable status message, e.g.
constexpr static auto SeverityError
Status severities.
constexpr static auto SeverityInfo
Status severities.
Status messages, e.g. from Core -> GUI.
bool isSuccess() const
All messages are marked as success.
static CWorker * fromTask(QObject *owner, const QString &name, F &&task)
Returns a new worker object which lives in a new thread.
Definition: worker.h:201
void freezeOrder()
Current order of list will be new order values.
void setUtcTimestamp(const QDateTime &timestamp)
Set timestamp.
Value object encapsulating a list of aircraft models.
Simple hardcoded info about the corresponding simulator.
Definition: simulatorinfo.h:41
Load the aircraft for a simulator.
void loadingProgress(const CSimulatorInfo &simulator, const QString &message, int progressPercentage)
Loading progress, normally from disk.
std::function< int(swift::misc::simulation::CAircraftModelList &, bool)> ModelConsolidationCallback
Callback to consolidate data, normally with DB data.
std::atomic< bool > m_cancelLoading
flag, requesting to cancel loading
const CSimulatorInfo & getSimulator() const
Simulator.
QStringList getInitializedModelDirectories(const QStringList &modelDirectories, const CSimulatorInfo &simulator) const
Get model directories from settings if empty, otherwise checked and UNC path fixed.
void loadingFinished(const CStatusMessageList &status, const CSimulatorInfo &simulator, IAircraftModelLoader::LoadFinishedInfo info)
Parsing is finished or cache has been loaded.
void diskLoadingStarted(const CSimulatorInfo &simulator, IAircraftModelLoader::LoadMode loadMode)
Disk loading started.
settings::CMultiSimulatorSettings m_settings
settings
CStatusMessageList m_loadingMessages
loading messages
@ LoadInBackground
load in background, asyncronously
@ LoadDirectly
load synchronously (blocking), normally for testing
CStatusMessage setCachedModels(const CAircraftModelList &models, const CSimulatorInfo &simulator)
Look like IMultiSimulatorModelCaches interface.
Definition: modelcaches.h:550
void setModelsForSimulator(const CAircraftModelList &models, const CSimulatorInfo &simulator)
Set models.
Definition: modelcaches.h:597
Set of aircraft.cfg entries representing an aircraft (FSX)
void setSimName(const QString &simName)
Simulator name.
void setDescription(const QString &description)
Description.
void setCreatedBy(const QString &createdBy)
Created by.
void setAtcModel(const QString &atcModel)
ATC model.
void setUiType(const QString &type)
UI type (e.g. A321-231 IAE)
void setRotorcraft(bool isRotorcraft)
Is Rotorcraft?
void setUiVariation(const QString &variation)
UI variation (e.g. White,Green)
void setAtcIdColor(const QString &color)
ATC color (e.g. 0xffffffff)
void setUiManufacturer(const QString &manufacturer)
UI manufacturer (e.g. Airbus)
void setAtcParkingCode(const QString &parkingCode)
Parking code.
swift::misc::simulation::CAircraftModelList toAircraftModelList(bool ignoreDuplicatesAndEmptyModelStrings, CStatusMessageList &msgs) const
As aircraft models.
Utility, parsing the aircraft.cfg files.
virtual bool isLoadingFinished() const
Loading finished?
CAircraftCfgParser(const CSimulatorInfo &simInfo, QObject *parent=nullptr)
Constructor.
static CAircraftCfgParser * createModelLoader(const CSimulatorInfo &simInfo, QObject *parent=nullptr)
Create an parser object for given simulator.
static CAircraftCfgEntriesList performParsingOfSingleFile(const QString &fileName, bool &ok, CStatusMessageList &msgs)
Parse a single file.
virtual void startLoadingFromDisk(LoadMode mode, const ModelConsolidationCallback &modelConsolidation, const QStringList &modelDirectories)
Start the loading process from disk.
static const QString & airFileFilter()
.air file filter
QStringList getModelExcludeDirectoryPatternsOrDefault(const CSimulatorInfo &simulator) const
Model exclude patterns per simulator.
Free functions in swift::misc.
SWIFT_MISC_EXPORT bool hasBalancedQuotes(const QString &in, char quote='"')
Has balanced quotes.