diff options
-rw-r--r-- | api/logic/CMakeLists.txt | 2 | ||||
-rw-r--r-- | api/logic/modplatform/flame/FlamePackIndex.cpp | 92 | ||||
-rw-r--r-- | api/logic/modplatform/flame/FlamePackIndex.h | 43 | ||||
-rw-r--r-- | application/CMakeLists.txt | 1 | ||||
-rw-r--r-- | application/pages/modplatform/flame/FlameData.h | 38 | ||||
-rw-r--r-- | application/pages/modplatform/flame/FlameModel.cpp | 96 | ||||
-rw-r--r-- | application/pages/modplatform/flame/FlameModel.h | 7 | ||||
-rw-r--r-- | application/pages/modplatform/flame/FlamePage.cpp | 95 | ||||
-rw-r--r-- | application/pages/modplatform/flame/FlamePage.h | 9 | ||||
-rw-r--r-- | application/pages/modplatform/flame/FlamePage.ui | 171 |
10 files changed, 333 insertions, 221 deletions
diff --git a/api/logic/CMakeLists.txt b/api/logic/CMakeLists.txt index 3193d813..beaa0c00 100644 --- a/api/logic/CMakeLists.txt +++ b/api/logic/CMakeLists.txt @@ -466,6 +466,8 @@ set(FTB_SOURCES set(FLAME_SOURCES # Flame + modplatform/flame/FlamePackIndex.cpp + modplatform/flame/FlamePackIndex.h modplatform/flame/PackManifest.h modplatform/flame/PackManifest.cpp modplatform/flame/FileResolvingTask.h diff --git a/api/logic/modplatform/flame/FlamePackIndex.cpp b/api/logic/modplatform/flame/FlamePackIndex.cpp new file mode 100644 index 00000000..3d8ea22a --- /dev/null +++ b/api/logic/modplatform/flame/FlamePackIndex.cpp @@ -0,0 +1,92 @@ +#include "FlamePackIndex.h" + +#include "Json.h" + +void Flame::loadIndexedPack(Flame::IndexedPack & pack, QJsonObject & obj) +{ + pack.addonId = Json::requireInteger(obj, "id"); + pack.name = Json::requireString(obj, "name"); + pack.websiteUrl = Json::ensureString(obj, "websiteUrl", ""); + pack.description = Json::ensureString(obj, "summary", ""); + + bool thumbnailFound = false; + auto attachments = Json::requireArray(obj, "attachments"); + for(auto attachmentRaw: attachments) { + auto attachmentObj = Json::requireObject(attachmentRaw); + bool isDefault = attachmentObj.value("isDefault").toBool(false); + if(isDefault) { + thumbnailFound = true; + pack.logoName = Json::requireString(attachmentObj, "title"); + pack.logoUrl = Json::requireString(attachmentObj, "thumbnailUrl"); + break; + } + } + + if(!thumbnailFound) { + throw JSONValidationError(QString("Pack without an icon, skipping: %1").arg(pack.name)); + } + + auto authors = Json::requireArray(obj, "authors"); + for(auto authorIter: authors) { + auto author = Json::requireObject(authorIter); + Flame::ModpackAuthor packAuthor; + packAuthor.name = Json::requireString(author, "name"); + packAuthor.url = Json::requireString(author, "url"); + pack.authors.append(packAuthor); + } + int defaultFileId = Json::requireInteger(obj, "defaultFileId"); + + bool found = false; + // check if there are some files before adding the pack + auto files = Json::requireArray(obj, "latestFiles"); + for(auto fileIter: files) { + auto file = Json::requireObject(fileIter); + int id = Json::requireInteger(file, "id"); + + // NOTE: for now, ignore everything that's not the default... + if(id != defaultFileId) { + continue; + } + + auto versionArray = Json::requireArray(file, "gameVersion"); + if(versionArray.size() < 1) { + continue; + } + + found = true; + break; + } + if(!found) { + throw JSONValidationError(QString("Pack with no good file, skipping: %1").arg(pack.name)); + } +} + +void Flame::loadIndexedPackVersions(Flame::IndexedPack & pack, QJsonArray & arr) +{ + QVector<Flame::IndexedVersion> unsortedVersions; + for(auto versionIter: arr) { + auto version = Json::requireObject(versionIter); + Flame::IndexedVersion file; + + file.addonId = pack.addonId; + file.fileId = Json::requireInteger(version, "id"); + auto versionArray = Json::requireArray(version, "gameVersion"); + if(versionArray.size() < 1) { + continue; + } + + // pick the latest version supported + file.mcVersion = versionArray[0].toString(); + file.version = Json::requireString(version, "displayName"); + file.downloadUrl = Json::requireString(version, "downloadUrl"); + unsortedVersions.append(file); + } + + auto orderSortPredicate = [](const IndexedVersion & a, const IndexedVersion & b) -> bool + { + return a.fileId > b.fileId; + }; + std::sort(unsortedVersions.begin(), unsortedVersions.end(), orderSortPredicate); + pack.versions = unsortedVersions; + pack.versionsLoaded = true; +} diff --git a/api/logic/modplatform/flame/FlamePackIndex.h b/api/logic/modplatform/flame/FlamePackIndex.h new file mode 100644 index 00000000..cdeb2c13 --- /dev/null +++ b/api/logic/modplatform/flame/FlamePackIndex.h @@ -0,0 +1,43 @@ +#pragma once + +#include <QList> +#include <QMetaType> +#include <QString> +#include <QVector> + +#include "multimc_logic_export.h" + +namespace Flame { + +struct ModpackAuthor { + QString name; + QString url; +}; + +struct IndexedVersion { + int addonId; + int fileId; + QString version; + QString mcVersion; + QString downloadUrl; +}; + +struct IndexedPack +{ + int addonId; + QString name; + QString description; + QList<ModpackAuthor> authors; + QString logoName; + QString logoUrl; + QString websiteUrl; + + bool versionsLoaded = false; + QVector<IndexedVersion> versions; +}; + +MULTIMC_LOGIC_EXPORT void loadIndexedPack(IndexedPack & m, QJsonObject & obj); +MULTIMC_LOGIC_EXPORT void loadIndexedPackVersions(IndexedPack & m, QJsonArray & arr); +} + +Q_DECLARE_METATYPE(Flame::IndexedPack) diff --git a/application/CMakeLists.txt b/application/CMakeLists.txt index afd13574..c240baf2 100644 --- a/application/CMakeLists.txt +++ b/application/CMakeLists.txt @@ -145,7 +145,6 @@ SET(MULTIMC_SOURCES pages/modplatform/legacy_ftb/ListModel.h pages/modplatform/legacy_ftb/ListModel.cpp - pages/modplatform/flame/FlameData.h pages/modplatform/flame/FlameModel.cpp pages/modplatform/flame/FlameModel.h pages/modplatform/flame/FlamePage.cpp diff --git a/application/pages/modplatform/flame/FlameData.h b/application/pages/modplatform/flame/FlameData.h deleted file mode 100644 index 9245ba8a..00000000 --- a/application/pages/modplatform/flame/FlameData.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include <QString> -#include <QList> - -namespace Flame { - -struct ModpackAuthor { - QString name; - QString url; -}; - -struct ModpackFile { - int addonId; - int fileId; - QString version; - QString mcVersion; - QString downloadUrl; -}; - -struct Modpack -{ - bool broken = true; - int addonId = 0; - - QString name; - QString description; - QList<ModpackAuthor> authors; - QString mcVersion; - QString logoName; - QString logoUrl; - QString websiteUrl; - - ModpackFile latestFile; -}; -} - -Q_DECLARE_METATYPE(Flame::Modpack) diff --git a/application/pages/modplatform/flame/FlameModel.cpp b/application/pages/modplatform/flame/FlameModel.cpp index 6d9dbda7..228a88c5 100644 --- a/application/pages/modplatform/flame/FlameModel.cpp +++ b/application/pages/modplatform/flame/FlameModel.cpp @@ -1,5 +1,6 @@ #include "FlameModel.h" #include "MultiMC.h" +#include <Json.h> #include <MMCStrings.h> #include <Version.h> @@ -38,7 +39,7 @@ QVariant ListModel::data(const QModelIndex &index, int role) const return QString("INVALID INDEX %1").arg(pos); } - Modpack pack = modpacks.at(pos); + IndexedPack pack = modpacks.at(pos); if(role == Qt::DisplayRole) { return pack.name; @@ -163,13 +164,12 @@ void ListModel::performPaginatedSearch() "https://addons-ecs.forgesvc.net/api/v2/addon/search?" "categoryId=0&" "gameId=432&" - //"gameVersion=1.12.2&" "index=%1&" "pageSize=25&" "searchFilter=%2&" "sectionId=4471&" - "sort=0" - ).arg(nextSearchOffset).arg(currentSearchTerm); + "sort=%3" + ).arg(nextSearchOffset).arg(currentSearchTerm).arg(currentSort); netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), &response)); jobPtr = netJob; jobPtr->start(); @@ -177,12 +177,13 @@ void ListModel::performPaginatedSearch() QObject::connect(netJob, &NetJob::failed, this, &ListModel::searchRequestFailed); } -void ListModel::searchWithTerm(const QString& term) +void ListModel::searchWithTerm(const QString& term, int sort) { - if(currentSearchTerm == term) { + if(currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort) { return; } currentSearchTerm = term; + currentSort = sort; if(jobPtr) { jobPtr->abort(); searchState = ResetRequested; @@ -210,79 +211,24 @@ void Flame::ListModel::searchRequestFinished() return; } - QList<Modpack> newList; - auto objs = doc.array(); - for(auto projectIter: objs) { - Modpack pack; - auto project = projectIter.toObject(); - pack.addonId = project.value("id").toInt(0); - if (pack.addonId == 0) { - qWarning() << "Pack without an ID, skipping: " << pack.name; - continue; - } - pack.name = project.value("name").toString(); - pack.websiteUrl = project.value("websiteUrl").toString(); - pack.description = project.value("summary").toString(); - bool thumbnailFound = false; - auto attachments = project.value("attachments").toArray(); - for(auto attachmentIter: attachments) { - auto attachment = attachmentIter.toObject(); - bool isDefault = attachment.value("isDefault").toBool(false); - if(isDefault) { - thumbnailFound = true; - pack.logoName = attachment.value("title").toString(); - pack.logoUrl = attachment.value("thumbnailUrl").toString(); - break; - } - } - if(!thumbnailFound) { - qWarning() << "Pack without an icon, skipping: " << pack.name; - continue; - } - auto authors = project.value("authors").toArray(); - for(auto authorIter: authors) { - auto author = authorIter.toObject(); - ModpackAuthor packAuthor; - packAuthor.name = author.value("name").toString(); - packAuthor.url = author.value("url").toString(); - pack.authors.append(packAuthor); - } - int defaultFileId = project.value("defaultFileId").toInt(0); - if(defaultFileId == 0) { - qWarning() << "Pack without default file, skipping: " << pack.name; - continue; - } - bool found = false; - auto files = project.value("latestFiles").toArray(); - for(auto fileIter: files) { - auto file = fileIter.toObject(); - int id = file.value("id").toInt(0); - // NOTE: for now, ignore everything that's not the default... - if(id != defaultFileId) { - continue; - } - pack.latestFile.addonId = pack.addonId; - pack.latestFile.fileId = id; - auto versionArray = file.value("gameVersion").toArray(); - if(versionArray.size() < 1) { - continue; - } - - // pick the latest version supported - pack.latestFile.mcVersion = versionArray[0].toString(); - pack.latestFile.version = file.value("displayName").toString(); - pack.latestFile.downloadUrl = file.value("downloadUrl").toString(); - found = true; - break; + QList<Flame::IndexedPack> newList; + auto packs = doc.array(); + for(auto packRaw : packs) { + auto packObj = packRaw.toObject(); + + Flame::IndexedPack pack; + try + { + Flame::loadIndexedPack(pack, packObj); + newList.append(pack); } - if(!found) { - qWarning() << "Pack with no good file, skipping: " << pack.name; + catch(const JSONValidationError &e) + { + qWarning() << "Error while loading pack from CurseForge: " << e.cause(); continue; } - pack.broken = false; - newList.append(pack); } - if(objs.size() < 25) { + if(packs.size() < 25) { searchState = Finished; } else { nextSearchOffset += 25; diff --git a/application/pages/modplatform/flame/FlameModel.h b/application/pages/modplatform/flame/FlameModel.h index b4dded76..24383db0 100644 --- a/application/pages/modplatform/flame/FlameModel.h +++ b/application/pages/modplatform/flame/FlameModel.h @@ -15,7 +15,7 @@ #include <functional> #include <net/NetJob.h> -#include "FlameData.h" +#include <modplatform/flame/FlamePackIndex.h> namespace Flame { @@ -39,7 +39,7 @@ public: void fetchMore(const QModelIndex & parent) override; void getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback); - void searchWithTerm(const QString & term); + void searchWithTerm(const QString & term, const int sort); private slots: void performPaginatedSearch(); @@ -54,13 +54,14 @@ private: void requestLogo(QString file, QString url); private: - QList<Modpack> modpacks; + QList<IndexedPack> modpacks; QStringList m_failedLogos; QStringList m_loadingLogos; LogoMap m_logoMap; QMap<QString, LogoCallback> waitingCallbacks; QString currentSearchTerm; + int currentSort = 0; int nextSearchOffset = 0; enum SearchState { None, diff --git a/application/pages/modplatform/flame/FlamePage.cpp b/application/pages/modplatform/flame/FlamePage.cpp index 3889f15a..2b7d9004 100644 --- a/application/pages/modplatform/flame/FlamePage.cpp +++ b/application/pages/modplatform/flame/FlamePage.cpp @@ -2,6 +2,7 @@ #include "ui_FlamePage.h" #include "MultiMC.h" +#include <Json.h> #include "dialogs/NewInstanceDialog.h" #include <InstanceImportTask.h> #include "FlameModel.h" @@ -13,9 +14,20 @@ FlamePage::FlamePage(NewInstanceDialog* dialog, QWidget *parent) ui->setupUi(this); connect(ui->searchButton, &QPushButton::clicked, this, &FlamePage::triggerSearch); ui->searchEdit->installEventFilter(this); - model = new Flame::ListModel(this); - ui->packView->setModel(model); + listModel = new Flame::ListModel(this); + ui->packView->setModel(listModel); + + // index is used to set the sorting with the curseforge api + ui->sortByBox->addItem(tr("Sort by featured")); + ui->sortByBox->addItem(tr("Sort by popularity")); + ui->sortByBox->addItem(tr("Sort by last updated")); + ui->sortByBox->addItem(tr("Sort by name")); + ui->sortByBox->addItem(tr("Sort by author")); + ui->sortByBox->addItem(tr("Sort by total downloads")); + + connect(ui->sortByBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &FlamePage::triggerSearch); connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlamePage::onSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &FlamePage::onVersionSelectionChanged); } FlamePage::~FlamePage() @@ -44,26 +56,28 @@ bool FlamePage::shouldDisplay() const void FlamePage::openedImpl() { suggestCurrent(); + triggerSearch(); } void FlamePage::triggerSearch() { - model->searchWithTerm(ui->searchEdit->text()); + listModel->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex()); } void FlamePage::onSelectionChanged(QModelIndex first, QModelIndex second) { + ui->versionSelectionBox->clear(); + if(!first.isValid()) { if(isOpened) { dialog->setSuggestedPack(); } - ui->frame->clear(); return; } - current = model->data(first, Qt::UserRole).value<Flame::Modpack>(); + current = listModel->data(first, Qt::UserRole).value<Flame::IndexedPack>(); QString text = ""; QString name = current.name; @@ -82,12 +96,56 @@ void FlamePage::onSelectionChanged(QModelIndex first, QModelIndex second) for(auto & author: current.authors) { authorStrs.push_back(authorToStr(author)); } - text += tr(" by ") + authorStrs.join(", "); + text += "<br>" + tr(" by ") + authorStrs.join(", "); } + text += "<br><br>"; - ui->frame->setModText(text); - ui->frame->setModDescription(current.description); - suggestCurrent(); + ui->packDescription->setHtml(text + current.description); + + if (current.versionsLoaded == false) + { + qDebug() << "Loading flame modpack versions"; + NetJob *netJob = new NetJob(QString("Flame::PackVersions(%1)").arg(current.name)); + std::shared_ptr<QByteArray> response = std::make_shared<QByteArray>(); + int addonId = current.addonId; + netJob->addNetAction(Net::Download::makeByteArray(QString("https://addons-ecs.forgesvc.net/api/v2/addon/%1/files").arg(addonId), response.get())); + + QObject::connect(netJob, &NetJob::succeeded, this, [this, response] + { + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if(parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from CurseForge at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + QJsonArray arr = doc.array(); + try + { + Flame::loadIndexedPackVersions(current, arr); + } + catch(const JSONValidationError &e) + { + qDebug() << *response; + qWarning() << "Error while reading flame modpack version: " << e.cause(); + } + + for(auto version : current.versions) { + ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + + suggestCurrent(); + }); + netJob->start(); + } + else + { + for(auto version : current.versions) { + ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + + suggestCurrent(); + } } void FlamePage::suggestCurrent() @@ -96,16 +154,23 @@ void FlamePage::suggestCurrent() { return; } - if(current.broken) - { - dialog->setSuggestedPack(); - } - dialog->setSuggestedPack(current.name, new InstanceImportTask(current.latestFile.downloadUrl)); + dialog->setSuggestedPack(current.name, new InstanceImportTask(selectedVersion)); QString editedLogoName; editedLogoName = "curseforge_" + current.logoName.section(".", 0, 0); - model->getLogo(current.logoName, current.logoUrl, [this, editedLogoName](QString logo) + listModel->getLogo(current.logoName, current.logoUrl, [this, editedLogoName](QString logo) { dialog->setSuggestedIconFromFile(logo, editedLogoName); }); } + +void FlamePage::onVersionSelectionChanged(QString data) +{ + if(data.isNull() || data.isEmpty()) + { + selectedVersion = ""; + return; + } + selectedVersion = ui->versionSelectionBox->currentData().toString(); + suggestCurrent(); +}
\ No newline at end of file diff --git a/application/pages/modplatform/flame/FlamePage.h b/application/pages/modplatform/flame/FlamePage.h index e50186f5..467bb44b 100644 --- a/application/pages/modplatform/flame/FlamePage.h +++ b/application/pages/modplatform/flame/FlamePage.h @@ -20,7 +20,7 @@ #include "pages/BasePage.h" #include <MultiMC.h> #include "tasks/Task.h" -#include "FlameData.h" +#include <modplatform/flame/FlamePackIndex.h> namespace Ui { @@ -68,10 +68,13 @@ private: private slots: void triggerSearch(); void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(QString data); private: Ui::FlamePage *ui = nullptr; NewInstanceDialog* dialog = nullptr; - Flame::ListModel* model = nullptr; - Flame::Modpack current; + Flame::ListModel* listModel = nullptr; + Flame::IndexedPack current; + + QString selectedVersion; }; diff --git a/application/pages/modplatform/flame/FlamePage.ui b/application/pages/modplatform/flame/FlamePage.ui index 21e23f1f..9723815a 100644 --- a/application/pages/modplatform/flame/FlamePage.ui +++ b/application/pages/modplatform/flame/FlamePage.ui @@ -1,91 +1,90 @@ <?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"> - <class>FlamePage</class> - <widget class="QWidget" name="FlamePage"> - <property name="geometry"> - <rect> - <x>0</x> - <y>0</y> - <width>875</width> - <height>745</height> - </rect> - </property> - <layout class="QVBoxLayout" name="verticalLayout"> - <item> - <widget class="QWidget" name="widget" native="true"> - <layout class="QHBoxLayout" name="horizontalLayout"> - <property name="leftMargin"> - <number>0</number> - </property> - <property name="topMargin"> - <number>0</number> - </property> - <property name="rightMargin"> - <number>0</number> - </property> - <property name="bottomMargin"> - <number>0</number> - </property> - <item> - <widget class="QLineEdit" name="searchEdit"/> + <class>FlamePage</class> + <widget class="QWidget" name="FlamePage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>837</width> + <height>685</height> + </rect> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="1" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_3"> + <item row="1" column="0"> + <widget class="QListView" name="packView"> + <property name="iconSize"> + <size> + <width>48</width> + <height>48</height> + </size> + </property> + <property name="horizontalScrollBarPolicy"> + <enum>Qt::ScrollBarAlwaysOff</enum> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QTextBrowser" name="packDescription"> + <property name="openExternalLinks"> + <bool>true</bool> + </property> + <property name="openLinks"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> </item> - <item> - <widget class="QPushButton" name="searchButton"> - <property name="text"> - <string>Search</string> - </property> - </widget> + <item row="2" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_4" columnstretch="0,0,0" rowminimumheight="0" columnminimumwidth="0,0,0"> + <item row="0" column="2"> + <widget class="QComboBox" name="versionSelectionBox"/> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Version selected:</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QComboBox" name="sortByBox"/> + </item> + </layout> </item> - </layout> - </widget> - </item> - <item> - <widget class="QListView" name="packView"> - <property name="horizontalScrollBarPolicy"> - <enum>Qt::ScrollBarAlwaysOff</enum> - </property> - <property name="alternatingRowColors"> - <bool>true</bool> - </property> - <property name="iconSize"> - <size> - <width>48</width> - <height>48</height> - </size> - </property> - </widget> - </item> - <item> - <widget class="MCModInfoFrame" name="frame"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Minimum"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="frameShape"> - <enum>QFrame::StyledPanel</enum> - </property> - <property name="frameShadow"> - <enum>QFrame::Raised</enum> - </property> - </widget> - </item> - </layout> - </widget> - <customwidgets> - <customwidget> - <class>MCModInfoFrame</class> - <extends>QFrame</extends> - <header>widgets/MCModInfoFrame.h</header> - <container>1</container> - </customwidget> - </customwidgets> - <tabstops> - <tabstop>searchEdit</tabstop> - <tabstop>searchButton</tabstop> - <tabstop>packView</tabstop> - </tabstops> - <resources/> - <connections/> + <item row="0" column="1"> + <widget class="QPushButton" name="searchButton"> + <property name="text"> + <string>Search</string> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLineEdit" name="searchEdit"> + <property name="placeholderText"> + <string>Search and filter ...</string> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>searchEdit</tabstop> + <tabstop>searchButton</tabstop> + <tabstop>packView</tabstop> + <tabstop>packDescription</tabstop> + <tabstop>sortByBox</tabstop> + <tabstop>versionSelectionBox</tabstop> + </tabstops> + <resources/> + <connections/> </ui> |