From dbd21789c90dcd549c058172d5f02733b5db1c8f Mon Sep 17 00:00:00 2001 From: Peery Date: Sat, 16 Dec 2023 23:08:30 +0100 Subject: [PATCH] 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. --- ModManager.py | 121 ++++++++++++++++++++++++++++++------------ main.py | 2 +- window/main_window.py | 38 +++++++------ 3 files changed, 110 insertions(+), 51 deletions(-) diff --git a/ModManager.py b/ModManager.py index c6ecf75..5a9228c 100755 --- a/ModManager.py +++ b/ModManager.py @@ -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}\" ...") diff --git a/main.py b/main.py index 43820b4..57b898f 100755 --- a/main.py +++ b/main.py @@ -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() diff --git a/window/main_window.py b/window/main_window.py index 3b27a0c..89e79ee 100755 --- a/window/main_window.py +++ b/window/main_window.py @@ -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