Compare commits

...

7 Commits
v0.1 ... main

Author SHA1 Message Date
Peery 69f387ce5a
Fixed folder creation
Fixed an Issue where if a nested folder was used it would not create all of them but only the parent and then crash.
4 months ago
Peery be194b2ad6
Fixed crash with unrecognized mods
This fixes issue #4 by giving unrecognized aka "untracked" mods an "Not Tracked" version string.
5 months ago
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
pandro cb28f4fcf9 Updated README
Updated the formatting on the README
5 months ago

@ -6,7 +6,7 @@ import os
import shutil
import sys
import tempfile
from typing import Tuple, List
from typing import Tuple
from zipfile import ZipFile
from PyQt6 import QtWidgets
@ -17,8 +17,9 @@ from window.main_window import MainWindow
class ModManager:
VERSION = "0.3.1"
VERSION = "0.1"
UNKNOWN_MOD_VERSION_STRING = "Not Tracked"
def __init__(self, log_level: int = logging.INFO):
self.__logger = logging.getLogger("ModManager")
@ -63,17 +64,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 +151,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 +212,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 +227,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 +255,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,21 +271,21 @@ 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:
unknown_mod[os.path.basename(file)] = {"mod_files": [file]}
for key in unknown_mod.keys():
self.installed_mods[key] = unknown_mod[key]
self.installed_mods[(key, ModManager.UNKNOWN_MOD_VERSION_STRING)] = unknown_mod[key]
self.__window.set_installed_mods(self.installed_mods)
self.__window.set_available_mods(self.available_mods)
@ -262,6 +297,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 +310,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 +321,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 +347,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]
parent_dir = os.path.join(self.__settings.get_game_folder(), file).split(os.path.basename(file))[0]\
.replace("/", os.path.sep)
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)))
os.makedirs(parent_dir)
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}\" ...")

@ -17,8 +17,11 @@ The mod manager needs to be told where the game is though (Settings > Set game p
## 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.
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.
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.
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 delete every file by that mod it knows through the zip-archive.
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.
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"
mod_version = self.parent.UNKNOWN_MOD_VERSION_STRING
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)
@ -94,12 +102,16 @@ class MainWindow(QtWidgets.QMainWindow):
result = dialog.getOpenFileName(filter='ZIP (*.zip)')
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]):
dialog = QtWidgets.QMessageBox()
dialog.setWindowTitle("Not a file")
dialog.setInformativeText(f"The given file \"{result}\" did not look like a file!")
dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning)
dialog.exec()
return
if not self.parent.is_valid_mod_file(result[0]):
dialog = QtWidgets.QMessageBox()
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?")
dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning)
dialog.exec()
return
self.parent.add_mod_file(result[0])
@ -166,20 +179,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