Compare commits

..

No commits in common. 'main' and 'v0.1' have entirely different histories.
main ... v0.1

@ -6,7 +6,7 @@ import os
import shutil import shutil
import sys import sys
import tempfile import tempfile
from typing import Tuple from typing import Tuple, List
from zipfile import ZipFile from zipfile import ZipFile
from PyQt6 import QtWidgets from PyQt6 import QtWidgets
@ -17,9 +17,8 @@ from window.main_window import MainWindow
class ModManager: class ModManager:
VERSION = "0.3.1"
UNKNOWN_MOD_VERSION_STRING = "Not Tracked" 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")
@ -64,36 +63,17 @@ class ModManager:
status = self.__app.exec() status = self.__app.exec()
sys.exit(status) sys.exit(status)
@staticmethod def __adjust_folder_paths(self, path: str) -> str:
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 path: :param input:
: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"):]
@ -151,31 +131,26 @@ class ModManager:
self.index_installed_mods() self.index_installed_mods()
return is_valid return is_valid
def is_mod_installed(self, mod_name: str, mod_version: str) -> bool: def is_mod_installed(self, mod_name: 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, mod_version)] r = self.available_mods[mod_name]
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( hash_installed = self.get_file_hash(open(os.path.join(self.__settings.get_game_folder(), file), 'rb'))
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"])
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):
@ -212,13 +187,12 @@ 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], list[str]]: def get_mod_info(self, file_path: str) -> Tuple[str, 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}")
@ -227,21 +201,13 @@ 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, includes_bepinx=contains_bepinex, includes_folders=contains_folders) file = self.__adjust_folder_paths(file)
files.append(file) files.append(file)
@ -255,7 +221,6 @@ 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
@ -271,9 +236,9 @@ 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, mod_version in self.available_mods.keys(): for mod_name in self.available_mods.keys():
if self.is_mod_installed(mod_name, mod_version): if self.is_mod_installed(mod_name):
self.installed_mods[(mod_name, mod_version)] = self.available_mods[(mod_name, mod_version)] self.installed_mods[mod_name] = self.available_mods[mod_name]
unknown_mod = dict() unknown_mod = dict()
unresolved_files = files.copy() unresolved_files = files.copy()
@ -285,7 +250,7 @@ class ModManager:
if file in unresolved_files: if file in unresolved_files:
unknown_mod[os.path.basename(file)] = {"mod_files": [file]} unknown_mod[os.path.basename(file)] = {"mod_files": [file]}
for key in unknown_mod.keys(): for key in unknown_mod.keys():
self.installed_mods[(key, ModManager.UNKNOWN_MOD_VERSION_STRING)] = unknown_mod[key] self.installed_mods[key] = unknown_mod[key]
self.__window.set_installed_mods(self.installed_mods) self.__window.set_installed_mods(self.installed_mods)
self.__window.set_available_mods(self.available_mods) self.__window.set_available_mods(self.available_mods)
@ -297,7 +262,6 @@ 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
@ -310,8 +274,7 @@ 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[ModManager.get_mod_key(mod_name, mod_version)] = {"path": file, "version": mod_version, self.available_mods[mod_name] = {"path": file, "version": mod_version, "mod_files": mod_files,
"mod_files": mod_files,
"orig_mod_files": orig_mod_files} "orig_mod_files": orig_mod_files}
self.__window.set_available_mods(self.available_mods) self.__window.set_available_mods(self.available_mods)
@ -321,7 +284,6 @@ 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!")
@ -347,70 +309,55 @@ 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, mod_version: str): def install_mod(self, mod_name: 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:
""" """
self.__logger.debug(f"Trying to install mod \"{mod_name} - {mod_version}\"") if mod_name not in self.available_mods.keys():
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}")
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} - {mod_version}\" ...") self.__logger.info(f"Installing mod \"{mod_name}\" ...")
mod_zip = self.available_mods[(mod_name, mod_version)]["path"] mod_zip = self.available_mods[mod_name]["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, includes_bepinx=contains_bepinex, file = self.__adjust_folder_paths(file)
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):
self.__logger.debug(f"Skipped moving of {os.path.join(tmp_dir, content)}") #print("Skipped moving", os.path.join(tmp_dir, content))
continue continue
parent_dir = os.path.join(self.__settings.get_game_folder(), file).split(os.path.basename(file))[0]\ #print("Moving", os.path.join(tmp_dir, content), "to",
.replace("/", os.path.sep) # 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): if not os.path.exists(parent_dir):
os.makedirs(parent_dir) os.mkdir(parent_dir)
if not os.path.exists(os.path.join(self.__settings.get_game_folder().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))):
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.__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, mod_version: str): def uninstall_mod(self, mod_name: 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} - {mod_version}\" ...") self.__logger.info(f"Uninstalling mod \"{mod_name}\" ...")
for file in self.installed_mods[(mod_name, mod_version)]["mod_files"]: for file in self.installed_mods[mod_name]["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}\" ...")

