aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_fomod_plus/share
diff options
context:
space:
mode:
Diffstat (limited to 'libs/installer_fomod_plus/share')
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h160
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDB.h146
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h129
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodRescan.h277
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/PluginReader.h119
-rw-r--r--libs/installer_fomod_plus/share/stringutil.h150
-rw-r--r--libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp44
-rw-r--r--libs/installer_fomod_plus/share/xml/FomodInfoFile.h25
-rw-r--r--libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp352
-rw-r--r--libs/installer_fomod_plus/share/xml/ModuleConfiguration.h305
-rw-r--r--libs/installer_fomod_plus/share/xml/XmlHelper.h17
-rw-r--r--libs/installer_fomod_plus/share/xml/XmlParseException.h10
12 files changed, 1734 insertions, 0 deletions
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("&#13;&#10;"), "<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) {}
+};