Support for multiple versions of same mod

This fixes issue #3 where multiple versions of the same mod.

Changed they dictionary keys for available_mods and installed_mods to also be a tuple of mod name and mod version instead of only the name.
dev
Peery 11 months ago
parent cb28f4fcf9
commit dbd21789c9
Signed by: pandro
SSH Key Fingerprint: SHA256:iBUZSuDxqYr4hYpe9U3BA9NJmXKpbGt4H0S8hUwIbrA

@ -17,8 +17,7 @@ from window.main_window import MainWindow
class ModManager:
VERSION = "0.1"
VERSION = "0.2"
def __init__(self, log_level: int = logging.INFO):
self.__logger = logging.getLogger("ModManager")
@ -63,17 +62,36 @@ class ModManager:
status = self.__app.exec()
sys.exit(status)
def __adjust_folder_paths(self, path: str) -> str:
@staticmethod
def get_mod_key(mod_name: str, version: str) -> Tuple[str, str]:
"""
Creates the mod_key used for referencing this mod in the dictionaries
:param mod_name:
:param version:
:return:
"""
return mod_name, version
@staticmethod
def __adjust_folder_paths(path: str, includes_bepinx=True, includes_folders=True) -> str:
"""
Adjusts a given path containing common issues to one compatible with BepInEx
:param input:
:param path:
:param includes_bepinx: bool toggle if the mod archive included paths with "BepInEx", if not it will be added
:param includes_folders: bool toggle if the mod archive included paths with folders, if not within plugins will be assumed
:return:
"""
if not includes_bepinx:
if path.startswith("config") or path.startswith("plugins"):
path = os.path.join("BepInEx", path)
else:
path = os.path.join("BepInEx", "plugins", path)
if path.endswith(".dll") and "BepInEx" not in path:
path = os.path.join("BepInEx", "plugins", path)
if path.startswith("config" + os.path.sep) or path.startswith("plugins" + os.path.sep):
path = os.path.join("BepInEx", path)
# if path.startswith("config" + os.path.sep) or path.startswith("plugins" + os.path.sep):
# path = os.path.join("BepInEx", path)
if path.lower().startswith("bepinex") and not path.startswith("BepInEx"):
path = "BepInEx" + path[len("BepinEx"):]
@ -131,26 +149,31 @@ class ModManager:
self.index_installed_mods()
return is_valid
def is_mod_installed(self, mod_name: str) -> bool:
def is_mod_installed(self, mod_name: str, mod_version: str) -> bool:
"""
Checks if a given mod has been installed to the game already
:param mod_name:
:param mod_version:
:return:
"""
r = self.available_mods[mod_name]
r = self.available_mods[(mod_name, mod_version)]
mod_files = r["mod_files"]
for i in range(len(mod_files)):
file = mod_files[i]
orig_file = r["orig_mod_files"][i]
if not os.path.exists(os.path.join(self.__settings.get_game_folder(), file)):
self.__logger.debug(f"Checking if mod \"{mod_name} - {mod_version}\" is installed ... FALSE")
return False
else:
if os.path.isfile(os.path.join(self.__settings.get_game_folder(), file)):
hash_installed = self.get_file_hash(open(os.path.join(self.__settings.get_game_folder(), file), 'rb'))
modzip = os.path.join(self.__settings.get_mod_folder(), self.available_mods[mod_name]["path"])
hash_installed = self.get_file_hash(
open(os.path.join(self.__settings.get_game_folder(), file), 'rb'))
modzip = os.path.join(self.__settings.get_mod_folder(), self.available_mods[(mod_name, mod_version)]["path"])
hash_in_storage = self.get_file_hash(ZipFile(modzip).open(orig_file, 'r'))
if hash_installed != hash_in_storage:
self.__logger.debug(f"Checking if mod \"{mod_name} - {mod_version}\" is installed ... FALSE")
return False
self.__logger.debug(f"Checking if mod \"{mod_name} - {mod_version}\" is installed ... TRUE")
return True
def is_valid_mod_file(self, file_path: str):
@ -187,12 +210,13 @@ class ModManager:
md5_obj.update(buffer)
return md5_obj.hexdigest()
def get_mod_info(self, file_path: str) -> Tuple[str, str, List[str]]:
def get_mod_info(self, file_path: str) -> tuple[str, str, list[str], list[str]]:
"""
Returns the name and version string of a given mod file
:param file_path:
:return:
"""
self.__logger.debug(f"Trying to get mod info of file \"{file_path}\" ...")
if not self.is_valid_mod_file(file_path):
self.__logger.error(f"Tried to get mod info of an invalid file: {file_path}")
raise ValueError(f"Tried to get mod info of an invalid file: {file_path}")
@ -201,13 +225,21 @@ class ModManager:
f = zip.open('manifest.json')
contents = zip.namelist()
contains_bepinex = False
contains_folders = False
for i in range(len(contents)):
if "/" in contents[i]:
contains_folders = True
if contents[i].lower().startswith("bepinex"):
contains_bepinex = True
orig_files = []
files = []
for file in contents:
if "icon.png" in file or "manifest.json" in file or file.endswith(".md"):
continue
orig_files.append(file)
file = self.__adjust_folder_paths(file)
file = self.__adjust_folder_paths(file, includes_bepinx=contains_bepinex, includes_folders=contains_folders)
files.append(file)
@ -221,6 +253,7 @@ class ModManager:
For unknown mods a placeholder is created
:return:
"""
self.__logger.debug("Indexing all installed mods ...")
self.installed_mods = dict()
if self.__settings.get_game_folder() is None:
return
@ -236,15 +269,15 @@ class ModManager:
elif os.path.isfile(curr) and curr.endswith('.dll'):
files.append(curr)
for mod_name in self.available_mods.keys():
if self.is_mod_installed(mod_name):
self.installed_mods[mod_name] = self.available_mods[mod_name]
for mod_name, mod_version in self.available_mods.keys():
if self.is_mod_installed(mod_name, mod_version):
self.installed_mods[(mod_name, mod_version)] = self.available_mods[(mod_name, mod_version)]
unknown_mod = dict()
unresolved_files = files.copy()
for file in files:
for mod in self.installed_mods.keys():
file_rel = file[len(self.__settings.get_game_folder())+1:]
file_rel = file[len(self.__settings.get_game_folder()) + 1:]
if file_rel.replace(os.path.sep, "/") in self.installed_mods[mod]["mod_files"]:
unresolved_files.remove(file)
if file in unresolved_files:
@ -262,6 +295,7 @@ class ModManager:
Ignores mods that are already on the list
:return:
"""
self.__logger.debug("Indexing all stored mods ...")
self.available_mods = dict()
if self.__settings.get_game_folder() is None:
return
@ -274,9 +308,10 @@ class ModManager:
self.__logger.warning(f"Mod \"{full_path}\" did not have the expected path. Ignoring it ...")
continue
self.available_mods[mod_name] = {"path": file, "version": mod_version, "mod_files": mod_files,
"orig_mod_files": orig_mod_files}
self.__window.set_available_mods(self.available_mods)
self.available_mods[ModManager.get_mod_key(mod_name, mod_version)] = {"path": file, "version": mod_version,
"mod_files": mod_files,
"orig_mod_files": orig_mod_files}
self.__window.set_available_mods(self.available_mods)
def add_mod_file(self, file_path: str):
"""
@ -284,6 +319,7 @@ class ModManager:
:param file_path:
:return:
"""
self.__logger.debug(f"Trying to add mod file \"{file_path}\" to manager ...")
if self.__settings.get_settings()["game_path"] is None:
self.__logger.error("Can't add a mod without a valid game path!")
@ -309,55 +345,70 @@ class ModManager:
self.index_stored_mods()
self.index_installed_mods()
def install_mod(self, mod_name: str):
def install_mod(self, mod_name: str, mod_version: str):
"""
Installs the given mod by extracting all files into the game directory.
This assumes that the .zip is structured like the game folder as all mods should be
:param mod_name:
:param mod_version:
:return:
"""
if mod_name not in self.available_mods.keys():
self.__logger.critical(f"Tried to install a mod that doesn't exist: {mod_name}")
self.__logger.debug(f"Trying to install mod \"{mod_name} - {mod_version}\"")
if (mod_name, mod_version) not in self.available_mods.keys():
self.__logger.critical(f"Tried to install a mod that doesn't exist: {mod_name} - {mod_version}")
return
self.__logger.info(f"Installing mod \"{mod_name}\" ...")
mod_zip = self.available_mods[mod_name]["path"]
self.__logger.info(f"Installing mod \"{mod_name} - {mod_version}\" ...")
mod_zip = self.available_mods[(mod_name, mod_version)]["path"]
with ZipFile(os.path.join(self.__settings.get_mod_folder(), mod_zip), 'r') as zip_ref:
#zip_ref.extractall(self.__settings.get_game_folder())
contents = zip_ref.namelist()
contains_bepinex = False
contains_folders = False
for i in range(len(contents)):
if "/" in contents[i]:
contains_folders = True
if contents[i].lower().startswith("bepinex"):
contains_bepinex = True
with tempfile.TemporaryDirectory() as tmp_dir:
for file in contents:
content = file
if "icon.png" in file or "manifest.json" in file or file.endswith(".md"):
continue
file = self.__adjust_folder_paths(file)
file = self.__adjust_folder_paths(file, includes_bepinx=contains_bepinex,
includes_folders=contains_folders)
#print("Extracting", content, "to", tmp_dir)
zip_ref.extract(content, tmp_dir)
if content.endswith(os.path.sep):
#print("Skipped moving", os.path.join(tmp_dir, content))
self.__logger.debug(f"Skipped moving of {os.path.join(tmp_dir, content)}")
continue
#print("Moving", os.path.join(tmp_dir, content), "to",
# os.path.join(self.__settings.get_game_folder(), file))
parent_dir = os.path.join(self.__settings.get_game_folder().replace("/", os.path.sep),
file.replace("/", os.path.sep)).split(file.replace("/", os.path.sep))[0]
if not os.path.exists(parent_dir):
os.mkdir(parent_dir)
if not os.path.exists(os.path.join(self.__settings.get_game_folder().replace("/", os.path.sep), file.replace("/", os.path.sep))):
shutil.move(os.path.join(tmp_dir, content.replace("/", os.path.sep)), os.path.join(self.__settings.get_game_folder().replace("/", os.path.sep), file.replace("/", os.path.sep)))
if not os.path.exists(os.path.join(self.__settings.get_game_folder().replace("/", os.path.sep),
file.replace("/", os.path.sep))):
self.__logger.debug(
f"Extracting file \"{os.path.join(tmp_dir, content.replace('/', os.path.sep))}\" to " +
f"\"{os.path.join(self.__settings.get_game_folder().replace('/', os.path.sep), file.replace('/', os.path.sep))}\"")
shutil.move(os.path.join(tmp_dir, content.replace("/", os.path.sep)),
os.path.join(self.__settings.get_game_folder().replace("/", os.path.sep),
file.replace("/", os.path.sep)))
self.index_installed_mods()
def uninstall_mod(self, mod_name: str):
def uninstall_mod(self, mod_name: str, mod_version: str):
"""
Uninstalls the given mod by removing all files (not folders) that the manager is aware of
Note: For untracked mods this will only be the .dll in the plugins folder
:param mod_name:
:param mod_version:
:return:
"""
self.__logger.info(f"Uninstalling mod \"{mod_name}\" ...")
for file in self.installed_mods[mod_name]["mod_files"]:
self.__logger.info(f"Uninstalling mod \"{mod_name} - {mod_version}\" ...")
for file in self.installed_mods[(mod_name, mod_version)]["mod_files"]:
full_path = os.path.join(self.__settings.get_game_folder(), file)
if os.path.isfile(full_path):
self.__logger.debug(f"Deleting file \"{full_path}\" ...")