@ -17,11 +17,8 @@ The mod manager needs to be told where the game is though (Settings > Set game p
## Usage ## Usage
You just add new mods (Mods > Add new mod) by selecting the downloaded zip-archive of them and they get automatically copied to the mod storage folder of the manager. You just add new mods (Mods > Add new mod) by selecting the downloaded zip-archive of them and they get automatically copied to the mod storage folder of the manager.
Now you can just tick them among the available mods and the mod manager extracts all files for you. Now you can just tick them among the available mods and the mod manager extracts all files for you.
Uninstalling is just as easy! Just untick the mod again and it'll hunt down every file it knows through the zip-archive.
Uninstalling is just as easy! Just untick the mod again and it'll delete every file by that mod it knows through the zip-archive. It somewhat handles mods you haven't given to the mod manger yet by showing them as installed and "Untracked" with the option to delete them.
Note: the manager only deletes the dll-file it found of the mod. It doesn't know what other files might be part of that mod.
It handles mods you haven't given to the mod manger yet by showing them under installed and "Untracked" with the option to delete them.
__Note:__ For "untracked mods" the manager only deletes the dll-file it found of the mod. It doesn't know what other files might or might not be part of that mod unlike when it was supplied a zip-Archive of the mod in question.

@ -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.DEBUG) m = ModManager(log_level=logging.INFO)
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, Tuple from typing import Dict, List
from PyQt6 import QtWidgets from PyQt6 import QtWidgets
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@ -42,25 +42,21 @@ 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[Tuple[str, str], Dict]): def set_available_mods(self, available_mods: Dict[str, str]):
""" """
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 key in keys: for mod_name in available_mods.keys():
mod_name, mod_version = key[1][0], key[1][1] item = QStandardItem(mod_name + f" ({available_mods[mod_name]['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, mod_version), Qt.ItemDataRole.UserRole) item.setData(mod_name, Qt.ItemDataRole.UserRole)
item_model.appendRow(item) item_model.appendRow(item)
if self.parent.is_mod_installed(mod_name, mod_version): if self.parent.is_mod_installed(mod_name):
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)
@ -72,20 +68,16 @@ 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 key in keys: for mod_name in installed_mods.keys():
mod_name, mod_version = key[1][0], key[1][1] if mod_name in self.parent.available_mods.keys():
if (mod_name, mod_version) in self.parent.available_mods.keys(): mod_version = self.parent.available_mods[mod_name]['version']
mod_version = self.parent.available_mods[(mod_name, mod_version)]['version']
else: else:
mod_version = self.parent.UNKNOWN_MOD_VERSION_STRING 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, mod_version), Qt.ItemDataRole.UserRole) item.setData(mod_name, Qt.ItemDataRole.UserRole)
item_model.appendRow(item) item_model.appendRow(item)
item.setCheckState(Qt.CheckState.Checked) item.setCheckState(Qt.CheckState.Checked)
@ -102,16 +94,12 @@ 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")
@ -119,7 +107,6 @@ 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])
@ -179,20 +166,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, mod_version = item.data(Qt.ItemDataRole.UserRole) mod_name = item.data(Qt.ItemDataRole.UserRole)
if item.checkState() == Qt.CheckState.Checked: if item.checkState() == Qt.CheckState.Checked:
self.parent.install_mod(mod_name, mod_version) self.parent.install_mod(mod_name)
elif item.checkState() == Qt.CheckState.Unchecked: elif item.checkState() == Qt.CheckState.Unchecked:
self.parent.uninstall_mod(mod_name, mod_version) self.parent.uninstall_mod(mod_name)
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, mod_version = item.data(Qt.ItemDataRole.UserRole) mod_name = 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, mod_version) self.parent.uninstall_mod(mod_name)
else: else:
return return

Loading…
Cancel
Save