summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAl <26797547+Al12rs@users.noreply.github.com>2026-05-04 04:47:20 +0200
committerGitHub <noreply@github.com>2026-05-03 21:47:20 -0500
commitca7a149874e99a8f401cb30c430f58a48be4c642 (patch)
tree9764a43289eab32bfb838aa91234ddf87fae4eb0 /src
parentef7499aade74a148416b5fde7e8c03a75ea381c3 (diff)
Improve mod update check accuracy (#2385)
* Index API response into lookup maps * extract new findLatestActiveSuccessor method from update check function * extract method to check if a file is active * use new isActiveFileStatus in modInfoRegular * fix bug when merging during mod install * remember ordering of installed nexus file ids * refactor update check logic to prioritize Nexus file IDs over filenames * refactor nxmUpdatesAvailable to simplify update checking logic * refactor update check logic to find Nexus file IDs by filename and streamline successor retrieval * refactor update checking to streamline version resolution and improve successor retrieval
Diffstat (limited to 'src')
-rw-r--r--src/installationmanager.cpp3
-rw-r--r--src/mainwindow.cpp272
-rw-r--r--src/modinforegular.cpp14
-rw-r--r--src/modinforegular.h20
-rw-r--r--src/nexusinterface.h25
5 files changed, 225 insertions, 109 deletions
diff --git a/src/installationmanager.cpp b/src/installationmanager.cpp
index 37c86c7e..87c4681f 100644
--- a/src/installationmanager.cpp
+++ b/src/installationmanager.cpp
@@ -491,13 +491,14 @@ InstallationResult InstallationManager::doInstall(GuessedValue<QString>& 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 0bc7edf8..6ed7104f 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -46,6 +46,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#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 <http://www.gnu.org/licenses/>.
#include "spawn.h"
#include "statusbar.h"
#include "systemtraymanager.h"
+#include <ranges>
+
#include <bsainvalidation.h>
#include <dataarchives.h>
#include <safewritefile.h>
@@ -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<int>
+findUpdateChainSuccessors(int installedFileId,
+ const QHash<int, QVariantMap>& updatesByOldId)
{
- QVariantMap resultInfo = resultData.toMap();
- QList files = resultInfo["files"].toList();
- QList fileUpdates = resultInfo["file_updates"].toList();
- QString gameNameReal;
+ std::vector<int> successors;
+ QSet<int> visited;
+ int currentId = installedFileId;
- for (IPluginGame* game : m_PluginContainer.plugins<IPluginGame>()) {
- 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<ModInfo::Ptr> 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<int> resolveInstalledFileId(const ModInfo::Ptr& mod,
+ const QList<QVariant>& files,
+ const QList<QVariant>& fileUpdates)
+{
+ if (auto modRegular = dynamic_cast<ModInfoRegular*>(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<int, QVariantMap>& filesById,
+ const QHash<int, QVariantMap>& updatesByOldId)
+{
+ const auto chainSuccessors =
+ findUpdateChainSuccessors(installedFileId, updatesByOldId);
- if (newModStatus != NexusInterface::FileStatus::OLD_VERSION &&
- newModStatus != NexusInterface::FileStatus::REMOVED &&
- newModStatus != NexusInterface::FileStatus::ARCHIVED) {
+ std::optional<int> latestActiveSuccessor;
+ std::optional<int> 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<int> 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<IPluginGame>()) {
+ 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<int, QVariantMap> 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<int, QVariantMap> 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<ModInfo::Ptr> 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 0e73b70c..2042f772 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 062f3dd8..6f5daba8 100644
--- a/src/modinforegular.h
+++ b/src/modinforegular.h
@@ -2,6 +2,7 @@
#define MODINFOREGULAR_H
#include <limits>
+#include <optional>
#include "modinfowithconflictinfo.h"
#include "nexusinterface.h"
@@ -445,7 +446,21 @@ public:
virtual bool validated() const override { return m_Validated; }
virtual std::set<std::pair<int, int>> 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<int> nexusFileId() const
+ {
+ if (m_InstalledFileIDs.empty()) {
+ return std::nullopt;
+ }
+ return m_InstalledFileIDs.back().second;
}
public: // Plugin operations:
@@ -505,7 +520,8 @@ private:
QColor m_Color;
int m_NexusID;
- std::set<std::pair<int, int>> m_InstalledFileIDs;
+ // Ordered by install time, oldest first; back is the most recent install.
+ std::vector<std::pair<int, int>> m_InstalledFileIDs;
// List of plugin settings:
std::map<QString, std::map<QString, QVariant>> m_PluginSettings;
diff --git a/src/nexusinterface.h b/src/nexusinterface.h
index f6efef1d..dd9650b1 100644
--- a/src/nexusinterface.h
+++ b/src/nexusinterface.h
@@ -183,6 +183,31 @@ public:
// 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);