@ -4,5 +4,5 @@ from ModManager import ModManager
# Press the green button in the gutter to run the script.
if __name__ == '__main__':
m = ModManager(log_level=logging.INFO)
m = ModManager(log_level=logging.DEBUG)
m.run()

@ -1,7 +1,7 @@
import logging
import os.path
import sys
from typing import Dict, List
from typing import Dict, List, Tuple
from PyQt6 import QtWidgets
from PyQt6.QtCore import Qt
@ -42,21 +42,25 @@ class MainWindow(QtWidgets.QMainWindow):
self.ui.actionCheck_for_Updates.setDisabled(True)
def set_available_mods(self, available_mods: Dict[str, str]):
def set_available_mods(self, available_mods: Dict[Tuple[str, str], Dict]):
"""
Sets the given mods as the list of available mods
:param available_mods:
:return:
"""
keys = [(name + "|" + version, (name, version)) for name, version in available_mods]
keys.sort(key=lambda x: x[0])
item_model = QStandardItemModel(self.ui.AvailableModsList)
for mod_name in available_mods.keys():
item = QStandardItem(mod_name + f" ({available_mods[mod_name]['version']})")
for key in keys:
mod_name, mod_version = key[1][0], key[1][1]
item = QStandardItem(mod_name + f" ({mod_version})")
item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
item.setData(mod_name, Qt.ItemDataRole.UserRole)
item.setData((mod_name, mod_version), Qt.ItemDataRole.UserRole)
item_model.appendRow(item)
if self.parent.is_mod_installed(mod_name):
if self.parent.is_mod_installed(mod_name, mod_version):
item.setCheckState(Qt.CheckState.Checked)
item_model.itemChanged.connect(self.on_available_mod_item_changed)
@ -68,16 +72,20 @@ class MainWindow(QtWidgets.QMainWindow):
:param installed_mods: string list of all the mods as to be written to the list
:return:
"""
keys = [(name + "|" + version, (name, version)) for name, version in installed_mods]
keys.sort(key=lambda x: x[0])
item_model = QStandardItemModel(self.ui.InstalledModsListView)
for mod_name in installed_mods.keys():
if mod_name in self.parent.available_mods.keys():
mod_version = self.parent.available_mods[mod_name]['version']
for key in keys:
mod_name, mod_version = key[1][0], key[1][1]
if (mod_name, mod_version) in self.parent.available_mods.keys():
mod_version = self.parent.available_mods[(mod_name, mod_version)]['version']
else:
mod_version = "Not Tracked"
item = QStandardItem(mod_name + f" ({mod_version})")
item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
item.setData(mod_name, Qt.ItemDataRole.UserRole)
item.setData((mod_name, mod_version), Qt.ItemDataRole.UserRole)
item_model.appendRow(item)
item.setCheckState(Qt.CheckState.Checked)
@ -166,20 +174,20 @@ class MainWindow(QtWidgets.QMainWindow):
def on_available_mod_item_changed(self, item: QStandardItem):
self.__logger.debug(f"Available Mod list item \"{item.text()}\" changed to {item.checkState()}")
mod_name = item.data(Qt.ItemDataRole.UserRole)
mod_name, mod_version = item.data(Qt.ItemDataRole.UserRole)
if item.checkState() == Qt.CheckState.Checked:
self.parent.install_mod(mod_name)
self.parent.install_mod(mod_name, mod_version)
elif item.checkState() == Qt.CheckState.Unchecked:
self.parent.uninstall_mod(mod_name)
self.parent.uninstall_mod(mod_name, mod_version)
def on_installed_mod_item_changed(self, item: QStandardItem):
self.__logger.debug(f"Installed Mod list item \"{item.text()}\" changed to {item.checkState()}")
mod_name = item.data(Qt.ItemDataRole.UserRole)
mod_name, mod_version = item.data(Qt.ItemDataRole.UserRole)
if item.checkState() == Qt.CheckState.Unchecked: # mod should be uninstalled
accepted = QtWidgets.QMessageBox.question(self, "Really uninstall mod?",
f"Do you really want to uninstall the mod \"{mod_name}\"?\n"
"This could lead to permanent data loss if it wasn't tracked!")
if accepted:
self.parent.uninstall_mod(mod_name)
self.parent.uninstall_mod(mod_name, mod_version)
else:
return

Loading…
Cancel
Save