diff --git a/.gitignore b/.gitignore index 5d381cc..ceff23f 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,8 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +bin/ +lib64 +profile_db.json +pyvenv.cfg +seven-mods.cfg diff --git a/README.md b/README.md index dc0d567..a5d429e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,72 @@ # seven-mods -This is a very basic mod manager for 7 Days to Die on Linux. \ No newline at end of file +This is a very basic mod manager for 7 Days to Die on Linux. + +## Dependencies + +This program requires: + +- Python 3.7 or newer +- Tcl/Tk (python-tk on Debian, tk on Arch) + +## General Usage + +This mod manager works by storing your mods in a separate directory outside of +your 7 Days to Die installation, then creating or breaking symlinks to the +individual mods from the `Mods` folder within your 7 Days to Die installation +to enable and disable them, respectively. + +Upon first use, you will be prompted for the locations of your mods folder and +7 Days to Die installation. After configuring these, they are stored in +`seven-mods.cfg`. + +seven-days also allows the user to create separate mod profiles. If, for +instance, you frequent a multiplayer server with a certain set of mods, but +have a different prefered set of mods for single player, then you could create +two mod profiles, called `multiplayer` and `singleplayer`, for instance. +Loading a mod profile will instantly change the enabled mods to the ones that +were enabled when the profile was last saved. + +## CLI Version + +The main source file for the CLI version is `seven-mods.py`. General usage is: + +`python seven-mods.py ` + +The available commands are: + +- `list`: show a listing of all installed mods. Disabled mods appear in red. +Enabled ones appear in green, and are followed by an asterisk (`*`). +- `enable`: enable mods. A list of mod names can be given, separated by spaces. +The mod names are the names of their respective folders, *not* the names listed +in their respective `ModInfo.xml` files. Alternatively, to enable all mods, +simply type `-a` rather than listing them all out. +- `disable`: disable mods. Arguments are the same as for `enable`, including +`-a` to disable all mods. +- `toggle`: toggles mods on or off. Does *not* support `-a`. +- `save`: saves the currently enabled mods to a profile. The only argument +that should be given is the name of the profile to save. If the profile already +exists, then it will be overwritten. +- `load`: sets the enabled mods to those defined by the given profile. + +Tip: If you run the script from the directory in which you store your mods, +then bash autocompletion works for mod names - hence why it uses the ugly +folder names rather than the nice `ModInfo.xml` names. + +Warning: Due to Python's built-in `input()` function being jank on Linux, it +may be easier to start out by just using the `list` command, accepting the +defaults (by just striking the return key without typing anything), then editing +the `seven-mods.cfg` file later. This is not ideal, so a better solution may +come in the future. + +## GUI Version + +A GUI version that uses tkinter is planned, but not yet ready. It'll be great +though! + +Trust me. + +I'm a doctor.\* + +\* I'm not an actual doctor. + diff --git a/seven-mods.py b/seven-mods.py new file mode 100755 index 0000000..39cccba --- /dev/null +++ b/seven-mods.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# seven-mods.py +# +# Copyright 2023 Patrick Marsee +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. +# +# + +from tkinter import * +from tkinter import ttk +import os +import os.path +import json +import re + +PROFILE_DB = "profile_db.json" +HOME = os.getenv("HOME", os.getcwd()) # Use relative path in attmpt to salvage things +MODS_DIR = os.getcwd() +STEAM_LIBRARIES_DIR = os.path.join(HOME, ".local/share/Steam/config/libraryfolders.vdf") +SEVEN_DIR = os.path.join(HOME, "/.steam/steam/steamapps/common/7 Days To Die") +CONFIG_FILE = "seven-mods.cfg" + +class Config: + + def __init__(self, **kwargs): + if "mods_dir" in kwargs: + self.mods_dir = kwargs["mods_dir"] + else: + self.mods_dir = MODS_DIR + + if "seven_dir" in kwargs: + self.seven_dir = kwargs["seven_dir"] + else: + self.seven_dir = get_seven_days_install_path() + +class ModProfiles: + + def __init__(self): + self.profiles = {} + + def load_mod_profiles(self): + try: + with open(PROFILE_DB, 'r') as pro_file: + self.profiles = json.load(pro_file) + except: + # If loading failed, then assume that there are no profiles currently. + # We don't need to do anything special here. + pass + + def save_mod_profiles(self): + try: + with open(PROFILE_DB, 'w') as pro_file: + json.dump(self.profiles, pro_file) + except: + print("Could not save profiles.") + +class AppFrame(ttk.Frame): + + def __init__(self, root): + self.super(SevenDaysToMod, self).__init__(root) + self.grid(column = 0, row = 0) + self.initialize_widgets() + + def initialize_widgets(self): + pass + +VAL_RE = re.compile(r'\s*"([^"]*)"', re.MULTILINE) +DICT_START_RE = re.compile(r'\s*{', re.MULTILINE) +DICT_END_RE = re.compile(r'\s*}', re.MULTILINE) + +class VDF_Error(RuntimeError): + pass + +def parse_vdf_val(text: str) -> tuple: + ret = VAL_RE.match(text) + if ret: + return ret[1], ret.end() + text_sample = text[:min(32, len(text))] + if len(text) > 32: + text_sample += "..." + return None, 0 + +def parse_vdf_dict(text: str): + start_match = DICT_START_RE.match(text) + if start_match: + start_cursor = start_match.end() + text = text[start_cursor:] + ret = {} + end_match = DICT_END_RE.match(text) + while not end_match: + var, var_cursor = parse_vdf_var(text) + if var != None: + ret[var[0]] = var[1] + text = text[var_cursor:] + start_cursor += var_cursor + else: + # if it's neither the end of a dictionary or the start of a var, + # then it's a syntax error. + text_sample = text[:min(32, len(text))] + if len(text) > 32: + text_sample += "..." + raise VDF_Error(f"Syntax error: dict failed to parse.\nValue: {ret}\nText: {text_sample}") + end_match = DICT_END_RE.match(text) + return ret, start_cursor + end_match.end() + else: + return None, 0 + +def parse_vdf_var(text:str) -> tuple: + var_name, name_cursor = parse_vdf_val(text) + if var_name == None: + text_sample = text[:min(32, len(text))] + if len(text) > 32: + text_sample += "..." + raise VDF_Error(f"Syntax error: var failed to parse name. Text: {text_sample}") + text = text[name_cursor:] + var_val, val_cursor = parse_vdf_val(text) + if var_val == None: # it didn't match, so it's a dict + var_val, val_cursor = parse_vdf_dict(text) + if var_val == None: + text_sample = text[:min(32, len(text))] + if len(text) > 32: + text_sample += "..." + raise VDF_Error(f"Syntax error: var failed to parse value. Text: {text_sample}") + return (var_name, var_val), val_cursor + name_cursor + +def parse_vdf(text: str) -> dict: + # val : "[^"]" + # + # var : val dict + # | val val + # + # dict : { var ... } + + return parse_vdf_var(text)[0] + +def get_seven_days_install_path() -> str: + if os.path.isfile(STEAM_LIBRARIES_DIR): + text = None + with open(STEAM_LIBRARIES_DIR, 'r') as library_file: + text = library_file.read() + vdf_data = parse_vdf(text) + for folder in vdf_data[1]: + if "251570" in vdf_data[1][folder]["apps"]: + return os.path.join(vdf_data[1][folder]["path"], "steamapps/common/7 Days To Die") + else: + return "" + +def get_internal_mods_path(cfg: Config) -> str: + ret = os.path.join(cfg.seven_dir, "Mods") + if not os.path.exists(ret): + try: + os.makedirs(ret) + except: + print(f"Could not create directory at {ret}.") + raise + elif not os.path.isdir(ret): + os.remove(ret) + os.makedirs(ret) + return ret + +def prompt_configuration_cli(): + SEVEN_DIR = get_seven_days_install_path() + mods_dir = input(f"Mod directory (default = {MODS_DIR}): ") + if mods_dir == "": + mods_dir = MODS_DIR + seven_dir = input(f"Seven Days to Die installation (default = {SEVEN_DIR}): ") + if seven_dir == "": + seven_dir = SEVEN_DIR + cfg = Config(mods_dir=mods_dir, seven_dir=seven_dir) + if not save_config(cfg): + print("There was a problem writing the configuration file.") + return cfg + +def save_config(cfg: Config) -> bool: + try: + with open(CONFIG_FILE, 'w') as config_file: + config_file.write(f"mods_dir={cfg.mods_dir}\nseven_dir={cfg.seven_dir}\n") + return True + except RuntimeError as e: + print(e) + return False + +def load_config(): + try: + lines = [] + with open(CONFIG_FILE, 'r') as config_file: + lines = config_file.readlines() + cfg_dict = {} + for line in lines: + if not "=" in line: + continue + var, val = line.split("=") + cfg_dict[var.strip()] = val.strip() + return Config(**cfg_dict) + except: + # no config or invalid config + return prompt_configuration_cli() + +def get_loaded_mods(cfg: Config) -> list: + internal_mods_path = get_internal_mods_path(cfg) + ret = [] + with os.scandir(internal_mods_path) as it: + for entry in it: + if entry.is_symlink() and entry.is_dir(): + # only + ret.append(entry.name) + return ret + +def get_available_mods(cfg: Config) -> list: + ret = [] + with os.scandir(cfg.mods_dir) as it: + for entry in it: + if entry.is_dir(): + ret.append(entry.name) + return ret + +def enable_mod(cfg: Config, mod_name: str): + link_source = os.path.join(cfg.mods_dir, mod_name) + link_dest = os.path.join(get_internal_mods_path(cfg), mod_name) + if not os.path.exists(link_source): + print(f"Could not enable {mod_name}: mod not available.") + return + if os.path.exists(link_dest): + print(f"Could not enable {mod_name}: already enabled.") + return + os.symlink(link_source, link_dest) + +def disable_mod(cfg: Config, mod_name: str): + link_dest = os.path.join(get_internal_mods_path(cfg), mod_name) + if not os.path.exists(link_dest): + print(f"Could not disable {mod_name}: already disabled.") + return + os.remove(link_dest) + +def toggle_mod(cfg: Config, mod_name: str): + link_dest = os.path.join(get_internal_mods_path(cfg), mod_name) + if os.path.exists(link_dest): + os.remove(link_dest) + else: + link_source = os.path.join(cfg.mods_dir, mod_name) + if not os.path.exists(link_source): + print(f"Could not enable {mod_name}: mod not available.") + return + os.symlink(link_source, link_dest) + +def command_list(args: list, cfg: Config, profiles: ModProfiles): + if len(args) == 2: + available_mods = get_available_mods(cfg) + loaded_mods = get_loaded_mods(cfg) + for mod in available_mods: + if mod in loaded_mods: + print(f"\x1b[32m{mod}\x1b[0m *") + else: + print(f"\x1b[31m{mod}\x1b[0m") + + elif len(args) == 3 and args[2] == "profiles": + for profile in profiles.profiles: + print(profile) + + else: + print(f"Usage: python {args[0]} {args[1]} [profiles]") + +def command_enable(args: list, cfg: Config, profiles: ModProfiles): + for mod in args[2:]: + if not mod in ("-a", "-A", "*"): + enable_mod(cfg, mod) + else: + available_mods = get_available_mods(cfg) + loaded_mods = get_loaded_mods(cfg) + for mod in available_mods: + if mod not in loaded_mods: + enable_mod(cfg, mod) + break + +def command_disable(args: list, cfg: Config, profiles: ModProfiles): + for mod in args[2:]: + if not mod in ("-a", "-A", "*"): + disable_mod(cfg, mod) + else: + loaded_mods = get_loaded_mods(cfg) + for mod in loaded_mods: + disable_mod(cfg, mod) + break + +def command_toggle(args: list, cfg: Config, profiles: ModProfiles): + for mod in args[2:]: + toggle_mod(cfg, mod) + +def command_save(args: list, cfg: Config, profiles: ModProfiles): + if len(args) > 2: + for prof in args[2:]: + profiles.profiles[prof] = get_loaded_mods(cfg) + profiles.save_mod_profiles() + else: + print(f"Usage: python {args[0]} {args[1]} ") + +def command_load(args: list, cfg: Config, profiles: ModProfiles): + if len(args) == 3 and args[2] in profiles.profiles: + command_disable(args[:2] + ["*"], cfg, profiles) + command_enable(args[:2] + profiles.profiles[args[2]], cfg, profiles) + else: + print(f"Usage: python {args[0]} {args[1]} ") + +def parse_input(args: list, cfg: Config, profiles: ModProfiles): + if len(args) > 1: + commands = {"ls" : command_list, + "list" : command_list, + "enable" : command_enable, + "disable" : command_disable, + "toggle" : command_toggle, + "save" : command_save, + "load" : command_load} + if args[1] in commands: + commands[args[1]](args, cfg, profiles) + else: + print(f"Command {args[1]} not recogised. Valid commands are:") + for command in commands: + print(command) + +def main(args): + cfg = load_config() + profiles = ModProfiles() + profiles.load_mod_profiles() + parse_input(args, cfg, profiles) + return 0 + +if __name__ == '__main__': + import sys + sys.exit(main(sys.argv))