diff --git a/src/installationmanager.cpp b/src/installationmanager.cpp index 37c86c7e4..87c4681f4 100644 --- a/src/installationmanager.cpp +++ b/src/installationmanager.cpp @@ -491,13 +491,14 @@ InstallationResult InstallationManager::doInstall(GuessedValue& modName return {IPluginInstaller::RESULT_FAILED}; } - bool merge = false; // determine target directory InstallationResult result = testOverwrite(modName); if (!result) { return result; } + const bool merge = result.merged(); + result.m_name = modName; QString targetDirectory = QDir(m_ModsDirectory + "/" + modName).canonicalPath(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a651c2d7f..1abe6c7c7 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -46,6 +46,7 @@ along with Mod Organizer. If not, see . #include "listdialog.h" #include "localsavegames.h" #include "messagedialog.h" +#include "modinforegular.h" #include "modlist.h" #include "modlistcontextmenu.h" #include "modlistviewactions.h" @@ -68,6 +69,8 @@ along with Mod Organizer. If not, see . #include "spawn.h" #include "statusbar.h" #include "systemtraymanager.h" +#include + #include #include #include @@ -3199,146 +3202,211 @@ void MainWindow::finishUpdateInfo(const NxmUpdateInfoData& data) } } -void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, - QVariant resultData, int requestID) +/** + * @brief Walks the Nexus file update chain forward and collects every + * successor of an installed file, in chain order. + * + * @param installedFileId The Nexus file_id to start walking from. Returns an + * empty list when the chain has no entry keyed on this id. + * @param updatesByOldId hash map of file_updates entries keyed by old_file_id. + * @return File ids of every chain successor in order (oldest first). + */ +static std::vector +findUpdateChainSuccessors(int installedFileId, + const QHash& updatesByOldId) { - QVariantMap resultInfo = resultData.toMap(); - QList files = resultInfo["files"].toList(); - QList fileUpdates = resultInfo["file_updates"].toList(); - QString gameNameReal; + std::vector successors; + QSet visited; + int currentId = installedFileId; - for (IPluginGame* game : m_PluginContainer.plugins()) { - if (game->gameNexusName() == gameName) { - gameNameReal = game->gameShortName(); + while (true) { + const auto updateIt = updatesByOldId.constFind(currentId); + if (updateIt == updatesByOldId.constEnd()) { break; } + + currentId = updateIt.value()["new_file_id"].toInt(); + if (visited.contains(currentId)) { + break; + } + visited.insert(currentId); + successors.push_back(currentId); } - std::vector modsList = ModInfo::getByModID(gameNameReal, modID); + return successors; +} - bool requiresInfo = false; +/** + * @brief Resolve the Nexus file_id of a mod's installed file (recorded id, + * else filename match). + * + * @return The file_id, or nullopt if neither path yields one. + */ +static std::optional resolveInstalledFileId(const ModInfo::Ptr& mod, + const QList& files, + const QList& fileUpdates) +{ + if (auto modRegular = dynamic_cast(mod.get())) { + if (auto fileId = modRegular->nexusFileId()) { + return fileId; + } + } - for (auto mod : modsList) { - QString validNewVersion; - int newModStatus = -1; - QString installedFile = QFileInfo(mod->installationFile()).fileName(); + const QString installedFileName = QFileInfo(mod->installationFile()).fileName(); + if (installedFileName.isEmpty()) { + return std::nullopt; + } - if (!installedFile.isEmpty()) { - QVariantMap foundFileData; + // Match the installed filename against the files list. + for (const auto& file : files) { + const auto fileData = file.toMap(); + if (fileData["file_name"].toString().compare(installedFileName, + Qt::CaseInsensitive) == 0) { + return fileData["file_id"].toInt(); + } + } - // update the file status - for (auto& file : files) { - QVariantMap fileData = file.toMap(); + // Match against the update chain, archived files can be hidden from the + // files list but still appear here. + for (const auto& updateEntry : fileUpdates) { + const auto updateData = updateEntry.toMap(); + if (installedFileName.compare(updateData["old_file_name"].toString(), + Qt::CaseInsensitive) == 0) { + return updateData["old_file_id"].toInt(); + } + if (installedFileName.compare(updateData["new_file_name"].toString(), + Qt::CaseInsensitive) == 0) { + return updateData["new_file_id"].toInt(); + } + } + return std::nullopt; +} - if (fileData["file_name"].toString().compare(installedFile, - Qt::CaseInsensitive) == 0) { - foundFileData = fileData; - newModStatus = foundFileData["category_id"].toInt(); +/** + * @brief Pick the newest available version for an installed file by walking + * the update chain. Active files prefer an active successor; obsolete + * files also accept the latest downloadable (listed, not removed) + * successor; both fall back to the file's own version. + * + * @return Version string, or empty if none can be derived. + */ +static QString pickNewestVersion(int installedFileId, bool fileIsActive, + const QHash& filesById, + const QHash& updatesByOldId) +{ + const auto chainSuccessors = + findUpdateChainSuccessors(installedFileId, updatesByOldId); - if (newModStatus != NexusInterface::FileStatus::OLD_VERSION && - newModStatus != NexusInterface::FileStatus::REMOVED && - newModStatus != NexusInterface::FileStatus::ARCHIVED) { + std::optional latestActiveSuccessor; + std::optional latestDownloadableSuccessor; - // since the file is still active if there are no updates for it, use this - // as current version - validNewVersion = foundFileData["version"].toString(); - } - break; - } - } + // Find the latest active and latest downloadable successors + for (const int successorId : std::views::reverse(chainSuccessors)) { + const auto succIt = filesById.constFind(successorId); - if (foundFileData.isEmpty()) { - // The file was not listed, the file is likely archived and archived files are - // being hidden on the mod - newModStatus = NexusInterface::FileStatus::ARCHIVED_HIDDEN; - } + if (succIt == filesById.constEnd()) { + // Not in files list — effectively archived and hidden by the author. + continue; + } - // look for updates of the file - int currentUpdateId = -1; + const int succStatus = succIt.value()["category_id"].toInt(); - // find installed file ID from the updates list since old filenames are not - // guaranteed to be unique - for (auto& updateEntry : fileUpdates) { - const QVariantMap& updateData = updateEntry.toMap(); + if (NexusInterface::isActiveFileStatus(succStatus)) { + latestActiveSuccessor = successorId; + break; + } - if (installedFile.compare(updateData["old_file_name"].toString(), - Qt::CaseInsensitive) == 0) { - currentUpdateId = updateData["old_file_id"].toInt(); - break; - } - } + if (!latestDownloadableSuccessor && + succStatus != NexusInterface::FileStatus::REMOVED) { + latestDownloadableSuccessor = successorId; + } + } - bool foundActiveUpdate = false; + std::optional versionSourceId; + if (latestActiveSuccessor) { + versionSourceId = latestActiveSuccessor; - // there is at least one update - if (currentUpdateId > 0) { - bool lookForMoreUpdates = true; + } else if (!fileIsActive && latestDownloadableSuccessor) { + versionSourceId = latestDownloadableSuccessor; + + } else { + versionSourceId = installedFileId; + } - // follow the update chain until there are no more updates - while (lookForMoreUpdates) { - lookForMoreUpdates = false; + return filesById.value(*versionSourceId)["version"].toString(); +} - for (auto& updateEntry : fileUpdates) { - const QVariantMap& updateData = updateEntry.toMap(); +void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + QVariantMap resultInfo = resultData.toMap(); + QList files = resultInfo["files"].toList(); + QList fileUpdates = resultInfo["file_updates"].toList(); - if (currentUpdateId == updateData["old_file_id"].toInt()) { - currentUpdateId = updateData["new_file_id"].toInt(); + QString gameShortName; + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameShortName = game->gameShortName(); + break; + } + } - // check if the new file is still active - for (auto& file : files) { - const QVariantMap& fileData = file.toMap(); + QHash filesById; + filesById.reserve(files.size()); + for (const auto& file : files) { + const QVariantMap fileData = file.toMap(); + filesById.insert(fileData["file_id"].toInt(), fileData); + } - if (currentUpdateId == fileData["file_id"].toInt()) { - int updateStatus = fileData["category_id"].toInt(); + QHash updatesByOldId; + updatesByOldId.reserve(fileUpdates.size()); + for (const auto& updateEntry : fileUpdates) { + const QVariantMap updateData = updateEntry.toMap(); + updatesByOldId.insert(updateData["old_file_id"].toInt(), updateData); + } - if (updateStatus != NexusInterface::FileStatus::OLD_VERSION && - updateStatus != NexusInterface::FileStatus::REMOVED && - updateStatus != NexusInterface::FileStatus::ARCHIVED) { + bool needsModPageVersion = false; + std::vector installedMods = ModInfo::getByModID(gameShortName, modID); - // new version is active, so record it - validNewVersion = fileData["version"].toString(); - foundActiveUpdate = true; - } - break; - } - } + // Check each installed mod matching nexus modID against the update info + for (const auto& installedMod : installedMods) { + installedMod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); - lookForMoreUpdates = true; - break; - } - } - } - } + // Identify the installed file. + const auto installedFileId = + resolveInstalledFileId(installedMod, files, fileUpdates); - // if there were no active direct file updates for the installedFile - if (!foundActiveUpdate) { - // get the global mod version in case the file isn't an optional - if (newModStatus != NexusInterface::FileStatus::OPTIONAL_FILE && - newModStatus != NexusInterface::FileStatus::MISCELLANEOUS) { - requiresInfo = true; - } - } - } else { - // No installedFile means we don't know what to look at for a version so - // just get the global mod version - requiresInfo = true; + if (!installedFileId) { + // Manually created mod with mod_id assigned via the edit interface; + // fall back to mod page version. + needsModPageVersion = true; + continue; } - if (newModStatus > 0) { - mod->setNexusFileStatus(newModStatus); + // Determine the installed file's current Nexus status. + int nexusFileStatus = NexusInterface::FileStatus::ARCHIVED_HIDDEN; + + if (const auto fileIt = filesById.constFind(*installedFileId); + fileIt != filesById.constEnd()) { + nexusFileStatus = fileIt.value()["category_id"].toInt(); } + installedMod->setNexusFileStatus(nexusFileStatus); - if (!validNewVersion.isEmpty()) { - mod->setNewestVersion(validNewVersion); - mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + // Find the newest available version per the update chain. + const QString newestVersionValue = pickNewestVersion( + *installedFileId, NexusInterface::isActiveFileStatus(nexusFileStatus), + filesById, updatesByOldId); + + if (!newestVersionValue.isEmpty()) { + installedMod->setNewestVersion(newestVersionValue); } } // invalidate the filter to display mods with an update ui->modList->invalidateFilter(); - if (requiresInfo) { - NexusInterface::instance().requestModInfo(gameNameReal, modID, this, QVariant(), + if (needsModPageVersion) { + NexusInterface::instance().requestModInfo(gameShortName, modID, this, QVariant(), QString()); } } diff --git a/src/modinforegular.cpp b/src/modinforegular.cpp index 0e73b70cd..2042f7725 100644 --- a/src/modinforegular.cpp +++ b/src/modinforegular.cpp @@ -227,8 +227,8 @@ void ModInfoRegular::readMeta() int numFiles = metaFile.beginReadArray("installedFiles"); for (int i = 0; i < numFiles; ++i) { metaFile.setArrayIndex(i); - m_InstalledFileIDs.insert(std::make_pair(metaFile.value("modid").toInt(), - metaFile.value("fileid").toInt())); + m_InstalledFileIDs.emplace_back(metaFile.value("modid").toInt(), + metaFile.value("fileid").toInt()); } metaFile.endArray(); @@ -332,7 +332,7 @@ bool ModInfoRegular::updateAvailable() const if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { return false; } - if (m_NexusFileStatus == 4 || m_NexusFileStatus == 6) { + if (!NexusInterface::isActiveFileStatus(m_NexusFileStatus)) { return true; } return m_NewestVersion.isValid() && (m_Version < m_NewestVersion); @@ -942,7 +942,13 @@ QStringList ModInfoRegular::archives(bool checkOnDisk) void ModInfoRegular::addInstalledFile(int modId, int fileId) { - m_InstalledFileIDs.insert(std::make_pair(modId, fileId)); + const auto entry = std::make_pair(modId, fileId); + const auto existing = + std::find(m_InstalledFileIDs.begin(), m_InstalledFileIDs.end(), entry); + if (existing != m_InstalledFileIDs.end()) { + m_InstalledFileIDs.erase(existing); + } + m_InstalledFileIDs.push_back(entry); m_MetaInfoChanged = true; } diff --git a/src/modinforegular.h b/src/modinforegular.h index 062f3dd8f..6f5daba87 100644 --- a/src/modinforegular.h +++ b/src/modinforegular.h @@ -2,6 +2,7 @@ #define MODINFOREGULAR_H #include +#include #include "modinfowithconflictinfo.h" #include "nexusinterface.h" @@ -445,7 +446,21 @@ class ModInfoRegular : public ModInfoWithConflictInfo virtual bool validated() const override { return m_Validated; } virtual std::set> installedFiles() const override { - return m_InstalledFileIDs; + return {m_InstalledFileIDs.begin(), m_InstalledFileIDs.end()}; + } + + /** + * @brief Nexus file id of this mod's install. When multiple installs have + * been merged, returns the most recent. + * + * @return The file id, or nullopt if no nexusId has been recorded. + */ + std::optional nexusFileId() const + { + if (m_InstalledFileIDs.empty()) { + return std::nullopt; + } + return m_InstalledFileIDs.back().second; } public: // Plugin operations: @@ -505,7 +520,8 @@ private slots: QColor m_Color; int m_NexusID; - std::set> m_InstalledFileIDs; + // Ordered by install time, oldest first; back is the most recent install. + std::vector> m_InstalledFileIDs; // List of plugin settings: std::map> m_PluginSettings; diff --git a/src/nexusinterface.h b/src/nexusinterface.h index f6efef1d9..dd9650b11 100644 --- a/src/nexusinterface.h +++ b/src/nexusinterface.h @@ -183,6 +183,31 @@ class NexusInterface : public QObject // listed we can assume they were hidden. }; + /** + * @brief Whether a Nexus file category represents a file that is still + * listed and considered current on the mod page. + * + * Inactive (false) refers to files that have been marked as obsolete or have been + * removed. + */ + static bool isActiveFileStatus(int status) + { + switch (status) { + case FileStatus::MAIN: + case FileStatus::UPDATE: + case FileStatus::OPTIONAL_FILE: + case FileStatus::MISCELLANEOUS: + return true; + case FileStatus::OLD_VERSION: + case FileStatus::REMOVED: + case FileStatus::ARCHIVED: + case FileStatus::ARCHIVED_HIDDEN: + return false; + default: + return false; + } + } + public: static APILimits defaultAPILimits(); static APILimits parseLimits(const QNetworkReply* reply);