diff options
Diffstat (limited to 'libs/installer_fomod_plus/share/FOMODData')
5 files changed, 831 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 |
