Added the actual script, filled in the README.
This commit is contained in:
parent
c0f9599481
commit
5018edb246
3 changed files with 420 additions and 1 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -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
|
||||
|
|
71
README.md
71
README.md
|
@ -1,3 +1,72 @@
|
|||
# seven-mods
|
||||
|
||||
This is a very basic mod manager for 7 Days to Die on Linux.
|
||||
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 <command> <args...>`
|
||||
|
||||
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.
|
||||
|
||||
|
|
345
seven-mods.py
Executable file
345
seven-mods.py
Executable file
|
@ -0,0 +1,345 @@
|
|||
#!/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))
|
Loading…
Add table
Add a link
Reference in a new issue