#!/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. # # import os import os.path import json import re import sys 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" HELP_TEXT="""Usage: seven-mods.py Valid commands are: - help : usage guide. - list : show a listing of all installed mods. Disabled mods appear in red. Enabled ones appear in green, and are followed by an asterisk (`*`). `list` also accepts an optional argument, `profiles`, which tells it to list all saved profiles instead of mods. - 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. - config : reconfigure paths to your mod and 7 Days to Die directories.""" 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.") 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 SevenModsError(RuntimeError): pass class VDF_Error(SevenModsError): 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.mkdir(ret) except: raise SevenModsError(f"Could not create directory at {ret}.") 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): raise SevenModsError("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: return False def load_config(): 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) 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() and entry.name != "__pycache__": 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): raise SevenModsError(f"Could not enable {mod_name}: mod not available.") if os.path.exists(link_dest): raise SevenModsError(f"Could not enable {mod_name}: already enabled.") 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): raise SevenModsError(f"Could not disable {mod_name}: already disabled.") 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): raise SevenModsError(f"Could not enable {mod_name}: mod not available.") 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: {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: {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: {args[0]} {args[1]} ") def command_configure(args: list, cfg: Config, profiles: ModProfiles): prompt_configuration_cli() def command_help(args: list, cfg: Config, profiles: ModProfiles): print(HELP_TEXT) 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, "config" : command_configure, "help" : command_help, "--help" : command_help} 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 = None try: cfg = load_config() except: # no config or invalid config cfg = prompt_configuration_cli() profiles = ModProfiles() profiles.load_mod_profiles() try: parse_input(args, cfg, profiles) except SevenModsError as err: print(err, file = sys.stderr) return 1 return 0 if __name__ == '__main__': sys.exit(main(sys.argv))