seven-mods/seven-mods.py

346 lines
11 KiB
Python
Raw Normal View History

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# seven-mods.py
#
# Copyright 2023 Patrick Marsee <patrickm@patrick-b550mc>
#
# 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]} <profile-name>")
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]} <profile-name>")
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))