diff --git a/.gitignore b/.gitignore index 69dda72c..6b3f6246 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ CMakeLists.txt.user.* /.idea cmake-build-*/ Debug +.cache # Build dirs build diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 21ffbe17..bd1521d6 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -750,6 +750,9 @@ SET(LAUNCHER_SOURCES ui/pages/modplatform/flame/FlamePage.cpp ui/pages/modplatform/flame/FlamePage.h + ui/pages/modplatform/modrinth/ModrinthData.h + ui/pages/modplatform/modrinth/ModrinthModel.cpp + ui/pages/modplatform/modrinth/ModrinthModel.h ui/pages/modplatform/modrinth/ModrinthPage.cpp ui/pages/modplatform/modrinth/ModrinthPage.h diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthData.h b/launcher/ui/pages/modplatform/modrinth/ModrinthData.h new file mode 100644 index 00000000..7c4e7154 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthData.h @@ -0,0 +1,29 @@ +/* + * Copyright 2022 kb1000 + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include +#include +#include + +namespace Modrinth { +struct Modpack { + QString id; + + QString name; + QUrl iconUrl; + QString author; + QString description; + + bool metadataLoaded = false; + QString wikiUrl; + QString body; +}; +} + +Q_DECLARE_METATYPE(Modrinth::Modpack) diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp new file mode 100644 index 00000000..457b2ffe --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp @@ -0,0 +1,262 @@ +/* + * Copyright 2013-2022 MultiMC Contributors + * Copyright 2022 kb1000 + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "ModrinthModel.h" +#include "Application.h" +#include "Json.h" + +#include + +Modrinth::ListModel::ListModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +Modrinth::ListModel::~ListModel() = default; + +QVariant Modrinth::ListModel::data(const QModelIndex &index, int role) const +{ + int pos = index.row(); + if(pos >= modpacks.size() || pos < 0 || !index.isValid()) + { + return QString("INVALID INDEX %1").arg(pos); + } + + auto pack = modpacks.at(pos); + if(role == Qt::DisplayRole) + { + return pack.name; + } + else if(role == Qt::DecorationRole) + { + if(m_logoMap.contains(pack.id)) + { + return (m_logoMap.value(pack.id)); + } + QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder"); + ((ListModel *)this)->requestLogo(pack.id, pack.iconUrl); + return icon; + } + else if (role == Qt::ToolTipRole) + { + return pack.description; + } + else if(role == Qt::UserRole) + { + QVariant v; + v.setValue(pack); + return v; + } + return QVariant(); +} + +bool Modrinth::ListModel::canFetchMore(const QModelIndex& parent) const +{ + return searchState == CanPossiblyFetchMore; +} + + +void Modrinth::ListModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid()) + return; + if(nextSearchOffset == 0) { + qWarning() << "fetchMore with 0 offset is wrong..."; + return; + } + performPaginatedSearch(); +} + +int Modrinth::ListModel::columnCount(const QModelIndex &parent) const +{ + return 1; +} + +int Modrinth::ListModel::rowCount(const QModelIndex &parent) const +{ + return modpacks.size(); +} + +void Modrinth::ListModel::searchWithTerm(const QString& term, const QString &sort) +{ + if(currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort) { + return; + } + currentSearchTerm = term; + currentSort = sort; + if(jobPtr) { + jobPtr->abort(); + searchState = ResetRequested; + return; + } + else { + beginResetModel(); + modpacks.clear(); + endResetModel(); + searchState = None; + } + nextSearchOffset = 0; + performPaginatedSearch(); +} + +void Modrinth::ListModel::performPaginatedSearch() +{ + auto *netJob = new NetJob("Modrinth::Search", APPLICATION->network()); + QString searchUrl = ""; + if (currentSearchTerm.isEmpty()) { + searchUrl = QString("https://staging-api.modrinth.com/v2/search?facets=[[%22project_type:modpack%22]]&index=%1&limit=25&offset=%2").arg(currentSort).arg(nextSearchOffset); + } + else + { + searchUrl = QString( + "https://staging-api.modrinth.com/v2/search?facets=[[%22project_type:modpack%22]]&index=%1&limit=25&offset=%2&query=%3" + ).arg(currentSort).arg(nextSearchOffset).arg(currentSearchTerm); + } + netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), &response)); + jobPtr = netJob; + jobPtr->start(); + QObject::connect(netJob, &NetJob::succeeded, this, &ListModel::searchRequestFinished); + QObject::connect(netJob, &NetJob::failed, this, &ListModel::searchRequestFailed); +} + +void Modrinth::ListModel::searchRequestFinished() +{ + jobPtr.reset(); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if(parse_error.error != QJsonParseError::NoError) + { + qWarning() << "Error while parsing JSON response from Modrinth at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + QList newList; + QJsonArray hits; + int total_hits; + + try + { + auto obj = Json::requireObject(doc); + hits = Json::requireArray(obj, "hits"); + total_hits = Json::requireInteger(obj, "total_hits"); + } + catch(const JSONValidationError &e) + { + qWarning() << "Error while parsing response from Modrinth: " << e.cause(); + } + + for (auto packRaw : hits) + { + auto packObj = packRaw.toObject(); + Modrinth::Modpack pack; + try + { + if (Json::ensureString(packObj, "client_side", "required") == QStringLiteral("unsupported")) + continue; + pack.id = Json::requireString(packObj, "project_id"); + pack.name = Json::requireString(packObj, "title"); + pack.iconUrl = Json::requireUrl(packObj, "icon_url"); + pack.author = Json::requireString(packObj, "author"); + pack.description = Json::requireString(packObj, "description"); + newList.append(pack); + } + catch(const JSONValidationError &e) + { + qWarning() << "Error while loading pack from Modrinth: " << e.cause(); + continue; + } + } + + if ((total_hits - nextSearchOffset) <= 25) + searchState = Finished; + else + { + nextSearchOffset += 25; + searchState = CanPossiblyFetchMore; + } + beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1); + modpacks.append(newList); + endInsertRows(); +} + +void Modrinth::ListModel::searchRequestFailed() +{ + jobPtr.reset(); + + if(searchState == ResetRequested) + { + beginResetModel(); + modpacks.clear(); + endResetModel(); + + nextSearchOffset = 0; + performPaginatedSearch(); + } + else + { + searchState = Finished; + } +} + +void Modrinth::ListModel::logoLoaded(const QString &logo, const QIcon &out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + for(int i = 0; i < modpacks.size(); i++) { + if(modpacks[i].id == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), {Qt::DecorationRole}); + } + } +} + +void Modrinth::ListModel::logoFailed(const QString &logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void Modrinth::ListModel::requestLogo(const QString &logo, const QUrl &url) +{ + if(m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) + { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("FlamePacks", QString("logos/%1").arg(logo.section(".", 0, 0))); + auto *job = new NetJob(QString("Flame Icon Download %1").arg(logo), APPLICATION->network()); + job->addNetAction(Net::Download::makeCached(url, entry)); + + auto fullPath = entry->getFullPath(); + QObject::connect(job, &NetJob::succeeded, this, [this, logo, fullPath] + { + QIcon icon(fullPath); + QSize size = icon.actualSize(QSize(48, 48)); + if (size.width() < 48 && size.height() < 48) + { + /*while (size.width() < 48 && size.height() < 48) + size *= 2; + icon = icon.pixmap(48, 48).scaled(size);*/ + icon = icon.pixmap(48,48).scaled(48,48, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + } + logoLoaded(logo, icon); + if(waitingCallbacks.contains(logo)) + { + waitingCallbacks.value(logo)(fullPath); + } + }); + + QObject::connect(job, &NetJob::failed, this, [this, logo] + { + logoFailed(logo); + }); + + job->start(); + + m_loadingLogos.append(logo); +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h new file mode 100644 index 00000000..99d1adba --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h @@ -0,0 +1,66 @@ +/* + * Copyright 2013-2022 MultiMC Contributors + * Copyright 2022 kb1000 + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include "ModrinthData.h" +#include "net/NetJob.h" + +#include + +namespace Modrinth { + +using LogoCallback = std::function; + +class ListModel : public QAbstractListModel { + Q_OBJECT + +public: + explicit ListModel(QObject *parent); + ~ListModel() override; + + QVariant data(const QModelIndex &index, int role) const override; + int columnCount(const QModelIndex &parent) const override; + int rowCount(const QModelIndex &parent) const override; + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; + + void searchWithTerm(const QString &term, const QString &sort); + +private slots: + void searchRequestFinished(); + void searchRequestFailed(); + +private: + void performPaginatedSearch(); + + void logoFailed(const QString &logo); + void logoLoaded(const QString &logo, const QIcon &out); + + void requestLogo(const QString &logo, const QUrl &url); + + QList modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + QMap m_logoMap; + QMap waitingCallbacks; + + QString currentSearchTerm; + QString currentSort = QStringLiteral("relevance"); + int nextSearchOffset = 0; + enum SearchState { + None, + CanPossiblyFetchMore, + ResetRequested, + Finished + } searchState = None; + NetJob::Ptr jobPtr; + QByteArray response; +}; + +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp index 93b1ca02..a4a6d3d9 100644 --- a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -15,7 +15,9 @@ * limitations under the License. */ +#include "ModrinthModel.h" #include "ModrinthPage.h" +#include "ui/dialogs/NewInstanceDialog.h" #include "ui_ModrinthPage.h" @@ -24,6 +26,23 @@ ModrinthPage::ModrinthPage(NewInstanceDialog *dialog, QWidget *parent) : QWidget(parent), ui(new Ui::ModrinthPage), dialog(dialog) { ui->setupUi(this); + connect(ui->searchButton, &QPushButton::clicked, this, &ModrinthPage::triggerSearch); + ui->searchEdit->installEventFilter(this); + model = new Modrinth::ListModel(this); + ui->packView->setModel(model); + + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + ui->sortByBox->addItem(tr("Sort by relevance"), QStringLiteral("relevance")); + ui->sortByBox->addItem(tr("Sort by total downloads"), QStringLiteral("downloads")); + ui->sortByBox->addItem(tr("Sort by follow count"), QStringLiteral("follows")); + ui->sortByBox->addItem(tr("Sort by creation date"), QStringLiteral("newest")); + ui->sortByBox->addItem(tr("Sort by last updated"), QStringLiteral("updated")); + + connect(ui->sortByBox, SIGNAL(currentIndexChanged(int)), this, SLOT(triggerSearch())); + connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthPage::onSelectionChanged); + //connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &ModrinthPage::onVersionSelectionChanged); } ModrinthPage::~ModrinthPage() @@ -51,5 +70,24 @@ bool ModrinthPage::eventFilter(QObject *watched, QEvent *event) } void ModrinthPage::triggerSearch() { - + model->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->itemData(ui->sortByBox->currentIndex()).toString()); } + +void ModrinthPage::onSelectionChanged(QModelIndex first, QModelIndex second) { + if(!first.isValid()) + { + if(isOpened) + { + dialog->setSuggestedPack(); + } + //ui->frame->clear(); + return; + } + + current = model->data(first, Qt::UserRole).value(); + suggestCurrent(); +} + +void ModrinthPage::suggestCurrent() { + +} \ No newline at end of file diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h index 6c75b60d..9900d0fe 100644 --- a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h @@ -18,8 +18,8 @@ #pragma once #include "Application.h" -#include "ui/dialogs/NewInstanceDialog.h" #include "ui/pages/BasePage.h" +#include "ModrinthData.h" #include @@ -28,6 +28,12 @@ namespace Ui class ModrinthPage; } +class NewInstanceDialog; + +namespace Modrinth { + class ListModel; +} + class ModrinthPage : public QWidget, public BasePage { Q_OBJECT @@ -55,8 +61,13 @@ public: private slots: void triggerSearch(); + void onSelectionChanged(QModelIndex first, QModelIndex second); private: + void suggestCurrent(); + Ui::ModrinthPage *ui; NewInstanceDialog *dialog; + Modrinth::ListModel *model = nullptr; + Modrinth::Modpack current; };