Compare commits

...

4 Commits

Author SHA1 Message Date
Peery 44640c9427
Version bump: 0.3
Bumped version to 0.3 (was 0.2)
5 months ago
Peery 9e6cab5cba
Removed warning popup when canceling "add new mod"
Solves issue #1
When the file selection dialog closes without any selection it no longer causes "is no file" and "not a valid mod file" prompts to occur.
5 months ago
Peery 332f69f2de
Fixed creating parent folders when extracting mods
The parent directory of any given file path was incorrectly determined to be the game directory.
Also issue #2 (file extracted into wrong spot when mod uses no folder structure) is fixed
5 months ago
Peery dbd21789c9
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.
5 months ago

@ -17,8 +17,7 @@ from window.main_window import MainWindow
class ModManager: class ModManager:
VERSION = "0.3"
VERSION = "0.1"
def __init__(self, log_level: int = logging.INFO): def __init__(self, log_level: int = logging.INFO):
self.__logger = logging.getLogger("ModManager") self.__logger = logging.getLogger("ModManager")
@ -63,17 +62,36 @@ class ModManager:
status = self.__app.exec() status = self.__app.exec()
sys.exit(status) 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 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: :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: if path.endswith(".dll") and "BepInEx" not in path:
path = os.path.join("BepInEx", "plugins", path) path = os.path.join("BepInEx", "plugins", path)
if path.startswith("config" + os.path.sep) or path.startswith("plugins" + os.path.sep): # if path.startswith("config" + os.path.sep) or path.startswith("plugins" + os.path.sep):
path = os.path.join("BepInEx", path) # path = os.path.join("BepInEx", path)
if path.lower().startswith("bepinex") and not path.startswith("BepInEx"): if path.lower().startswith("bepinex") and not path.startswith("BepInEx"):
path = "BepInEx" + path[len("BepinEx"):] path = "BepInEx" + path[len("BepinEx"):]
@ -131,26 +149,31 @@ class ModManager:
self.index_installed_mods() self.index_installed_mods()
return is_valid 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 Checks if a given mod has been installed to the game already
:param mod_name: :param mod_name:
:param mod_version:
:return: :return:
""" """
r = self.available_mods[mod_name] r = self.available_mods[(mod_name, mod_version)]
mod_files = r["mod_files"] mod_files = r["mod_files"]
for i in range(len(mod_files)): for i in range(len(mod_files)):
file = mod_files[i] file = mod_files[i]
orig_file = r["orig_mod_files"][i] orig_file = r["orig_mod_files"][i]
if not os.path.exists(os.path.join(self.__settings.get_game_folder(), file)): 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 return False
else: else:
if os.path.isfile(os.path.join(self.__settings.get_game_folder(), file)): 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')) hash_installed = self.get_file_hash(
modzip = os.path.join(self.__settings.get_mod_folder(), self.available_mods[mod_name]["path"]) 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')) hash_in_storage = self.get_file_hash(ZipFile(modzip).open(orig_file, 'r'))
if hash_installed != hash_in_storage: if hash_installed != hash_in_storage:
self.__logger.debug(f"Checking if mod \"{mod_name} - {mod_version}\" is installed ... FALSE")
return False return False
self.__logger.debug(f"Checking if mod \"{mod_name} - {mod_version}\" is installed ... TRUE")
return True return True
def is_valid_mod_file(self, file_path: str): def is_valid_mod_file(self, file_path: str):
@ -187,12 +210,13 @@ class ModManager:
md5_obj.update(buffer) md5_obj.update(buffer)
return md5_obj.hexdigest() 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 Returns the name and version string of a given mod file
:param file_path: :param file_path:
:return: :return:
""" """
self.__logger.debug(f"Trying to get mod info of file \"{file_path}\" ...")
if not self.is_valid_mod_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}") 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}") 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') f = zip.open('manifest.json')
contents = zip.namelist() 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 = [] orig_files = []
files = [] files = []
for file in contents: for file in contents:
if "icon.png" in file or "manifest.json" in file or file.endswith(".md"): if "icon.png" in file or "manifest.json" in file or file.endswith(".md"):
continue continue
orig_files.append(file) 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) files.append(file)
@ -221,6 +253,7 @@ class ModManager:
For unknown mods a placeholder is created For unknown mods a placeholder is created
:return: :return:
""" """
self.__logger.debug("Indexing all installed mods ...")
self.installed_mods = dict() self.installed_mods = dict()
if self.__settings.get_game_folder() is None: if self.__settings.get_game_folder() is None:
return return
@ -236,15 +269,15 @@ class ModManager:
elif os.path.isfile(curr) and curr.endswith('.dll'): elif os.path.isfile(curr) and curr.endswith('.dll'):
files.append(curr) files.append(curr)
for mod_name in self.available_mods.keys(): for mod_name, mod_version in self.available_mods.keys():
if self.is_mod_installed(mod_name): if self.is_mod_installed(mod_name, mod_version):
self.installed_mods[mod_name] = self.available_mods[mod_name] self.installed_mods[(mod_name, mod_version)] = self.available_mods[(mod_name, mod_version)]
unknown_mod = dict() unknown_mod = dict()
unresolved_files = files.copy() unresolved_files = files.copy()
for file in files: for file in files:
for mod in self.installed_mods.keys(): 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"]: if file_rel.replace(os.path.sep, "/") in self.installed_mods[mod]["mod_files"]:
unresolved_files.remove(file) unresolved_files.remove(file)
if file in unresolved_files: if file in unresolved_files:
@ -262,6 +295,7 @@ class ModManager:
Ignores mods that are already on the list Ignores mods that are already on the list
:return: :return:
""" """
self.__logger.debug("Indexing all stored mods ...")
self.available_mods = dict() self.available_mods = dict()
if self.__settings.get_game_folder() is None: if self.__settings.get_game_folder() is None:
return return
@ -274,9 +308,10 @@ class ModManager:
self.__logger.warning(f"Mod \"{full_path}\" did not have the expected path. Ignoring it ...") self.__logger.warning(f"Mod \"{full_path}\" did not have the expected path. Ignoring it ...")
continue continue
self.available_mods[mod_name] = {"path": file, "version": mod_version, "mod_files": mod_files, self.available_mods[ModManager.get_mod_key(mod_name, mod_version)] = {"path": file, "version": mod_version,
"orig_mod_files": orig_mod_files} "mod_files": mod_files,
self.__window.set_available_mods(self.available_mods) "orig_mod_files": orig_mod_files}
self.__window.set_available_mods(self.available_mods)
def add_mod_file(self, file_path: str): def add_mod_file(self, file_path: str):
""" """
@ -284,6 +319,7 @@ class ModManager:
:param file_path: :param file_path:
:return: :return:
""" """
self.__logger.debug(f"Trying to add mod file \"{file_path}\" to manager ...")
if self.__settings.get_settings()["game_path"] is None: if self.__settings.get_settings()["game_path"] is None:
self.__logger.error("Can't add a mod without a valid game path!") 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_stored_mods()
self.index_installed_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. 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 This assumes that the .zip is structured like the game folder as all mods should be
:param mod_name: :param mod_name:
:param mod_version:
:return: :return:
""" """
if mod_name not in self.available_mods.keys(): self.__logger.debug(f"Trying to install mod \"{mod_name} - {mod_version}\"")
self.__logger.critical(f"Tried to install a mod that doesn't exist: {mod_name}") 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 return
self.__logger.info(f"Installing mod \"{mod_name}\" ...") self.__logger.info(f"Installing mod \"{mod_name} - {mod_version}\" ...")
mod_zip = self.available_mods[mod_name]["path"] 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: 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() 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: with tempfile.TemporaryDirectory() as tmp_dir:
for file in contents: for file in contents:
content = file content = file
if "icon.png" in file or "manifest.json" in file or file.endswith(".md"): if "icon.png" in file or "manifest.json" in file or file.endswith(".md"):
continue 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) zip_ref.extract(content, tmp_dir)
if content.endswith(os.path.sep): 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 continue
#print("Moving", os.path.join(tmp_dir, content), "to", parent_dir = os.path.join(self.__settings.get_game_folder(), file).split(os.path.basename(file))[0]\
# os.path.join(self.__settings.get_game_folder(), file)) .replace("/", os.path.sep)
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): if not os.path.exists(parent_dir):
os.mkdir(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))): if not os.path.exists(os.path.join(self.__settings.get_game_folder().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))) 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() 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 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 Note: For untracked mods this will only be the .dll in the plugins folder
:param mod_name: :param mod_name:
:param mod_version:
:return: :return:
""" """
self.__logger.info(f"Uninstalling mod \"{mod_name}\" ...") self.__logger.info(f"Uninstalling mod \"{mod_name} - {mod_version}\" ...")
for file in self.installed_mods[mod_name]["mod_files"]: for file in self.installed_mods[(mod_name, mod_version)]["mod_files"]:
full_path = os.path.join(self.__settings.get_game_folder(), file) full_path = os.path.join(self.__settings.get_game_folder(), file)
if os.path.isfile(full_path): if os.path.isfile(full_path):
self.__logger.debug(f"Deleting file \"{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. # Press the green button in the gutter to run the script.
if __name__ == '__main__': if __name__ == '__main__':
m = ModManager(log_level=logging.INFO) m = ModManager(log_level=logging.DEBUG)
m.run() m.run()

@ -1,7 +1,7 @@
import logging import logging
import os.path import os.path
import sys import sys
from typing import Dict, List from typing import Dict, List, Tuple
from PyQt6 import QtWidgets from PyQt6 import QtWidgets
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@ -42,21 +42,25 @@ class MainWindow(QtWidgets.QMainWindow):
self.ui.actionCheck_for_Updates.setDisabled(True) 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 Sets the given mods as the list of available mods
:param available_mods: :param available_mods:
:return: :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) item_model = QStandardItemModel(self.ui.AvailableModsList)
for mod_name in available_mods.keys(): for key in keys:
item = QStandardItem(mod_name + f" ({available_mods[mod_name]['version']})") 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.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole) 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_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.setCheckState(Qt.CheckState.Checked)
item_model.itemChanged.connect(self.on_available_mod_item_changed) 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 :param installed_mods: string list of all the mods as to be written to the list
:return: :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) item_model = QStandardItemModel(self.ui.InstalledModsListView)
for mod_name in installed_mods.keys(): for key in keys:
if mod_name in self.parent.available_mods.keys(): mod_name, mod_version = key[1][0], key[1][1]
mod_version = self.parent.available_mods[mod_name]['version'] if (mod_name, mod_version) in self.parent.available_mods.keys():
mod_version = self.parent.available_mods[(mod_name, mod_version)]['version']
else: else:
mod_version = "Not Tracked" mod_version = "Not Tracked"
item = QStandardItem(mod_name + f" ({mod_version})") item = QStandardItem(mod_name + f" ({mod_version})")
item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled) item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole) 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_model.appendRow(item)
item.setCheckState(Qt.CheckState.Checked) item.setCheckState(Qt.CheckState.Checked)
@ -94,12 +102,16 @@ class MainWindow(QtWidgets.QMainWindow):
result = dialog.getOpenFileName(filter='ZIP (*.zip)') result = dialog.getOpenFileName(filter='ZIP (*.zip)')
self.__logger.debug(f"user selected \"{result[0]}\"") self.__logger.debug(f"user selected \"{result[0]}\"")
if result == ('', ''):
self.__logger.debug("Action: \"add new mod\" was cancelled!")
return
if not os.path.isfile(result[0]): if not os.path.isfile(result[0]):
dialog = QtWidgets.QMessageBox() dialog = QtWidgets.QMessageBox()
dialog.setWindowTitle("Not a file") dialog.setWindowTitle("Not a file")
dialog.setInformativeText(f"The given file \"{result}\" did not look like a file!") dialog.setInformativeText(f"The given file \"{result}\" did not look like a file!")
dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning) dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning)
dialog.exec() dialog.exec()
return
if not self.parent.is_valid_mod_file(result[0]): if not self.parent.is_valid_mod_file(result[0]):
dialog = QtWidgets.QMessageBox() dialog = QtWidgets.QMessageBox()
dialog.setWindowTitle("Not a valid mod file") dialog.setWindowTitle("Not a valid mod file")
@ -107,6 +119,7 @@ class MainWindow(QtWidgets.QMainWindow):
f"The given file \"{result}\" did not look like a mod file. Is the manifest.json present?") f"The given file \"{result}\" did not look like a mod file. Is the manifest.json present?")
dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning) dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning)
dialog.exec() dialog.exec()
return
self.parent.add_mod_file(result[0]) self.parent.add_mod_file(result[0])
@ -166,20 +179,20 @@ class MainWindow(QtWidgets.QMainWindow):
def on_available_mod_item_changed(self, item: QStandardItem): def on_available_mod_item_changed(self, item: QStandardItem):
self.__logger.debug(f"Available Mod list item \"{item.text()}\" changed to {item.checkState()}") 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: 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: 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): def on_installed_mod_item_changed(self, item: QStandardItem):
self.__logger.debug(f"Installed Mod list item \"{item.text()}\" changed to {item.checkState()}") 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 if item.checkState() == Qt.CheckState.Unchecked: # mod should be uninstalled
accepted = QtWidgets.QMessageBox.question(self, "Really uninstall mod?", accepted = QtWidgets.QMessageBox.question(self, "Really uninstall mod?",
f"Do you really want to uninstall the mod \"{mod_name}\"?\n" f"Do you really want to uninstall the mod \"{mod_name}\"?\n"
"This could lead to permanent data loss if it wasn't tracked!") "This could lead to permanent data loss if it wasn't tracked!")
if accepted: if accepted:
self.parent.uninstall_mod(mod_name) self.parent.uninstall_mod(mod_name, mod_version)
else: else:
return return

Loading…
Cancel
Save