diff options
Diffstat (limited to 'libs/installer_fomod_plus')
78 files changed, 7837 insertions, 0 deletions
diff --git a/libs/installer_fomod_plus/.clang-format b/libs/installer_fomod_plus/.clang-format new file mode 100644 index 0000000..37702ff --- /dev/null +++ b/libs/installer_fomod_plus/.clang-format @@ -0,0 +1,4 @@ +BasedOnStyle: WebKit +AlignConsecutiveAssignments: Consecutive +AccessModifierOffset: -2 +ColumnLimit: 120
\ No newline at end of file diff --git a/libs/installer_fomod_plus/.clangd b/libs/installer_fomod_plus/.clangd new file mode 100644 index 0000000..f22e5c1 --- /dev/null +++ b/libs/installer_fomod_plus/.clangd @@ -0,0 +1,16 @@ +CompileFlags: + Add: + - "-ID:/var/mo2-fomod-plus/installer" + - "-ID:/var/mo2-fomod-plus/scanner" + - "-ID:/var/mo2-fomod-plus/share" + - "-ID:/var/mo2-fomod-plus/vsbuild/_deps/pugixml-src/src" + - "-ID:/var/mo2-fomod-plus/vsbuild/_deps/json-src/include" + - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include" + - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include/uibase" + - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include/uibase/game_features" + - "-IC:/Qt/6.7.3/msvc2022_64/include" + - "-IC:/Qt/6.7.3/msvc2022_64/include/QtCore" + - "-IC:/Qt/6.7.3/msvc2022_64/include/QtGui" + - "-IC:/Qt/6.7.3/msvc2022_64/include/QtWidgets" + - "-std=c++20" + diff --git a/libs/installer_fomod_plus/.editorconfig b/libs/installer_fomod_plus/.editorconfig new file mode 100644 index 0000000..a915e4f --- /dev/null +++ b/libs/installer_fomod_plus/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*.cpp] +indent_style = space +indent_size = 2 +insert_final_newline = true + +[*.h] +indent_style = space +indent_size = 2 +insert_final_newline = true + +[*.ui] +indent_style = space +indent_size = 2 +insert_final_newline = true diff --git a/libs/installer_fomod_plus/CMakeLists.txt b/libs/installer_fomod_plus/CMakeLists.txt new file mode 100644 index 0000000..3367145 --- /dev/null +++ b/libs/installer_fomod_plus/CMakeLists.txt @@ -0,0 +1,36 @@ +# mo2-fomod-plus — vendored from github.com/aglowinthefield/mo2-fomod-plus +# Commit: da6c07ed4bbe235759910c14c2450e1bfe8fe25e (2026-01-27) +# +# Pure C++20 FOMOD installer replacement (no .NET dependency). +# 3 plugin targets: installer, scanner, patch wizard. + +include(FetchContent) + +# Force pugixml to build as a static library so plugins don't need libpugixml.so at runtime. +# (The global BUILD_SHARED_LIBS is ON for MO2's own archive lib, which would make pugixml shared.) +set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS}) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +# Compatibility: sub-CMakeLists call mo2_install_target but Fluorine defines mo2_install_plugin +if (NOT COMMAND mo2_install_target AND COMMAND mo2_install_plugin) + function(mo2_install_target target) + mo2_install_plugin(${target}) + endfunction() +endif () + +# Compute include dirs from existing targets for sub-CMakeLists that reference these variables +get_target_property(MO2_UIBASE_INCLUDE_DIRS mo2::uibase INTERFACE_INCLUDE_DIRECTORIES) +foreach(dir ${MO2_UIBASE_INCLUDE_DIRS}) + list(APPEND MO2_UIBASE_INCLUDE_DIRS "${dir}/uibase" "${dir}/uibase/game_features") +endforeach() +get_target_property(MO2_ARCHIVE_INCLUDE_DIRS mo2::archive INTERFACE_INCLUDE_DIRECTORIES) +foreach(dir ${MO2_ARCHIVE_INCLUDE_DIRS}) + list(APPEND MO2_ARCHIVE_INCLUDE_DIRS "${dir}/archive") +endforeach() + +add_subdirectory(installer) +add_subdirectory(scanner) +add_subdirectory(patchwizard) + +# Restore BUILD_SHARED_LIBS so subsequent targets aren't affected. +set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED} CACHE BOOL "" FORCE) diff --git a/libs/installer_fomod_plus/cmake/patch_pugixml.cmake b/libs/installer_fomod_plus/cmake/patch_pugixml.cmake new file mode 100644 index 0000000..01f1a68 --- /dev/null +++ b/libs/installer_fomod_plus/cmake/patch_pugixml.cmake @@ -0,0 +1,20 @@ +if(NOT DEFINED PUGIXML_CMAKELISTS) + message(FATAL_ERROR "PUGIXML_CMAKELISTS not set; cannot patch pugixml.") +endif() + +if(NOT EXISTS "${PUGIXML_CMAKELISTS}") + message(FATAL_ERROR "Pugixml CMakeLists.txt not found at '${PUGIXML_CMAKELISTS}'.") +endif() + +file(READ "${PUGIXML_CMAKELISTS}" _pugixml_content) +string(REGEX REPLACE + "cmake_minimum_required\\(VERSION [^)]+\\)" + "cmake_minimum_required(VERSION 3.10)" + _pugixml_patched "${_pugixml_content}") + +if(_pugixml_content STREQUAL _pugixml_patched) + message(STATUS "pugixml CMakeLists already uses a modern cmake_minimum_required.") +else() + file(WRITE "${PUGIXML_CMAKELISTS}" "${_pugixml_patched}") + message(STATUS "Updated pugixml cmake_minimum_required to 3.10 to silence deprecation warnings.") +endif() diff --git a/libs/installer_fomod_plus/installer/CMakeLists.txt b/libs/installer_fomod_plus/installer/CMakeLists.txt new file mode 100644 index 0000000..70018ff --- /dev/null +++ b/libs/installer_fomod_plus/installer/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.16) +include(FetchContent) + +file(GLOB_RECURSE INSTALLER_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) +file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp") + +add_library(fomod_plus_installer SHARED ${INSTALLER_SOURCES} ${SHARE_SOURCES}) + +target_include_directories( + fomod_plus_installer + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${MO2_UIBASE_INCLUDE_DIRS} + ${MO2_ARCHIVE_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +FetchContent_Declare( + pugixml + GIT_REPOSITORY https://github.com/zeux/pugixml + GIT_TAG v1.14 + PATCH_COMMAND ${CMAKE_COMMAND} + -DPUGIXML_CMAKELISTS=<SOURCE_DIR>/CMakeLists.txt + -P ${CMAKE_CURRENT_LIST_DIR}/../cmake/patch_pugixml.cmake +) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_MakeAvailable(pugixml json) + +set(project_type plugin) +project(fomod_plus_installer) + +target_link_libraries(fomod_plus_installer PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json) +if (WIN32) + target_link_libraries(fomod_plus_installer PRIVATE dbghelp) +endif () +mo2_configure_plugin(fomod_plus_installer NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS) + +if (MSVC) + target_compile_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>) + target_link_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>) + install(FILES $<TARGET_PDB_FILE:fomod_plus_installer> + DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO} + CONFIGURATIONS RelWithDebInfo + OPTIONAL) +endif () + +mo2_install_target(fomod_plus_installer) diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp new file mode 100644 index 0000000..e5f2e18 --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp @@ -0,0 +1,840 @@ +#include "FomodInstallerWindow.h" + +#include "ui/FomodImageViewer.h" + +#include "ui/ScaleLabel.h" +#include "ui/UIHelper.h" +#include "xml/ModuleConfiguration.h" + +#include <QButtonGroup> +#include <QCheckBox> +#include <QComboBox> +#include <QCompleter> +#include <QGroupBox> +#include <QLabel> +#include <QRadioButton> +#include <QScrollArea> +#include <QScrollBar> +#include <QSettings> +#include <QSizePolicy> +#include <QSplitter> +#include <QVBoxLayout> +#include <utility> + +#include "ui/FomodViewModel.h" + +#include <unordered_set> + +/** + * + * @param installer + * @param modName + * @param tree + * @param fomodPath + * @param viewModel + * @param fomodJson + * @param parent + */ +FomodInstallerWindow::FomodInstallerWindow( + FomodPlusInstaller* installer, + GuessedValue<QString>& modName, + const std::shared_ptr<IFileTree>& tree, + QString fomodPath, + const std::shared_ptr<FomodViewModel>& viewModel, + const nlohmann::json& fomodJson, + QWidget* parent): QDialog(parent), + mInstaller(installer), + mFomodPath(std::move(fomodPath)), + mModName(modName), + mTree(tree), + mViewModel(viewModel), + mFomodJson(fomodJson) +{ + setupUi(); + + mInstallStepStack = new QStackedWidget(this); + + // Handle legacy FOMODs with no steps + if (mViewModel->getSteps().empty()) { + // Create a simple "Install" widget for legacy FOMODs + auto* legacyWidget = new QWidget(this); + auto* layout = new QVBoxLayout(legacyWidget); + auto* label = new QLabel("This mod will install all files automatically.", legacyWidget); + layout->addWidget(label); + mInstallStepStack->addWidget(legacyWidget); + } else { + updateInstallStepStack(); + stylePreviouslySelectedOptions(); + stylePreviouslyDeselectedOptions(); + } + + const auto containerLayout = createContainerLayout(); + setLayout(containerLayout); + + updateButtons(); + restoreGeometryAndState(); + + if (!mViewModel->getSteps().empty()) { + populatePluginMap(); + if (mInstaller->shouldAutoRestoreChoices()) { + onSelectPreviousClicked(); + } + } else { + // For empty FOMODs, set default description + mDescriptionBox->setText("This mod will install all files automatically."); + } +} + +void FomodInstallerWindow::closeEvent(QCloseEvent* event) +{ + saveGeometryAndState(); + QDialog::closeEvent(event); +} + +void FomodInstallerWindow::saveGeometryAndState() const +{ + const auto cwd = QDir::currentPath(); + QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat); + settings.setValue("windowGeometry", saveGeometry()); + settings.setValue("centerSplitState", mCenterRow->saveState()); + settings.setValue("leftSplitState", mLeftPane->saveState()); +} + +void FomodInstallerWindow::restoreGeometryAndState() +{ + const auto cwd = QDir::currentPath(); + const QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat); + restoreGeometry(settings.value("windowGeometry").toByteArray()); + mCenterRow->restoreState(settings.value("centerSplitState").toByteArray()); + mLeftPane->restoreState(settings.value("leftSplitState").toByteArray()); +} + +void FomodInstallerWindow::populatePluginMap() +{ + const auto checkboxes = findChildren<QCheckBox*>(); + const auto radioButtons = findChildren<QRadioButton*>(); + + for (const auto& step : mViewModel->getSteps()) { + for (const auto& group : step->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + + const auto name = createObjectName(plugin, group); + + for (auto* checkbox : checkboxes) { + if (checkbox->objectName() == name) { + mPluginMap.insert({ name, { plugin, checkbox } }); + } + } + for (auto* radioButton : radioButtons) { + if (radioButton->objectName() == name) { + mPluginMap.insert({ name, { plugin, radioButton } }); + } + } + } + } + } +} + +void FomodInstallerWindow::onNextClicked() +{ + // For legacy FOMODs with no steps, always install + if (mViewModel->getSteps().empty()) { + onInstallClicked(); + return; + } + + if (!mViewModel->isLastVisibleStep()) { + mViewModel->stepForward(); + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); + updateButtons(); + updateDisplayForActivePlugin(); + } else { + onInstallClicked(); + } +} + +void FomodInstallerWindow::updateCheckboxStates() const +{ + // PluginMap presumed to be populated + for (const auto& [objectName, pluginData] : mPluginMap) { + if (pluginData.plugin->isSelected() != pluginData.uiElement->isChecked()) { + const auto widgetType = pluginData.uiElement->metaObject()->className(); + if (objectName != nullptr) { + logMessage(DEBUG, + "Updating " + objectName.toStdString() + " to state: " + (pluginData.plugin->isSelected() + ? "TRUE" + : "FALSE") + " because " + widgetType + + " selection state is " + (pluginData.uiElement->isChecked() ? "TRUE" : "FALSE")); + } + pluginData.uiElement->setChecked(pluginData.plugin->isSelected()); + } + + if (pluginData.plugin->isEnabled() != pluginData.uiElement->isEnabled()) { + logMessage(DEBUG, "[WINDOW] Changing enabled state of element " + objectName.toStdString() + " to " + + (pluginData.plugin->isEnabled() ? "TRUE" : "FALSE")); + pluginData.uiElement->setEnabled(pluginData.plugin->isEnabled()); + } + } +} + +void FomodInstallerWindow::onPluginToggled(const bool selected, const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin) const +{ + logMessage(INFO, + "onPluginToggled called with " + plugin->getName() + " in " + group->getName() + ": " + + std::to_string(selected)); + if (mViewModel->togglePlugin(group, plugin, selected)) { + updateCheckboxStates(); + } + if (mNextInstallButton != nullptr) { + updateButtons(); + } +} + +void FomodInstallerWindow::onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const +{ + logMessage(INFO, "onPluginManuallyUnchecked called with " + plugin->getName()); + mViewModel->markManuallySet(plugin); +} + +void FomodInstallerWindow::onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const +{ + mViewModel->setActivePlugin(plugin); + updateDisplayForActivePlugin(); +} + +void FomodInstallerWindow::onBackClicked() const +{ + mViewModel->stepBack(); + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); + updateButtons(); + updateDisplayForActivePlugin(); +} + +void FomodInstallerWindow::onInstallClicked() +{ + saveGeometryAndState(); + + logMessage(DEBUG, "Installing mod: " + mModName->toStdString()); + mModName.update(mModNameInput->currentText(), GUESS_USER); + mViewModel->preinstall(mTree, mFomodPath); + // now the installer is available in the outer scope + // the outer scope should call getFileInstaller() and install there. + this->accept(); +} + +void FomodInstallerWindow::updateButtons() const +{ + // For legacy FOMODs with no steps, always show Install + if (mViewModel->getSteps().empty()) { + mBackButton->setEnabled(false); + mNextInstallButton->setText(tr("Install")); + return; + } + + if (mViewModel->isFirstVisibleStep()) { + mBackButton->setEnabled(false); + } else { + mBackButton->setEnabled(true); + } + + if (mViewModel->isLastVisibleStep()) { + mNextInstallButton->setText(tr("Install")); + } else { + mNextInstallButton->setText(tr("Next")); + } +} + +void FomodInstallerWindow::setupUi() +{ + setWindowIcon(QIcon(":/fomod/hat")); + setWindowFlags(Qt::Window); // Allows OS-controlled resizing, including snapping + setMinimumSize(UiConstants::WINDOW_MIN_WIDTH, UiConstants::WINDOW_MIN_HEIGHT); + setWindowTitle(mModName); + setWindowModality(Qt::NonModal); // To allow scrolling modlist without closing the window +} + +// mInstallStepStack must be initialized before calling this +void FomodInstallerWindow::updateInstallStepStack() +{ + if (!mInstallStepStack) { + logMessage(ERR, "updateInstallStepStack called with no initialized mInstallStepStack. tf?"); + return; + } + // Create the widgets for each step. Not sure if we need these as member variables. Try it like this for now. + for (const auto& steps = mViewModel->getSteps(); const auto& installStep : steps) { + mInstallStepStack->addWidget(createStepWidget(installStep)); + } + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); +} + +/* ++-------------------------------------------------------------------+ +| +----------------------------------------------------------------+| +| | || +| | Metadata and Name Input || +| | || +| +----------------------------------------------------------------+| +| +------------------------------++--------------------------------+| +| | || || +| | || || +| | Description || || +| | || || +| | || Step/Group/Plugins || +| | || || +| +------------------------------+| || +| +------------------------------+| || +| | || || +| | || || +| | Image || || +| | || || +| | || || +| | || || +| +----------------------------------------------------------------+| +| | || +| | Bottom Bar || +| +----------------------------------------------------------------+| ++-------------------------------------------------------------------+ +*/ +QBoxLayout* FomodInstallerWindow::createContainerLayout() +{ + const auto layout = new QVBoxLayout(this); + + mTopRow = createTopRow(); + mCenterRow = createCenterRow(); + mBottomRow = createBottomRow(); + + layout->addWidget(mTopRow); + layout->addWidget(mCenterRow, 1); // stretch 1 here so the others are static size + layout->addWidget(mBottomRow); + + if (mInstaller->shouldShowNotifications()) { + mNotificationsPanel = createNotificationPanel(); + layout->addWidget(mNotificationsPanel); + // Set a default welcome message + addNotification("FOMOD Plus notification panel initialized :)", INFO); + } + return layout; +} + +QSplitter* FomodInstallerWindow::createCenterRow() +{ + mLeftPane = createLeftPane(); // Instance var to persist the geometry + const auto centerRow = new QSplitter(Qt::Horizontal, this); + const auto rightPane = createRightPane(); + centerRow->addWidget(mLeftPane); + centerRow->addWidget(rightPane); + centerRow->setSizes({ width() / 2, width() / 2 }); + return centerRow; +} + +QWidget* FomodInstallerWindow::createTopRow() +{ + const auto topRow = new QWidget(this); + + auto* mainHLayout = new QHBoxLayout(topRow); + + // Holds the name (label), author, version, and website + auto* metadataLayout = new QHBoxLayout(); + + // left side metadata. just the titles of the metadata + auto* labelsColumn = new QVBoxLayout(); + QLabel* nameLabel = UIHelper::createLabel(tr("Name:"), topRow); + QLabel* authorLabel = UIHelper::createLabel(tr("Author:"), topRow); + QLabel* versionLabel = UIHelper::createLabel(tr("Version:"), topRow); + QLabel* websiteLabel = UIHelper::createLabel(tr("Website:"), topRow); + + labelsColumn->addWidget(nameLabel); + labelsColumn->addWidget(authorLabel); + labelsColumn->addWidget(versionLabel); + labelsColumn->addWidget(websiteLabel); + + UIHelper::reduceLabelPadding(labelsColumn); + UIHelper::setGlobalAlignment(labelsColumn, Qt::AlignTop); + + // the values of the metadata MINUS the search box + auto* valuesColumn = new QVBoxLayout(); + QLabel* emptyLabel = UIHelper::createLabel("", topRow); + QLabel* authorValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getAuthor().c_str(), topRow); + QLabel* versionValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getVersion().c_str(), topRow); + QLabel* websiteValueLabel = UIHelper::createHyperlink(mViewModel->getInfoViewModel()->getWebsite().c_str(), topRow); + + valuesColumn->addWidget(emptyLabel); + valuesColumn->addWidget(authorValueLabel); + valuesColumn->addWidget(versionValueLabel); + valuesColumn->addWidget(websiteValueLabel); + + // We want these cleanup fns to be at the layout level directly containing the labels. + // Since we aren't recursing down the UI forever we can't just call it for mainHLayout. + UIHelper::reduceLabelPadding(valuesColumn); + UIHelper::setGlobalAlignment(valuesColumn, Qt::AlignTop); + + metadataLayout->addLayout(labelsColumn); + metadataLayout->addLayout(valuesColumn, 1); // To push the right column close to the edge of the left. + + mainHLayout->addLayout(metadataLayout, 1); + + // Now make the search bar layout + auto* modNameComboBox = createModNameComboBox(); + mainHLayout->addWidget(modNameComboBox, 4); + UIHelper::setGlobalAlignment(mainHLayout, Qt::AlignTop); + + // Extra stuff + topRow->setLayout(mainHLayout); + topRow->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + return topRow; +} + +QComboBox* FomodInstallerWindow::createModNameComboBox() +{ + mModNameInput = new QComboBox(this); + mModNameInput->setEditable(true); + + mModNameInput->addItem(mModName); + + for (const auto& variant : mModName.variants()) { + if (variant.toStdString() != mModName->toStdString()) { + mModNameInput->addItem(variant); + } + } + mModNameInput->completer()->setCaseSensitivity(Qt::CaseSensitive); + return mModNameInput; +} + +QWidget* FomodInstallerWindow::createBottomRow() +{ + // In vanilla FOMOD installer, left has the Manual button, right has back, next/install, and cancel buttons + const auto bottomRow = new QWidget(this); + auto* layout = new QHBoxLayout(bottomRow); + + // Manual on far left + mManualButton = UIHelper::createButton(tr("Manual"), bottomRow); + mSelectPreviousButton = UIHelper::createButton(tr("Restore Previous Choices"), bottomRow); + mResetChoicesButton = UIHelper::createButton(tr("Reset Choices"), bottomRow); + + layout->addWidget(mManualButton); + layout->addWidget(mSelectPreviousButton); + layout->addWidget(mResetChoicesButton); + + // Space to push remaining buttons right + layout->addStretch(); + + mBackButton = UIHelper::createButton(tr("Back"), bottomRow); + mNextInstallButton = UIHelper::createButton(tr("Next"), bottomRow); + mCancelButton = UIHelper::createButton(tr("Cancel"), bottomRow); + + mNextInstallButton->setDefault(true); + mNextInstallButton->setAutoDefault(true); + + connect(mManualButton, SIGNAL(clicked()), this, SLOT(onManualClicked())); + connect(mNextInstallButton, SIGNAL(clicked()), this, SLOT(onNextClicked())); + connect(mBackButton, SIGNAL(clicked()), this, SLOT(onBackClicked())); + connect(mCancelButton, SIGNAL(clicked()), this, SLOT(onCancelClicked())); + connect(mSelectPreviousButton, SIGNAL(clicked()), this, SLOT(onSelectPreviousClicked())); + connect(mResetChoicesButton, SIGNAL(clicked()), this, SLOT(onResetChoicesClicked())); + // connect(mHideImagesButton, SIGNAL(clicked()), this, SLOT(toggleImagesShown())); + + layout->addWidget(mBackButton); + layout->addWidget(mNextInstallButton); + layout->addWidget(mCancelButton); + + bottomRow->setLayout(layout); + return bottomRow; +} + +QSplitter* FomodInstallerWindow::createLeftPane() +{ + const auto leftPane = new QSplitter(Qt::Vertical, this); + + // Add description box + // Initialize with defaults (the first plugin's description (which defaults to the module image otherwise)) + auto* scrollArea = new QScrollArea(leftPane); + scrollArea->setWidgetResizable(true); + + mDescriptionBox = new QLabel("", leftPane); + mDescriptionBox->setTextFormat(Qt::RichText); + mDescriptionBox->setTextInteractionFlags(Qt::TextBrowserInteraction); + mDescriptionBox->setOpenExternalLinks(true); + mDescriptionBox->setWordWrap(true); + mDescriptionBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + mDescriptionBox->setAlignment(Qt::AlignTop | Qt::AlignLeft); + + scrollArea->setWidget(mDescriptionBox); + leftPane->addWidget(scrollArea); + + // Add image + // Initialize with defaults (the first plugin's image) + mImageLabel = new ScaleLabel(leftPane); + mImageLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + connect(mImageLabel, &ScaleLabel::clicked, this, [this] { + if (!mImageLabel->hasResource()) { + return; + } + const auto viewer = new FomodImageViewer(this, mFomodPath, mViewModel->getActiveStep(), + mViewModel->getActivePlugin()); + viewer->showMaximized(); + }); + + if (!mInstaller->shouldShowImages()) { + mImageLabel->hide(); + } + + leftPane->addWidget(mImageLabel); + leftPane->setSizes({ height() / 2, height() / 2 }); + + updateDisplayForActivePlugin(); + + return leftPane; +} + +QWidget* FomodInstallerWindow::createRightPane() +{ + const auto rightPane = new QWidget(this); + auto* layout = new QVBoxLayout(rightPane); + + layout->addWidget(mInstallStepStack); + + return rightPane; +} + +QTextEdit* FomodInstallerWindow::createNotificationPanel() +{ + auto* panel = new QTextEdit(this); + panel->setReadOnly(true); + panel->setMaximumHeight(100); // Limit height + panel->setStyleSheet("font-family: monospace; font-size: 9pt;"); + + return panel; +} + +QWidget* FomodInstallerWindow::createStepWidget(const std::shared_ptr<StepViewModel>& installStep) +{ + const auto stepBox = new QGroupBox(QString::fromStdString(installStep->getName()), this); + const auto stepBoxLayout = new QVBoxLayout(stepBox); + + auto* scrollArea = new QScrollArea(stepBox); + scrollArea->setWidgetResizable(true); + + const auto scrollAreaContent = new QWidget(scrollArea); + auto* scrollAreaLayout = new QVBoxLayout(scrollAreaContent); + + for (const auto& group : installStep->getGroups()) { + const auto groupSection = renderGroup(group); + scrollAreaLayout->addWidget(groupSection); + } + + scrollAreaContent->setLayout(scrollAreaLayout); + scrollArea->setWidget(scrollAreaContent); + + stepBoxLayout->addWidget(scrollArea); + stepBox->setLayout(stepBoxLayout); + return stepBox; +} + +QWidget* FomodInstallerWindow::renderGroup(const std::shared_ptr<GroupViewModel>& group) +{ + const auto groupBox = new QGroupBox(QString::fromStdString(group->getName()), this); + const auto groupBoxLayout = new QVBoxLayout(groupBox); + + switch (group->getType()) { + case SelectAtLeastOne: + case SelectAny: + case SelectAll: + renderCheckboxGroup(groupBox, groupBoxLayout, group); + break; + case SelectExactlyOne: + case SelectAtMostOne: + renderSelectExactlyOne(groupBox, groupBoxLayout, group); + break; + default: ; + } + + groupBox->setLayout(groupBoxLayout); + return groupBox; +} + +QString FomodInstallerWindow::createObjectName(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group) +{ + const std::string objectName = std::format("[{}:{}] {}-{}", + group->getStepIndex(), group->getOwnIndex(), + group->getName(), plugin->getName()); + + return QString::fromStdString(objectName); +} + +QRadioButton* FomodInstallerWindow::createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, + QWidget* parent) +{ + auto* radioButton = new QRadioButton(QString::fromStdString(plugin->getName()), parent); + radioButton->setObjectName(createObjectName(plugin, group)); + auto* hoverFilter = new HoverEventFilter(plugin, this); + radioButton->installEventFilter(hoverFilter); + connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered); + + connect(radioButton, &QRadioButton::toggled, this, [this, radioButton, group, plugin](const bool checked) { + logMessage(INFO, + "Received toggled signal for radio: " + plugin->getName() + ": " + (checked ? "TRUE" : "FALSE") + + " Radio is now: " + (radioButton->isChecked() ? "TRUE" : "FALSE")); + onPluginToggled(checked, group, plugin); + }); + + radioButton->setEnabled(plugin->isEnabled()); + radioButton->setChecked(plugin->isSelected()); + return radioButton; +} + +QCheckBox* FomodInstallerWindow::createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QWidget* parent) +{ + auto* checkBox = new QCheckBox(QString::fromStdString(plugin->getName()), parent); + checkBox->setObjectName(createObjectName(plugin, group)); + + // Make the hover stuff work + auto* hoverFilter = new HoverEventFilter(plugin, this); + checkBox->installEventFilter(hoverFilter); + connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered); + + // Install Ctrl+click event filter + auto* ctrlClickFilter = new CtrlClickEventFilter(plugin, group, this); + checkBox->installEventFilter(ctrlClickFilter); + + checkBox->setEnabled(plugin->isEnabled()); + checkBox->setChecked(plugin->isSelected()); + connect(checkBox, &QCheckBox::clicked, this, [this, plugin](const bool checked) { + // Send a message to viewModel saying the user deactivated the plugin manually. + // This may get overridden later by automatic checking, but we'll reconcile that at JSON serialization time. + if (!checked) { + onPluginManuallyUnchecked(plugin); + } + }); + connect(checkBox, &QCheckBox::toggled, this, [this, checkBox, group, plugin](const bool checked) { + logMessage(INFO, + "Received toggled signal for checkbox: " + plugin->getName() + ": " + (checked ? "true" : "false") + + " Checkbox was previously " + (checkBox->isChecked() ? "true" : "false")); + onPluginToggled(checked, group, plugin); + }); + return checkBox; +} + +void FomodInstallerWindow::renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group) +{ + // This is for parity with the legacy installer. Both styles are functionally equivalent + // for a group size of 1, but they chose checkbox. + if (group->getPlugins().size() == 1) { + renderCheckboxGroup(parent, parentLayout, group); + } else { + renderRadioGroup(parent, parentLayout, group); + } +} + +void FomodInstallerWindow::renderCheckboxGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group) +{ + for (const auto& plugin : group->getPlugins()) { + auto* checkbox = createPluginCheckBox(plugin, group, parent); + parentLayout->addWidget(checkbox); + } +} + +QButtonGroup* FomodInstallerWindow::renderRadioGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group) +{ + auto* buttonGroup = new QButtonGroup(parent); + buttonGroup->setExclusive(true); // Ensure only one button can be selected + + for (const auto& plugin : group->getPlugins()) { + auto* radioButton = createPluginRadioButton(plugin, group, parent); + buttonGroup->addButton(radioButton); + parentLayout->addWidget(radioButton); + } + return buttonGroup; +} + +void FomodInstallerWindow::addNotification(const QString& message, const LogLevel level) const +{ + if (!mNotificationsPanel) { + return; + } + + const QString timestamp = QDateTime::currentDateTime().toString("hh:mm:ss"); + const QString formattedMsg = QString("<span>[%2] [%3] %4</span>") + .arg(timestamp).arg(logLevelToString(level)).arg(message); + + mNotificationsPanel->append(formattedMsg); + + // Auto-scroll to bottom + QScrollBar* scrollbar = mNotificationsPanel->verticalScrollBar(); + scrollbar->setValue(scrollbar->maximum()); +} + +[[deprecated]] +void FomodInstallerWindow::toggleImagesShown() const +{ + logMessage(DEBUG, "Toggling image visibility"); + mInstaller->toggleShouldShowImages(); + if (mInstaller->shouldShowImages()) { + logMessage(DEBUG, "Turning images ON"); + mImageLabel->show(); + mHideImagesButton->setText(tr("Hide Images")); + } else { + logMessage(DEBUG, "Turning images OFF"); + mImageLabel->hide(); + mHideImagesButton->setText(tr("Show Images")); + } +} + + +// Updates the image and description field for a given plugin. Also use this on initialization of those widgets. +void FomodInstallerWindow::updateDisplayForActivePlugin() const +{ + // Skip if no steps (legacy FOMOD) + if (mViewModel->getSteps().empty()) { + return; + } + + auto plugin = mViewModel->getActivePlugin(); + if (!plugin) { + const auto activeStep = mViewModel->getActiveStep(); + if (!activeStep || activeStep->getGroups().empty() || activeStep->getGroups().front()->getPlugins().empty()) { + mDescriptionBox->setText(tr("Select a plugin to see its description.")); + mImageLabel->clear(); + return; + } + // Fall back to the first plugin in the active step when no active plugin is set. + plugin = activeStep->getGroups().front()->getPlugins().front(); + mViewModel->setActivePlugin(plugin); + } + + const QString description = formatPluginDescription(QString::fromStdString(plugin->getDescription())); + mDescriptionBox->setText(description); + + const auto image = mViewModel->getDisplayImage(); + if (image.empty()) { + mImageLabel->clear(); + return; + } + + const auto imagePath = UIHelper::getFullImagePath(mFomodPath, QString::fromStdString(image)); + mImageLabel->setScalableResource(imagePath); +} + +/** + * + * @param pluginSelector For now either 'plugins', or 'deselected'. The key of the member of 'groups' to iterate over. + * @param fn The callback for each plugin in the chosen group member. + */ +void FomodInstallerWindow::applyFnFromJson(const std::string& pluginSelector, + const std::function<void(QAbstractButton*)>& fn) +{ + if (mFomodJson.empty()) { + return; + } + + const auto jsonSteps = mFomodJson["steps"]; + // for each step in JSON, create a <group>-<plugin> string out of the { groups: [ { plugins... } ] } array + vector<std::string> selectedPlugins; + + // TODO: Can groups have the same name within a step, or across steps? How do we account for that? + for (int stepIndex = 0; stepIndex < jsonSteps.size(); ++stepIndex) { + const auto& step = jsonSteps[stepIndex]; + for (int groupIndex = 0; groupIndex < step["groups"].size(); ++groupIndex) { + const auto& group = step["groups"][groupIndex]; + + if (!group.contains(pluginSelector)) { + continue; + } + + for (int pluginIndex = 0; pluginIndex < group[pluginSelector].size(); ++pluginIndex) { + const auto& plugin = group[pluginSelector][pluginIndex]; + + std::string name = std::format("[{}:{}] {}-{}", + stepIndex, groupIndex, group["name"].get<std::string>(), plugin.get<std::string>()); + selectedPlugins.push_back(name); + } + } + } + + const auto checkboxes = findChildren<QCheckBox*>(); + const auto radioButtons = findChildren<QRadioButton*>(); + + for (auto* checkbox : checkboxes) { + for (const auto& selectedPlugin : selectedPlugins) { + if (checkbox->objectName().toStdString() == selectedPlugin) { + fn(checkbox); + } + } + } + for (auto* radio : radioButtons) { + for (const auto& selectedPlugin : selectedPlugins) { + if (radio->objectName().toStdString() == selectedPlugin) { + fn(radio); + } + } + } +} + +void FomodInstallerWindow::stylePreviouslySelectedOptions() +{ + const auto stylesheet = getColorStyle(UiColors::ColorApplication::BACKGROUND); + + const auto tooltip = "You previously selected this plugin when installing this mod."; + + logMessage(INFO, "Styling previously selected choices with stylesheet " + stylesheet.toStdString(), true); + applyFnFromJson("plugins", [stylesheet, tooltip](QAbstractButton* button) { + button->setStyleSheet(stylesheet); + button->setToolTip(tooltip); + }); +} + +void FomodInstallerWindow::stylePreviouslyDeselectedOptions() +{ + const auto stylesheet = getColorStyle(UiColors::ColorApplication::BORDER); + const auto tooltip = "You previously unchecked this plugin when installing this mod."; + applyFnFromJson("deselected", [stylesheet, tooltip](QAbstractButton* button) { + button->setStyleSheet(stylesheet); + button->setToolTip(tooltip); + }); +} + +void FomodInstallerWindow::selectPreviouslySelectedOptions() const +{ + logMessage(INFO, "Selecting previously selected choices", true); + logMessage(INFO, "Existing JSON provided: " + mFomodJson.dump(4)); + if (mFomodJson.empty()) { + return; + } + try { + mViewModel->selectFromJson(mFomodJson); + } catch (Exception& e) { + logMessage(ERR, std::string("Error selecting previously selected options: ") + e.what(), true); + } + updateCheckboxStates(); +} + +void FomodInstallerWindow::onResetChoicesClicked() +{ + logMessage(INFO, "Resetting choices to author defaults", true); + try { + mViewModel->resetToDefaults(); + } catch (Exception& e) { + logMessage(ERR, std::string("Error resetting choices: ") + e.what(), true); + return; + } + + // Reset the UI to show the first step + mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex()); + updateCheckboxStates(); + updateButtons(); + updateDisplayForActivePlugin(); +} + +QString FomodInstallerWindow::getColorStyle(const UiColors::ColorApplication color_application) const +{ + const auto selectedColor = mInstaller->getSelectedColor(); + logMessage(DEBUG, "Selected color: " + selectedColor.toStdString()); + return getStyle(selectedColor, color_application); +} diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.h b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h new file mode 100644 index 0000000..bca2215 --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h @@ -0,0 +1,214 @@ +#ifndef FOMODINSTALLERWINDOW_H +#define FOMODINSTALLERWINDOW_H + +#include <qboxlayout.h> +#include <qbuttongroup.h> +#include <qcheckbox.h> +#include <qcombobox.h> +#include <qtextedit.h> + +#include "FomodPlusInstaller.h" +#include "xml/ModuleConfiguration.h" + +#include <QDialog> +#include <QStackedWidget> +#include <qradiobutton.h> +#include <ui/ScaleLabel.h> + +#include "FomodInstallerWindow.h" +#include "lib/FileInstaller.h" +#include "ui/FomodViewModel.h" +#include "ui/Colors.h" + +#include <QSplitter> + +using namespace MOBase; + +struct PluginData { + std::shared_ptr<PluginViewModel> plugin; + QAbstractButton* uiElement; +}; + + +class FomodPlusInstaller; +/** + * @class FomodInstallerWindow + * @brief This class represents a window for the FOMOD installer. + * + * The FomodInstallerWindow class is designed to handle and manage the FOMOD installation + * process. It integrates functionalities specific to FOMOD package installations. + * By inheriting from QObject, it supports signal-slot mechanisms, enabling interaction + * and communication with other components in the application. + * + * This class is primarily intended to provide a user interface and functionality + * to process and run the FOMOD installer in a structured manner. + */ +class FomodInstallerWindow final : public QDialog { + Q_OBJECT + +public: + FomodInstallerWindow(FomodPlusInstaller* installer, + GuessedValue<QString>& modName, + const std::shared_ptr<IFileTree>& tree, + QString fomodPath, + const std::shared_ptr<FomodViewModel>& viewModel, + const nlohmann::json& fomodJson, + QWidget* parent = nullptr); + + void closeEvent(QCloseEvent* event) override; + + void saveGeometryAndState() const; + + void restoreGeometryAndState(); + + void populatePluginMap(); + + + // So FomodPlusInstaller can check if the user wants to manually install + [[nodiscard]] bool isManualInstall() const + { + return mIsManualInstall; + } + + [[nodiscard]] std::shared_ptr<FileInstaller> getFileInstaller() const { return mViewModel->getFileInstaller(); } + +private slots: + void onNextClicked(); + + void updateCheckboxStates() const; + + void onPluginToggled(bool selected, const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin) const; + + void onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const; + + void onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const; + + void onSelectPreviousClicked() const { this->selectPreviouslySelectedOptions(); } + + void onResetChoicesClicked(); + + void onBackClicked() const; + + void onCancelClicked() + { + this->saveGeometryAndState(); + this->reject(); + } + + void onManualClicked() + { + mIsManualInstall = true; + this->saveGeometryAndState(); + this->reject(); + } + + void onInstallClicked(); + + [[deprecated]] void toggleImagesShown() const; + +private: + Logger& log = Logger::getInstance(); + FomodPlusInstaller* mInstaller; + QString mFomodPath; + GuessedValue<QString>& mModName; + std::shared_ptr<IFileTree> mTree; + std::shared_ptr<FomodViewModel> mViewModel; + bool mInitialized{ false }; + std::unordered_map<QString, PluginData> mPluginMap; + + + // Meta + bool mIsManualInstall{}; + nlohmann::json mFomodJson; + + // Buttons + QPushButton* mNextInstallButton{}; + QPushButton* mBackButton{}; + QPushButton* mCancelButton{}; + QPushButton* mManualButton{}; + QPushButton* mSelectPreviousButton{}; + QPushButton* mResetChoicesButton{}; + QPushButton* mHideImagesButton{}; + + // Widgets + QComboBox* mModNameInput{}; + QLabel* mDescriptionBox{}; + QSplitter* mCenterRow{}; + QSplitter* mLeftPane{}; + QStackedWidget* mInstallStepStack{}; + QTextEdit* mNotificationsPanel{}; + QWidget* mBottomRow{}; + QWidget* mTopRow{}; + ScaleLabel* mImageLabel{}; + + // Fn + void setupUi(); + + void updateButtons() const; + + void updateInstallStepStack(); + + void updateDisplayForActivePlugin() const; + + void applyFnFromJson(const std::string& pluginSelector, const std::function<void(QAbstractButton*)> &fn); + + void stylePreviouslySelectedOptions(); + + void stylePreviouslyDeselectedOptions(); + + void selectPreviouslySelectedOptions() const; + + [[nodiscard]] QString getColorStyle(UiColors::ColorApplication color_application) const; + + [[nodiscard]] QBoxLayout* createContainerLayout(); + + [[nodiscard]] QSplitter* createCenterRow(); + + [[nodiscard]] QWidget* createTopRow(); + + [[nodiscard]] QComboBox* createModNameComboBox(); + + [[nodiscard]] QWidget* createBottomRow(); + + [[nodiscard]] QSplitter* createLeftPane(); + + [[nodiscard]] QWidget* createRightPane(); + + [[nodiscard]] QTextEdit* createNotificationPanel(); + + [[nodiscard]] QWidget* createStepWidget(const std::shared_ptr<StepViewModel>& installStep); + + [[nodiscard]] QWidget* renderGroup(const std::shared_ptr<GroupViewModel>& group); + + static QString createObjectName(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group); + + QRadioButton* createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QWidget* parent); + + QCheckBox* createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QWidget* parent); + + void renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, const std::shared_ptr<GroupViewModel>& group); + + void renderCheckboxGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group); + + QButtonGroup* renderRadioGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr<GroupViewModel>& group); + + void addNotification(const QString& message, LogLevel level) const; + + void logMessage(const LogLevel level, const std::string& message, const bool asNotification = true) const + { + log.logMessage(level, "[WINDOW] " + message); + if (asNotification) { + addNotification(QString::fromStdString(message), level); + } + } + +}; + + +#endif //FOMODINSTALLERWINDOW_H diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp new file mode 100644 index 0000000..690bdbb --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp @@ -0,0 +1,512 @@ +#include "FomodPlusInstaller.h" + +#include <igamefeatures.h> +#include <iinstallationmanager.h> +#include <iplugingame.h> +#include <QEventLoop> +#include <QTreeWidget> +#include <xml/FomodInfoFile.h> +#include <xml/ModuleConfiguration.h> +#include <xml/XmlParseException.h> + +#include "FomodInstallerWindow.h" +#include "stringutil.h" +#include "integration/FomodDataContent.h" +#include "ui/Colors.h" +#include "ui/FomodViewModel.h" +#include "lib/CrashHandler.h" + +#include <QMessageBox> +#include <QSettings> + +using namespace Qt::Literals::StringLiterals; + +/* +-------------------------------------------------------------------------------- + Init +-------------------------------------------------------------------------------- +*/ +#pragma region Initialization + +bool FomodPlusInstaller::init(IOrganizer* organizer) +{ + CrashHandler::initialize(); + mOrganizer = organizer; + mFomodContent = make_shared<FomodDataContent>(organizer); + log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus.log"); + std::cout << "QDir::currentPath(): " << QDir::currentPath().toStdString() << std::endl; + std::cout << "mOrganizer->basePath() : " << mOrganizer->basePath().toStdString() << std::endl; + + // REMEMBER: This mFomodDB persists beyond the scope of an individual install. Do not do anything nasty to it. + mFomodDb = std::make_unique<FomodDB>(mOrganizer->basePath().toStdString()); + setupUiInjection(); + return true; +} + +void FomodPlusInstaller::setupUiInjection() const +{ + if (shouldShowSidebarFilter()) { + mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false); + } + + mOrganizer->onPluginSettingChanged([this](const QString& pluginName, const QString& key, const QVariant& oldValue, const QVariant& newValue) { + if (pluginName == name() && key == "show_fomod_filter") { + toggleFeature(newValue.toBool()); + } + }); +} + +void FomodPlusInstaller::toggleFeature(const bool enabled) const +{ + if (enabled) { + mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false); + } else { + mOrganizer->gameFeatures()->unregisterFeature(mFomodContent); + } + mOrganizer->refresh(); +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Settings +-------------------------------------------------------------------------------- +*/ +#pragma region Settings +bool FomodPlusInstaller::shouldFallbackToLegacyInstaller() const +{ + return mOrganizer->pluginSetting(name(), "fallback_to_legacy").value<bool>(); +} + +bool FomodPlusInstaller::shouldShowImages() const +{ + return mOrganizer->pluginSetting(name(), "show_images").value<bool>(); +} + +bool FomodPlusInstaller::shouldShowNotifications() const +{ + return mOrganizer->pluginSetting(name(), "show_notifications").value<bool>(); +} + +bool FomodPlusInstaller::shouldShowSidebarFilter() const +{ + return mOrganizer->pluginSetting(name(), "show_fomod_filter").value<bool>(); +} + +bool FomodPlusInstaller::shouldAutoRestoreChoices() const +{ + return mOrganizer->pluginSetting(name(), "always_restore_choices").value<bool>(); +} + +bool FomodPlusInstaller::isWizardIntegrated() const +{ + return mOrganizer->pluginSetting(name(), "wizard_integration").value<bool>(); +} + +void FomodPlusInstaller::toggleShouldShowImages() const +{ + const bool showImages = shouldShowImages(); + mOrganizer->setPluginSetting(name(), "show_images", !showImages); +} + +QString FomodPlusInstaller::getSelectedColor() const +{ + const auto colorName = mOrganizer->pluginSetting(name(), "color_theme").toString(); + const auto it = UiColors::colorStyles.find(colorName); + return it != UiColors::colorStyles.end() ? it->first : "Blue"; +} + +std::vector<std::shared_ptr<const IPluginRequirement> > FomodPlusInstaller::requirements() const +{ + return { Requirements::gameDependency( + { u"Morrowind"_s, u"Oblivion"_s, u"Fallout 3"_s, u"New Vegas"_s, u"Skyrim"_s, u"Enderal"_s, + u"Fallout 4"_s, u"Skyrim Special Edition"_s, u"Enderal Special Edition"_s, + u"Skyrim VR"_s, u"Fallout 4 VR"_s, u"Starfield"_s }) }; +} + +QList<PluginSetting> FomodPlusInstaller::settings() const +{ + return { + { u"fallback_to_legacy"_s, u"When hitting cancel, fall back to the legacy FOMOD installer."_s, false }, + { u"always_restore_choices"_s, u"Restore previous choices without clicking the magic button"_s, false }, + { u"show_images"_s, u"Show image previews and the image carousel in installer windows."_s, true }, + { u"color_theme"_s, u"Select the color theme for the installer"_s, QString("Blue") }, // Default color name + { u"show_notifications"_s, u"Show the notifications panel"_s, false }, //WIP + { u"wizard_integration"_s, u"Integrate the installer with patch wizard."_s, true }, //WIP + { u"show_fomod_filter"_s, u"Show the filter in the sidebar (may break other content filters)"_s, true } + }; +} + +#pragma endregion + +bool FomodPlusInstaller::isArchiveSupported(std::shared_ptr<const IFileTree> tree) const +{ + tree = findFomodDirectory(tree); + if (tree != nullptr) { + return tree->exists(StringConstants::FomodFiles::MODULE_CONFIG.data(), FileTreeEntry::FILE); + } + return false; +} + + +std::pair<nlohmann::json, IModInterface*> FomodPlusInstaller::getExistingFomodJson(const GuessedValue<QString>& modName, + const int& nexusId, + const int& stepsInCurrentFomod) const +{ + logMessage(DEBUG, "FomodPlusInstaller::getExistingFomodJson - modName: " + modName->toStdString() + + " nexusId: " + std::to_string(nexusId)); + + // Need to have better mod matching based on presence of FOMOD plugin data. + struct ModMatch { + MOBase::IModInterface* mod; + bool hasFomodData; + int stepCount; + nlohmann::json fomodJson; + }; + std::vector<ModMatch> matches; + + auto parseStepCount = [](const QVariant& fomodData) -> std::pair<bool, int> { + try { + if (fomodData.isValid() && !fomodData.isNull()) { + if (auto json = nlohmann::json::parse(fomodData.toString().toStdString()); json.contains("steps") && json["steps"].is_array()) { + return { true, static_cast<int>(json["steps"].size()) }; + } + } + } catch (...) { + // Ignore parsing errors + } + return { false, 0 }; + }; + + // Check exact match first + const auto modList = mOrganizer->modList(); + if (modList == nullptr) { + return {}; + } + + if (const auto exactMod = modList->getMod(modName)) { + const auto fomodData = exactMod->pluginSetting(name(), "fomod", 0); + if (auto [valid, stepCount] = parseStepCount(fomodData); valid) { + matches.push_back({ + exactMod, + true, + stepCount, + nlohmann::json::parse(fomodData.toString().toStdString()) + }); + } else { + matches.push_back({ + exactMod, + false, + 0, + nlohmann::json() + }); + } + } + + // Check all variants + for (const auto& variant : modName.variants()) { + if (const auto variantMod = modList->getMod(variant)) { + const auto fomodData = variantMod->pluginSetting(name(), "fomod", 0); + if (auto [valid, stepCount] = parseStepCount(fomodData); valid) { + matches.push_back({ + variantMod, + true, + stepCount, + nlohmann::json::parse(fomodData.toString().toStdString()) + }); + } else { + matches.push_back({ + variantMod, + false, + 0, + nlohmann::json() + }); + } + } + } + + // No matches found + if (matches.empty()) { + return {}; + } + + // First try to find exact step count match + const auto exactMatch = ranges::find_if(matches, + [stepsInCurrentFomod](const ModMatch& match) { + return match.hasFomodData && match.stepCount == stepsInCurrentFomod; + }); + + if (exactMatch != matches.end()) { + logMessage(DEBUG, "Found exact step count match in mod: " + + exactMatch->mod->name().toStdString() + + " with " + std::to_string(exactMatch->stepCount) + " steps"); + return std::make_pair(exactMatch->fomodJson, exactMatch->mod); + } + + // Find the closest step count among mods with FOMOD data + const auto closestMatch = ranges::min_element(matches, + [stepsInCurrentFomod](const ModMatch& a, const ModMatch& b) { + if (!a.hasFomodData || !b.hasFomodData) + return false; + // The min difference between the step counts + return std::abs(a.stepCount - stepsInCurrentFomod) < + std::abs(b.stepCount - stepsInCurrentFomod); + }); + + if (closestMatch != matches.end() && closestMatch->hasFomodData) { + logMessage(DEBUG, "Using closest step count match from mod: " + + closestMatch->mod->name().toStdString() + + " with " + std::to_string(closestMatch->stepCount) + " steps"); + return std::make_pair(closestMatch->fomodJson, closestMatch->mod); + } + + // Fallback to first mod with any FOMOD data + const auto anyFomod = ranges::find_if(matches, + [](const ModMatch& match) { return match.hasFomodData; }); + + if (anyFomod != matches.end()) { + logMessage(DEBUG, "Using first available FOMOD data from mod: " + + anyFomod->mod->name().toStdString()); + return std::make_pair(anyFomod->fomodJson, anyFomod->mod); + } + + logMessage(DEBUG, "No matching FOMOD data found"); + return {}; +} + +void FomodPlusInstaller::clearPriorInstallData() +{ + mInstallerUsed = false; + mFomodJson = nullptr; + mFomodPath = ""; +} + +/** + * + * @param modName + * @param tree + * @param version + * @param nexusID + * @return + */ +IPluginInstaller::EInstallResult FomodPlusInstaller::install(GuessedValue<QString>& modName, + std::shared_ptr<IFileTree>& tree, QString& version, + int& nexusID) +{ + clearPriorInstallData(); + + logMessage(INFO, std::format("FomodPlusInstaller::install - modName: {}, version: {}, nexusID: {}", + modName->toStdString(), + version.toStdString(), + nexusID + )); + logMessage(INFO, std::format("FomodPlusInstaller::install - tree size: {}", tree->size())); + + auto [infoFile, moduleConfigFile, filePaths] = parseFomodFiles(tree); + + if (infoFile == nullptr || moduleConfigFile == nullptr) { + return RESULT_FAILED; + } + + std::vector<QString> pluginPaths = {}; + for (const auto& filePath : filePaths) { + if (isPluginFile(filePath)) { + pluginPaths.emplace_back(filePath); + } + } + + const auto dbEntry = mFomodDb->getEntryFromFomod(moduleConfigFile.get(), pluginPaths, nexusID); + + // create ui & pass xml classes to ui + auto [json, matchMod] = getExistingFomodJson(modName, nexusID, static_cast<int>(moduleConfigFile->installSteps.installSteps.size())); + if (matchMod != nullptr) { + modName.update(matchMod->name(), GUESS_USER); + } + auto fomodViewModel = FomodViewModel::create(mOrganizer, std::move(moduleConfigFile), std::move(infoFile)); + const auto window = std::make_shared<FomodInstallerWindow>(this, modName, tree, mFomodPath, fomodViewModel, json); + + // ReSharper disable once CppTooWideScopeInitStatement + const QDialog::DialogCode result = showInstallerWindow(window); + if (result == QDialog::Accepted) { + // modname was updated in window + mInstallerUsed = true; + const std::shared_ptr<IFileTree> installTree = window->getFileInstaller()->install(); + tree = installTree; + mFomodJson = std::make_shared<nlohmann::json>(window->getFileInstaller()->generateFomodJson()); + + try { + mFomodDb->addEntry(dbEntry); + mFomodDb->saveToFile(); + } catch ([[maybe_unused]] Exception& e) { + logMessage(ERR, "Failed to add FomodDB entries."); + logMessage(ERR, e.what()); + } + return RESULT_SUCCESS; + } + if (window->isManualInstall()) { + return RESULT_MANUALREQUESTED; + } + if (shouldFallbackToLegacyInstaller()) { + return RESULT_NOTATTEMPTED; + } + return RESULT_CANCELED; +} + + +/** + * + * @param tree + * @return + */ +ParsedFilesTuple FomodPlusInstaller::parseFomodFiles(const std::shared_ptr<IFileTree>& tree) +{ + const auto emptyResult = std::make_tuple(nullptr, nullptr, QStringList()); + + const auto fomodDir = findFomodDirectory(tree); + if (fomodDir == nullptr) { + logMessage(ERR, "FomodPlusInstaller::install - fomod directory not found"); + return emptyResult; + } + + // This is a strange place to set this value but okay for now. + mFomodPath = fomodDir->parent()->path(); + + const auto infoXML = fomodDir->find( + StringConstants::FomodFiles::INFO_XML.data(), + FileTreeEntry::FILE + ); + const auto moduleConfig = fomodDir->find( + StringConstants::FomodFiles::MODULE_CONFIG.data(), + FileTreeEntry::FILE + ); + + // Extract files first. + vector<std::shared_ptr<const FileTreeEntry> > toExtract = {}; + if (moduleConfig) { + toExtract.push_back(moduleConfig); + } else { + logMessage(ERR, "FomodPlusInstaller::install - error parsing moduleConfig.xml: Not Present"); + return emptyResult; + } + if (infoXML) { + toExtract.push_back(infoXML); + } + appendImageFiles(toExtract, tree); + appendPluginFiles(toExtract, tree); // For patch wizard data collection + auto paths = manager()->extractFiles(toExtract); + // Normalize backslash separators from MO2's internal file tree to forward slashes for Linux + for (auto& p : paths) { + p.replace('\\', '/'); + } + + auto moduleConfiguration = std::make_unique<ModuleConfiguration>(); + try { + moduleConfiguration->deserialize(paths.at(0)); + } catch (XmlParseException& e) { + logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing moduleConfig.xml: {}", e.what())); + return emptyResult; + } + + auto infoFile = std::make_unique<FomodInfoFile>(); + if (infoXML) { + try { + infoFile->deserialize(paths.at(1)); + } catch (XmlParseException& e) { + logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing info.xml: {}", e.what())); + } + } + + return std::make_tuple(std::move(infoFile), std::move(moduleConfiguration), paths); +} + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp#L123 +void FomodPlusInstaller::appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries, + const shared_ptr<const IFileTree>& tree) +{ + static std::set<QString, FileNameComparator> imageSuffixes{ "png", "jpg", "jpeg", "gif", "bmp" }; + for (auto entry : *tree) { + if (entry->isDir()) { + appendImageFiles(entries, entry->astree()); + } else if (imageSuffixes.contains(entry->suffix())) { + entries.push_back(entry); + } + } +} + +void FomodPlusInstaller::appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries, + const shared_ptr<const IFileTree>& tree) +{ + // Don't bother with this stuff if the user doesn't care about the wizard. + // It may slightly bloat the temp file directory if not needed. + if (isWizardIntegrated()) { + for (auto entry : *tree) { + if (entry->isDir()) { + appendPluginFiles(entries, entry->astree()); + } else if (isPluginFile(entry->suffix())) { + entries.push_back(entry); + } + } + } +} + +void FomodPlusInstaller::onInstallationStart(QString const& archive, const bool reinstallation, + IModInterface* currentMod) +{ + IPluginInstallerSimple::onInstallationStart(archive, reinstallation, currentMod); +} + +void FomodPlusInstaller::onInstallationEnd(const EInstallResult result, IModInterface* newMod) +{ + IPluginInstallerSimple::onInstallationEnd(result, newMod); + + // Update the meta.ini file with the fomod information + if (mFomodJson != nullptr && result == RESULT_SUCCESS && newMod != nullptr && mInstallerUsed) { + newMod->setPluginSetting(this->name(), "fomod", mFomodJson->dump().c_str()); + mOrganizer->refresh(); + } + clearPriorInstallData(); +} + +// Borrowed from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp +std::shared_ptr<const IFileTree> FomodPlusInstaller::findFomodDirectory(const std::shared_ptr<const IFileTree>& tree) +{ + // ReSharper disable once CppTooWideScopeInitStatement + const auto entry = tree->find(StringConstants::FomodFiles::FOMOD_DIR.data(), FileTreeEntry::DIRECTORY); + + if (entry != nullptr) { + return entry->astree(); + } + + if (tree->size() == 1 && tree->at(0)->isDir()) { + return findFomodDirectory(tree->at(0)->astree()); + } + return nullptr; +} + +QDialog::DialogCode FomodPlusInstaller::showInstallerWindow(const std::shared_ptr<FomodInstallerWindow>& window) +{ + QEventLoop loop; + connect(window.get(), SIGNAL(accepted()), &loop, SLOT(quit())); + connect(window.get(), SIGNAL(rejected()), &loop, SLOT(quit())); + window->show(); + loop.exec(); + return static_cast<QDialog::DialogCode>(window->result()); +} + +void showError(const Exception& e) +{ + const QString errorText = "Mod Name: \n" "Nexus ID: " "\n" "Exception: " + QString(e.what()) + "\n"; + + QMessageBox msgBox; + msgBox.setWindowTitle("FOMOD Plus Error :("); + msgBox.setTextFormat(Qt::RichText); + msgBox.setText("Sorry this happened. Please copy the following error and report it to me on Nexus or GitHub.\n" + "<pre style='background-color: #f0f0f0; padding: 10px;'>" + + errorText + + "</pre>" + ); + msgBox.setIcon(QMessageBox::Critical); + msgBox.exec(); + +} diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.h b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h new file mode 100644 index 0000000..494ad8e --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h @@ -0,0 +1,115 @@ +#pragma once + +#include "stringutil.h" + +#include <iplugin.h> +#include <iplugininstaller.h> +#include <iplugininstallersimple.h> + +#include <nlohmann/json.hpp> +#include "FomodInstallerWindow.h" +#include "lib/Logger.h" +#include "xml/FomodInfoFile.h" +#include "xml/ModuleConfiguration.h" + +#include <QDialog> +#include <integration/FomodDataContent.h> +#include <FOMODData/FomodDB.h> + +class FomodInstallerWindow; + +using namespace MOBase; +using namespace std; + +using ParsedFilesTuple = std::tuple<std::unique_ptr<FomodInfoFile>, std::unique_ptr<ModuleConfiguration>, QStringList>; + +class FomodPlusInstaller final : public IPluginInstallerSimple { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginInstaller) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlus" FILE "fomodplus.json") + +public: + bool init(IOrganizer* organizer) override; + + // constant values + [[nodiscard]] QString name() const override { return StringConstants::Plugin::NAME.data(); } + [[nodiscard]] QString author() const override { return StringConstants::Plugin::AUTHOR.data(); } + [[nodiscard]] QString description() const override { return StringConstants::Plugin::DESCRIPTION.data(); } + [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_FINAL }; } + + [[nodiscard]] unsigned int priority() const override + { + return 999; /* Above installer_fomod's highest priority. */ + } + + [[nodiscard]] std::vector<std::shared_ptr<const IPluginRequirement> > requirements() const override; + + [[nodiscard]] bool isManualInstaller() const override { return false; } + + [[nodiscard]] bool isArchiveSupported(std::shared_ptr<const IFileTree> tree) const override; + + [[nodiscard]] QList<PluginSetting> settings() const override; + + std::pair<nlohmann::json, IModInterface*> getExistingFomodJson(const GuessedValue<QString>& modName, + const int& nexusId, const int& stepsInCurrentFomod) const; + + void clearPriorInstallData(); + + EInstallResult install(GuessedValue<QString>& modName, std::shared_ptr<IFileTree>& tree, QString& version, + int& nexusID) override; + + void onInstallationStart(QString const& archive, bool reinstallation, IModInterface* currentMod) override; + + void onInstallationEnd(EInstallResult result, IModInterface* newMod) override; + + [[nodiscard]] bool shouldShowImages() const; + + [[nodiscard]] bool shouldShowNotifications() const; + bool shouldShowSidebarFilter() const; + + [[nodiscard]] bool shouldAutoRestoreChoices() const; + + [[nodiscard]] bool isWizardIntegrated() const; + + void toggleShouldShowImages() const; + + QString getSelectedColor() const; + +private: + Logger& log = Logger::getInstance(); + IOrganizer* mOrganizer = nullptr; + QString mFomodPath{}; + std::shared_ptr<nlohmann::json> mFomodJson{ nullptr }; + bool mInstallerUsed{ false }; + std::shared_ptr<FomodDataContent> mFomodContent{ nullptr }; + std::unique_ptr<FomodDB> mFomodDb; + + /** + * @brief Retrieve the tree entry corresponding to the fomod directory. + * + * @param tree Tree to look-up the directory in. + * + * @return the entry corresponding to the fomod directory in the tree, or a null + * pointer if the entry was not found. + */ + [[nodiscard]] static shared_ptr<const IFileTree> findFomodDirectory(const shared_ptr<const IFileTree>& tree); + + [[nodiscard]] static QDialog::DialogCode showInstallerWindow(const shared_ptr<FomodInstallerWindow>& window); + + [[nodiscard]] ParsedFilesTuple parseFomodFiles(const shared_ptr<IFileTree>& tree); + + static void appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries, + const shared_ptr<const IFileTree>& tree); + + void appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries, const shared_ptr<const IFileTree>& tree); + + void setupUiInjection() const; + void toggleFeature(bool enabled) const; + + [[nodiscard]] bool shouldFallbackToLegacyInstaller() const; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[INSTALLER] " + message); + } +}; diff --git a/libs/installer_fomod_plus/installer/fomod_plus_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_de.ts new file mode 100644 index 0000000..1552582 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_de.ts @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_en.ts new file mode 100644 index 0000000..bc6d6e7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_en.ts @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts new file mode 100644 index 0000000..ad7bfed --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +<context> + <name>FomodInstallerWindow</name> + <message> + <location filename="FomodInstallerWindow.cpp" line="194"/> + <source>Installieren</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="196"/> + <location filename="FomodInstallerWindow.cpp" line="370"/> + <source>Weiter</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="288"/> + <source>Name:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="289"/> + <source>Autor:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="290"/> + <source>Version:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="291"/> + <source>Webseite:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="357"/> + <source>Manuell</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="358"/> + <source>Alle Vorherige Optionen Auswählen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="359"/> + <source>Reset Choices</source> + <translation>Optionen Zurücksetzen</translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="361"/> + <location filename="FomodInstallerWindow.cpp" line="586"/> + <source>Bilder Ausblenden</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="369"/> + <source>Zurück</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="371"/> + <source>Abbrechen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="360"/> + <location filename="FomodInstallerWindow.cpp" line="590"/> + <source>Bilder Einblenden</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts new file mode 100644 index 0000000..5cbe0a7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>FomodInstallerWindow</name> + <message> + <location filename="FomodInstallerWindow.cpp" line="231"/> + <location filename="FomodInstallerWindow.cpp" line="242"/> + <source>Install</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="244"/> + <location filename="FomodInstallerWindow.cpp" line="423"/> + <source>Next</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="342"/> + <source>Name:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="343"/> + <source>Author:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="344"/> + <source>Version:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="345"/> + <source>Website:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="411"/> + <source>Manual</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="412"/> + <source>Restore Previous Choices</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="413"/> + <source>Reset Choices</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="422"/> + <source>Back</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="424"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="682"/> + <source>Hide Images</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="686"/> + <source>Show Images</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="703"/> + <source>Select a plugin to see its description.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts new file mode 100644 index 0000000..f2769e6 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="zh_CN"> +<context> + <name>FomodInstallerWindow</name> + <message> + <location filename="FomodInstallerWindow.cpp" line="194"/> + <source>安装</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="196"/> + <location filename="FomodInstallerWindow.cpp" line="370"/> + <source>下一步</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="288"/> + <source>名称</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="289"/> + <source>作者</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="290"/> + <source>版本</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="291"/> + <source>网址</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="357"/> + <source>手动</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="358"/> + <source>选中所有上次安装的选择</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="359"/> + <source>Reset Choices</source> + <translation>重置选择</translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="361"/> + <location filename="FomodInstallerWindow.cpp" line="586"/> + <source>隐藏图片</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="369"/> + <source>上一步</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="371"/> + <source>取消</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodInstallerWindow.cpp" line="360"/> + <location filename="FomodInstallerWindow.cpp" line="590"/> + <source>显示图片</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts new file mode 100644 index 0000000..e5ca8aa --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="zh_CN"> +</TS> diff --git a/libs/installer_fomod_plus/installer/fomodplus.json b/libs/installer_fomod_plus/installer/fomodplus.json new file mode 100644 index 0000000..dde5a05 --- /dev/null +++ b/libs/installer_fomod_plus/installer/fomodplus.json @@ -0,0 +1,4 @@ +{ + "Id": "FOMODPlus", + "Version" : "1.0.0" +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp new file mode 100644 index 0000000..ac4508d --- /dev/null +++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp @@ -0,0 +1,50 @@ +#include "FomodDataContent.h" + +#include <ifiletree.h> + +#include "stringutil.h" + +#include <iplugingame.h> + +FomodDataContent::FomodDataContent(MOBase::IOrganizer* organizer) : mOrganizer(organizer) +{ +} + +std::vector<MOBase::ModDataContent::Content> FomodDataContent::getAllContents() const +{ + static const std::vector<Content> contents = { + {FomodDataContentConstants::FOMOD_CONTENT_ID, "FOMOD", ":/fomod/hat", false} + }; + + return contents; +} + +// Confirmed working, no need to update +std::vector<int> FomodDataContent::getContentsFor(const std::shared_ptr<const MOBase::IFileTree> fileTree) const +{ + std::vector<int> contents; + if (!mOrganizer || !fileTree) { + return contents; + } + + const auto modList = mOrganizer->modList(); + if (!modList) { + return contents; + } + + const auto mod = modList->getMod(fileTree->name()); + if (modHasFomodContent(mod)) { + contents.emplace_back(FomodDataContentConstants::FOMOD_CONTENT_ID); + } + return contents; +} + +bool FomodDataContent::modHasFomodContent(const MOBase::IModInterface* mod) +{ + if (!mod) { + return false; + } + const auto pluginName = QString::fromStdString(StringConstants::Plugin::NAME.data()); + const auto fomodMeta = mod->pluginSetting(pluginName, "fomod", 0); + return fomodMeta != 0; +} diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.h b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h new file mode 100644 index 0000000..4ffa3d7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h @@ -0,0 +1,21 @@ +#pragma once + +#include <imoinfo.h> +#include <moddatacontent.h> + +namespace FomodDataContentConstants { +constexpr int FOMOD_CONTENT_ID = 400400; +} + +class FomodDataContent final : public MOBase::ModDataContent { +public: + explicit FomodDataContent(MOBase::IOrganizer* organizer); + + [[nodiscard]] std::vector<Content> getAllContents() const override; + + [[nodiscard]] std::vector<int> getContentsFor(std::shared_ptr<const MOBase::IFileTree> fileTree) const override; + +private: + MOBase::IOrganizer* mOrganizer; + static bool modHasFomodContent(const MOBase::IModInterface* mod); +}; diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp new file mode 100644 index 0000000..c35bf47 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp @@ -0,0 +1,176 @@ +#include "ConditionTester.h" + +#include "ui/FomodViewModel.h" + +#include <iplugingame.h> +#include <ipluginlist.h> + +std::string setToString(const std::set<int> &set) +{ + std::string str; + for (const auto& i : set) { + str += std::to_string(i) + ", "; + } + return str; +} + +bool ConditionTester::isStepVisible(const std::shared_ptr<FlagMap>& flags, + const CompositeDependency& compositeDependency, + const int stepIndex, + const std::vector<std::shared_ptr<StepViewModel>>& steps) const +{ + + // first things first: is it visible? + if (!testCompositeDependency(flags, compositeDependency)) { + return false; + } + + const auto flagDependencies = compositeDependency.flagDependencies; + if (flagDependencies.empty()) { + return true; + } + + std::set<int> stepsThatSetThisFlag; + + for (const auto& flagDependency : flagDependencies) { + // for this flag, find the plugins that set it + for (int i = stepIndex - 1; i >= 0; --i) { + for (const auto& group : steps[i]->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + if (std::ranges::any_of(plugin->getPlugin()->conditionFlags.flags, + [&flagDependency](const ConditionFlag& flag) { + return flag.name == flagDependency.flag && flag.value == flagDependency.value; + })) { + stepsThatSetThisFlag.insert(i); + } + } + } + } + } + const auto anyVisible = std::ranges::any_of(stepsThatSetThisFlag, [this, &steps, &flags](const int index) { + return isStepVisible(flags, steps[index]->getVisibilityConditions(), index, steps); + }); + if (!anyVisible) { + log.logMessage(DEBUG, "Step " + steps[stepIndex]->getName() + " has no dependent steps that are visible."); + log.logMessage(DEBUG, "Steps that set this flag: " + setToString(stepsThatSetThisFlag)); + } + return anyVisible; + +} + +bool ConditionTester::testCompositeDependency(const std::shared_ptr<FlagMap>& flags, + const CompositeDependency& compositeDependency) const +{ + const auto fileDependencies = compositeDependency.fileDependencies; + const auto flagDependencies = compositeDependency.flagDependencies; + const auto gameDependencies = compositeDependency.gameDependencies; + const auto nestedDependencies = compositeDependency.nestedDependencies; + const auto globalOperatorType = compositeDependency.operatorType; + + // For the globalOperatorType + // Evaluate all conditions and store the results in a vector<bool>, then return based on operator. + // These aren't expensive to calculate so rather than do some fancy logic to short-circuit, just calculate all of 'em. + std::vector<bool> results; + for (const auto& fileDependency : fileDependencies) { + results.emplace_back(testFileDependency(fileDependency)); + } + for (const auto& flagDependency : flagDependencies) { + results.emplace_back(testFlagDependency(flags, flagDependency)); + } + for (const auto& gameDependency : gameDependencies) { + results.emplace_back(testGameDependency(gameDependency)); + } + for (const auto& nestedDependency : nestedDependencies) { + results.emplace_back(testCompositeDependency(flags, nestedDependency)); + } + + if (globalOperatorType == OperatorTypeEnum::AND) { + return std::ranges::all_of(results, [](const bool result) { return result; }); + } + return std::ranges::any_of(results, [](const bool result) { return result; }); +} + + +bool ConditionTester::testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency) +{ + // Every instance of this flag being set in the map. + const auto flagList = flags->getFlagsByKey(flagDependency.flag); + + // Find the first instance of this flag being set (in the order specified by getFlagsByKey) + if (flagList.empty()) { + // If the dependency value is an empty string, it means this flag should be unset. + // So if we don't have any value for this flag, the result is true. + return flagDependency.value.empty(); + } + + return flagList.front().second == flagDependency.value; +} + +bool ConditionTester::testFileDependency(const FileDependency& fileDependency) const +{ + const std::string& pluginName = fileDependency.file; + const auto pluginState = getFileDependencyStateForPlugin(pluginName); + return pluginState == fileDependency.state; +} + +bool ConditionTester::testGameDependency(const GameDependency& gameDependency) const +{ + const auto gameVersion = mOrganizer->managedGame()->gameVersion().toStdString(); + log.logMessage(DEBUG, "Comparing condition version " + gameDependency.version + " against " + gameVersion); + if ( gameDependency.version <= gameVersion) { + log.logMessage(DEBUG, "Version matches!"); + } + return gameDependency.version <= gameVersion; +} + +FileDependencyTypeEnum ConditionTester::getFileDependencyStateForPlugin(const std::string& pluginName) const +{ + if (const auto it = pluginStateCache.find(pluginName); it != pluginStateCache.end()) { + return it->second; + } + + const QFlags<MOBase::IPluginList::PluginState> pluginState = mOrganizer->pluginList()->state( + QString::fromStdString(pluginName)); + + FileDependencyTypeEnum state; + + if (pluginState == MOBase::IPluginList::STATE_MISSING) { + state = FileDependencyTypeEnum::Missing; + } else if (pluginState == MOBase::IPluginList::STATE_INACTIVE) { + state = FileDependencyTypeEnum::Inactive; + } else if (pluginState == MOBase::IPluginList::STATE_ACTIVE) { + state = FileDependencyTypeEnum::Active; + } else { + state = FileDependencyTypeEnum::UNKNOWN_STATE; + } + + pluginStateCache[pluginName] = state; + return state; +} + +PluginTypeEnum ConditionTester::getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin, + const std::shared_ptr<FlagMap>& flags) const +{ + // NOTE: A plugin's ConditionFlags aren't the same thing as a step visibility one. + // A plugin's ConditionFlags are toggled based on the selection state of the plugin + // We only evaluate the typeDescriptor here. + + // We will return the 'winning' type or the default. If multiple conditions are met, + // ...well, I'm not sure. + // ReSharper disable once CppTooWideScopeInitStatement + const auto& dependencyType = plugin->typeDescriptor.dependencyType; + for (const auto& pattern : dependencyType.patterns.patterns) { + if (testCompositeDependency(flags, pattern.dependencies)) { + return pattern.type; + } + } + + // Sometimes authors do this. + if (plugin->typeDescriptor.type != PluginTypeEnum::Optional) { + return plugin->typeDescriptor.type; + } + if (plugin->typeDescriptor.dependencyType.defaultType.has_value()) { + return plugin->typeDescriptor.dependencyType.defaultType.value(); + } + return PluginTypeEnum::Optional; +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.h b/libs/installer_fomod_plus/installer/lib/ConditionTester.h new file mode 100644 index 0000000..09aa26e --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.h @@ -0,0 +1,42 @@ +#pragma once + +#include <imoinfo.h> + +#include "FlagMap.h" +#include "Logger.h" +#include "xml/ModuleConfiguration.h" + +class CompositeDependency; +class StepViewModel; + +class ConditionTester { +public: + explicit ConditionTester(MOBase::IOrganizer* organizer) : mOrganizer(organizer) {} + + bool isStepVisible(const std::shared_ptr<FlagMap>& flags, const CompositeDependency& compositeDependency, + int stepIndex, + const std::vector<std::shared_ptr<StepViewModel>>& steps) const; + + bool testCompositeDependency(const std::shared_ptr<FlagMap>& flags, + const CompositeDependency& compositeDependency) const; + + static bool testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency); + + [[nodiscard]] bool testFileDependency(const FileDependency& fileDependency) const; + + bool testGameDependency(const GameDependency& gameDependency) const; + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* mOrganizer; + + friend class FomodViewModel; + + [[nodiscard]] FileDependencyTypeEnum getFileDependencyStateForPlugin(const std::string& pluginName) const; + + PluginTypeEnum getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin, + const std::shared_ptr<FlagMap>& flags) const; + + mutable std::unordered_map<std::string, FileDependencyTypeEnum> pluginStateCache; + +}; diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp new file mode 100644 index 0000000..98ed1f3 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp @@ -0,0 +1,122 @@ +#include "CrashHandler.h" +#include <iostream> + +#ifdef _WIN32 +#include <windows.h> +#include <dbghelp.h> +#include <sstream> +#include <fstream> + +LPTOP_LEVEL_EXCEPTION_FILTER CrashHandler::previousFilter = nullptr; +PVOID CrashHandler::vectoredHandler = nullptr; +#endif + +void CrashHandler::initialize() { +#ifdef _WIN32 + std::cout << "CrashHandler initializing..." << std::endl; + vectoredHandler = AddVectoredExceptionHandler(1, vectoredExceptionHandler); + previousFilter = SetUnhandledExceptionFilter(unhandledExceptionFilter); + std::cout << "CrashHandler initialized successfully" << std::endl; +#else + // No-op on Linux — Windows crash handler not needed. +#endif +} + +void CrashHandler::cleanup() { +#ifdef _WIN32 + if (vectoredHandler) { + RemoveVectoredExceptionHandler(vectoredHandler); + vectoredHandler = nullptr; + } + if (previousFilter) { + SetUnhandledExceptionFilter(previousFilter); + previousFilter = nullptr; + } +#endif +} + +#ifdef _WIN32 +LONG WINAPI CrashHandler::vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo) { + // Log all exceptions but only create dumps for serious ones + std::cout << "Exception caught: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl; + + // Only create dumps for serious crashes, not benign exceptions + if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION || + exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW || + exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ILLEGAL_INSTRUCTION) { + + // For stack overflow, we need to handle it on a different thread with its own stack + if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW) { + // Create a new thread to handle the crash dump since our stack is corrupted + HANDLE dumpThread = CreateThread(nullptr, 0, [](LPVOID param) -> DWORD { + auto* exceptionInfo = static_cast<EXCEPTION_POINTERS*>(param); + logCrashInfo(exceptionInfo); + + HANDLE file = CreateFileA("fomod_plus_stack_overflow.dmp", GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (file != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0}; + dumpInfo.ThreadId = GetCurrentThreadId(); + dumpInfo.ExceptionPointers = exceptionInfo; + dumpInfo.ClientPointers = FALSE; + + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, + MiniDumpNormal, &dumpInfo, nullptr, nullptr); + CloseHandle(file); + + std::cout << "Stack overflow crash dump written to fomod_plus_stack_overflow.dmp" << std::endl; + } + return 0; + }, exceptionInfo, 0, nullptr); + + if (dumpThread) { + WaitForSingleObject(dumpThread, 5000); // Wait up to 5 seconds + CloseHandle(dumpThread); + } + } else { + logCrashInfo(exceptionInfo); + + // Generate minidump + HANDLE file = CreateFileA("fomod_plus_crash.dmp", GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (file != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0}; + dumpInfo.ThreadId = GetCurrentThreadId(); + dumpInfo.ExceptionPointers = exceptionInfo; + dumpInfo.ClientPointers = FALSE; + + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, + MiniDumpNormal, &dumpInfo, nullptr, nullptr); + CloseHandle(file); + + std::cout << "Crash dump written to fomod_plus_crash.dmp" << std::endl; + } + } + } + + return EXCEPTION_CONTINUE_SEARCH; // Let other handlers process it +} + +LONG WINAPI CrashHandler::unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo) { + logCrashInfo(exceptionInfo); + return EXCEPTION_EXECUTE_HANDLER; +} + +void CrashHandler::logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo) { + std::ostringstream oss; + oss << "FOMOD PLUS CRASH DETECTED!" << std::endl; + oss << "Exception Code: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl; + oss << "Exception Address: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionAddress << std::endl; + + // Write to stdout immediately + std::cout << oss.str() << std::endl; + + // Also write to crash log file + std::ofstream crashLog("fomod_plus_crash.log", std::ios::app); + if (crashLog.is_open()) { + crashLog << oss.str() << std::endl; + } +} +#endif diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.h b/libs/installer_fomod_plus/installer/lib/CrashHandler.h new file mode 100644 index 0000000..78c8552 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef _WIN32 +#include <windows.h> +#endif + +class CrashHandler { +public: + static void initialize(); + static void cleanup(); + +private: +#ifdef _WIN32 + static LONG WINAPI unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo); + static LONG WINAPI vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo); + static void logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo); + static LPTOP_LEVEL_EXCEPTION_FILTER previousFilter; + static PVOID vectoredHandler; +#endif +}; diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp new file mode 100644 index 0000000..e47e088 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp @@ -0,0 +1,274 @@ +#include "FileInstaller.h" + +#include <utility> + +#include "ui/FomodViewModel.h" + +using namespace MOBase; + +FileInstaller::FileInstaller( + IOrganizer* organizer, + QString fomodPath, + const std::shared_ptr<IFileTree>& fileTree, + std::unique_ptr<ModuleConfiguration> fomodFile, + const std::shared_ptr<FlagMap>& flagMap, + const std::vector<std::shared_ptr<StepViewModel> >& steps) : mOrganizer(organizer), + mFomodPath(std::move(fomodPath)), + mFileTree(fileTree), + mFomodFile(std::move(fomodFile)), + mFlagMap(flagMap), + mConditionTester(organizer), mSteps(steps) +{ + const auto requiredCount = + (mFomodFile != nullptr) ? mFomodFile->requiredInstallFiles.files.size() : 0; + const auto stepCount = mSteps.size(); + logMessage(DEBUG, + "FileInstaller constructed. steps=" + std::to_string(stepCount) + ", required files=" + + std::to_string(requiredCount) + ", fomodPath=" + mFomodPath.toStdString()); +} + +std::shared_ptr<IFileTree> FileInstaller::install() const +{ + logMessage(DEBUG, "Starting FileInstaller::install()"); + const auto filesToInstall = collectFilesToInstall(); + logMessage(INFO, "Installing " + std::to_string(filesToInstall.size()) + " files"); + logMessage(INFO, "FlagMap"); + logMessage(INFO, mFlagMap->toString()); + + // update the file tree with the new files + const std::shared_ptr<IFileTree> installTree = mFileTree->createOrphanTree(); + + for (const auto& file : filesToInstall) { + + logMessage(DEBUG, + "Processing install entry source=" + file.source + ", destination=" + + (file.destination.has_value() ? file.destination.value() : "<default>") + + ", priority=" + std::to_string(file.priority)); + const auto sourcePath = getQualifiedFilePath(file.source); + const auto sourceNode = mFileTree->find(QString::fromStdString(sourcePath)); + if (sourceNode == nullptr) { + logMessage(ERR, "Could not find source: " + file.source); + continue; + } + const auto targetPath = file.destination.has_value() + ? QString::fromStdString(file.destination.value()) + : QString::fromStdString(sourcePath); + + // If it's a folder, copy the contents of the folder, not the folder itself. + if (sourceNode->isDir()) { + logMessage(DEBUG, + "Copying directory '" + file.source + "' into target '" + targetPath.toStdString() + + "'"); + // TODO: Check if target path is literally undefined/null + const auto& tree = sourceNode->astree(); + for (auto it = tree->begin(); it != tree->end(); ++it) { + const auto& entry = *it; + const auto path = targetPath.isEmpty() ? entry->name() : targetPath + "/" + entry->name(); + logMessage(DEBUG, + " Copying entry '" + entry->name().toStdString() + "' to '" + + path.toStdString() + "'"); + installTree->copy(entry, path, IFileTree::InsertPolicy::MERGE); + } + } else { + logMessage(DEBUG, + "Copying file '" + file.source + "' to '" + targetPath.toStdString() + "'"); + installTree->copy(sourceNode, targetPath, IFileTree::InsertPolicy::MERGE); + } + } + + // This file will be written by the InstallationManager later. + const auto jsonFilePath = "fomod.json"; + installTree->addFile(QString::fromStdString(jsonFilePath), true); + logMessage(DEBUG, "Added fomod.json placeholder file to install tree."); + + logMessage(DEBUG, "FileInstaller::install completed."); + return installTree; +} + +nlohmann::json FileInstaller::generateFomodJson() const +{ + nlohmann::json fomodJson; + logMessage(DEBUG, "Generating fomod.json representation for " + std::to_string(mSteps.size()) + + " steps."); + + fomodJson["steps"] = nlohmann::json::array(); + for (const auto& stepViewModel : mSteps) { + auto stepJson = nlohmann::json::object(); + stepJson["name"] = stepViewModel->getName(); + stepJson["groups"] = nlohmann::json::array(); + logMessage(DEBUG, "Serializing step '" + stepViewModel->getName() + "'"); + + for (const auto& groupViewModel : stepViewModel->getGroups()) { + auto groupJson = nlohmann::json::object(); + groupJson["name"] = groupViewModel->getName(); + auto pluginArray = nlohmann::json::array(); + auto deselectedArray = nlohmann::json::array(); + logMessage(DEBUG, + " Serializing group '" + groupViewModel->getName() + + "' with " + std::to_string(groupViewModel->getPlugins().size()) + " plugins"); + + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + logMessage(DEBUG, + " Plugin '" + pluginViewModel->getName() + "' selected=" + + (pluginViewModel->isSelected() ? "true" : "false") + ", manually-set=" + + (pluginViewModel->wasManuallySet() ? "true" : "false")); + if (pluginViewModel->isSelected()) { + pluginArray.emplace_back(pluginViewModel->getName()); + } + // Add deselected plugins here. (TODO: This will be replaced with an embedded db.) + if (!pluginViewModel->isSelected() && pluginViewModel->wasManuallySet()) { + deselectedArray.emplace_back(pluginViewModel->getName()); + } + } + groupJson["plugins"] = pluginArray; + groupJson["deselected"] = deselectedArray; + stepJson["groups"].emplace_back(groupJson); + } + fomodJson["steps"].emplace_back(stepJson); + } + return fomodJson; +} + +std::string FileInstaller::getQualifiedFilePath(const std::string& treePath) const +{ + // We need to prepend the fomod path to whatever source we reference. Guess we're passing that path around. + const auto qualifiedPath = mFomodPath.toStdString() + "/" + treePath; + logMessage(DEBUG, "Qualifying tree path '" + treePath + "' to '" + qualifiedPath + "'"); + return qualifiedPath; +} + +std::vector<std::string> FileInstaller::collectPositiveFileNamesFromDependencyPatterns( + const std::vector<DependencyPattern>& patterns) +{ + std::vector<std::string> usableFileDependencyPluginNames = {}; + logMessage(DEBUG, + "Collecting positive file names from " + std::to_string(patterns.size()) + " patterns."); + + for (const auto& pattern : patterns) { + if (pattern.type == PluginTypeEnum::NotUsable) { + logMessage(DEBUG, "Skipping pattern marked as NotUsable."); + continue; + } + + if (pattern.dependencies.fileDependencies.empty() && pattern.dependencies.nestedDependencies.empty()) { + logMessage(DEBUG, "Skipping pattern with no dependencies."); + continue; + } + + const auto fileDependencies = pattern.dependencies.fileDependencies; + const auto nestedDependencies = pattern.dependencies.nestedDependencies; + + for (const auto& fileDependency : fileDependencies) { + if (fileDependency.state != FileDependencyTypeEnum::Active) { + continue; + } + usableFileDependencyPluginNames.emplace_back(fileDependency.file); + logMessage(DEBUG, "Adding active file dependency: " + fileDependency.file); + } + + for (const auto& nestedDependency : nestedDependencies) { + for (const auto& fileDependency : nestedDependency.fileDependencies) { + if (fileDependency.state != FileDependencyTypeEnum::Active) { + continue; + } + usableFileDependencyPluginNames.emplace_back(fileDependency.file); + logMessage(DEBUG, "Adding nested active file dependency: " + fileDependency.file); + } + } + // Not handling twice-nested dependencies now. IDK if that's even feasible. + } + + logMessage(DEBUG, + "CollectPositiveFileNamesFromDependencyPatterns found " + + std::to_string(usableFileDependencyPluginNames.size()) + " entries."); + return usableFileDependencyPluginNames; +} + +// Generic vector appender +void FileInstaller::addFiles(std::vector<File>& main, std::vector<File> toAdd) const +{ + for (const auto& add : toAdd) { + logMessage(INFO, "Adding file with source: " + add.source); + } + main.insert(main.end(), toAdd.begin(), toAdd.end()); + logMessage(DEBUG, + "addFiles merged " + std::to_string(toAdd.size()) + " files. Total is now " + + std::to_string(main.size())); +} + +// TODO: Unclear if we're copying. oh well. +// TODO: Rebuild flagmap and step indeces before installing +std::vector<File> FileInstaller::collectFilesToInstall() const +{ + logMessage(DEBUG, "collectFilesToInstall started."); + std::vector<File> allFiles; + + // Required files from FOMOD + const FileList requiredInstallFiles = mFomodFile->requiredInstallFiles; + logMessage(DEBUG, + "Adding " + std::to_string(requiredInstallFiles.files.size()) + + " required install files from fomod."); + addFiles(allFiles, requiredInstallFiles.files); + + // Selected files from visible steps + for (const auto& stepViewModel : mSteps) { + if (!mConditionTester.testCompositeDependency(mFlagMap, stepViewModel->getVisibilityConditions())) { + logMessage(DEBUG, + "Skipping invisible step '" + stepViewModel->getName() + "'"); + continue; + } + logMessage(DEBUG, + "Processing visible step '" + stepViewModel->getName() + "' with " + + std::to_string(stepViewModel->getGroups().size()) + " groups."); + for (const auto& groupViewModel : stepViewModel->getGroups()) { + logMessage(DEBUG, + " Processing group '" + groupViewModel->getName() + "' with " + + std::to_string(groupViewModel->getPlugins().size()) + " plugins."); + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + if (pluginViewModel->isSelected()) { + logMessage(DEBUG, + " Adding selected plugin '" + pluginViewModel->getName() + + "' with " + + std::to_string(pluginViewModel->getPlugin()->files.files.size()) + + " files."); + addFiles(allFiles, pluginViewModel->getPlugin()->files.files); + } else { + logMessage(DEBUG, + " Skipping plugin '" + pluginViewModel->getName() + + "' (not selected)."); + } + } + } + } + + // ConditionalInstall files + for (const auto conditionals = mFomodFile->conditionalFileInstalls; const auto& pattern : conditionals.patterns) { + //<folder source="CR\Dagi-Raht LL\VLrn_Custom Race - Dagi-Raht LL" destination="" priority="2" /> + + if (mConditionTester.testCompositeDependency(mFlagMap, pattern.dependencies)) { + // also check if the plugins setting these flags are visible. at least one + + addFiles(allFiles, pattern.files.files); + logMessage(DEBUG, + "Conditional install pattern matched; added " + + std::to_string(pattern.files.files.size()) + " files."); + } else { + logMessage(DEBUG, "Conditional install pattern did not match."); + } + } + + // Files will all have a default priority of 0 if not specified, so the order should also be informed by the + // order they appear within XML. That's why we put conditionalFileInstalls after. + logMessage(DEBUG, "Sorting " + std::to_string(allFiles.size()) + " files by priority."); + std::ranges::sort(allFiles, [](const auto& a, const auto& b) { + return a.priority < b.priority; + }); + + for (const auto& toInstall : allFiles) { + logMessage(DEBUG, "File to install: " + toInstall.source); + } + logMessage(DEBUG, + "collectFilesToInstall completed with " + std::to_string(allFiles.size()) + " files."); + + return allFiles; +} diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.h b/libs/installer_fomod_plus/installer/lib/FileInstaller.h new file mode 100644 index 0000000..1c122a7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.h @@ -0,0 +1,94 @@ +#pragma once + +#include <ifiletree.h> +#include <nlohmann/json.hpp> + +#include "ConditionTester.h" +#include "FlagMap.h" +#include "Logger.h" +#include "xml/ModuleConfiguration.h" + + +/* This is what legacy fomodInstaller does: + modName.update(dialog.getName(), GUESS_USER); + return dialog.updateTree(tree); + + This is a link to the legacy updateTree method: + https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/fc263f2d923c704b4853c11ed4f8b8cf3920f30d/src/fomodinstallerdialog.cpp#L546 + +*/ + +// Things this should do: +// - Copy all requiredInstall files +// - Copy all conditionalInstall files +// - Copy all selected files from steps that were visible to the user at the point of install + +using namespace MOBase; + +using FileGlobalIndex = int; +using FileDescriptor = std::pair<File, FileGlobalIndex>; + +class StepViewModel; + +class FileInstaller { +public: + FileInstaller( + IOrganizer* organizer, + QString fomodPath, + const std::shared_ptr<IFileTree>& fileTree, + std::unique_ptr<ModuleConfiguration> fomodFile, + const std::shared_ptr<FlagMap>& flagMap, + const std::vector<std::shared_ptr<StepViewModel> >& steps); + + std::shared_ptr<IFileTree> install() const; + + /** + * @brief Create a 'fomod.json' file to add to the base of the installTree. Functionally similar to MO2's meta.ini. + * + * Until there's more utility in the JSON structure itself, it will simply be of this format: + * @code + * { + * "steps": [ + * { + * "name": "Step 1", + * "groups": [ + * { + * "name": "Group 1", + * "plugins: [ + * "Plugin1.esp", + * "Plugin2.esp" + * ] + * } + * ] + * } + * ] + * } + * @endcode + * + * @return nhlohmann::json + */ + nlohmann::json generateFomodJson() const; + + std::string getQualifiedFilePath(const std::string& treePath) const; + + std::vector<std::string> collectPositiveFileNamesFromDependencyPatterns(const std::vector<DependencyPattern> &patterns); + + void addFiles(std::vector<File>& main, std::vector<File> toAdd) const; + +private: + IOrganizer* mOrganizer; + Logger& log = Logger::getInstance(); + QString mFomodPath; + std::shared_ptr<IFileTree> mFileTree; + std::unique_ptr<ModuleConfiguration> mFomodFile; + std::shared_ptr<FlagMap> mFlagMap; + ConditionTester mConditionTester; + std::vector<std::shared_ptr<StepViewModel> > mSteps; // TODO: Maybe this is nasty. Idk. + + std::vector<File> collectFilesToInstall() const; + + void logMessage(LogLevel level, const std::string& message) const + { + log.logMessage(level, "[INSTALLER] " + message); + } +}; diff --git a/libs/installer_fomod_plus/installer/lib/FlagMap.h b/libs/installer_fomod_plus/installer/lib/FlagMap.h new file mode 100644 index 0000000..4959cec --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/FlagMap.h @@ -0,0 +1,113 @@ +#pragma once + +#include "ViewModels.h" +#include "stringutil.h" + +#include <ranges> +#include <string> +#include <unordered_map> + +using Flag = std::pair<std::string, std::string>; +using FlagList = std::vector<Flag>; + +class FlagMap { +public: + + [[nodiscard]] std::vector<std::shared_ptr<PluginViewModel>> getPluginsSettingFlag(const std::string& key, const std::string& value) const + { + std::vector<std::shared_ptr<PluginViewModel>> result; + for (const auto& [plugin, theseFlags] : flags) { + for (const auto& [fst, snd] : theseFlags) { + if (toLower(fst) == toLower(key) && snd == value) { + result.emplace_back(plugin); + } + } + } + return result; + } + + /** + * + * @param key The flag key + * @return A list of flags currently set in this map with the given key. The list is ordered by step descending, then plugin ascending. + * So if steps 1, 2, and 3 set flag X in their first two plugins, it'll be ordered [3:1, 3:2, 2:1, 2:2, 1:2, 1:1] + */ + [[nodiscard]] FlagList getFlagsByKey(const std::string& key) const + { + FlagList result; + std::vector<std::pair<int, std::shared_ptr<PluginViewModel>>> orderedPlugins; + + // Collect all plugins with their stepIndex and ownIndex + for (const auto& plugin : flags | std::views::keys) { + orderedPlugins.emplace_back(plugin->getStepIndex(), plugin); + } + + // Sort plugins by stepIndex and ownIndex, stepIndex descending and ownIndex ascending + // TODO: Might need group sorting too. How can I just do the natural order?? + std::ranges::sort(orderedPlugins, [](const auto& a, const auto& b) { + return a.first > b.first || (a.first == b.first && a.second->getOwnIndex() < b.second->getOwnIndex()); + }); + + // Collect flags in the sorted order + for (const auto& plugin : orderedPlugins | std::views::values) { + for (const auto& flag : flags.at(plugin)) { + if (toLower(flag.first) == toLower(key)) { + result.emplace_back(flag); + } + } + } + return result; + } + + void setFlagsForPlugin(PluginRef plugin) + { + // Don't clutter the map with empty key-vals + if (plugin->getConditionFlags().empty()) { + return; + } + unsetFlagsForPlugin(plugin); + + FlagList flagList; + for (const auto& conditionFlag : plugin->getConditionFlags()) { + flagList.emplace_back(toLower(conditionFlag.name), conditionFlag.value); + } + flags[plugin] = flagList; + } + + void unsetFlagsForPlugin(PluginRef plugin) + { + if (flags.contains(plugin)) { + flags.erase(plugin); + } + } + + std::string toString() + { + auto result = std::string(); + result += "FlagMap:\n"; + + for (const auto& [plugin, theseFlags] : flags) { + result += plugin->getName() + " ["; + for (const auto& [fst, snd] : theseFlags) { + result += fst + ": " + snd + ", "; + } + result.erase(result.size() - 2); + result += "]\n"; + } + return result; + } + + void clearAll() + { + flags.clear(); + } + + [[nodiscard]] size_t getFlagCount() const + { + return flags.size(); + } + + +private: + std::unordered_map<std::shared_ptr<PluginViewModel>, FlagList> flags; +}; diff --git a/libs/installer_fomod_plus/installer/lib/Logger.h b/libs/installer_fomod_plus/installer/lib/Logger.h new file mode 100644 index 0000000..fdeb1f0 --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/Logger.h @@ -0,0 +1,118 @@ +#pragma once +#include <fstream> +#include <iostream> +#include <mutex> +#include <regex> + + +// Log levels +enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERR = 3 +}; + +// Convert LogLevel to string +inline const char* logLevelToString(const LogLevel level) { + switch (level) { + case DEBUG: return "DEBUG"; + case INFO: return "INFO"; + case WARN: return "WARN"; + case ERR: return "ERROR"; + default: return "UNKNOWN"; + } +} + + +class Logger { +public: + static Logger& getInstance() + { + static Logger instance; + return instance; + } + + void setLogFilePath(const std::string& filePath) + { + std::lock_guard lock(mMutex); + if (mLogFile.is_open()) { + mLogFile.close(); + } + mLogFile.open(filePath, std::ios::out); + // std::ios::app is an option for appending but dont wanna grow it forever. + } + + void setDebugMode(bool debug) + { + std::lock_guard lock(mMutex); + mDebugMode = debug; + } + + void logMessage(const LogLevel level, const std::string& message) + { + +#if defined(__GNUC__) || defined(__clang__) + std::string functionName = __PRETTY_FUNCTION__; +#elif defined(_MSC_VER) + std::string functionName = __FUNCSIG__; +#else + std::string functionName = "UnknownFunction"; +#endif + + std::regex classNameRegex(R"((\w+)::\w+\()"); + std::smatch match; + std::string className = "UnknownClass"; + + if (std::regex_search(functionName, match, classNameRegex) && match.size() > 1) { + className = match.str(1); + } + + std::lock_guard lock(mMutex); + + auto writeLog = [&](std::ostream& stream) { + switch (level) { + case DEBUG: + stream << "[DEBUG] " << message << std::endl; + break; + case INFO: + stream << "[INFO] " << message << std::endl; + break; + case WARN: + stream << "[WARN] " << message << std::endl; + break; + case ERR: + stream << "[ERROR] " << message << std::endl; + break; + } + }; + + if (mLogFile.is_open()) { + writeLog(mLogFile); + } + + writeLog(std::cout); + } + + Logger& operator=(const Logger&) = delete; + +private: + Logger() = default; + + ~Logger() + { + if (mLogFile.is_open()) { + mLogFile.close(); + } + } + + Logger(const Logger&) = delete; + + std::ofstream mLogFile; + std::mutex mMutex; +#if !defined(NDEBUG) || defined(CMAKE_BUILD_TYPE_RELWITHDEBINFO) + bool mDebugMode = true; // Auto-enable in debug/RelWithDebInfo builds +#else + bool mDebugMode = false; // Disable in release builds +#endif +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/lib/ViewModels.h b/libs/installer_fomod_plus/installer/lib/ViewModels.h new file mode 100644 index 0000000..062445a --- /dev/null +++ b/libs/installer_fomod_plus/installer/lib/ViewModels.h @@ -0,0 +1,120 @@ +#pragma once + +#include <memory> +#include <vector> + +#include "xml/ModuleConfiguration.h" + + +template <typename T> +using shared_ptr_list = std::vector<std::shared_ptr<T> >; + + +/* +-------------------------------------------------------------------------------- + Plugins +-------------------------------------------------------------------------------- +*/ +class PluginViewModel { +public: + PluginViewModel(const std::shared_ptr<Plugin>& plugin_, const bool selected, bool, const int index) + : ownIndex(index), selected(selected), enabled(true), manuallySet(false), plugin(plugin_) {} + + void setSelected(const bool selected) { this->selected = selected; } + void setEnabled(const bool enabled) { this->enabled = enabled; } + [[nodiscard]] std::string getName() const { return plugin ? plugin->name : std::string(); } + [[nodiscard]] std::string getDescription() const { return plugin ? plugin->description : std::string(); } + [[nodiscard]] std::string getImagePath() const { return plugin ? plugin->image.path : std::string(); } + [[nodiscard]] bool isSelected() const { return selected; } + [[nodiscard]] bool isEnabled() const { return enabled; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] std::vector<ConditionFlag> getConditionFlags() const { return plugin->conditionFlags.flags; } + [[nodiscard]] PluginTypeEnum getCurrentPluginType() const { return currentPluginType; } + [[nodiscard]] bool wasManuallySet() const { return manuallySet; } + + void setCurrentPluginType(const PluginTypeEnum type) { currentPluginType = type; } + void setStepIndex(const int stepIndex) { this->stepIndex = stepIndex; } + void setGroupIndex(const int groupIndex) { this->groupIndex = groupIndex; } + + [[nodiscard]] int getStepIndex() const { return stepIndex; } + [[nodiscard]] int getGroupIndex() const { return groupIndex; } + + friend class FomodViewModel; + friend class FileInstaller; + friend class ConditionTester; + +protected: + [[nodiscard]] std::shared_ptr<Plugin> getPlugin() const { return plugin; } + +private: + int ownIndex; + bool selected; + bool enabled; + bool manuallySet; + PluginTypeEnum currentPluginType = PluginTypeEnum::UNKNOWN; + std::shared_ptr<Plugin> plugin; + + int stepIndex{ -1 }; + int groupIndex{ -1 }; +}; + +/* +-------------------------------------------------------------------------------- + Groups +-------------------------------------------------------------------------------- +*/ +class GroupViewModel { +public: + GroupViewModel(const std::shared_ptr<Group>& group_, const shared_ptr_list<PluginViewModel>& plugins, + const int index, const int stepIndex) + : plugins(plugins), group(group_), ownIndex(index), stepIndex(stepIndex) {} + + void addPlugin(const std::shared_ptr<PluginViewModel>& plugin) { plugins.emplace_back(plugin); } + + [[nodiscard]] std::string getName() const { return group->name; } + [[nodiscard]] GroupTypeEnum getType() const { return group->type; } + [[nodiscard]] const shared_ptr_list<PluginViewModel>& getPlugins() const { return plugins; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] int getStepIndex() const { return stepIndex; } + +private: + shared_ptr_list<PluginViewModel> plugins; + std::shared_ptr<Group> group; + int ownIndex; + int stepIndex; +}; + +/* +-------------------------------------------------------------------------------- + Steps +-------------------------------------------------------------------------------- +*/ +class StepViewModel { +public: + StepViewModel(const std::shared_ptr<InstallStep>& installStep_, const shared_ptr_list<GroupViewModel>& groups, + const int index) + : installStep(installStep_), groups(groups), ownIndex(index) {} + + [[nodiscard]] CompositeDependency& getVisibilityConditions() const { return installStep->visible; } + [[nodiscard]] std::string getName() const { return installStep->name; } + [[nodiscard]] const shared_ptr_list<GroupViewModel>& getGroups() const { return groups; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] bool getHasVisited() const { return visited; } + void setVisited(const bool visited) { this->visited = visited; } + +private: + bool visited{ false }; + std::shared_ptr<InstallStep> installStep; + shared_ptr_list<GroupViewModel> groups; + int ownIndex; +}; + + +/* +-------------------------------------------------------------------------------- + Outbound Types +-------------------------------------------------------------------------------- +*/ +using StepRef = const std::shared_ptr<StepViewModel>&; +using GroupRef = const std::shared_ptr<GroupViewModel>&; +using PluginRef = const std::shared_ptr<PluginViewModel>&; diff --git a/libs/installer_fomod_plus/installer/resources.qrc b/libs/installer_fomod_plus/installer/resources.qrc new file mode 100644 index 0000000..6c583a7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources.qrc @@ -0,0 +1,14 @@ +<RCC> + <qresource prefix="/fomod"> + <file alias="hat">resources/fomod_icon.png</file> + </qresource> + <qresource prefix="/fomod"> + <file alias="previous">resources/left-chevron.png</file> + </qresource> + <qresource prefix="/fomod"> + <file alias="next">resources/right-chevron.png</file> + </qresource> + <qresource prefix="/fomod"> + <file alias="close">resources/delete.png</file> + </qresource> +</RCC>
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/resources/delete.png b/libs/installer_fomod_plus/installer/resources/delete.png Binary files differnew file mode 100644 index 0000000..f6502b1 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/delete.png diff --git a/libs/installer_fomod_plus/installer/resources/fomod_icon.png b/libs/installer_fomod_plus/installer/resources/fomod_icon.png Binary files differnew file mode 100644 index 0000000..e044052 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/fomod_icon.png diff --git a/libs/installer_fomod_plus/installer/resources/image_icon.png b/libs/installer_fomod_plus/installer/resources/image_icon.png Binary files differnew file mode 100644 index 0000000..b300751 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/image_icon.png diff --git a/libs/installer_fomod_plus/installer/resources/left-chevron.png b/libs/installer_fomod_plus/installer/resources/left-chevron.png Binary files differnew file mode 100644 index 0000000..28222a1 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/left-chevron.png diff --git a/libs/installer_fomod_plus/installer/resources/right-chevron.png b/libs/installer_fomod_plus/installer/resources/right-chevron.png Binary files differnew file mode 100644 index 0000000..1a0af49 --- /dev/null +++ b/libs/installer_fomod_plus/installer/resources/right-chevron.png diff --git a/libs/installer_fomod_plus/installer/ui/ClickableWidget.h b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h new file mode 100644 index 0000000..881600f --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h @@ -0,0 +1,24 @@ +#pragma once + +#include <QMouseEvent> +#include <QWidget> + +class ClickableWidget final : public QWidget { + Q_OBJECT + +public: + explicit ClickableWidget(QWidget* parent = nullptr) : QWidget(parent) { + setCursor(Qt::PointingHandCursor); + } + + signals: + void clicked(); + +protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QWidget::mousePressEvent(event); + } +}; diff --git a/libs/installer_fomod_plus/installer/ui/Colors.h b/libs/installer_fomod_plus/installer/ui/Colors.h new file mode 100644 index 0000000..c3262eb --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/Colors.h @@ -0,0 +1,155 @@ +#pragma once + +#include <QString> +#include <map> + +namespace UiColors { + +enum class ColorApplication { + BACKGROUND, + BORDER, + TEXT, + ALL +}; + +// Color values +namespace Colors { + // Light + const QString Light0 = "251, 241, 199"; + const QString Light1 = "235, 219, 178"; + const QString Light2 = "213, 196, 161"; + const QString Light3 = "189, 174, 147"; + + // Dark + const QString Dark0 = "40, 40, 40"; + const QString Dark1 = "60, 56, 54"; + const QString Dark2 = "80, 73, 69"; + const QString Dark3 = "102, 92, 84"; + + // Red + const QString Red = "204, 36, 29"; + const QString RedBright = "251, 73, 52"; + + // Green + const QString Green = "152, 151, 26"; + const QString GreenBright = "184, 187, 38"; + + // Yellow + const QString Yellow = "215, 153, 33"; + const QString YellowBright = "250, 189, 47"; + + // Blue + const QString Blue = "69, 133, 136"; + const QString BlueBright = "131, 165, 152"; + + // Purple + const QString Purple = "177, 98, 134"; + const QString PurpleBright = "211, 134, 155"; + + // Aqua + const QString Aqua = "104, 157, 106"; + const QString AquaBright = "142, 192, 124"; + + // Orange + const QString Orange = "214, 93, 14"; + const QString OrangeBright = "254, 128, 25"; +} + +// Helper function to generate style strings based on color and application +inline QString generateStyle(const QString& color, const ColorApplication application, const float opacity = 0.4, + const int borderWidth = 1) +{ + QString style; + + switch (application) { + case ColorApplication::BACKGROUND: + style = QString("QCheckBox { background-color: rgba(%1, %2); } " + "QRadioButton { background-color: rgba(%1, %2); }") + .arg(color).arg(opacity); + break; + + case ColorApplication::BORDER: + style = QString("QCheckBox { border: %1px dashed rgb(%2); } " + "QRadioButton { border: %1px dashed rgb(%2); }") + .arg(borderWidth).arg(color); + break; + + case ColorApplication::TEXT: + style = QString("QCheckBox { color: rgb(%1); } " + "QRadioButton { color: rgb(%1); }") + .arg(color); + break; + + case ColorApplication::ALL: + style = QString("QCheckBox { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); } " + "QRadioButton { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); }") + .arg(color).arg(opacity).arg(borderWidth); + break; + } + + return style; +} + +// Main function to get style for a color name and application +inline QString getStyle(const QString& colorName, const ColorApplication application = ColorApplication::BACKGROUND, + const float opacity = 0.4, const int borderWidth = 1) +{ + static const std::map<QString, QString> colorValues = { + { "Light0", Colors::Light0 }, + { "Light1", Colors::Light1 }, + { "Light2", Colors::Light2 }, + { "Light3", Colors::Light3 }, + { "Dark0", Colors::Dark0 }, + { "Dark1", Colors::Dark1 }, + { "Dark2", Colors::Dark2 }, + { "Dark3", Colors::Dark3 }, + { "Red", Colors::Red }, + { "Red Bright", Colors::RedBright }, + { "Green", Colors::Green }, + { "Green Bright", Colors::GreenBright }, + { "Yellow", Colors::Yellow }, + { "Yellow Bright", Colors::YellowBright }, + { "Blue", Colors::Blue }, + { "Blue Bright", Colors::BlueBright }, + { "Purple", Colors::Purple }, + { "Purple Bright", Colors::PurpleBright }, + { "Aqua", Colors::Aqua }, + { "Aqua Bright", Colors::AquaBright }, + { "Orange", Colors::Orange }, + { "Orange Bright", Colors::OrangeBright } + }; + + if (const auto it = colorValues.find(colorName); it != colorValues.end()) { + return generateStyle(it->second, application, opacity, borderWidth); + } + + return {}; +} + +// For backward compatibility +const static std::map<QString, QString> colorStyles = { + { "Light0", getStyle("Light0", ColorApplication::BACKGROUND) }, + { "Light1", getStyle("Light1", ColorApplication::BACKGROUND) }, + { "Light2", getStyle("Light2", ColorApplication::BACKGROUND) }, + { "Light3", getStyle("Light3", ColorApplication::BACKGROUND) }, + { "Dark0", getStyle("Dark0", ColorApplication::BACKGROUND) }, + { "Dark1", getStyle("Dark1", ColorApplication::BACKGROUND) }, + { "Dark2", getStyle("Dark2", ColorApplication::BACKGROUND) }, + { "Dark3", getStyle("Dark3", ColorApplication::BACKGROUND) }, + { "Red", getStyle("Red", ColorApplication::BACKGROUND) }, + { "Red Bright", getStyle("Red Bright", ColorApplication::BACKGROUND) }, + { "Green", getStyle("Green", ColorApplication::BACKGROUND) }, + { "Green Bright", getStyle("Green Bright", ColorApplication::BACKGROUND) }, + { "Yellow", getStyle("Yellow", ColorApplication::BACKGROUND) }, + { "Yellow Bright", getStyle("Yellow Bright", ColorApplication::BACKGROUND) }, + { "Blue", getStyle("Blue", ColorApplication::BACKGROUND) }, + { "Blue Bright", getStyle("Blue Bright", ColorApplication::BACKGROUND) }, + { "Purple", getStyle("Purple", ColorApplication::BACKGROUND) }, + { "Purple Bright", getStyle("Purple Bright", ColorApplication::BACKGROUND) }, + { "Aqua", getStyle("Aqua", ColorApplication::BACKGROUND) }, + { "Aqua Bright", getStyle("Aqua Bright", ColorApplication::BACKGROUND) }, + { "Orange", getStyle("Orange", ColorApplication::BACKGROUND) }, + { "Orange Bright", getStyle("Orange Bright", ColorApplication::BACKGROUND) } +}; + +} diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp new file mode 100644 index 0000000..cb99d27 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp @@ -0,0 +1,284 @@ +#include "FomodImageViewer.h" + +#include "ScaleLabel.h" +#include "UIHelper.h" + +#include <QLabel> +#include <QScrollArea> +#include <QtConcurrent/QtConcurrent> + +constexpr int PREVIEW_IMAGE_WIDTH = 160; +constexpr int PREVIEW_IMAGE_HEIGHT = 90; + +/* ++----------------------------------------------------------+ +|n/N X | ++---+--------------------------------------------------+---+ +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| < | Image | > | +| | | | +| | | | +| | | | +| | | | +| | label | | ++------+------+------+---------------------------------+---+ +| | | | ...previews | +| | | | | ++------+------+------+-------------------------------------+ +*/ +constexpr auto BUTTON_STYLE = + "font-size: 16px; font-weight: bold; color: white; background-color: black; padding: 5px; border-radius: 1px solid black;"; + +FomodImageViewer::FomodImageViewer(QWidget* parent, + const QString& fomodPath, + const std::shared_ptr<StepViewModel>& activeStep, + const std::shared_ptr<PluginViewModel>& activePlugin) : QDialog(parent), mFomodPath(fomodPath), + mActiveStep(activeStep), + mActivePlugin(activePlugin) +{ + + setWindowFlags(Qt::FramelessWindowHint | Qt::Window); + setAttribute(Qt::WA_TranslucentBackground); + setStyleSheet("background-color: rgba(0, 0, 0, 150);"); + + const QScreen* screen = this->screen(); + const QRect availableGeometry = screen->availableGeometry(); + setFixedSize(availableGeometry.width(), availableGeometry.height()); + move(availableGeometry.x(), availableGeometry.y()); + + collectImages(); + mMainImageWrapper = createSinglePhotoPane(this); + mTopBar = createTopBar(this); + mPreviewImages = createPreviewImages(this); + mCenterRow = createCenterRow(this); + + const auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); // Remove margins + layout->setSpacing(0); // Remove spacing between widgets + layout->addWidget(mTopBar); + layout->addWidget(mCenterRow, 1); + layout->addWidget(mPreviewImages); + setLayout(layout); + + select(mCurrentIndex); + + setFocusPolicy(Qt::StrongFocus); // so we can receive key events +} + +void FomodImageViewer::collectImages() +{ + mLabelsAndImages.clear(); + for (const auto& groupViewModel : mActiveStep->getGroups()) { + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + if (pluginViewModel->getImagePath().empty()) { + continue; + } + QString imagePath = UIHelper::getFullImagePath(mFomodPath, + QString::fromStdString(pluginViewModel->getImagePath())); + mLabelsAndImages.emplace_back(QString::fromStdString(pluginViewModel->getName()), imagePath); + + if (pluginViewModel == mActivePlugin) { + mCurrentIndex = static_cast<int>(mLabelsAndImages.size()) - 1; + } + } + } +} + +QWidget* FomodImageViewer::createCenterRow(QWidget* parent) +{ + const auto centerRow = new QWidget(parent); + const auto layout = new QHBoxLayout(centerRow); + + mBackButton = createBackButton(centerRow); + mForwardButton = createForwardButton(centerRow); + + mBackButton->setFocusPolicy(Qt::NoFocus); + mForwardButton->setFocusPolicy(Qt::NoFocus); + + layout->addWidget(mBackButton); + layout->addWidget(mMainImageWrapper, 1); + layout->addWidget(mForwardButton); + + return centerRow; +} + +// ReSharper disable once CppMemberFunctionMayBeStatic +QWidget* FomodImageViewer::createSinglePhotoPane(QWidget* parent) +{ + const auto singlePhotoPane = new QWidget(parent); + const auto layout = new QVBoxLayout(singlePhotoPane); + + // const auto [labelText, imagePath] = pair; + + mMainImage = new ScaleLabel(singlePhotoPane); + mMainImage->setAlignment(Qt::AlignCenter); + layout->addWidget(mMainImage, 1); + + mLabel = new QLabel(singlePhotoPane); + // mLabel->setText(labelText); + mLabel->setAlignment(Qt::AlignCenter); + mLabel->setStyleSheet("color: white; font-size: 20px;"); + layout->addWidget(mLabel); + + return singlePhotoPane; +} + +QScrollArea* FomodImageViewer::createPreviewImages(QWidget* parent) +{ + mImagePanes.clear(); + const auto previewImages = new QScrollArea(parent); + const auto widget = new QWidget(previewImages); + const auto layout = new QHBoxLayout(previewImages); + + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + for (int i = 0; i < mLabelsAndImages.size(); i++) { + const auto imageLabel = new ScaleLabel(previewImages); + imageLabel->setAlignment(Qt::AlignCenter); + imageLabel->setFixedSize(PREVIEW_IMAGE_WIDTH, PREVIEW_IMAGE_HEIGHT); + imageLabel->setFocusPolicy(Qt::NoFocus); + connect(imageLabel, &ScaleLabel::clicked, this, [this, i] { + select(i); + }); + layout->addWidget(imageLabel); + mImagePanes.emplace_back(imageLabel); + + // imageLabel->setScalableResource(mLabelsAndImages[i].second); + const auto imagePath = mLabelsAndImages[i].second; + + QThreadPool::globalInstance()->start([imageLabel, imagePath]() { + QMetaObject::invokeMethod(imageLabel, [imageLabel, imagePath]() { + imageLabel->setScalableResource(imagePath); + }, Qt::QueuedConnection); + }); + } + + widget->setLayout(layout); + previewImages->setFixedHeight(PREVIEW_IMAGE_HEIGHT + 10); + previewImages->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + previewImages->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + previewImages->setFocusPolicy(Qt::NoFocus); + + previewImages->setWidget(widget); + previewImages->setStyleSheet("QScrollArea { border: none; }"); + + return previewImages; +} + +QPushButton* FomodImageViewer::createBackButton(QWidget* parent) const +{ + const auto backButton = new QPushButton(parent); + backButton->setText("<"); + backButton->setStyleSheet(BUTTON_STYLE); + connect(backButton, &QPushButton::clicked, this, &FomodImageViewer::goBack); + return backButton; +} + +QPushButton* FomodImageViewer::createForwardButton(QWidget* parent) const +{ + const auto forwardButton = new QPushButton(parent); + forwardButton->setText(">"); + forwardButton-> + setStyleSheet(BUTTON_STYLE); + connect(forwardButton, &QPushButton::clicked, this, &FomodImageViewer::goForward); + return forwardButton; +} + +QWidget* FomodImageViewer::createTopBar(QWidget* parent) +{ + const auto topBar = new QWidget(parent); + const auto layout = new QHBoxLayout(topBar); + + // counter, spacer, close button + mCounter = new QLabel(topBar); + mCounter->setStyleSheet(BUTTON_STYLE); + layout->addWidget(mCounter); + + layout->addStretch(); + + mCloseButton = createCloseButton(topBar); + layout->addWidget(mCloseButton); + return topBar; +} + +QPushButton* FomodImageViewer::createCloseButton(QWidget* parent) +{ + const auto closeButton = new QPushButton(parent); + // const QIcon icon(":/fomod/close"); + // closeButton->setIcon(icon); + closeButton->setText("X"); + closeButton->setStyleSheet("color: white; background-color: black; padding: 5px; border-radius: 1px solid black;"); + connect(closeButton, &QPushButton::clicked, this, &FomodImageViewer::close); + return closeButton; +} + +void FomodImageViewer::updateCounterText() const +{ + mCounter->setText(QString::number(mCurrentIndex + 1) + "/" + QString::number(mLabelsAndImages.size())); +} + +void FomodImageViewer::goBack() +{ + if (mCurrentIndex == 0) { + return; + } + select(--mCurrentIndex); +} + +void FomodImageViewer::goForward() +{ + if (mCurrentIndex == mLabelsAndImages.size() - 1) { + return; + } + select(++mCurrentIndex); +} + +void FomodImageViewer::select(const int index) +{ + if (index < 0 || index >= mLabelsAndImages.size()) { + return; + } + + // Remove border from previously selected image + mCurrentIndex = index; // check for bounds? + updateCounterText(); + + for (int i = 0; i < mImagePanes.size(); i++) { + if (i == mCurrentIndex) { + mImagePanes[i]->setStyleSheet("border: 2px solid white;"); + } else { + mImagePanes[i]->setStyleSheet(""); + } + } + + const auto& imagePath = mLabelsAndImages[index].second; + const auto& labelText = mLabelsAndImages[index].first; + mLabel->setText(labelText); + mMainImage->setScalableResource(imagePath); +} + +void FomodImageViewer::keyPressEvent(QKeyEvent* event) +{ + switch (event->key()) { + case Qt::Key_Left: + goBack(); + break; + case Qt::Key_Right: + goForward(); + break; + default: + QDialog::keyPressEvent(event); + } +} + +void FomodImageViewer::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + setFocus(); +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h new file mode 100644 index 0000000..73a06f7 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h @@ -0,0 +1,92 @@ +#pragma once + +#include "FomodViewModel.h" + +#include <QDialog> +#include <QKeyEvent> +#include <QLabel> +#include <QScrollArea> + +class ScaleLabel; +using LabelImagePair = std::pair<QString, QString>; + + +/* ++----------------------------------------------------------+ +|n/N X | ++---+--------------------------------------------------+---+ +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| < | Image | > | +| | | | +| | | | +| | | | +| | | | +| | label | | ++------+------+------+---------------------------------+---+ +| | | | ...previews | +| | | | | ++------+------+------+-------------------------------------+ +*/ + +class FomodImageViewer final : public QDialog { + Q_OBJECT + +public: + explicit FomodImageViewer(QWidget* parent, + const QString& fomodPath, + const std::shared_ptr<StepViewModel>& activeStep, + const std::shared_ptr<PluginViewModel>& activePlugin); + +private: + void collectImages(); + + QWidget* createCenterRow(QWidget* parent); + + QWidget* createSinglePhotoPane(QWidget* parent); + + QScrollArea* createPreviewImages(QWidget* parent); + + QPushButton* createBackButton(QWidget* parent) const; + + QPushButton* createForwardButton(QWidget* parent) const; + + QWidget* createTopBar(QWidget* parent); + + QPushButton* createCloseButton(QWidget* parent); + + void updateCounterText() const; + + void goBack(); + + void goForward(); + + void select(int index); + + void keyPressEvent(QKeyEvent* event) override; + + void showEvent(QShowEvent* event) override; + + std::vector<LabelImagePair> mLabelsAndImages; + std::vector<QWidget*> mImagePanes{}; + int mCurrentIndex{ 0 }; + + QString mFomodPath; + const std::shared_ptr<StepViewModel>& mActiveStep; + const std::shared_ptr<PluginViewModel>& mActivePlugin; + + QWidget* mCenterRow{ nullptr }; + QWidget* mTopBar{ nullptr }; + QWidget* mCloseButton{ nullptr }; + QPushButton* mBackButton{ nullptr }; + QPushButton* mForwardButton{ nullptr }; + QLabel* mCounter{ nullptr }; + QWidget* mMainImageWrapper{ nullptr }; + ScaleLabel* mMainImage{ nullptr }; + QLabel* mLabel{ nullptr }; + QScrollArea* mPreviewImages{ nullptr }; +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp new file mode 100644 index 0000000..b11674c --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp @@ -0,0 +1,746 @@ +#include "FomodViewModel.h" +#include "xml/ModuleConfiguration.h" +#include "lib/Logger.h" + +using GroupCallback = std::function<void(GroupRef)>; +using PluginCallback = std::function<void(GroupRef, PluginRef)>; + + +/* +-------------------------------------------------------------------------------- + Helpers +-------------------------------------------------------------------------------- +*/ +#pragma region Helpers + +bool isRadioLike(GroupRef group) +{ + return group->getType() == SelectExactlyOne + || (group->getType() == SelectAtMostOne && group->getPlugins().size() > 1); +} + +bool moreThanOneSelected(GroupRef group) +{ + auto selectedPlugins = group->getPlugins() | std::views::filter([](const auto& plugin) { + return plugin->isSelected(); + }); + return std::ranges::distance(selectedPlugins) > 1; +} + +bool anySelected(GroupRef group) +{ + return std::ranges::any_of(group->getPlugins(), [](const auto& plugin) { return plugin->isSelected(); }); +} + +std::string pluginTypeEnumToString(const PluginTypeEnum type) +{ + switch (type) { + case PluginTypeEnum::Recommended: + return "Recommended"; + case PluginTypeEnum::Required: + return "Required"; + case PluginTypeEnum::Optional: + return "Optional"; + case PluginTypeEnum::NotUsable: + return "NotUsable"; + case PluginTypeEnum::CouldBeUsable: + return "CouldBeUsable"; + default: + return "Unknown"; + } +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Lifecycle +-------------------------------------------------------------------------------- +*/ +#pragma region ViewModel Lifecycle + +/** + * @brief FomodViewModel constructor + * + * @note DO NOT USE DIRECTLY. We should only use FomodViewModel::create() to create a new instance. + * @see FomodViewModel::create + * + * @param organizer The organizer instance passed from the IInstaller + * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file + * @param infoFile The FomodInfoFile instance created from the raw info.xml file + * + * @return A FomodViewModel instance + */ +FomodViewModel::FomodViewModel(MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile) + : mOrganizer(organizer), mFomodFile(std::move(fomodFile)), mInfoFile(std::move(infoFile)), + mConditionTester(organizer), + mInfoViewModel(std::make_shared<InfoViewModel>(mInfoFile)) +{ + mFlags = std::make_shared<FlagMap>(); +} + +/** + * + * @param organizer The organizer instance passed from the IInstaller + * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file + * @param infoFile The FomodInfoFile instance created from the raw info.xml file + * @return A shared pointer to the FomodViewModel instance + */ +std::shared_ptr<FomodViewModel> FomodViewModel::create(MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile) +{ + auto viewModel = std::make_shared<FomodViewModel>(organizer, std::move(fomodFile), std::move(infoFile)); + if (viewModel->mFlags == nullptr) { + viewModel->mFlags = std::make_shared<FlagMap>(); + } + viewModel->createStepViewModels(); + + // Handle FOMODs with no steps + if (viewModel->mSteps.empty()) { + viewModel->mInitialized = true; + viewModel->logMessage(INFO, "FOMOD with no steps - initialization complete"); + return viewModel; + } + + viewModel->processPluginConditions(-1); // please dont judge me. ill fix this someday. + viewModel->enforceGroupConstraints(); + viewModel->updateVisibleSteps(); + viewModel->mInitialized = true; + viewModel->mCurrentStepIndex = viewModel->mVisibleStepIndices.front(); + viewModel->mActiveStep = viewModel->mSteps.at(viewModel->mVisibleStepIndices.front()); + viewModel->mActivePlugin = viewModel->getFirstPluginForActiveStep(); + viewModel->getActiveStep()->setVisited(true); + viewModel->logMessage(DEBUG, "VIEWMODEL INITIALIZED"); + viewModel->logMessage(DEBUG, viewModel->toString()); + return viewModel; +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Traversal Functions +-------------------------------------------------------------------------------- +*/ +#pragma region Traversal Functions + +void FomodViewModel::forEachGroup(const GroupCallback& callback) const +{ + for (const auto& stepViewModel : mSteps) { + for (const auto& groupViewModel : stepViewModel->getGroups()) { + callback(groupViewModel); + } + } +} + +void FomodViewModel::forEachPlugin(const PluginCallback& callback) const +{ + for (const auto& stepViewModel : mSteps) { + for (const auto& groupViewModel : stepViewModel->getGroups()) { + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + callback(groupViewModel, pluginViewModel); + } + } + } +} + +void FomodViewModel::forEachFuturePlugin(const int fromStepIndex, const PluginCallback& callback) const +{ + for (int i = fromStepIndex + 1; i < mSteps.size(); ++i) { + for (const auto& groupViewModel : mSteps[i]->getGroups()) { + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + callback(groupViewModel, pluginViewModel); + } + } + } +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Initialization +-------------------------------------------------------------------------------- +*/ +#pragma region Initializers +void FomodViewModel::createStepViewModels() +{ + shared_ptr_list<StepViewModel> stepViewModels; + + // Handle legacy FOMODs with no install steps + if (mFomodFile->installSteps.installSteps.empty()) { + logMessage(INFO, "No install steps found - creating default step for legacy FOMOD"); + return; + } + + for (int stepIndex = 0; stepIndex < mFomodFile->installSteps.installSteps.size(); ++stepIndex) { + const auto& installStep = mFomodFile->installSteps.installSteps[stepIndex]; + shared_ptr_list<GroupViewModel> groupViewModels; + + for (int groupIndex = 0; groupIndex < installStep.optionalFileGroups.groups.size(); ++groupIndex) { + const auto& group = installStep.optionalFileGroups.groups[groupIndex]; + shared_ptr_list<PluginViewModel> pluginViewModels; + + for (int pluginIndex = 0; pluginIndex < group.plugins.plugins.size(); ++pluginIndex) { + const auto& plugin = group.plugins.plugins[pluginIndex]; + auto pluginViewModel = std::make_shared<PluginViewModel>(std::make_shared<Plugin>(plugin), false, true, + pluginIndex); + + pluginViewModel->setStepIndex(stepIndex); + pluginViewModel->setGroupIndex(groupIndex); + pluginViewModels.emplace_back(pluginViewModel); // Assuming default values for selected and enabled + } + auto groupViewModel = std::make_shared<GroupViewModel>(std::make_shared<Group>(group), pluginViewModels, + groupIndex, stepIndex); + if (groupViewModel->getType() == SelectAtMostOne && groupViewModel->getPlugins().size() > 1) { + createNonePluginForGroup(groupViewModel); + } + groupViewModels.emplace_back(groupViewModel); + } + auto stepViewModel = std::make_shared<StepViewModel>(std::make_shared<InstallStep>(installStep), + std::move(groupViewModels), stepIndex); + stepViewModels.emplace_back(stepViewModel); + + } + mSteps = std::move(stepViewModels); +} + +void FomodViewModel::createNonePluginForGroup(GroupRef group) +{ + const auto nonePlugin = std::make_shared<Plugin>(); + nonePlugin->name = "None"; + nonePlugin->typeDescriptor.type = PluginTypeEnum::Optional; + const int newIndex = static_cast<int>(group->getPlugins().size()); + const auto nonePluginViewModel = std::make_shared<PluginViewModel>(nonePlugin, true, true, newIndex); + group->addPlugin(nonePluginViewModel); +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Group Constraints +-------------------------------------------------------------------------------- +*/ +#pragma region Group Constraints + +void FomodViewModel::enforceRadioGroupConstraints(GroupRef group) const +{ + if (!isRadioLike(group)) { + return; + } + + logMessage(INFO, "Enforcing group constraints for group " + group->getName()); + + if (group->getType() == SelectExactlyOne && group->getPlugins().size() == 1) { + logMessage(INFO, + "Disabling " + group->getPlugins().at(0)->getName() + " because it's the only plugin."); + group->getPlugins().at(0)->setEnabled(false); + } + + if (moreThanOneSelected(group)) { + logMessage(ERR, "More than one plugin is selected in a SelectExactlyOne group. Deselecting all."); + for (const auto& plugin : group->getPlugins()) { + plugin->setSelected(false); // don't call toggle here, that'll do the radio stuff. + } + } + + if (anySelected(group)) { + logMessage(INFO, "At least one plugin is selected. Nothing to enforce."); + return; + } + + // First, try to select the first Recommended plugin + for (const auto& plugin : group->getPlugins()) { + if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) == PluginTypeEnum::Recommended) { + logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first recommended plugin."); + togglePlugin(group, plugin, true); + return; + } + } + + // If no Recommended plugin is found, select the first one that isn't NotUsable + for (const auto& plugin : group->getPlugins()) { + if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) { + logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first usable plugin."); + togglePlugin(group, plugin, true); + return; + } + } +} + +void FomodViewModel::enforceSelectAllConstraint(GroupRef groupViewModel) const +{ + if (groupViewModel->getType() != SelectAll) { + return; + } + + for (const auto& pluginViewModel : groupViewModel->getPlugins()) { + togglePlugin(groupViewModel, pluginViewModel, true); + pluginViewModel->setEnabled(false); + } + +} + +void FomodViewModel::enforceSelectAtLeastOneConstraint(GroupRef group) const +{ + if (group->getType() != SelectAtLeastOne || group->getPlugins().size() != 1) { + return; + } + + const auto plugin = group->getPlugins().front(); + if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) { + logMessage(DEBUG, "Selecting " + plugin->getName() + " because it's the only plugin in a SelectAtLeastOne."); + togglePlugin(group, plugin, true); + plugin->setEnabled(false); + } +} + +void FomodViewModel::enforceGroupConstraints() const +{ + forEachGroup([this](const auto& groupViewModel) { + enforceRadioGroupConstraints(groupViewModel); + enforceSelectAllConstraint(groupViewModel); + enforceSelectAtLeastOneConstraint(groupViewModel); + }); +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Plugin Constraints +-------------------------------------------------------------------------------- +*/ +#pragma region Plugin Constraints + +void FomodViewModel::processPlugin(GroupRef group, PluginRef plugin) const +{ + if (group->getType() == SelectAll) { + return; + } + const auto typeDescriptor = mConditionTester.getPluginTypeDescriptorState(plugin->plugin, mFlags); + + if (typeDescriptor == plugin->getCurrentPluginType()) { + return; + } + + logMessage(DEBUG, + "Plugin " + plugin->getName() + " in group " + std::to_string(group->getOwnIndex()) + " has changed type from " + + pluginTypeEnumToString(plugin->getCurrentPluginType()) + " to " + pluginTypeEnumToString(typeDescriptor)); + + plugin->setCurrentPluginType(typeDescriptor); + + const bool isOnlyPlugin = group->getPlugins().size() == 1 + && (group->getType() == SelectExactlyOne || group->getType() == SelectAtLeastOne); + + // check if step hasVisited, if it hasn't been, set it to unchecked if it's optional. + const auto stepNotVisitedYet = !mSteps[group->getStepIndex()]->getHasVisited(); + + switch (typeDescriptor) { + case PluginTypeEnum::Recommended: + plugin->setEnabled(true); + if (!plugin->isSelected()) { + togglePlugin(group, plugin, true); + } + break; + case PluginTypeEnum::Required: + plugin->setEnabled(false); + if (!plugin->isSelected()) { + togglePlugin(group, plugin, true); + } + break; + case PluginTypeEnum::Optional: + if (!isOnlyPlugin) { + plugin->setEnabled(true); + } + // In the case where we're changing flags to make something optional from Recommended, set it back to unchecked. + if (plugin->isSelected() & stepNotVisitedYet && group->getType() == SelectAny) { + togglePlugin(group, plugin, false); + } + break; + case PluginTypeEnum::NotUsable: + plugin->setEnabled(false); + if (plugin->isSelected()) { + togglePlugin(group, plugin, false); + } + break; + case PluginTypeEnum::CouldBeUsable: + plugin->setEnabled(true); + break; + default: ; + } +} + +void FomodViewModel::processPluginConditions(const int fromStepIndex) const +{ + // We only want to update plugins that haven't been seen yet. Otherwise, we could undo manual selections by the user. + if (fromStepIndex >= 0) { + logMessage(DEBUG, "Processing plugins from step " + std::to_string(fromStepIndex)); + forEachFuturePlugin(fromStepIndex, [this](const auto& groupViewModel, const auto& pluginViewModel) { + processPlugin(groupViewModel, pluginViewModel); + }); + } else { + forEachPlugin([this](const auto& groupViewModel, const auto& pluginViewModel) { + processPlugin(groupViewModel, pluginViewModel); + }); + } +} + +void FomodViewModel::setFlagForPluginState(PluginRef plugin) const +{ + if (plugin->isSelected()) { + mFlags->setFlagsForPlugin(plugin); + } else { + mFlags->unsetFlagsForPlugin(plugin); + } +} + +/* + * In an exclusive group, this gets called for the deselected plugin and then the selected plugin. + * So if we're unselecting modB to select modA, we will get calls like + * togglePlugin(group, modB, false) + * togglePlugin(group, modA, true) + */ +bool FomodViewModel::togglePlugin(GroupRef group, PluginRef plugin, const bool selected) const +{ + if (plugin->isSelected() == selected) { + logMessage(DEBUG, "Plugin " + plugin->getName() + " is already " + (selected ? "selected" : "deselected")); + return false; + } + + // Disable other radio options first. + if (selected && isRadioLike(group)) { + for (const auto& otherPlugin : group->getPlugins()) { + if (otherPlugin != plugin && plugin->isSelected()) { + logMessage(DEBUG, + "Deselecting " + otherPlugin->getName() + " because " + plugin->getName() + " was selected."); + otherPlugin->setSelected(false); + setFlagForPluginState(otherPlugin); + } + } + } + + const auto stepIndex = group->getStepIndex(); + + logMessage(INFO, "Toggling " + plugin->getName() + " to " + (selected ? "true" : "false")); + plugin->setSelected(selected); + setFlagForPluginState(plugin); + + if (mInitialized) { + mActivePlugin = plugin; + } + processPluginConditions(stepIndex); + updateVisibleSteps(); + return true; +} + +void FomodViewModel::markManuallySet(PluginRef plugin) +{ + plugin->manuallySet = true; +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Step Constraints +-------------------------------------------------------------------------------- +*/ +#pragma region Step Constraints + +void FomodViewModel::updateVisibleSteps() const +{ + mVisibleStepIndices.clear(); + mFlags->clearAll(); + + for (int i = 0; i < mSteps.size(); ++i) { + if (i == 0) { + rebuildConditionFlagsForStep(i); + } + + // This also depends on previous flags that may have set this particular flag. + if (mConditionTester.isStepVisible(mFlags, mSteps[i]->getVisibilityConditions(), i, mSteps)) { + mVisibleStepIndices.push_back(i); + rebuildConditionFlagsForStep(i); + } + } + if (mFlags->getFlagCount() > 0) { + logMessage(DEBUG, mFlags->toString()); + } +} + +void FomodViewModel::rebuildConditionFlagsForStep(const int stepIndex) const +{ + for (const auto& group : mSteps[stepIndex]->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + setFlagForPluginState(plugin); + } + } +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Navigation/UI +-------------------------------------------------------------------------------- +*/ +#pragma region Navigation/UI + +void FomodViewModel::stepBack() +{ + if (mSteps.empty()) { + return; // No steps to move back to + } + + logMessage(DEBUG, "Stepping back from step " + std::to_string(mCurrentStepIndex)); + const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex); + if (it != mVisibleStepIndices.end() && it != mVisibleStepIndices.begin()) { + mCurrentStepIndex = *std::prev(it); + mActiveStep = mSteps[mCurrentStepIndex]; + mActivePlugin = getFirstPluginForActiveStep(); + } + logMessage(DEBUG, "Stepped back to step " + std::to_string(mCurrentStepIndex)); +} + +void FomodViewModel::stepForward() +{ + if (mSteps.empty()) { + return; // No steps to move forward to + } + + logMessage(DEBUG, "Stepping forward from step " + std::to_string(mCurrentStepIndex)); + const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex); + if (it != mVisibleStepIndices.end() && std::next(it) != mVisibleStepIndices.end()) { + mCurrentStepIndex = *std::next(it); + mActiveStep = mSteps[mCurrentStepIndex]; + mActivePlugin = getFirstPluginForActiveStep(); + } + mActiveStep->setVisited(true); + logMessage(DEBUG, "Stepped forward to step " + std::to_string(mCurrentStepIndex)); +} + +bool FomodViewModel::isLastVisibleStep() const +{ + if (mSteps.empty()) { + return true; // Legacy FOMODs are always "last step" + } + return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.back(); +} + +bool FomodViewModel::isFirstVisibleStep() const +{ + if (mSteps.empty()) { + return true; // Legacy FOMODs are always "first step" + } + return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.front(); +} + +void FomodViewModel::preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath) +{ + mFileInstaller = std::make_shared< + FileInstaller>(mOrganizer, fomodPath, tree, std::move(mFomodFile), mFlags, mSteps); +} + + +std::string FomodViewModel::getDisplayImage() const +{ + // if the active plugin has an image, return it + if (mActivePlugin && !mActivePlugin->getImagePath().empty()) { + return mActivePlugin->getImagePath(); + } + return mCurrentStepIndex == 0 ? mFomodFile->moduleImage.path : ""; +} + +std::shared_ptr<PluginViewModel> FomodViewModel::getFirstPluginForActiveStep() const +{ + if (!mActiveStep) { + return nullptr; + } + + const auto& groups = mActiveStep->getGroups(); + if (groups.empty()) { + return nullptr; + } + + const auto& plugins = groups.front()->getPlugins(); + if (plugins.empty()) { + return nullptr; + } + + return plugins.front(); +} +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Utility +-------------------------------------------------------------------------------- +*/ +#pragma region Utility +std::string FomodViewModel::toString() const +{ + std::string viewModel = "\n"; + for (const auto& step : mSteps) { + + const auto isVisible = std::ranges::find(mVisibleStepIndices, step->getOwnIndex()) != mVisibleStepIndices.end(); + viewModel += "Step " + std::to_string(step->getOwnIndex()) + ": " + step->getName() + "[Visible: " + + std::to_string(isVisible) + "]\n"; + + for (const auto& group : step->getGroups()) { + + viewModel += "\tGroup " + std::to_string(group->getOwnIndex()) + ": " + group->getName() + "\n"; + + for (const auto& plugin : group->getPlugins()) { + viewModel += "\t\tPlugin: " + plugin->getName() + "[Selected: " + (plugin->isSelected() + ? "TRUE" + : "FALSE") + "]\n"; + } + } + } + viewModel += "\n"; + std::ostringstream oss; + std::ranges::transform(mVisibleStepIndices, + std::ostream_iterator<std::string>(oss, ", "), + [](const int i) { return std::to_string(i); }); + + std::string stepList = oss.str(); + stepList.erase(stepList.length() - 2); + + viewModel += "Visible Steps: [" + stepList + "]\n"; + viewModel += mFlags->toString(); + return viewModel; +} + + +void FomodViewModel::resetToDefaults() +{ + logMessage(INFO, "Resetting all choices to author defaults"); + + // Clear all flags first + mFlags->clearAll(); + + // Reset all plugins to deselected and clear visited states + for (const auto& step : mSteps) { + step->setVisited(false); + for (const auto& group : step->getGroups()) { + for (const auto& plugin : group->getPlugins()) { + plugin->setSelected(false); + plugin->setEnabled(true); + plugin->manuallySet = false; + plugin->setCurrentPluginType(PluginTypeEnum::UNKNOWN); + } + } + } + + // Re-run the initial constraint enforcement to restore author defaults + processPluginConditions(-1); + enforceGroupConstraints(); + updateVisibleSteps(); + + // Reset to first step + mCurrentStepIndex = mVisibleStepIndices.empty() ? 0 : mVisibleStepIndices.front(); + mActiveStep = mSteps.empty() ? nullptr : mSteps.at(mCurrentStepIndex); + mActivePlugin = getFirstPluginForActiveStep(); + if (mActiveStep) { + mActiveStep->setVisited(true); + } + + logMessage(DEBUG, "Reset complete. Current state:\n" + toString()); +} + +void FomodViewModel::selectFromJson(nlohmann::json json) const +{ + const auto jsonSteps = json["steps"]; + const auto stepCount = jsonSteps.size(); + + for (int stepIndex = 0; stepIndex < stepCount; ++stepIndex) { + + if (stepIndex > mSteps.size() - 1) { + logMessage(ERR, "Step index " + std::to_string(stepIndex) + " is out of bounds."); + continue; + } + + const auto currentStep = mSteps[stepIndex]; + const auto step = jsonSteps[stepIndex]; + const auto groupCount = step["groups"].size(); + + logMessage(DEBUG, "Selecting plugins for step " + std::to_string(stepIndex)); + logMessage(DEBUG, "There are " + std::to_string(groupCount) + " groups."); + + for (int groupIndex = 0; groupIndex < groupCount; ++groupIndex) { + if (groupIndex > currentStep->getGroups().size() - 1) { + logMessage(ERR, "Group index " + std::to_string(groupIndex) + " is out of bounds."); + continue; + } + + const auto group = step["groups"][groupIndex]; + const auto currentGroup = currentStep->getGroups()[groupIndex]; + + for (const auto jsonPlugin : group["plugins"]) { + + const auto& allPlugins = currentGroup->getPlugins(); + const auto searchName = jsonPlugin.get<std::string>(); + + logMessage(DEBUG, "Looking for plugin " + searchName); + + const auto currentPlugin = std::ranges::find_if(allPlugins, + [searchName](PluginRef p) { + return p->getName() == searchName; + }); + + if (currentPlugin == allPlugins.end()) { + logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName()); + continue; + } + + if ((*currentPlugin)->isSelected()) { + logMessage(DEBUG, "Plugin " + searchName + " is already selected."); + continue; + } + logMessage(DEBUG, "Toggle plugin " + searchName + " to selected."); + if (!(*currentPlugin)->isEnabled()) { + logMessage(DEBUG, "Plugin " + searchName + " is not enabled."); + continue; + } + togglePlugin(currentGroup, *currentPlugin, true); + } + + if (!group.contains("deselected")) { + continue; + } + + // Do the opposite of the above. For unchecked plugins, disable them. + for (const auto jsonPlugin : group["deselected"]) { + + const auto& allPlugins = currentGroup->getPlugins(); + const auto searchName = jsonPlugin.get<std::string>(); + + logMessage(DEBUG, "Looking for plugin to disable: " + searchName); + + const auto currentPlugin = std::ranges::find_if(allPlugins, + [searchName](PluginRef p) { + return p->getName() == searchName; + }); + + if (currentPlugin == allPlugins.end()) { + logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName()); + continue; + } + + if (!(*currentPlugin)->isSelected()) { + logMessage(DEBUG, "Plugin " + searchName + " is already deselected."); + continue; + } + logMessage(DEBUG, "Toggle plugin " + searchName + " to deselected."); + if (!(*currentPlugin)->isEnabled()) { + logMessage(DEBUG, "Plugin " + searchName + " is not enabled."); + continue; + } + togglePlugin(currentGroup, *currentPlugin, false); + (*currentPlugin)->manuallySet = true; // To preserve this state when serializing JSON. + } + } + } +} +#pragma endregion diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.h b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h new file mode 100644 index 0000000..f0ce070 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h @@ -0,0 +1,154 @@ +#pragma once + +#include <imoinfo.h> +#include <string> + +#include "lib/ConditionTester.h" +#include "lib/FlagMap.h" +#include "lib/FileInstaller.h" +#include "lib/ViewModels.h" +#include "xml/FomodInfoFile.h" + +/* +-------------------------------------------------------------------------------- + Info +-------------------------------------------------------------------------------- +*/ +class InfoViewModel { +public: + explicit InfoViewModel(const std::unique_ptr<FomodInfoFile>& infoFile) + { + if (infoFile) { + // Copy the necessary members from FomodInfoFile to InfoViewModel + mName = infoFile->getName(); + mVersion = infoFile->getVersion(); + mAuthor = infoFile->getAuthor(); + mWebsite = infoFile->getWebsite(); + } + } + + // Accessor methods + [[nodiscard]] std::string getName() const { return mName; } + [[nodiscard]] std::string getVersion() const { return mVersion; } + [[nodiscard]] std::string getAuthor() const { return mAuthor; } + [[nodiscard]] std::string getWebsite() const { return mWebsite; } + +private: + std::string mName; + std::string mVersion; + std::string mAuthor; + std::string mWebsite; +}; + +/* +-------------------------------------------------------------------------------- + View Model +-------------------------------------------------------------------------------- +*/ +class FomodViewModel { +public: + FomodViewModel( + MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile); + + static std::shared_ptr<FomodViewModel> create( + MOBase::IOrganizer* organizer, + std::unique_ptr<ModuleConfiguration> fomodFile, + std::unique_ptr<FomodInfoFile> infoFile); + + void forEachGroup(const std::function<void(GroupRef)>& callback) const; + + void forEachPlugin(const std::function<void(GroupRef, PluginRef)>& callback) const; + + void forEachFuturePlugin(int fromStepIndex, const std::function<void(GroupRef, PluginRef)>& callback) const; + + void selectFromJson(nlohmann::json json) const; + + void resetToDefaults(); + + [[nodiscard]] std::shared_ptr<PluginViewModel> getFirstPluginForActiveStep() const; + + // Steps + [[nodiscard]] shared_ptr_list<StepViewModel> getSteps() const { return mSteps; } + [[nodiscard]] StepRef getActiveStep() const { return mActiveStep; } + [[nodiscard]] int getCurrentStepIndex() const { return mCurrentStepIndex; } + [[deprecated]] void setCurrentStepIndex(const int index) { mCurrentStepIndex = index; } + + void updateVisibleSteps() const; + + void rebuildConditionFlagsForStep(int stepIndex) const; + + void preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath); + + std::shared_ptr<FileInstaller> getFileInstaller() { return mFileInstaller; } + + std::string getDisplayImage() const; + + // Plugins + [[nodiscard]] PluginRef getActivePlugin() const { return mActivePlugin; } + + // Info + [[nodiscard]] std::shared_ptr<InfoViewModel> getInfoViewModel() const { return mInfoViewModel; } + + // Interactions + void stepBack(); + + void stepForward(); + + bool isLastVisibleStep() const; + + bool isFirstVisibleStep() const; + + bool togglePlugin(const GroupRef, const PluginRef, bool selected) const; + + bool ctrlTogglePlugin(const GroupRef, const PluginRef, bool selected) const; + + void setActivePlugin(const PluginRef plugin) const { mActivePlugin = plugin; } + + static void markManuallySet(PluginRef plugin); + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* mOrganizer = nullptr; + std::unique_ptr<ModuleConfiguration> mFomodFile; + std::unique_ptr<FomodInfoFile> mInfoFile; + std::shared_ptr<FlagMap> mFlags{ nullptr }; + ConditionTester mConditionTester; + std::shared_ptr<InfoViewModel> mInfoViewModel; + std::vector<std::shared_ptr<StepViewModel> > mSteps; + mutable std::shared_ptr<PluginViewModel> mActivePlugin{ nullptr }; + mutable std::shared_ptr<StepViewModel> mActiveStep{ nullptr }; + mutable std::vector<int> mVisibleStepIndices; + std::shared_ptr<FileInstaller> mFileInstaller{ nullptr }; + bool mInitialized{ false }; + + void createStepViewModels(); + + void setFlagForPluginState(const std::shared_ptr<PluginViewModel>& plugin) const; + + static void createNonePluginForGroup(const std::shared_ptr<GroupViewModel>& group); + + void processPlugin(const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin) const; + + void enforceRadioGroupConstraints(const std::shared_ptr<GroupViewModel>& group) const; + + void enforceSelectAllConstraint(const std::shared_ptr<GroupViewModel>& groupViewModel) const; + + void enforceSelectAtLeastOneConstraint(const std::shared_ptr<GroupViewModel>& group) const; + + void enforceGroupConstraints() const; + + void processPluginConditions(int fromStepIndex) const; + + // Indices + int mCurrentStepIndex{ 0 }; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[VIEWMODEL] " + message); + } + + std::string toString() const; +}; diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp new file mode 100644 index 0000000..3a33640 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp @@ -0,0 +1,120 @@ +#include "ScaleLabel.h" +#include <QResizeEvent> +#include <iostream> + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h +static bool isResourceMovie(const QString& path) +{ + const auto formats = QMovie::supportedFormats(); + return std::ranges::any_of(formats, [&path](const QByteArray& format) { + return path.endsWith("." + QString::fromUtf8(format)); + }); +} + +void ScaleLabel::setScalableResource(const QString& path) +{ + if (const auto m = movie()) { + setMovie(nullptr); + delete m; + mOriginalMovieSize = QSize(); + } + if (!pixmap().isNull()) { + setPixmap(QPixmap()); + mUnscaledImage = QImage(); + } + + if (path.isEmpty()) { + return; + } + + if (isResourceMovie(path)) { + setScalableMovie(path); + } else { + setScalableImage(path); + } +} + +void ScaleLabel::setStatic(const bool isStatic) +{ + misStatic = isStatic; + + if (const auto m = movie()) { + if (isStatic) { + m->stop(); + } else { + m->start(); + } + } +} + +void ScaleLabel::setScalableMovie(const QString& path) +{ + const auto m = new QMovie(path); + if (!m->isValid()) { + qWarning(">%s< is an invalid movie. Reason: %s", qUtf8Printable(path), + m->lastErrorString().toStdString().c_str()); + delete m; + return; + } + + m->setParent(this); + setMovie(m); + m->start(); + m->stop(); + mOriginalMovieSize = m->currentImage().size(); + + m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio)); + if (!misStatic) { + m->start(); + } + mHasResource = true; +} + +void ScaleLabel::setScalableImage(const QString& path) +{ + if (const QImage image(path); image.isNull()) { + qWarning(">%s< is a null image", qUtf8Printable(path)); + } else { + mUnscaledImage = image; + setPixmap(QPixmap::fromImage(image).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + mHasResource = true; + } +} + +void ScaleLabel::resizeEvent(QResizeEvent* event) +{ + if (const auto m = movie()) { + m->stop(); + m->setScaledSize(mOriginalMovieSize.scaled(event->size(), Qt::KeepAspectRatio)); + m->start(); + + // We can't just skip the start() above since that is what triggers the label to + // resize the movie The only way to resize the movie but keep it paused is to start + // and then re-stop it + if (misStatic) { + m->stop(); + } + } + if (const auto p = pixmap(); !p.isNull()) { + setPixmap( + QPixmap::fromImage(mUnscaledImage).scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +} + +void ScaleLabel::showEvent(QShowEvent* event) +{ + QLabel::showEvent(event); + + if (const auto m = movie()) { + m->stop(); + m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio)); + m->start(); + + if (misStatic) { + m->stop(); + } + } + if (const auto p = pixmap(); !p.isNull()) { + setPixmap(QPixmap::fromImage(mUnscaledImage).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.h b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h new file mode 100644 index 0000000..f4fb5f2 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h @@ -0,0 +1,45 @@ +#pragma once + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h +#include <QLabel> +#include <QMouseEvent> +#include <QMovie> + + +class ScaleLabel final : public QLabel { + Q_OBJECT + +public: + explicit ScaleLabel(QWidget* parent = nullptr) : QLabel(parent) + { + setCursor(Qt::PointingHandCursor); + } + + void setScalableResource(const QString& path); + void setStatic(bool isStatic); + [[nodiscard]] bool hasResource() const { return mHasResource; } + +signals: + void clicked(); + +protected: + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QLabel::mousePressEvent(event); + } + + void resizeEvent(QResizeEvent* event) override; + void showEvent(QShowEvent* event) override; + +private: + void setScalableMovie(const QString& path); + void setScalableImage(const QString& path); + + QImage mUnscaledImage; + QSize mOriginalMovieSize; + bool mHasResource = false; + bool misStatic = false; +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.cpp b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp new file mode 100644 index 0000000..2312fd1 --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp @@ -0,0 +1,95 @@ +#include "UIHelper.h" + +#include <qcoreevent.h> +#include <QDir> +#include <QMouseEvent> + +HoverEventFilter::HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent) + : QObject(parent), mPlugin(plugin) {} + +bool HoverEventFilter::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::HoverEnter) { + emit hovered(mPlugin); + return true; + } + return QObject::eventFilter(obj, event); +} + +CtrlClickEventFilter::CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QObject* parent) + : QObject(parent), mPlugin(plugin), mGroup(group) {} + +bool CtrlClickEventFilter::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::MouseButtonPress) { + const QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event); + if (mouseEvent->button() == Qt::LeftButton && + mouseEvent->modifiers() & Qt::ControlModifier) { + // TODO: Add Ctrl+click handler logic here + std::cout << "Ctrl+click detected on plugin: " << mPlugin->getName() << " in group: " << mGroup->getName() << std::endl; + // For now, just fall through to default behavior + } + } + return QObject::eventFilter(obj, event); +} + +QPushButton* UIHelper::createButton(const QString& text, QWidget* parent = nullptr) +{ + const auto button = new QPushButton(text, parent); + return button; +} + +QLabel* UIHelper::createLabel(const QString& text, QWidget* parent = nullptr) +{ + const auto label = new QLabel(text, parent); + return label; +} + +QLabel* UIHelper::createHyperlink(const QString& url, QWidget* parent = nullptr) +{ + if (url.isEmpty() || !QUrl(url).isValid()) { + return createLabel(url, parent); + } + const auto label = new QLabel(url, parent); + const QString hyperlink = QString("<a href=\"%1\">%2</a>").arg(url, "Link"); + label->setText(hyperlink); + label->setOpenExternalLinks(true); + label->setTextFormat(Qt::RichText); + return label; +} + +QString UIHelper::getFullImagePath(const QString& fomodPath, const QString& imagePath) +{ + return QDir::tempPath() + "/" + fomodPath + "/" + imagePath; +} + +void UIHelper::setGlobalAlignment(QBoxLayout* layout, const Qt::Alignment alignment) +{ + for (int i = 0; i < layout->count(); ++i) { + if (const QLayoutItem* item = layout->itemAt(i); item->widget()) { + layout->setAlignment(item->widget(), alignment); + } + } +} + +void UIHelper::setDebugBorders(QWidget* widget) +{ + widget->setStyleSheet("border: 1px solid red;"); + for (auto* child : widget->findChildren<QWidget*>()) { + child->setStyleSheet("border: 1px solid red;"); + } +} + +void UIHelper::reduceLabelPadding(const QLayout* layout) +{ + for (int i = 0; i < layout->count(); ++i) { + const QLayoutItem* item = layout->itemAt(i); + if (QWidget* widget = item->widget()) { + if (const auto label = qobject_cast<QLabel*>(widget)) { + label->setContentsMargins(0, 0, 0, 0); + label->setStyleSheet("padding: 0px; margin: 0px;"); + } + } + } +} diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.h b/libs/installer_fomod_plus/installer/ui/UIHelper.h new file mode 100644 index 0000000..1007e7c --- /dev/null +++ b/libs/installer_fomod_plus/installer/ui/UIHelper.h @@ -0,0 +1,80 @@ +#pragma once + +#include <QPushButton> +#include <QVBoxLayout> +#include <QLabel> + +#include "FomodViewModel.h" + +class HoverEventFilter final : public QObject { + Q_OBJECT + +public: + explicit HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent = nullptr); + +signals: + void hovered(const std::shared_ptr<PluginViewModel>& plugin); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + std::shared_ptr<PluginViewModel> mPlugin; +}; + +class CtrlClickEventFilter final : public QObject { + Q_OBJECT + +public: + explicit CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin, + const std::shared_ptr<GroupViewModel>& group, QObject* parent = nullptr); + +signals: + void ctrlClicked(bool selected, const std::shared_ptr<GroupViewModel>& group, + const std::shared_ptr<PluginViewModel>& plugin); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + std::shared_ptr<PluginViewModel> mPlugin; + std::shared_ptr<GroupViewModel> mGroup; +}; + + +namespace UiConstants { +constexpr int WINDOW_MIN_WIDTH = 900; +constexpr int WINDOW_MIN_HEIGHT = 600; +} + +class UIHelper { +public: + /* + -------------------------------------------------------------------------------- + Widgets & Events + -------------------------------------------------------------------------------- + */ + static QPushButton* createButton(const QString& text, QWidget* parent); + + static QLabel* createLabel(const QString& text, QWidget* parent); + + static QLabel* createHyperlink(const QString& url, QWidget* parent); + + /* + -------------------------------------------------------------------------------- + Helpers + -------------------------------------------------------------------------------- + */ + static QString getFullImagePath(const QString& fomodPath, const QString& imagePath); + + static void setGlobalAlignment(QBoxLayout* layout, Qt::Alignment alignment); + + static void reduceLabelPadding(const QLayout* layout); + + /* + -------------------------------------------------------------------------------- + Development + -------------------------------------------------------------------------------- + */ + static void setDebugBorders(QWidget* widget); +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/CMakeLists.txt b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt new file mode 100644 index 0000000..be46b32 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.16) +project(fomod_plus_patch_wizard) + +include(FetchContent) +set(project_type plugin) + +file(GLOB_RECURSE PATCHWIZARD_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) +file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp") + +add_library(fomod_plus_patch_wizard SHARED ${PATCHWIZARD_SOURCES} ${SHARE_SOURCES}) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14) +FetchContent_MakeAvailable(pugixml json) + +target_include_directories( + fomod_plus_patch_wizard + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData + ${CMAKE_CURRENT_SOURCE_DIR}/../share/xml + ${MO2_ARCHIVE_INCLUDE_DIRS} +) + +if (MSVC) + target_compile_options( + fomod_plus_patch_wizard + PRIVATE + /bigobj + /W4 + /WX + /wd4201 + /wd4458 + ) +endif () + +target_link_libraries(fomod_plus_patch_wizard PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json) +mo2_configure_plugin(fomod_plus_patch_wizard NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive) +mo2_install_target(fomod_plus_patch_wizard) diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp new file mode 100644 index 0000000..00be8d5 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp @@ -0,0 +1,181 @@ +#include "FomodPlusPatchWizard.h" + +#include <QApplication> +#include <QDialog> +#include <QHBoxLayout> +#include <QLabel> +#include <QMessageBox> +#include <QProgressDialog> +#include <QPushButton> +#include <QVBoxLayout> + +#include "lib/PatchFinder.h" +#include <FomodRescan.h> + +bool FomodPlusPatchWizard::init(IOrganizer* organizer) +{ + mOrganizer = organizer; + mDialog = new QDialog(); + mDialog->setWindowTitle(tr("Patch Wizard")); + mDialog->setMinimumSize(400, 200); + log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus-patchwizard.log"); + + mOrganizer->onUserInterfaceInitialized([this](QMainWindow*) { + logMessage(DEBUG, "patches populated."); + mPatchFinder = std::make_unique<PatchFinder>(mOrganizer); + mPatchFinder->populateInstalledPlugins(); + mAvailablePatches = mPatchFinder->getAvailablePatchesForModList(); + logMessage(DEBUG, "Available Patches: " + std::to_string(mAvailablePatches.size())); + }); + + return true; +} + +void FomodPlusPatchWizard::display() const +{ + // Clear any existing layout + if (mDialog->layout() != nullptr) { + QLayoutItem* item; + while ((item = mDialog->layout()->takeAt(0)) != nullptr) { + delete item->widget(); + delete item; + } + delete mDialog->layout(); + } + + if (mAvailablePatches.empty()) { + setupEmptyState(); + } else { + setupPatchList(); + } + + mDialog->exec(); +} + +void FomodPlusPatchWizard::setupEmptyState() const +{ + auto* mainLayout = new QVBoxLayout(mDialog); + mainLayout->setAlignment(Qt::AlignCenter); + + auto* contentWidget = new QWidget(mDialog); + auto* contentLayout = new QHBoxLayout(contentWidget); + contentLayout->setAlignment(Qt::AlignCenter); + contentLayout->setSpacing(16); + + auto* imageLabel = new QLabel(contentWidget); + imageLabel->setPixmap(QPixmap(":/fomod/infoscroll").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + + auto* textLabel = new QLabel( + tr("Nothing of interest yet. The wizard gets wiser as you\ninstall FOMODs, so check back later!"), + contentWidget + ); + textLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); + + contentLayout->addWidget(imageLabel); + contentLayout->addWidget(textLabel); + + mainLayout->addWidget(contentWidget); + + auto* rescanButton = new QPushButton(tr("Rescan Load Order"), mDialog); + // Use const_cast since setupEmptyState is const but onRescanClicked modifies state + connect(rescanButton, &QPushButton::clicked, const_cast<FomodPlusPatchWizard*>(this), + &FomodPlusPatchWizard::onRescanClicked); + mainLayout->addWidget(rescanButton, 0, Qt::AlignCenter); +} + +void FomodPlusPatchWizard::onRescanClicked() +{ + const auto confirmResult = QMessageBox::question( + mDialog, + tr("Rescan Load Order"), + tr("Rescanning will populate as many existing choices and options as we can, " + "but if some downloads are deleted it may be missing things! " + "It may take a few minutes depending on the size of your load order and all that."), + QMessageBox::Ok | QMessageBox::Cancel, + QMessageBox::Cancel + ); + + if (confirmResult != QMessageBox::Ok) { + return; + } + + logMessage(DEBUG, "Rescan requested by user"); + + // Create progress dialog + QProgressDialog progress(tr("Scanning mods..."), tr("Cancel"), 0, 100, mDialog); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(0); + progress.setValue(0); + + bool cancelled = false; + + // Perform the rescan + FomodRescan rescan(mOrganizer, mPatchFinder->mFomodDb.get()); + auto result = rescan.scanAllModsWithChoices([&](int current, int total, const QString& modName) { + if (progress.wasCanceled()) { + cancelled = true; + return; + } + const int percent = total > 0 ? (current * 100 / total) : 0; + progress.setValue(percent); + progress.setLabelText(tr("Scanning: %1 (%2/%3)").arg(modName).arg(current).arg(total)); + QApplication::processEvents(); + }); + + progress.setValue(100); + + if (cancelled) { + QMessageBox::information( + mDialog, + tr("Rescan Cancelled"), + tr("The rescan was cancelled. Partial results may have been saved.") + ); + logMessage(INFO, "Rescan cancelled by user"); + } else { + // Show result summary + QString summary = tr("Rescan complete!\n\n" + "Mods processed: %1\n" + "Successfully scanned: %2\n" + "Missing archives: %3\n" + "Parse errors: %4") + .arg(result.totalModsProcessed) + .arg(result.successfullyScanned) + .arg(result.missingArchives) + .arg(result.parseErrors); + + if (!result.failedMods.empty() && result.failedMods.size() <= 10) { + summary += tr("\n\nFailed mods:"); + for (const auto& mod : result.failedMods) { + summary += QString("\n- %1").arg(QString::fromStdString(mod)); + } + } else if (result.failedMods.size() > 10) { + summary += tr("\n\n%1 mods failed (see log for details)").arg(result.failedMods.size()); + for (const auto& mod : result.failedMods) { + logMessage(INFO, "Failed mod: " + mod); + } + } + + QMessageBox::information(mDialog, tr("Rescan Complete"), summary); + + logMessage(INFO, "Rescan complete: " + std::to_string(result.successfullyScanned) + + "/" + std::to_string(result.totalModsProcessed) + " successful"); + } + + // Refresh available patches + mPatchFinder->populateInstalledPlugins(); + mAvailablePatches = mPatchFinder->getAvailablePatchesForModList(); + logMessage(DEBUG, "Available Patches after rescan: " + std::to_string(mAvailablePatches.size())); + + // Refresh the UI + display(); +} + +void FomodPlusPatchWizard::setupPatchList() const +{ + auto* mainLayout = new QVBoxLayout(mDialog); + + auto* label = new QLabel(tr("Available patches: %1").arg(mAvailablePatches.size()), mDialog); + mainLayout->addWidget(label); + + // TODO: Implement actual patch list UI +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h new file mode 100644 index 0000000..2324fb0 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h @@ -0,0 +1,54 @@ +#pragma once +#include "../installer/lib/Logger.h" +#include "lib/PatchFinder.h" + +#include <iplugintool.h> +#include <qtmetamacros.h> + +using namespace MOBase; + +class FomodPlusPatchWizard final : public IPluginTool { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool) +#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusPatchWizard" FILE "fomodpluspatchwizard.json") +#endif + +public: + bool init(IOrganizer* organizer) override; + + [[nodiscard]] QString name() const override { return tr("Patch Wizard"); }; + + [[nodiscard]] QString author() const override { return "clearing"; }; + + [[nodiscard]] QString description() const override { return tr("Find missing patches from FOMODs in your load order."); }; + + [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_BETA }; }; + + [[nodiscard]] QList<PluginSetting> settings() const override { return {}; }; + + [[nodiscard]] QString displayName() const override { return tr("Patch Wizard"); }; + + [[nodiscard]] QString tooltip() const override { return tr("Find missing patches from FOMODs in your load order."); }; + + [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); } + + void display() const override; + +private: + Logger& log = Logger::getInstance(); + QDialog* mDialog{ nullptr }; + IOrganizer* mOrganizer{ nullptr }; + std::unique_ptr<PatchFinder> mPatchFinder{ nullptr }; + std::vector<AvailablePatch> mAvailablePatches; + + void setupEmptyState() const; + void setupPatchList() const; + void onRescanClicked(); + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[PATCHFINDER] " + message); + } + +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts new file mode 100644 index 0000000..7da04f1 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts @@ -0,0 +1,96 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>FomodPlusPatchWizard</name> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="19"/> + <location filename="FomodPlusPatchWizard.h" line="20"/> + <location filename="FomodPlusPatchWizard.h" line="30"/> + <source>Patch Wizard</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="69"/> + <source>Nothing of interest yet. The wizard gets wiser as you +install FOMODs, so check back later!</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="79"/> + <location filename="FomodPlusPatchWizard.cpp" line="90"/> + <source>Rescan Load Order</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="91"/> + <source>Rescanning will populate as many existing choices and options as we can, but if some downloads are deleted it may be missing things! It may take a few minutes depending on the size of your load order and all that.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="105"/> + <source>Scanning mods...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="105"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="121"/> + <source>Scanning: %1 (%2/%3)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="130"/> + <source>Rescan Cancelled</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="131"/> + <source>The rescan was cancelled. Partial results may have been saved.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="136"/> + <source>Rescan complete! + +Mods processed: %1 +Successfully scanned: %2 +Missing archives: %3 +Parse errors: %4</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="147"/> + <source> + +Failed mods:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="152"/> + <source> + +%1 mods failed (see log for details)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="158"/> + <source>Rescan Complete</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="177"/> + <source>Available patches: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.h" line="24"/> + <location filename="FomodPlusPatchWizard.h" line="32"/> + <source>Find missing patches from FOMODs in your load order.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts new file mode 100644 index 0000000..aaa26e2 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +<context> + <name>FomodPlusPatchWizard</name> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="26"/> + <location filename="FomodPlusPatchWizard.cpp" line="51"/> + <location filename="FomodPlusPatchWizard.cpp" line="74"/> + <source>Patch Wizard</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusPatchWizard.cpp" line="36"/> + <source>Finde Patches, die du aus den FOMODs in deiner Modliste installieren möchtest.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json new file mode 100644 index 0000000..2d6025f --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json @@ -0,0 +1,8 @@ +{ + "author": "clearing", + "date": "2025/02/06", + "name": "FOMOD Plus - Patch Wizard", + "version": "1.0.0", + "des": "Find missing patches in your modlist", + "dependencies": [] +} diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp new file mode 100644 index 0000000..cd06b3a --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp @@ -0,0 +1,86 @@ +#include "PatchFinder.h" + +std::vector<AvailablePatch> PatchFinder::getAvailablePatchesForMod(const MOBase::IModInterface* mod) +{ + std::vector<AvailablePatch> available_patches = {}; + + // Verify that the mod has plugins in some shape or form. + if (const auto it = m_installedPlugins.find(mod); it == m_installedPlugins.end()) { + return available_patches; + } + + // Look through the database + for (const auto& fomodDbEntries = mFomodDb->getEntries(); const auto& entry : fomodDbEntries) { + for (const auto& option : entry->getOptions()) { + // Skip options that are already selected/installed + if (option.selectionState == SelectionState::Selected) { + continue; + } + + // Extract just the filename from the path (handle both / and \) + const auto fileName = option.fileName.substr(option.fileName.find_last_of("/\\") + 1); + + // Skip if already installed in the modlist + if (m_installedPluginsCacheSet.contains(fileName)) { + continue; + } + + // Technically without this check, we're doing a lookup for the entire modlist, which actually + // kinda works, but isn't the intention of this function. Might want to consider reworking + // it to map to mod names with one pass of the DB instead of a DB pass per mod. + if (!std::ranges::any_of(option.masters, [&mod](const std::string& master) { + return master == mod->name().toStdString(); + })) { + continue; + } + + // If we have all of this patch's masters in our modlist, and it's not installed, add it to the results. + if (std::ranges::all_of(option.masters, [this](const std::string& master) { + return m_installedPluginsCacheSet.contains(master); + })) { + AvailablePatch patch{ + option, + entry->getDisplayName(), + mod->name().toStdString(), + false, // not installed + false, // not hidden + option.selectionState == SelectionState::Deselected // userDeselected + }; + available_patches.push_back(patch); + } + } + } + + return available_patches; +} + +std::vector<AvailablePatch> PatchFinder::getAvailablePatchesForModList() +{ + std::vector<AvailablePatch> available_patches = {}; + for (const auto& modName : m_organizer->modList()->allMods()) { + const auto mod = m_organizer->modList()->getMod(modName); + if (mod == nullptr) { + continue; + } + for (const auto& available_patch : getAvailablePatchesForMod(mod)) { + available_patches.emplace_back(available_patch); + } + } + + return available_patches; +} + +void PatchFinder::populateInstalledPlugins() +{ + for (const auto& modName : m_organizer->modList()->allMods()) { + const auto mod = m_organizer->modList()->getMod(modName); + const auto mod_tree = mod->fileTree(); + for (auto it = mod_tree->begin(); it != mod_tree->end(); ++it) { + if ((*it)->isFile() && isPluginFile((*it)->name())) { + std::cout << "Plugin: " << (*it)->name().toStdString() << std::endl; + m_installedPlugins[mod].emplace_back((*it)->name().toStdString()); + m_installedPluginsCacheSet.insert((*it)->name().toStdString()); + } + } + } +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h new file mode 100644 index 0000000..02327e3 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h @@ -0,0 +1,49 @@ +#pragma once + +#include "../../installer/lib/Logger.h" + +#include <FomodDB.h> +#include <imodinterface.h> +#include <imoinfo.h> +#include <ifiletree.h> + +struct AvailablePatch { + FomodOption fomod_option; + std::string installer_name; + std::string patch_for_mod; + bool installed = false; + bool hidden = false; + bool userDeselected = false; // True if user explicitly chose not to install +}; + +class PatchFinder { +friend class FomodPlusPatchWizard; + +public: + explicit PatchFinder(MOBase::IOrganizer* m_organizer) : m_organizer(m_organizer) + { + mFomodDb = std::make_unique<FomodDB>(m_organizer->basePath().toStdString()); + logMessage(DEBUG, "mFomodDb loaded."); + } + + std::vector<AvailablePatch> getAvailablePatchesForMod(const MOBase::IModInterface* mod); + std::vector<AvailablePatch> getAvailablePatchesForModList(); + +protected: + void populateInstalledPlugins(); + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* m_organizer; + std::unique_ptr<FomodDB> mFomodDb; + + // Map of { pluginPtr: [1.esp, 2.esp, 3.esp] } + std::unordered_map<const MOBase::IModInterface*, std::vector<std::string> > m_installedPlugins; + std::unordered_set<std::string> m_installedPluginsCacheSet; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[PATCHFINDER] " + message); + } + +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/resources.qrc b/libs/installer_fomod_plus/patchwizard/resources.qrc new file mode 100644 index 0000000..fe6971e --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources.qrc @@ -0,0 +1,6 @@ +<RCC> + <qresource prefix="/fomod"> + <file alias="hat">resources/fomod_icon.png</file> + <file alias="infoscroll">resources/infoscroll.png</file> + </qresource> +</RCC> diff --git a/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png Binary files differnew file mode 100644 index 0000000..e044052 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png diff --git a/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png Binary files differnew file mode 100644 index 0000000..0c31777 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png diff --git a/libs/installer_fomod_plus/scanner/CMakeLists.txt b/libs/installer_fomod_plus/scanner/CMakeLists.txt new file mode 100644 index 0000000..daea477 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.16) +project(fomod_plus_scanner) + +include(FetchContent) +set(project_type plugin) + +file(GLOB_RECURSE SCANNER_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) + +add_library(fomod_plus_scanner SHARED ${SCANNER_SOURCES}) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14) +FetchContent_MakeAvailable(pugixml json) + +target_include_directories( + fomod_plus_scanner + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData + ${MO2_UIBASE_INCLUDE_DIRS} + ${MO2_ARCHIVE_INCLUDE_DIRS} +) + +if (MSVC) + target_compile_options( + fomod_plus_scanner + PRIVATE + /bigobj + /W4 + /WX + /wd4201 + /wd4458 + ) +endif () + +target_link_libraries(fomod_plus_scanner PRIVATE mo2::uibase mo2::archive pugixml nlohmann_json::nlohmann_json) +mo2_configure_plugin(fomod_plus_scanner NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive) + +if (MSVC) + target_compile_options(fomod_plus_scanner PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>) + target_link_options(fomod_plus_scanner PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>) + install(FILES $<TARGET_PDB_FILE:fomod_plus_scanner> + DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO} + CONFIGURATIONS RelWithDebInfo + OPTIONAL) +endif () + +mo2_install_target(fomod_plus_scanner) diff --git a/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp b/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp new file mode 100644 index 0000000..4770178 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp @@ -0,0 +1,128 @@ +#include "FomodPlusScanner.h" + +#include "archiveparser.h" +#include "FomodDBEntry.h" + +#include <QDialog> +#include <QPushButton> +#include <QProgressBar> +#include <QVBoxLayout> +#include <QMessageBox> +#include <archive.h> + +#include <QLabel> +#include <QMovie> +#include <iostream> + +#include "stringutil.h" + +using ScanCallbackFn = std::function<bool(IModInterface*, ScanResult result)>; + +bool FomodPlusScanner::init(IOrganizer* organizer) +{ + mOrganizer = organizer; + + mDialog = new QDialog(); + mDialog->setWindowTitle(tr("FOMOD Scanner")); + + const auto layout = new QVBoxLayout(mDialog); + + const QString description = tr("Greetings, traveler.\n" + "This tool will scan your load order for mods installed via FOMOD.\n" + "It will also fix up any erroneous FOMOD flags from previous versions of FOMOD Plus :) \n\n" + "Safe travels, and may your load order be free of conflicts."); + + const auto descriptionLabel = new QLabel(description, mDialog); + descriptionLabel->setWordWrap(true); // Enable word wrap for large text + descriptionLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + descriptionLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); // Align text to the top-left corner + + const auto gifLabel = new QLabel(mDialog); + const auto movie = new QMovie(":/fomod/wizard"); + gifLabel->setMovie(movie); + movie->start(); + + mProgressBar = new QProgressBar(mDialog); + mProgressBar->setRange(0, mOrganizer->modList()->allMods().size()); + mProgressBar->setVisible(false); + + const auto scanButton = new QPushButton(tr("Scan"), mDialog); + const auto cancelButton = new QPushButton(tr("Cancel"), mDialog); + + layout->addWidget(descriptionLabel, 1); + layout->addWidget(gifLabel, 1); + layout->addWidget(mProgressBar, 1); + layout->addWidget(scanButton, 1); + layout->addWidget(cancelButton, 1); + + connect(cancelButton, &QPushButton::clicked, mDialog, &QDialog::reject); + connect(scanButton, &QPushButton::clicked, this, &FomodPlusScanner::onScanClicked); + connect(mDialog, &QDialog::finished, this, &FomodPlusScanner::cleanup); + + mDialog->setLayout(layout); + mDialog->setMinimumSize(400, 300); + mDialog->adjustSize(); + descriptionLabel->adjustSize(); + return true; +} + +void FomodPlusScanner::onScanClicked() const +{ + mProgressBar->setVisible(true); + const int added = scanLoadOrder(setFomodInfoForMod); + mDialog->accept(); + QMessageBox::information(mDialog, tr("Scan Complete"), + tr("The load order scan is complete. Updated filter info for ") + QString::number(added) + tr(" mods.")); + mOrganizer->refresh(); +} + +void FomodPlusScanner::cleanup() const +{ + mProgressBar->reset(); + mProgressBar->setVisible(false); +} + +void FomodPlusScanner::display() const +{ + mDialog->exec(); +} + +int FomodPlusScanner::scanLoadOrder(const ScanCallbackFn& callback) const +{ + int progress = 0; + int modified = 0; + for (const auto& modName : mOrganizer->modList()->allMods()) { + const auto mod = mOrganizer->modList()->getMod(modName); + if (const ScanResult result = openInstallationArchive(mod); callback(mod, result)) { + modified++; + } + mProgressBar->setValue(++progress); + } + return modified; +} + +ScanResult FomodPlusScanner::openInstallationArchive(const IModInterface* mod) const +{ + const auto downloadsDir = mOrganizer->downloadsPath(); + const auto installationFilePath = mod->installationFile(); + return ArchiveParser::scanForFomodFiles(downloadsDir, installationFilePath, mod->name()); +} + +bool FomodPlusScanner::setFomodInfoForMod(IModInterface* mod, const ScanResult result) +{ + const auto pluginName = "FOMOD Plus"; + const auto setting = mod->pluginSetting(pluginName, "fomod", 0); + if (setting == 0 && ScanResult::HAS_FOMOD == result) { + return mod->setPluginSetting(pluginName, "fomod", "{}"); + } + if (setting != 0 && ScanResult::NO_FOMOD == result) { + return mod->setPluginSetting(pluginName, "fomod", 0); + } + return false; +} + +bool FomodPlusScanner::removeFomodInfoFromMod(IModInterface* mod, ScanResult) +{ + const auto pluginName = QString::fromStdString("FOMOD Plus"); + return mod->setPluginSetting(pluginName, "fomod", 0); +} diff --git a/libs/installer_fomod_plus/scanner/FomodPlusScanner.h b/libs/installer_fomod_plus/scanner/FomodPlusScanner.h new file mode 100644 index 0000000..b7893f7 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/FomodPlusScanner.h @@ -0,0 +1,64 @@ +#ifndef FOMODPLUSSCANNER_H +#define FOMODPLUSSCANNER_H + +#include "archiveparser.h" + +#include <iplugin.h> +#include <iplugintool.h> + +#include <QDialog> +#include <QProgressBar> + +using namespace MOBase; + +class FomodPlusScanner final : public IPluginTool { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool) +#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusScanner" FILE "fomodplusscanner.json") +#endif + +public: + ~FomodPlusScanner() override + { + delete mDialog; + mDialog = nullptr; + mProgressBar = nullptr; + } + + bool init(IOrganizer* organizer) override; + + void onScanClicked() const; + + void cleanup() const; + + [[nodiscard]] QString name() const override { return "FOMOD Scanner"; } // This should not be translated + [[nodiscard]] QString author() const override { return "clearing"; } + [[nodiscard]] QString description() const override { return tr("Scans modlist for files installed via FOMOD"); } + [[nodiscard]] VersionInfo version() const override { return {1, 0, 0, VersionInfo::RELEASE_FINAL}; } + + [[nodiscard]] QList<PluginSetting> settings() const override { return {}; } + + [[nodiscard]] QString displayName() const override { return tr("FOMOD Scanner"); } + + [[nodiscard]] QString tooltip() const override { return tr("Scan modlist for files installed via FOMOD"); } + + [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); } + + void display() const override; + + int scanLoadOrder(const std::function<bool(IModInterface*, ScanResult result)> &callback) const; + + ScanResult openInstallationArchive(const IModInterface* mod) const; + + static bool setFomodInfoForMod(IModInterface *mod, ScanResult result); + + static bool removeFomodInfoFromMod(IModInterface *mod, ScanResult); + +private: + QDialog* mDialog{ nullptr }; + QProgressBar* mProgressBar{ nullptr }; + IOrganizer* mOrganizer{ nullptr }; +}; + +#endif // FOMODPLUSSCANNER_H
\ No newline at end of file diff --git a/libs/installer_fomod_plus/scanner/archiveparser.h b/libs/installer_fomod_plus/scanner/archiveparser.h new file mode 100644 index 0000000..4058d43 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/archiveparser.h @@ -0,0 +1,98 @@ +#pragma once +#include "stringutil.h" + +#include <QDir> +#include <QString> +#include <archive.h> +#include <iostream> +#include <ostream> + +inline std::ostream& operator<<(std::ostream& os, const Archive::Error& error) +{ + switch (error) { + case Archive::Error::ERROR_NONE: + os << "No error"; + break; + case Archive::Error::ERROR_ARCHIVE_NOT_FOUND: + os << "File not found"; + break; + case Archive::Error::ERROR_FAILED_TO_OPEN_ARCHIVE: + os << "Failed to open file"; + break; + case Archive::Error::ERROR_INVALID_ARCHIVE_FORMAT: + os << "Invalid archive format"; + break; + default: + os << "Unknown error??"; + } + return os; +} + +inline bool hasFomodFiles(const std::vector<FileData*>& files) +{ + bool hasModuleXml = false; + bool hasInfoXml = false; + + for (const auto* file : files) { + if (endsWithCaseInsensitive(file->getArchiveFilePath(), StringConstants::FomodFiles::W_MODULE_CONFIG.data())) { + hasModuleXml = true; + } + if (endsWithCaseInsensitive(file->getArchiveFilePath(), StringConstants::FomodFiles::W_INFO_XML.data())) { + hasInfoXml = true; + } + } + return hasModuleXml && hasInfoXml; +} + +/* This class can do the following: + * 1.) Detects if an archive has FOMOD files in it (without extracting) + * - This is used by the FOMOD scanner to set content flags + * + * 2.) It may support extracting ESPs and FOMODs, but this might be better served + * by a separate class. Maybe it could return a ModuleConfiguration to use like + * the installer. + */ + +enum class ScanResult { + HAS_FOMOD, + NO_FOMOD, + NO_ARCHIVE +}; + +class ArchiveParser { +public: + static ScanResult scanForFomodFiles(const QString& downloadsPath, const QString& installationFilePath, + const QString& modName) + { + if (installationFilePath.isEmpty()) { + return ScanResult::NO_ARCHIVE; + } + const auto qualifiedInstallerPath = QDir(installationFilePath).isAbsolute() + ? installationFilePath + : downloadsPath + "/" + installationFilePath; + + const auto archive = CreateArchive(); + + if (!archive->isValid()) { + logErrorForMod(modName, "Failed to load the archive module ", archive); + return ScanResult::NO_ARCHIVE; + } + if (!archive->open(qualifiedInstallerPath.toStdWString(), nullptr)) { + logErrorForMod(modName, "Failed to open archive [" + qualifiedInstallerPath + "]", archive); + return ScanResult::NO_ARCHIVE; + } + + if (hasFomodFiles(archive->getFileList())) { + std::cout << "Found FOMOD files in " << qualifiedInstallerPath.toStdString() << std::endl; + return ScanResult::HAS_FOMOD; + } + return ScanResult::NO_FOMOD; + } + +private: + static void logErrorForMod(const QString& modName, const QString& message, const std::unique_ptr<Archive>& archive) + { + std::cerr << "[" << modName.toStdString() << "] " << message.toStdString() << " (" << archive->getLastError() << + ")" << std::endl; + } +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts new file mode 100644 index 0000000..759824f --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="de_DE"> +<context> + <name>FomodPlusScanner</name> + <message> + <location filename="FomodPlusScanner.cpp" line="23"/> + <location filename="FomodPlusScanner.h" line="47"/> + <source>FOMOD Scanner</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="27"/> + <source>Sei gegrüßt, Reisender. +Dieses Tool scannt deine Ladereihenfolge nach Mods, die über einen FOMOD installiert wurden. +Es wird außerdem fehlerhafte FOMOD-Selektierungen aus früheren Versionen von FOMOD Plus beheben. :) + +Gute Reise, und möge deine Ladereihenfolge frei von Konflikten sein.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="46"/> + <source>Scannen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="47"/> + <source>Abbrechen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="71"/> + <source>Scan Abgeschlossen</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source>Der Scan der Ladereihenfolge ist abgeschlossen. Aktualisierte Filterinformationen für </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source> Mods.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="42"/> + <source>Scannt die Modliste nach Dateien, die über einen FOMOD installiert wurden.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="49"/> + <source>Modliste nach Dateien durchsuchen, die über einen FOMOD installiert wurden.</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts new file mode 100644 index 0000000..83a310b --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>FomodPlusScanner</name> + <message> + <location filename="FomodPlusScanner.cpp" line="26"/> + <location filename="FomodPlusScanner.h" line="42"/> + <source>FOMOD Scanner</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="30"/> + <source>Greetings, traveler. +This tool will scan your load order for mods installed via FOMOD. +It will also fix up any erroneous FOMOD flags from previous versions of FOMOD Plus :) + +Safe travels, and may your load order be free of conflicts.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="49"/> + <source>Scan</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="50"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="74"/> + <source>Scan Complete</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="75"/> + <source>The load order scan is complete. Updated filter info for </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="75"/> + <source> mods.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="37"/> + <source>Scans modlist for files installed via FOMOD</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="44"/> + <source>Scan modlist for files installed via FOMOD</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts new file mode 100644 index 0000000..01f84c8 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="zh_CN"> +<context> + <name>FomodPlusScanner</name> + <message> + <location filename="FomodPlusScanner.cpp" line="23"/> + <location filename="FomodPlusScanner.h" line="47"/> + <source>FOMOD Scanner</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="27"/> + <source>你好,旅行者。 +该工具将扫描模组列表来查找通过 FOMOD 安装的模组, +并修正旧版本错误的 FOMOD 标志(需保留安装包才能被检测到):) + +一路平安,祝您的 load order 没有冲突。</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="46"/> + <source>扫描</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="47"/> + <source>取消</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="71"/> + <source>扫描完成</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source>模组扫描已完成。更新了 </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.cpp" line="72"/> + <source> 个模组筛选框信息。</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="42"/> + <source>扫描模组列表查找通过 FOMOD 安装的文件</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="FomodPlusScanner.h" line="49"/> + <source>扫描模组列表查找通过 FOMOD 安装的文件</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_fomod_plus/scanner/fomodplusscanner.json b/libs/installer_fomod_plus/scanner/fomodplusscanner.json new file mode 100644 index 0000000..f6643ea --- /dev/null +++ b/libs/installer_fomod_plus/scanner/fomodplusscanner.json @@ -0,0 +1,8 @@ +{ + "author" : "clearing", + "date" : "2025/02/06", + "name" : "FOMOD Plus - Scanner", + "version" : "1.0.0", + "des" : "Scans load order for FOMOD installations", + "dependencies" : [] +} diff --git a/libs/installer_fomod_plus/scanner/resources.qrc b/libs/installer_fomod_plus/scanner/resources.qrc new file mode 100644 index 0000000..df7c48c --- /dev/null +++ b/libs/installer_fomod_plus/scanner/resources.qrc @@ -0,0 +1,6 @@ +<RCC> + <qresource prefix="/fomod"> + <file alias="hat">resources/fomod_icon.png</file> + <file alias="wizard">resources/wizard.gif</file> + </qresource> +</RCC>
\ No newline at end of file diff --git a/libs/installer_fomod_plus/scanner/resources/fomod_icon.png b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png Binary files differnew file mode 100644 index 0000000..e044052 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png diff --git a/libs/installer_fomod_plus/scanner/resources/wizard.gif b/libs/installer_fomod_plus/scanner/resources/wizard.gif Binary files differnew file mode 100644 index 0000000..c1f75d3 --- /dev/null +++ b/libs/installer_fomod_plus/scanner/resources/wizard.gif diff --git a/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h new file mode 100644 index 0000000..65cd0ae --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h @@ -0,0 +1,160 @@ +#pragma once + +#include "stringutil.h" + +#include <QDir> +#include <QFileInfo> +#include <QString> +#include <QTemporaryDir> +#include <archive.h> +#include <filesystem> +#include <functional> +#include <iostream> +#include <memory> +#include <optional> + +struct ExtractionResult { + bool success = false; + QString moduleConfigPath; + std::vector<QString> pluginPaths; + QString errorMessage; + std::unique_ptr<QTemporaryDir> tempDir; // Owns the temp directory lifetime +}; + +/** + * Utility class to extract FOMOD data from archives without being in an installer context. + * Used by the Patch Wizard's rescan functionality. + */ +class ArchiveExtractor { +public: + using ProgressCallback = std::function<void(const QString& fileName)>; + + /** + * Extract ModuleConfig.xml and plugin files from an archive. + * @param archiveFilePath Full path to the archive file + * @param progressCallback Optional callback for progress updates + * @return ExtractionResult containing paths to extracted files + */ + static ExtractionResult extractFomodData( + const QString& archiveFilePath, + const ProgressCallback& progressCallback = nullptr) + { + ExtractionResult result; + result.tempDir = std::make_unique<QTemporaryDir>(); + + if (!result.tempDir->isValid()) { + result.errorMessage = "Failed to create temporary directory"; + return result; + } + + const auto archive = CreateArchive(); + if (!archive->isValid()) { + result.errorMessage = "Failed to load archive module"; + return result; + } + + if (!archive->open(archiveFilePath.toStdWString(), nullptr)) { + result.errorMessage = QString("Failed to open archive (error %1)") + .arg(static_cast<int>(archive->getLastError())); + return result; + } + + // Get file list and mark files for extraction + const auto& fileList = archive->getFileList(); + QString moduleConfigInArchive; + + for (auto* fileData : fileList) { + const auto entryPath = QString::fromStdWString(fileData->getArchiveFilePath()); + + // Check for ModuleConfig.xml + if (entryPath.toLower().endsWith("fomod/moduleconfig.xml") || + entryPath.toLower().endsWith("fomod\\moduleconfig.xml")) { + moduleConfigInArchive = entryPath; + // Set output path relative to output directory for extract() + fileData->addOutputFilePath(L"ModuleConfig.xml"); + result.moduleConfigPath = result.tempDir->filePath("ModuleConfig.xml"); + } + // Check for plugin files + else if (isPluginFile(entryPath)) { + const auto fileName = QFileInfo(entryPath).fileName(); + const auto relativePath = QString("plugins/") + fileName; + fileData->addOutputFilePath(relativePath.toStdWString()); + result.pluginPaths.push_back(result.tempDir->filePath(relativePath)); + } + } + + if (moduleConfigInArchive.isEmpty()) { + result.errorMessage = "No ModuleConfig.xml found in archive"; + return result; + } + + // Create plugins subdirectory + QDir(result.tempDir->path()).mkpath("plugins"); + + // Extract the files + Archive::FileChangeCallback fileChangeCallback = [&progressCallback]( + Archive::FileChangeType, const std::wstring& fileName) { + if (progressCallback) { + progressCallback(QString::fromStdWString(fileName)); + } + }; + + Archive::ErrorCallback errorCallback = [&result](const std::wstring& error) { + result.errorMessage = QString::fromStdWString(error); + }; + + const bool extractSuccess = archive->extract( + result.tempDir->path().toStdWString(), + Archive::ProgressCallback{}, // progress callback + fileChangeCallback, + errorCallback + ); + + if (!extractSuccess) { + if (result.errorMessage.isEmpty()) { + result.errorMessage = "Extraction failed"; + } + return result; + } + + // Verify ModuleConfig.xml was extracted + if (!QFile::exists(result.moduleConfigPath)) { + result.errorMessage = "ModuleConfig.xml extraction failed"; + return result; + } + + // Filter to only existing plugin files + std::vector<QString> existingPlugins; + for (const auto& path : result.pluginPaths) { + if (QFile::exists(path)) { + existingPlugins.push_back(path); + } + } + result.pluginPaths = std::move(existingPlugins); + + result.success = true; + return result; + } + + /** + * Check if an archive contains FOMOD files without extracting. + * @param archiveFilePath Full path to the archive file + * @return true if the archive contains fomod/ModuleConfig.xml + */ + static bool hasFomodFiles(const QString& archiveFilePath) + { + const auto archive = CreateArchive(); + if (!archive->isValid() || !archive->open(archiveFilePath.toStdWString(), nullptr)) { + return false; + } + + for (const auto* fileData : archive->getFileList()) { + const auto path = QString::fromStdWString(fileData->getArchiveFilePath()); + if (path.toLower().endsWith("fomod/moduleconfig.xml") || + path.toLower().endsWith("fomod\\moduleconfig.xml")) { + return true; + } + } + return false; + } +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDB.h b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h new file mode 100644 index 0000000..d1a9aa2 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h @@ -0,0 +1,146 @@ +#pragma once + +#include <fstream> +#include <stringutil.h> + +#include "FomodDBEntry.h" + +#include <xml/ModuleConfiguration.h> + +#include "PluginReader.h" + +using FOMODDBEntries = std::vector<std::shared_ptr<FomodDbEntry> >; + +constexpr std::string FOMOD_DB_FILE = "fomod.db"; + +class FomodDB { +public: + /** + * + * @param moBasePath The organizer instance's basePath() value + * @param dbName The filename of the db. Only settable for testing purposes. + */ + explicit FomodDB(const std::string &moBasePath, const std::string &dbName = FOMOD_DB_FILE) { + dbFilePath = (std::filesystem::path(moBasePath) / dbName).string(); + loadFromFile(); + } + + // TODO: Also pull from non install steps (requiredInstallFiles or whatever, and optional); + static std::shared_ptr<FomodDbEntry> getEntryFromFomod(ModuleConfiguration *fomod, std::vector<QString> pluginPaths, + int modId) { + std::vector<FomodOption> options; + for (const auto &installStep: fomod->installSteps.installSteps) { + for (const auto &group: installStep.optionalFileGroups.groups) { + for (const auto &plugin: group.plugins.plugins) { + // Create a DB entry for the given plugin if it has an ESP + std::cout << "\nPlugin: " << plugin.name << std::endl; + + for (auto file: plugin.files.files) { + if (file.isFolder || !isPluginFile(file.source)) { + continue; + } + + // Find the path in pluginPaths that ends with this path + // PluginPaths is gathered from the archive contents. + auto it = std::ranges::find_if(pluginPaths, [&file](const QString &path) { + return path.endsWith(file.source.c_str()); + }); + if (it == pluginPaths.end()) { + continue; + } + const auto &pluginPath = *it; + const auto masters = PluginReader::readMasters(pluginPath.toStdString(), true); + options.emplace_back( + plugin.name, + file.source, + masters, + installStep.name, + group.name + ); + } + } + } + } + return std::make_shared<FomodDbEntry>(modId, fomod->moduleName, options); + } + + void addEntry(const std::shared_ptr<FomodDbEntry> &entry, const bool upsert = true) { + // TODO: Test this upsert. + if (upsert) { + const auto it = std::ranges::find_if(entries, [&entry](const std::shared_ptr<FomodDbEntry> &e) { + return e->getModId() == entry->getModId(); + }); + if (it != entries.end()) { + *it = entry; + } else { + entries.emplace_back(entry); + } + } else { + entries.emplace_back(entry); + } + } + + [[nodiscard]] const FOMODDBEntries &getEntries() { return entries; } + + void saveToFile() const { + try { + std::ofstream file(dbFilePath); + if (!file.is_open()) { + return; + } + + file << toJson().dump(2); // Pretty-print with 2-space indentation + file.close(); + } catch ([[maybe_unused]] const std::exception &e) { + // Handle saving errors + } + } + + [[nodiscard]] nlohmann::json toJson() const { + nlohmann::json jsonArray = nlohmann::json::array(); + + for (const auto &entry: entries) { + jsonArray.push_back(entry->toJson()); + } + + return jsonArray; + } + +private: + FOMODDBEntries entries; + std::string dbFilePath; + + void loadFromFile() { + entries.clear(); + + // Create empty file if it doesn't exist + if (!std::filesystem::exists(dbFilePath)) { + std::ofstream file(dbFilePath); + file << "[]"; // Empty JSON array + file.close(); + return; // No entries to load + } + + try { + // Read and parse the JSON file + std::ifstream file(dbFilePath); + if (!file.is_open()) { + return; + } + + nlohmann::json jsonArray = nlohmann::json::parse(file); + + // Ensure it's an array + if (!jsonArray.is_array()) { + return; + } + + // Process each entry in the array + for (const auto &entryJson: jsonArray) { + entries.push_back(std::make_unique<FomodDbEntry>(entryJson)); + } + } catch ([[maybe_unused]] const std::exception &e) { + // Handle parsing errors (leave entries empty) + } + } +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h new file mode 100644 index 0000000..5d39f95 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h @@ -0,0 +1,129 @@ +#pragma once + +#include <string> +#include <utility> +#include <vector> +#include <nlohmann/json.hpp> + +/* +The following JSON will be part of an array of similar objects in the root level "JSON DB" for FOMOD Plus. +It contains information to resolve the identity of a given mod (names can change), and then the options +in the FOMOD with their respective masters. +{ + modId: 12345, + displayName: "Lux (Patch Hub)", + options: [ + { + "name" "JK's The Hag's Cure", + "fileName": "Lux - JK's The Hag's Cure patch.esp", + "masters": [ + "Skyrim.esm", + "JK's The Hag's Cure.esp", + "Lux - Resources.esp", + "Lux.esp" + ], + "step": "Page One", + "group": "Group One", + "selectionState": "Available" + } + ] +} +*/ + +enum class SelectionState { + Unknown, // Not yet matched to choices + Selected, // User selected this option + Deselected, // User manually deselected + Available // Present but user didn't interact (or choices not recorded) +}; + +inline std::string selectionStateToString(SelectionState state) { + switch (state) { + case SelectionState::Unknown: return "Unknown"; + case SelectionState::Selected: return "Selected"; + case SelectionState::Deselected: return "Deselected"; + case SelectionState::Available: return "Available"; + default: return "Unknown"; + } +} + +inline SelectionState stringToSelectionState(const std::string& str) { + if (str == "Selected") return SelectionState::Selected; + if (str == "Deselected") return SelectionState::Deselected; + if (str == "Available") return SelectionState::Available; + return SelectionState::Unknown; +} + +struct FomodOption { + std::string name; + std::string fileName; + std::vector<std::string> masters; + std::string step; + std::string group; + SelectionState selectionState = SelectionState::Unknown; + + FomodOption(std::string n, std::string fn, std::vector<std::string> m, std::string s, std::string g, + SelectionState state = SelectionState::Unknown) + : name(std::move(n)), fileName(std::move(fn)), masters(std::move(m)), step(std::move(s)), group(std::move(g)), + selectionState(state) {} +}; + +class FomodDbEntry { +public: + explicit FomodDbEntry(nlohmann::json json) { + modId = json["modId"]; + displayName = json["displayName"]; + for (auto &option: json["options"]) { + // create an option from this object + SelectionState state = SelectionState::Unknown; + if (option.contains("selectionState")) { + state = stringToSelectionState(option["selectionState"]); + } + FomodOption fomodOption( + option["name"], + option["fileName"], + option["masters"], + option["step"], + option["group"], + state + ); + options.push_back(fomodOption); + } + } + + explicit FomodDbEntry(const int modId, std::string displayName, const std::vector<FomodOption> &options) + : modId(modId), displayName(std::move(displayName)), options(options) { + } + + + [[nodiscard]] int getModId() const { return modId; } + [[nodiscard]] std::string getDisplayName() const { return displayName; } + [[nodiscard]] const std::vector<FomodOption>& getOptions() const { return options; } + [[nodiscard]] std::vector<FomodOption>& getOptionsMutable() { return options; } + + [[nodiscard]] nlohmann::json toJson() const { + nlohmann::json result; + result["modId"] = modId; + result["displayName"] = displayName; + + nlohmann::json optionsArray = nlohmann::json::array(); + for (const auto &[name, fileName, masters, step, group, selectionState]: options) { + nlohmann::json optionJson; + optionJson["name"] = name; + optionJson["fileName"] = fileName; + optionJson["masters"] = masters; + optionJson["step"] = step; + optionJson["group"] = group; + optionJson["selectionState"] = selectionStateToString(selectionState); + optionsArray.push_back(optionJson); + } + + result["options"] = optionsArray; + return result; + } + +private: + int modId; + std::string displayName; + std::vector<FomodOption> options; +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h new file mode 100644 index 0000000..c7d53a2 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h @@ -0,0 +1,277 @@ +#pragma once + +#include "ArchiveExtractor.h" +#include "FomodDB.h" +#include "stringutil.h" +#include "xml/ModuleConfiguration.h" + +#include <QDir> +#include <QString> +#include <functional> +#include <imodinterface.h> +#include <imoinfo.h> +#include <nlohmann/json.hpp> + +struct RescanResult { + int totalModsProcessed = 0; + int successfullyScanned = 0; + int missingArchives = 0; + int parseErrors = 0; + std::vector<std::string> failedMods; +}; + +/** + * Orchestrates rescanning of all mods with stored FOMOD choices to repopulate the database. + * Used when fomod.db is missing or needs to be regenerated from existing installations. + */ +class FomodRescan { +public: + using ProgressCallback = std::function<void(int current, int total, const QString& modName)>; + + FomodRescan(MOBase::IOrganizer* organizer, FomodDB* db) + : mOrganizer(organizer), mFomodDb(db) {} + + /** + * Scan all mods that have stored FOMOD Plus choices and repopulate the database. + * @param progressCallback Optional callback for progress updates + * @return RescanResult with statistics about the scan + */ + RescanResult scanAllModsWithChoices(const ProgressCallback& progressCallback = nullptr) + { + RescanResult result; + + const auto modList = mOrganizer->modList(); + if (!modList) { + return result; + } + + // First pass: gather all mods with stored choices + std::vector<MOBase::IModInterface*> modsWithChoices; + for (const auto& modName : modList->allMods()) { + auto* mod = modList->getMod(modName); + if (mod && hasStoredChoices(mod)) { + modsWithChoices.push_back(mod); + } + } + + result.totalModsProcessed = static_cast<int>(modsWithChoices.size()); + + // Second pass: process each mod + int current = 0; + for (auto* mod : modsWithChoices) { + current++; + if (progressCallback) { + progressCallback(current, result.totalModsProcessed, mod->name()); + } + + const auto scanResult = processMod(mod); + switch (scanResult) { + case ScanOutcome::Success: + result.successfullyScanned++; + break; + case ScanOutcome::MissingArchive: + result.missingArchives++; + result.failedMods.push_back(mod->name().toStdString() + " (missing archive)"); + break; + case ScanOutcome::ParseError: + result.parseErrors++; + result.failedMods.push_back(mod->name().toStdString() + " (parse error)"); + break; + case ScanOutcome::NoFomod: + result.failedMods.push_back(mod->name().toStdString() + " (no FOMOD)"); + break; + } + } + + // Save the database + mFomodDb->saveToFile(); + + return result; + } + +private: + MOBase::IOrganizer* mOrganizer; + FomodDB* mFomodDb; + + enum class ScanOutcome { + Success, + MissingArchive, + ParseError, + NoFomod + }; + + /** + * Check if a mod has stored FOMOD Plus choices (non-zero pluginSetting). + */ + bool hasStoredChoices(MOBase::IModInterface* mod) const + { + const auto fomodData = mod->pluginSetting( + StringConstants::Plugin::NAME.data(), "fomod", 0); + + if (!fomodData.isValid() || fomodData.isNull()) { + return false; + } + + // Check if it's actually valid JSON with steps + try { + const auto json = nlohmann::json::parse(fomodData.toString().toStdString()); + return json.contains("steps") && json["steps"].is_array() && !json["steps"].empty(); + } catch (...) { + return false; + } + } + + /** + * Get the stored choices JSON from a mod's pluginSetting. + */ + nlohmann::json getStoredChoices(MOBase::IModInterface* mod) const + { + const auto fomodData = mod->pluginSetting( + StringConstants::Plugin::NAME.data(), "fomod", 0); + + try { + return nlohmann::json::parse(fomodData.toString().toStdString()); + } catch (...) { + return nlohmann::json(); + } + } + + /** + * Process a single mod: extract archive, parse FOMOD, create DB entry with selection states. + */ + ScanOutcome processMod(MOBase::IModInterface* mod) + { + // Get the archive path + const auto installationFile = mod->installationFile(); + if (installationFile.isEmpty()) { + return ScanOutcome::MissingArchive; + } + + const auto downloadsPath = mOrganizer->downloadsPath(); + const auto archivePath = QDir(installationFile).isAbsolute() + ? installationFile + : downloadsPath + "/" + installationFile; + + if (!QFile::exists(archivePath)) { + return ScanOutcome::MissingArchive; + } + + // Extract FOMOD data from archive + auto extractionResult = ArchiveExtractor::extractFomodData(archivePath); + if (!extractionResult.success) { + return ScanOutcome::ParseError; + } + + // Parse ModuleConfiguration + auto moduleConfig = std::make_unique<ModuleConfiguration>(); + try { + if (!moduleConfig->deserialize(extractionResult.moduleConfigPath)) { + return ScanOutcome::ParseError; + } + } catch (...) { + return ScanOutcome::ParseError; + } + + // Get the mod's Nexus ID + const int modId = mod->nexusId(); + + // Create FomodDbEntry using existing logic + auto entry = FomodDB::getEntryFromFomod( + moduleConfig.get(), + extractionResult.pluginPaths, + modId + ); + + if (!entry || entry->getOptions().empty()) { + return ScanOutcome::NoFomod; + } + + // Apply selection states from stored choices + const auto choices = getStoredChoices(mod); + applySelectionsToEntry(*entry, choices); + + // Add to database (upsert) + mFomodDb->addEntry(entry, true); + + return ScanOutcome::Success; + } + + /** + * Apply user selection states to a FomodDbEntry based on stored choices JSON. + * + * Choices JSON format: + * { + * "steps": [{ + * "name": "Step Name", + * "groups": [{ + * "name": "Group Name", + * "plugins": ["Selected Plugin 1"], + * "deselected": ["Manually Deselected Plugin"] + * }] + * }] + * } + */ + void applySelectionsToEntry(FomodDbEntry& entry, const nlohmann::json& choices) + { + if (!choices.contains("steps") || !choices["steps"].is_array()) { + // No choices data - mark all as Available + for (auto& option : entry.getOptionsMutable()) { + option.selectionState = SelectionState::Available; + } + return; + } + + // Build a lookup map for quick matching: stepName/groupName/pluginName -> state + struct PluginState { + bool selected = false; + bool deselected = false; + }; + std::map<std::string, PluginState> stateMap; + + for (const auto& step : choices["steps"]) { + if (!step.contains("name") || !step.contains("groups")) continue; + const std::string stepName = step["name"]; + + for (const auto& group : step["groups"]) { + if (!group.contains("name")) continue; + const std::string groupName = group["name"]; + + // Process selected plugins + if (group.contains("plugins") && group["plugins"].is_array()) { + for (const auto& plugin : group["plugins"]) { + const std::string pluginName = plugin; + const auto key = stepName + "/" + groupName + "/" + pluginName; + stateMap[key].selected = true; + } + } + + // Process deselected plugins + if (group.contains("deselected") && group["deselected"].is_array()) { + for (const auto& plugin : group["deselected"]) { + const std::string pluginName = plugin; + const auto key = stepName + "/" + groupName + "/" + pluginName; + stateMap[key].deselected = true; + } + } + } + } + + // Apply states to options + for (auto& option : entry.getOptionsMutable()) { + const auto key = option.step + "/" + option.group + "/" + option.name; + + if (auto it = stateMap.find(key); it != stateMap.end()) { + if (it->second.selected) { + option.selectionState = SelectionState::Selected; + } else if (it->second.deselected) { + option.selectionState = SelectionState::Deselected; + } else { + option.selectionState = SelectionState::Available; + } + } else { + // Plugin not found in choices - mark as Available + option.selectionState = SelectionState::Available; + } + } + } +}; diff --git a/libs/installer_fomod_plus/share/FOMODData/PluginReader.h b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h new file mode 100644 index 0000000..dade7e1 --- /dev/null +++ b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h @@ -0,0 +1,119 @@ +#pragma once + +#include <fstream> +#include <string> +#include <vector> +#include <cstdint> +#include <unordered_set> + +static const std::unordered_set<std::string> VANILLA_MASTERS = { + "Skyrim.esm", + "Update.esm", + "Dawnguard.esm", + "HearthFires.esm", + "Dragonborn.esm" +}; + +class PluginReader { +public: + + /** + * Reads the master files from a Bethesda plugin file (ESP/ESM/ESL) + * @param filePath Path to the plugin file + * @param trimVanilla Exclude the vanilla game masters or not. Mostly to save DB space. + * @return Vector of master filenames + */ + static std::vector<std::string> readMasters(const std::string& filePath, const bool trimVanilla = false) + { + std::vector<std::string> masters; + std::ifstream file(filePath, std::ios::binary); + + if (!file) { + return masters; + } + + // Check TES4 record signature + char signature[4]; + file.read(signature, 4); + if (strncmp(signature, "TES4", 4) != 0) { + return masters; + } + + // Read record size + uint32_t recordSize; + file.read(reinterpret_cast<char*>(&recordSize), 4); + + constexpr uint32_t skipSize = sizeof(uint32_t) // flags + + sizeof(uint32_t) // formId + + sizeof(uint16_t) // timestamp + + sizeof(uint16_t) // version control + + sizeof(uint16_t) // internal version + + sizeof(uint16_t); // unknown + + // Skip header flags, formID, etc. (total 8 bytes) + file.seekg(skipSize, std::ios::cur); + + // Calculate where the TES4 record ends + std::streampos recordEnd = file.tellg() + static_cast<std::streampos>(recordSize); + + // Read subrecords until we reach the end of the TES4 record + while (file && file.tellg() < recordEnd) { + char subRecordType[4]; + uint16_t subRecordSize; + + // Read subrecord type and size + file.read(subRecordType, 4); + file.read(reinterpret_cast<char*>(&subRecordSize), 2); + + if (strncmp(subRecordType, "MAST", 4) == 0) { + // Read master filename (null-terminated string) + std::string masterName; + masterName.resize(subRecordSize); + file.read(masterName.data(), subRecordSize); + + // Remove null terminator if present + if (!masterName.empty() && masterName.back() == '\0') { + masterName.pop_back(); + } + + // Only add if it's not a vanilla master or if we're not trimming + if (!trimVanilla || !VANILLA_MASTERS.contains(masterName)) { + masters.push_back(masterName); + } + + // Each MAST is followed by a DATA subrecord + char dataType[4]; + uint16_t dataSize; + file.read(dataType, 4); + file.read(reinterpret_cast<char*>(&dataSize), 2); + + // Skip DATA content (usually an 8-byte value) + file.seekg(dataSize, std::ios::cur); + } else { + // Skip other subrecord types + file.seekg(subRecordSize, std::ios::cur); + } + } + + return masters; + } + + /** + * Checks if a file is a valid Bethesda plugin (ESP/ESM/ESL) + * @param filePath Path to the file + * @return True if the file is a valid plugin + */ + static bool isValidPlugin(const std::string& filePath) + { + std::ifstream file(filePath, std::ios::binary); + + if (!file) { + return false; + } + + char signature[4]; + file.read(signature, 4); + + return strncmp(signature, "TES4", 4) == 0; + } +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/stringutil.h b/libs/installer_fomod_plus/share/stringutil.h new file mode 100644 index 0000000..c5d2fb8 --- /dev/null +++ b/libs/installer_fomod_plus/share/stringutil.h @@ -0,0 +1,150 @@ +#ifndef STRINGCONSTANTS_H +#define STRINGCONSTANTS_H +#include <algorithm> +#include <regex> +#include <string> +#include <vector> +#include <QString> + + +namespace StringConstants +{ + namespace Plugin + { + constexpr std::string_view NAME = "FOMOD Plus"; + constexpr std::string_view AUTHOR = "clearing"; + constexpr std::string_view DESCRIPTION = + "Extends the capabilities of the FOMOD installer for advanced users.\n\n" + "Available colors (enter exactly): \n" + "'Light0'\t'Light1'\t'Light2'\t'Light3'\n" + "'Dark0'\t'Dark1'\t'Dark2'\t'Dark3'\n" + "'Red'\t'Red Bright'\n" + "'Green'\t'Green Bright'\n" + "'Yellow'\t'Yellow Bright'\n" + "'Blue'\t'Blue Bright'\n" + "'Purple'\t'Purple Bright'\n" + "'Aqua'\t'Aqua Bright'\n" + "'Orange'\t'Orange Bright'\n"; + constexpr std::wstring_view W_NAME = L"FOMOD Plus"; + constexpr std::wstring_view W_AUTHOR = L"clearing"; + constexpr std::wstring_view W_DESCRIPTION = + L"Extends the capabilities of the FOMOD installer for advanced users."; + } + + namespace FomodFiles + { + constexpr std::string_view FOMOD_DIR = "fomod"; + constexpr std::string_view INFO_XML = "info.xml"; + constexpr std::string_view MODULE_CONFIG = "ModuleConfig.xml"; + + // Wide string versions for archive API + constexpr std::wstring_view W_FOMOD_DIR = L"fomod"; + constexpr std::wstring_view W_INFO_XML = L"fomod/info.xml"; + constexpr std::wstring_view W_MODULE_CONFIG = L"fomod/ModuleConfig.xml"; + + constexpr std::string_view TYPE_REQUIRED = "Required"; + constexpr std::string_view TYPE_OPTIONAL = "Optional"; + constexpr std::string_view TYPE_RECOMMENDED = "Recommended"; + constexpr std::string_view TYPE_NOT_USABLE = "NotUsable"; + constexpr std::string_view TYPE_COULD_BE_USABLE = "CouldBeUsable"; + } +} + +// Convert narrow string_view to wstring (for ASCII strings only) +inline std::wstring toWide(std::string_view sv) +{ + return std::wstring(sv.begin(), sv.end()); +} + +// trim from start (in place) +inline void ltrim(std::string& s) +{ + s.erase(s.begin(), std::ranges::find_if(s, [](const unsigned char ch) + { + return !std::isspace(ch); + })); +} + +// trim from end (in place) +inline void rtrim(std::string& s) +{ + s.erase(std::find_if(s.rbegin(), s.rend(), [](const unsigned char ch) + { + return !std::isspace(ch); + }).base(), s.end()); +} + +// trim from both ends (in place) +inline std::string& trim(std::string& s) +{ + ltrim(s); + rtrim(s); + return s; +} + +inline void trim(const std::vector<std::string>& strings) +{ + for (auto s : strings) { trim(s); } +} + +inline std::wstring toLower(const std::wstring& str) +{ + std::wstring lowerStr = str; + std::ranges::transform(lowerStr, lowerStr.begin(), towlower); + return lowerStr; +} + +inline std::string toLower(const std::string& str) +{ + std::string lowerStr = str; + std::ranges::transform(lowerStr, lowerStr.begin(), tolower); + return lowerStr; +} + +inline bool endsWithCaseInsensitive(const std::wstring& str, const std::wstring& suffix) +{ + const std::wstring lowerStr = toLower(str); + if (const std::wstring lowerSuffix = toLower(suffix); lowerStr.length() >= lowerSuffix.length()) + { + return 0 == lowerStr.compare(lowerStr.length() - lowerSuffix.length(), lowerSuffix.length(), lowerSuffix); + } + return false; +} + +inline QString formatPluginDescription(const QString& text) +{ + std::string formattedText = text.toStdString(); + // Replace URLs with <a href> tags + const std::regex + urlRegex(R"((http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-]))"); + formattedText = std::regex_replace(formattedText, urlRegex, R"(<a href="$&">$&</a>)"); + + // Replace line breaks + formattedText = std::regex_replace(formattedText, std::regex(" "), "<br>"); + formattedText = std::regex_replace(formattedText, std::regex("\\r\\n"), "<br>"); + formattedText = std::regex_replace(formattedText, std::regex("\\r"), "<br>"); + formattedText = std::regex_replace(formattedText, std::regex("\\n"), "<br>"); + + return QString::fromStdString(formattedText); +} + +// NOTE: This isn't perfect. Sometimes we have whole filenames, sometimes we're just passing +// the suffix. It should be fine as long as no one names a file like..."Testesl". Idk what that +// would do anyway. +inline bool isPluginFile(const QString& file) +{ + return file.toLower().endsWith("esl") + || file.toLower().endsWith("esp") + || file.toLower().endsWith("esm"); +} + +inline bool isPluginFile(const std::string& file) +{ + const auto lower = toLower(file); + return lower.ends_with("esl") + || lower.ends_with("esp") + || lower.ends_with("esm"); +} + + +#endif diff --git a/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp b/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp new file mode 100644 index 0000000..d4fed69 --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp @@ -0,0 +1,44 @@ +#include "FomodInfoFile.h" +#include "XmlParseException.h" +#include <format> +#include <pugixml.hpp> + +#include "stringutil.h" + +#include <QFile> +#include <QString> + +bool FomodInfoFile::deserialize(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + throw XmlParseException(std::format("Failed to open file: {}", filePath.toStdString())); + } + const QByteArray content = file.readAll(); + file.close(); + + pugi::xml_document doc; + if (const pugi::xml_parse_result result = doc.load_buffer(content.constData(), content.size()); !result) { + throw XmlParseException(std::format("XML parsed with errors: {}", result.description())); + } + + const pugi::xml_node fomodNode = doc.child("fomod"); + if (!fomodNode) { + throw XmlParseException("No <config> node found"); + } + + name = fomodNode.child("Name").text().as_string(); + author = fomodNode.child("Author").text().as_string(); + version = fomodNode.child("Version").text().as_string(); + website = fomodNode.child("Website").text().as_string(); + description = fomodNode.child("Description").text().as_string(); + + trim({ name, author, version, website, description }); + + for (pugi::xml_node groupNode : fomodNode.child("Groups").children("element")) { + groups.emplace_back(groupNode.text().as_string()); + } + + return true; + +}
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/FomodInfoFile.h b/libs/installer_fomod_plus/share/xml/FomodInfoFile.h new file mode 100644 index 0000000..d2c2a23 --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/FomodInfoFile.h @@ -0,0 +1,25 @@ +#pragma once + +#include <qstring.h> +#include <string> +#include <vector> + +class FomodInfoFile { +public: + bool deserialize(const QString &filePath); + + [[nodiscard]] const std::string& getName() const { return name; } + [[nodiscard]] const std::string& getAuthor() const { return author; } + [[nodiscard]] const std::string& getVersion() const { return version; } + [[nodiscard]] const std::string& getWebsite() const { return website; } + [[nodiscard]] const std::string& getDescription() const { return description; } + [[nodiscard]] const std::vector<std::string>& getGroups() const { return groups; } + +private: + std::string name; + std::string author; + std::string version; + std::string website; + std::string description; + std::vector<std::string> groups; +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp new file mode 100644 index 0000000..64d6b0b --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp @@ -0,0 +1,352 @@ +#include "ModuleConfiguration.h" + +#include <format> + +#include "XmlHelper.h" +#include "XmlParseException.h" +#include "stringutil.h" + +#include <QFile> +#include <QString> + +using namespace StringConstants::FomodFiles; + +static GroupTypeEnum groupTypeFromString(const std::string& groupType) +{ + if (groupType == "SelectAny") + return SelectAny; + if (groupType == "SelectAll") + return SelectAll; + if (groupType == "SelectExactlyOne") + return SelectExactlyOne; + if (groupType == "SelectAtMostOne") + return SelectAtMostOne; + if (groupType == "SelectAtLeastOne") + return SelectAtLeastOne; + return SelectAny; // is this a sane default? probably +} + +PluginTypeEnum pluginTypeFromString(const std::string& typeStr) +{ + if (typeStr == TYPE_REQUIRED) + return PluginTypeEnum::Required; + if (typeStr == TYPE_OPTIONAL) + return PluginTypeEnum::Optional; + if (typeStr == TYPE_RECOMMENDED) + return PluginTypeEnum::Recommended; + if (typeStr == TYPE_NOT_USABLE) + return PluginTypeEnum::NotUsable; + if (typeStr == TYPE_COULD_BE_USABLE) + return PluginTypeEnum::CouldBeUsable; + return PluginTypeEnum::Optional; +} + +template <typename T> +bool deserializeList(pugi::xml_node& node, const char* childName, std::vector<T>& list) +{ + for (pugi::xml_node childNode : node.children(childName)) { + T item; + item.deserialize(childNode); + list.push_back(item); + } + return true; +} + +/* + * NOTE: We call 'trim()' on all error-prone fields that rely on user-input. I'm assuming these FOMODs are created with + * the FOMOD creation tool, so I won't trim things like flags that the tool sets for the user. + */ + +bool FileDependency::deserialize(pugi::xml_node& node) +{ + file = node.attribute("file").as_string(); + trim(file); + // ReSharper disable once CppTooWideScopeInitStatement + // Do not use 'auto' for these. it will break equality checks + const std::string stateStr = node.attribute("state").as_string(); + + if (stateStr == "Missing") + state = FileDependencyTypeEnum::Missing; + else if (stateStr == "Inactive") + state = FileDependencyTypeEnum::Inactive; + else if (stateStr == "Active") + state = FileDependencyTypeEnum::Active; + return true; +} + +bool FlagDependency::deserialize(pugi::xml_node& node) +{ + flag = node.attribute("flag").as_string(); + value = node.attribute("value").as_string(); + trim({ flag, value }); + return true; +} + +bool GameDependency::deserialize(pugi::xml_node& node) +{ + version = node.attribute("version").as_string(); + trim(version); + return true; +} + +bool CompositeDependency::deserialize(pugi::xml_node& node) +{ + + // this could EITHER have a dependencies child or the dependencies are here. + // turns out they could have both. + pugi::xml_node possibleNode = node; + + // If the dependencies are all right inside, just use the root node as the dependency base. + // This looks hacky but accommodates both _nested_ dependencies for plugins, and extremely simple ones for step visibility. + if (node.child("dependencies") && !node.child("fileDependency") && !node.child("flagDependency") &&!node.child("gameDependency")) { + possibleNode = node.child("dependencies"); + } + + deserializeList(possibleNode, "fileDependency", fileDependencies); + deserializeList(possibleNode, "flagDependency", flagDependencies); + deserializeList(possibleNode, "gameDependency", gameDependencies); + deserializeList(possibleNode, "dependencies", nestedDependencies); + + operatorType = OperatorTypeEnum::AND; // safest default. + + if (const std::string operatorStr = possibleNode.attribute("operator").as_string(); operatorStr == "Or") { + operatorType = OperatorTypeEnum::OR; + } + + return true; +} + +bool DependencyPattern::deserialize(pugi::xml_node& node) +{ + if (!node) + return false; + pugi::xml_node dependenciesNode = node.child("dependencies"); + dependencies.deserialize(dependenciesNode); + + const pugi::xml_node typeNode = node.child("type"); + type = pluginTypeFromString(typeNode.attribute("name").as_string()); + return true; +} + +bool DependencyPatternList::deserialize(pugi::xml_node& node) +{ + return deserializeList(node, "pattern", patterns); +} + +bool DependencyPluginType::deserialize(pugi::xml_node& node) +{ + pugi::xml_node patternsNode = node.child("patterns"); + const pugi::xml_node defaultTypeNode = node.child("defaultType"); + defaultType = pluginTypeFromString(defaultTypeNode.attribute("name").as_string()); + patterns.deserialize(patternsNode); + return true; +} + +bool TypeDescriptor::deserialize(pugi::xml_node& node) +{ + pugi::xml_node dependencyTypeNode = node.child("dependencyType"); + dependencyType.deserialize(dependencyTypeNode); + const pugi::xml_node typeNode = node.child("type"); + type = pluginTypeFromString(typeNode.attribute("name").as_string()); + return true; +} + +bool Image::deserialize(pugi::xml_node& node) +{ + path = node.attribute("path").as_string(); + return true; +} + +bool HeaderImage::deserialize(pugi::xml_node& node) +{ + path = node.attribute("path").as_string(); + showImage = node.attribute("showImage").as_bool(); + showFade = node.attribute("showFade").as_bool(); + height = node.attribute("height").as_int(); + return true; +} + +bool FileList::deserialize(pugi::xml_node& node) +{ + for (pugi::xml_node childNode : node.children()) { + if (std::string(childNode.name()) == "folder" + || std::string(childNode.name()) == "file") { + File file; + file.deserialize(childNode); + files.emplace_back(file); + } + } + return true; +} + +bool ConditionalFileInstallPattern::deserialize(pugi::xml_node& node) +{ + pugi::xml_node dependenciesNode = node.child("dependencies"); + pugi::xml_node filesNode = node.child("files"); + + dependencies.deserialize(dependenciesNode); + files.deserialize(filesNode); + + return true; +} + +// <flag name="2">On</flag> +bool ConditionFlag::deserialize(pugi::xml_node& node) +{ + name = node.attribute("name").as_string(); + value = node.child_value(); // + return true; +} + +bool ConditionFlagList::deserialize(pugi::xml_node& node) +{ + return deserializeList(node, "flag", flags); +} + +bool File::deserialize(pugi::xml_node& node) +{ + source = node.attribute("source").as_string(); + // destination = node.attribute("destination").as_string(); + priority = node.attribute("priority").as_int(); + isFolder = strcmp(node.name(), "folder") == 0; + if (auto attr = node.attribute("destination"); attr) { + destination = attr.as_string(); + } else { + destination = std::nullopt; + } + return true; +} + + +bool Plugin::deserialize(pugi::xml_node& node) +{ + pugi::xml_node imageNode = node.child("image"); + pugi::xml_node typeDescriptorNode = node.child("typeDescriptor"); + pugi::xml_node conditionFlagsNode = node.child("conditionFlags"); + pugi::xml_node filesNode = node.child("files"); + + // Description is optional in the schema; guard against null C strings from pugixml. + if (const pugi::xml_node descNode = node.child("description")) { + if (const char* rawDesc = descNode.text().as_string()) { + description = rawDesc; + } + } + description = trim(description); // Find a better way to do this eventually. + image.deserialize(imageNode); + typeDescriptor.deserialize(typeDescriptorNode); + name = node.attribute("name").as_string(); + name = trim(name); + conditionFlags.deserialize(conditionFlagsNode); + files.deserialize(filesNode); + return true; +} + +bool PluginList::deserialize(pugi::xml_node& node) +{ + deserializeList(node, "plugin", plugins); + order = XmlHelper::getOrderType(node.attribute("order").as_string(), OrderTypeEnum::Ascending); + + // Sort the plugins based on the specified order + std::ranges::sort(plugins, [this](const Plugin& a, const Plugin& b) { + if (order == OrderTypeEnum::Ascending) { + return a.name < b.name; + } + if (order == OrderTypeEnum::Descending) { + return a.name > b.name; + } + return false; // Default case, no sorting + }); + + return true; +} + +bool Group::deserialize(pugi::xml_node& node) +{ + pugi::xml_node pluginsNode = node.child("plugins"); + plugins.deserialize(pluginsNode); + name = node.attribute("name").as_string(); + type = groupTypeFromString(node.attribute("type").as_string()); + return true; +} + +bool GroupList::deserialize(pugi::xml_node& node) +{ + deserializeList(node, "group", groups); + order = XmlHelper::getOrderType(node.attribute("order").as_string()); + + // Sort the groups based on the specified order + std::ranges::sort(groups, [this](const Group& a, const Group& b) { + if (order == OrderTypeEnum::Ascending) { + return a.name < b.name; + } + if (order == OrderTypeEnum::Descending) { + return a.name > b.name; + } + return false; // Default case, no sorting + }); + return true; +} + +bool InstallStep::deserialize(pugi::xml_node& node) +{ + pugi::xml_node visibleNode = node.child("visible"); + pugi::xml_node optionalFileGroupsNode = node.child("optionalFileGroups"); + visible.deserialize(visibleNode); + optionalFileGroups.deserialize(optionalFileGroupsNode); + name = node.attribute("name").as_string(); + return true; +} + +bool ConditionalFileInstall::deserialize(pugi::xml_node& node) +{ + pugi::xml_node patternsNode = node.child("patterns"); + deserializeList(patternsNode, "pattern", patterns); + return true; +} + +bool StepList::deserialize(pugi::xml_node& node) +{ + deserializeList(node, "installStep", installSteps); + order = XmlHelper::getOrderType(node.attribute("order").as_string()); + return true; +} + +bool ModuleConfiguration::deserialize(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + throw XmlParseException(std::format("Failed to open file: {}", filePath.toStdString())); + } + const QByteArray content = file.readAll(); + file.close(); + + pugi::xml_document doc; + if (const pugi::xml_parse_result result = doc.load_buffer(content.constData(), content.size()); !result) { + throw XmlParseException(std::format("XML parsed with errors: {}", result.description())); + } + + const pugi::xml_node configNode = doc.child("config"); + if (!configNode) { + throw XmlParseException("No <config> node found"); + } + + moduleName = configNode.child("moduleName").text().as_string(); + + moduleImage = HeaderImage(); + pugi::xml_node moduleImageNode = configNode.child("moduleImage"); + moduleImage.deserialize(moduleImageNode); + + pugi::xml_node moduleDependenciesNode = configNode.child("moduleDependencies"); + moduleDependencies.deserialize(moduleDependenciesNode); + + pugi::xml_node requiredInstallFilesNode = configNode.child("requiredInstallFiles"); + requiredInstallFiles.deserialize(requiredInstallFilesNode); + + pugi::xml_node installStepsNode = configNode.child("installSteps"); + installSteps.deserialize(installStepsNode); + + pugi::xml_node conditionalFileInstallsNode = configNode.child("conditionalFileInstalls"); + conditionalFileInstalls.deserialize(conditionalFileInstallsNode); + + return true; +} diff --git a/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h new file mode 100644 index 0000000..a0a017b --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h @@ -0,0 +1,305 @@ +#pragma once + +#include <iostream> +#include <optional> +#include <pugixml.hpp> +#include <qstring.h> +#include <string> +#include <vector> + +class XmlDeserializable { +public: + virtual ~XmlDeserializable() = default; + + virtual bool deserialize(pugi::xml_node& node) = 0; + +protected: + XmlDeserializable() = default; +}; + +enum GroupTypeEnum { + SelectAny, + SelectAll, + SelectExactlyOne, + SelectAtMostOne, + SelectAtLeastOne +}; + +enum class OperatorTypeEnum { + AND, + OR +}; + +enum class OrderTypeEnum { + Explicit, + Ascending, + Descending +}; + +enum class FileDependencyTypeEnum { + Missing, + Inactive, + Active, + UNKNOWN_STATE +}; + +template <typename T> +class OrderedContents { +public: + OrderTypeEnum order; + + OrderedContents() : order(OrderTypeEnum::Ascending) {} + explicit OrderedContents(const OrderTypeEnum orderType): order(orderType) {} + + template <typename Accessor> + bool compare(const T& a, const T& b, Accessor accessor) const + { + switch (order) { + case OrderTypeEnum::Ascending: + return accessor(a) < accessor(b); + case OrderTypeEnum::Descending: + return accessor(a) > accessor(b); + case OrderTypeEnum::Explicit: + default: + return false; // No sorting for explicit order + } + } +}; + +enum class PluginTypeEnum { + Recommended, + Required, + Optional, + NotUsable, + CouldBeUsable, + UNKNOWN +}; + + +inline std::ostream& operator<<(std::ostream& os, const PluginTypeEnum& type) +{ + switch (type) { + case PluginTypeEnum::Recommended: + os << "Recommended"; + break; + case PluginTypeEnum::Required: + os << "Required"; + break; + case PluginTypeEnum::Optional: + os << "Optional"; + break; + case PluginTypeEnum::NotUsable: + os << "NotUsable"; + break; + case PluginTypeEnum::CouldBeUsable: + os << "CouldBeUsable"; + break; + default: ; + } + return os; +} + +class PluginType final : public XmlDeserializable { +public: + PluginTypeEnum name = PluginTypeEnum::Optional; // sane default + + bool deserialize(pugi::xml_node& node) override; +}; + +class FileDependency final : public XmlDeserializable { +public: + std::string file; + FileDependencyTypeEnum state = FileDependencyTypeEnum::UNKNOWN_STATE; + + bool deserialize(pugi::xml_node& node) override; +}; + +class FlagDependency final : public XmlDeserializable { +public: + std::string flag; + std::string value; + + bool deserialize(pugi::xml_node& node) override; +}; + +class GameDependency final : public XmlDeserializable { +public: + std::string version; + + bool deserialize(pugi::xml_node& node) override; +}; + +class CompositeDependency final : public XmlDeserializable { +public: + std::vector<FileDependency> fileDependencies; + std::vector<FlagDependency> flagDependencies; + std::vector<GameDependency> gameDependencies; + std::vector<CompositeDependency> nestedDependencies; + OperatorTypeEnum operatorType = OperatorTypeEnum::AND; // safest default. + + bool deserialize(pugi::xml_node& node) override; +}; + +class DependencyPattern final : public XmlDeserializable { +public: + CompositeDependency dependencies; + PluginTypeEnum type; + + bool deserialize(pugi::xml_node& node) override; +}; + + +class DependencyPatternList final : public XmlDeserializable { +public: + std::vector<DependencyPattern> patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class DependencyPluginType final : public XmlDeserializable { +public: + std::optional<PluginTypeEnum> defaultType; + DependencyPatternList patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class TypeDescriptor final : public XmlDeserializable { +public: + DependencyPluginType dependencyType; + PluginTypeEnum type; + + bool deserialize(pugi::xml_node& node) override; +}; + +class Image final : public XmlDeserializable { +public: + std::string path; + + bool deserialize(pugi::xml_node& node) override; +}; + +class HeaderImage final : public XmlDeserializable { +public: + std::string path; + bool showImage; + bool showFade; + int height; + + bool deserialize(pugi::xml_node& node) override; +}; + +class File final : public XmlDeserializable { +public: + std::string source; + std::optional<std::string> destination; + int priority{ 0 }; + bool isFolder; + + bool deserialize(pugi::xml_node& node) override; +}; + + +class FileList final : public XmlDeserializable { +public: + std::vector<File> files; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ConditionalFileInstallPattern final : public XmlDeserializable { +public: + CompositeDependency dependencies; + FileList files; + + bool deserialize(pugi::xml_node& node) override; +}; + +// <flag name="2">On</flag> +class ConditionFlag final : public XmlDeserializable { +public: + std::string name; + std::string value; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ConditionFlagList final : public XmlDeserializable { +public: + std::vector<ConditionFlag> flags; + + bool deserialize(pugi::xml_node& node) override; +}; + +class Plugin final : public XmlDeserializable { +public: + std::string description; + Image image; + TypeDescriptor typeDescriptor; + std::string name; + ConditionFlagList conditionFlags; + FileList files; + + bool deserialize(pugi::xml_node& node) override; +}; + +class PluginList final : public XmlDeserializable, public OrderedContents<Plugin> { +public: + std::vector<Plugin> plugins; + OrderTypeEnum order; + + bool deserialize(pugi::xml_node& node) override; +}; + +class Group final : public XmlDeserializable { +public: + PluginList plugins; + std::string name; + GroupTypeEnum type; + + bool deserialize(pugi::xml_node& node) override; +}; + +class GroupList final : public XmlDeserializable, public OrderedContents<Group> { +public: + std::vector<Group> groups; + OrderTypeEnum order; + + bool deserialize(pugi::xml_node& node) override; +}; + +class InstallStep final : public XmlDeserializable { +public: + CompositeDependency visible; + GroupList optionalFileGroups; + std::string name; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ConditionalFileInstall final : public XmlDeserializable { +public: + std::vector<ConditionalFileInstallPattern> patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class StepList final : public XmlDeserializable, public OrderedContents<InstallStep> { +public: + std::vector<InstallStep> installSteps; + OrderTypeEnum order; + + bool deserialize(pugi::xml_node& node) override; +}; + +class ModuleConfiguration { +public: + std::string moduleName; + HeaderImage moduleImage; + CompositeDependency moduleDependencies; + FileList requiredInstallFiles; + StepList installSteps; + ConditionalFileInstall conditionalFileInstalls; + + bool deserialize(const QString& filePath); +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/XmlHelper.h b/libs/installer_fomod_plus/share/xml/XmlHelper.h new file mode 100644 index 0000000..6934a86 --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/XmlHelper.h @@ -0,0 +1,17 @@ +#pragma once + +#include "ModuleConfiguration.h" + +class XmlHelper { +public: + static OrderTypeEnum getOrderType(const std::string& orderType, OrderTypeEnum defaultOrder = OrderTypeEnum::Explicit) + { + if (orderType == "Explicit") + return OrderTypeEnum::Explicit; + if (orderType == "Ascending") + return OrderTypeEnum::Ascending; + if (orderType == "Descending") + return OrderTypeEnum::Descending; + return defaultOrder; // Ascending for plugins, Explicit for groups + } +};
\ No newline at end of file diff --git a/libs/installer_fomod_plus/share/xml/XmlParseException.h b/libs/installer_fomod_plus/share/xml/XmlParseException.h new file mode 100644 index 0000000..6406f3b --- /dev/null +++ b/libs/installer_fomod_plus/share/xml/XmlParseException.h @@ -0,0 +1,10 @@ +#pragma once + +#include <stdexcept> +#include <string> + +class XmlParseException final : public std::runtime_error { +public: + explicit XmlParseException(const std::string& message) + : std::runtime_error(message) {} +}; |
