ftl-save-manager/ftl-savemanager.py
Mr Finchum 98731a534d Now included all wanted function. Auto update, change settings, change repo and more.
New
Class for function used across all other classes.
Automatically check for new updates.
Change repo url, handy for development.
Backup profile file, change some settings in the program and more. See changelog for greater detail.
2024-12-10 13:33:28 +00:00

491 lines
22 KiB
Python

import os
import time
import sys
from datetime import datetime
version = "0.5.2" # actully 0.5.2 because 0.5.1 was just pushed
gitlab_url = "https://gitlab.com/python_projects3802849/ftl-save-manager/-/raw/main"
settings_location = "settings.txt"
dependencies = ("simple_term_menu", "requests")
if sys.platform != "linux":
print(f"{sys.platform} is not supported, only linux.")
exit()
def check_package_installed(package_name):
"""Check if a package is installed."""
try:
__import__(package_name)
return True
except ImportError as e:
print(f"{package_name} not found. Error: {e}")
return False
for item in dependencies:
if check_package_installed(item) == False:
print(f"One or more dependencies are missing, please ensure follwing packaged are installed:\nrequests\nsimple-term-menu")
exit()
import requests
from simple_term_menu import TerminalMenu
class BackupApp:
""""Backup class, copy FTL continue file to backup location and restores it."""
def __init__(self):
self.backup_files = {"1": "initiating"} # initiating variable
self.game_path = configuration.settings["GAME_PATH"]
self.save_path = configuration.settings["SAVE_PATH"]
def backup(self):
"""Copies the game file to the backup folder and adds date-time stamp."""
now = datetime.now()
formatted_now = now.strftime("%Y-%m-%d_%H-%M-%S")
u.copy_file(f"{self.game_path}/continue.sav", f"{self.save_path}/{formatted_now}.bkup")
time.sleep(0.5)
def restore_backup(self, file):
u.copy_file(f"{self.save_path}/{file}", f"{self.game_path}/continue.sav")
def delete_backup_file(self): # improvment: here also, more generic, delete_files with file type .something
"""Deltes all .bkup files in the backup folder."""
if u.yes_no("Are you sure you want to delete all save files?"):
for file in os.listdir(self.save_path):
if file[-5:] == ".bkup":
try:
print(f"Deleting {file}.")
os.remove(f"{self.save_path}/{file}")
time.sleep(0.3)
except Exception as e:
print(f"Failed to delete {file}. Reason: {e}.")
time.sleep(1)
else:
print("Deleting save files canceled")
time.sleep(0.5)
class Updater:
"""Update local version from gitlab repo."""
def __init__(self, version, local_file):
self.current_version = version
self.update_url = configuration.settings["REPO_URL"]
self.local_file = local_file
self.git = os.path.exists(".gitignore") # Checking if the program inside git env
self.auto_check()
def auto_check(self):
print("Auto update check")
check_interval = configuration.settings["AUTO_UPDATE_CHECK_INTERVAL_H"]
if check_interval == 0:
print("Auto update disabeld")
return # Exiting
if "LAST_UPDATE_CHECK" in configuration.settings:
last_time = configuration.settings["LAST_UPDATE_CHECK"]
t_object = datetime.strptime(last_time, "%Y-%m-%d %H-%M")
try:
h_since_update_check = u.get_difference_in_hours(datetime.now(), t_object)
if h_since_update_check > int(configuration.settings["AUTO_UPDATE_CHECK_INTERVAL_H"]):
print(f"More then {configuration.settings["AUTO_UPDATE_CHECK_INTERVAL_H"]} hours since last update check, checking now...")
self.check_for_update(0)
except ValueError:
print("Invalid timestamp format for LAST_UPDATE_CHECK.")
input("Error checking for updates:\nEnter to continue...")
else:
self.check_for_update(0)
def check_for_update(self, verbose = 1):
"""Check if a new version is available."""
try:
if verbose: print("Checking for updates...")
response = requests.get(f"{self.update_url}/latest_version.txt")
response.raise_for_status()
self.update_info = self.read_version_file(response)
configuration.settings["LAST_ONLINE_VERSION"] = self.update_info["LATEST_VERSION"] # updating the version number from update class to global var
now = datetime.now()
formatted_now = now.strftime("%Y-%m-%d %H-%M")
configuration.settings["LAST_UPDATE_CHECK"] = formatted_now
if self.compare_str_SemVer(self.current_version, self.update_info["LATEST_VERSION"]):
if verbose:
print(f"New version {self.update_info["LATEST_VERSION"]} available online.\nCurrent version: {self.current_version}")
print(f"\nUpdate comment:\n{self.update_info["COMMENT"]}\n")
return True
else:
if verbose:
print(f"You are up-to-date ({self.current_version}).\nLatest version is: {self.update_info["LATEST_VERSION"]}.")
input("Enter to continue...")
return False
except Exception as e:
if verbose: print(f"Error checking for updates: {e}")
input("UPDATE ERROR Press Enter to continue...")
return False
def read_version_file(self, response): # does basicly the same as the read settings function in settingmanger class, optimizing would combine them.
settings = {}
lines = response.text.splitlines()
for line in lines:
if line.startswith("#") or "=" not in line:
continue
key, value = map(str.strip, line.split("=", 1))
if value.startswith('"') and value.endswith('"'):
settings[key] = value[1:-1]
return settings
def compare_str_SemVer(self, first, second):
"""Compare two Semantic versioning strings."""
local_version = self.convert_str_list_to_int(first.split("."))
online_version = self.convert_str_list_to_int(second.split("."))
return local_version < online_version
def convert_str_list_to_int(self, list_str):
"""Converts a list with strings to a list with intengers."""
return [int(i) for i in list_str]
def download_update(self): # improvment: silly it takes the latest_version as variable
"""Download the new version and overwrite the current file."""
try:
print(f"Downloading version {self.update_info["LATEST_VERSION"]}...")
time.sleep(2)
response = requests.get(f"{self.update_url}/{self.local_file}")
response.raise_for_status()
with open(self.local_file, "wb") as file:
file.write(response.content)
print("Update downloaded.")
return True
except Exception as e:
print(f"Error downloading update: {e}")
return False
def restart_program(self):
"""Restart the current program."""
print("Restarting the program...")
os.execv(sys.executable, ["python"] + sys.argv)
def run_update(self):
"""Main method to check, update, and restart."""
if self.git:
print("Updating only works outside git env.")
time.sleep(2)
else:
if self.check_for_update():
if u.yes_no("Do you want to update the program?"):
success = self.download_update()
if success:
self.restart_program()
else:
print("Update cancelled")
return
class TerminalUI:
def __init__(self, title, options):
self.main_menu_title = title
self.main_menu_options = options
self.main_menu_keys = list(self.main_menu_options.keys()) # terminal ui takes a list for the menu
self.main_menu = TerminalMenu(
menu_entries = self.main_menu_keys,
title = self.main_menu_title,
menu_cursor = "> ",
menu_cursor_style = ("fg_gray", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = True
)
def show_main_menu(self):
# Adding try makes it possiable to add code after the True loop
try:
while True:
selected_index = self.main_menu.show()
selected_key = None
if isinstance(selected_index, int):
selected_key = self.main_menu_keys[selected_index]
if selected_index == None:
break
elif selected_key == "[l] Load":
self.restore_file_menu(app.save_path)
elif selected_key == "[c] Change settings":
self.change_settings_menu()
elif selected_key == "[p] Profile managment":
self.profile_menu()
elif selected_key in self.main_menu_options:
self.main_menu_options[selected_key]()
finally:
configuration.write_settings_file() # writing settings when exiting.
def restore_file_menu(self, folder):
menu_options = list(self.list_files(folder))
folder_menu = TerminalMenu(menu_entries = menu_options,
title = f"Content of {folder} (Q or Esc to go back).\n",
menu_cursor = "> ",
menu_cursor_style = ("fg_red", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = False,
)
menu_entry_index = folder_menu.show()
if menu_entry_index == None:
return
else:
app.restore_backup(menu_options[menu_entry_index])
def list_files(self, directory):
return (file for file in sorted(os.listdir(directory), reverse = True) if os.path.isfile(os.path.join(directory, file)) and file.endswith((".bkup")))
def yes_no_menu(self, message): # oh
menu_options = ["[y] yes", "[n] no"]
menu = TerminalMenu(menu_entries = menu_options,
title = f"{message}",
menu_cursor = "> ",
menu_cursor_style = ("fg_red", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = False)
menu_entry_index = menu.show()
if menu_entry_index == 0:
return True
elif menu_entry_index == 1:
return False
def change_settings_menu(self):
menu_options = [f"[g] Game path ({configuration.settings["GAME_PATH"]})",
f"[s] Save path ({configuration.settings["SAVE_PATH"]})",
f"[a] Auto update interval ({configuration.settings["AUTO_UPDATE_CHECK_INTERVAL_H"]})",
"[q] Quite"]
settings_option = ["GAME_PATH", "SAVE_PATH", "AUTO_UPDATE_CHECK_INTERVAL_H"]
settings_menu = TerminalMenu(menu_entries = menu_options,
title = "Change settings (Q or Esc to go back).\n",
menu_cursor = "> ",
menu_cursor_style = ("fg_red", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = False
)
try:
while True:
selected_index = settings_menu.show()
selected_key = None
if selected_index == 3:
break
if isinstance(selected_index, int):
selected_key = settings_option[selected_index]
if selected_index == None:
break
elif selected_key in settings_option:
if self.yes_no_menu(f"Do you want to change {selected_key} from {configuration.settings[selected_key]}?"):
new_value = input(f"Please enter new value for {selected_key}: ")
configuration.settings[selected_key] = new_value
configuration.validate_paths_in_settings()
configuration.convert_str_to_int_settings()
else:
print("No changed were made.")
finally:
configuration.write_settings_file() # writing settings when exiting.
def profile_menu(self):
menu_options = ["[b] Backup profile", "[r] Restore profile", "[q] Quite"]
menu = TerminalMenu(menu_entries = menu_options,
title = f"Backup or restore the profile file, which includes complete progress (Q or Esc to go back).\n",
menu_cursor = "> ",
menu_cursor_style = ("fg_red", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = False)
menu_entry_index = menu.show()
profile_path = f"{configuration.settings["GAME_PATH"]}/ae_prof.sav"
backup_path = f"{configuration.settings["SAVE_PATH"]}/ae_prof.sav"
if menu_entry_index == 0:
u.copy_file_overwrite_if_newer(profile_path, backup_path)
elif menu_entry_index == 1:
u.copy_file_overwrite_if_newer(backup_path, profile_path)
else:
return
class SettingManager:
"""Handles program settings, reading, validating, and writing them."""
# Refactored this class with suggestions from ChatGPT for better readability (code and language) and efficiency
def __init__(self, settings_path, repo_url):
self.accepted_settings_keys = ["GAME_PATH", "SAVE_PATH", "LAST_ONLINE_VERSION", "REPO_URL", "LAST_UPDATE_CHECK", "AUTO_UPDATE_CHECK_INTERVAL_H"]
self.settings_file_path = settings_path
self.settings = self.read_settings_file()
# Initialize settings
if self.settings:
self.validate_paths_in_settings()
self.set_default_values(repo_url)
else:
self.initialize_settings(repo_url)
self.convert_str_to_int_settings()
def set_default_values(self, repo_url):
default_values = {"REPO_URL": repo_url,
"AUTO_UPDATE_CHECK_INTERVAL_H": "72"
}
for key in default_values:
if key not in self.settings:
self.settings[key] = default_values[key]
print(f"Setting default value: {default_values[key]} for: {key}.")
#time.sleep(0.5)
def convert_str_to_int_settings(self):
"""Change str to int for a given keys in the settings, if not convertable then replace with default value"""
int_values = {"AUTO_UPDATE_CHECK_INTERVAL_H": 72} # Dict for all variables which sould be intinger, and their default values. Scable for future
for key in int_values:
try:
self.settings[key] = int(self.settings[key])
except ValueError:
print(f"Value: {self.settings[key]} could not be changed to an int.")
self.settings[key] = int_values[key]
print(f"Changing {key} to default value value: {int_values[key]}\nAcknowledged...")
time.sleep(1)
def initialize_settings(self, repo_url): # URL in settings file has higher priority, is good for feature branches.
"""Initialize settings when the file doesn't exist or is empty."""
print("Initializing settings...")
self.set_default_values(repo_url)
self.get_paths()
self.write_settings_file()
def validate_paths_in_settings(self):
"""Validate and set paths from the settings file."""
required_paths = ["GAME_PATH", "SAVE_PATH"]
if all(self.settings.get(path) for path in required_paths):
valid_game_path = u.check_path_existence(self.settings["GAME_PATH"], "ae_prof.sav")
valid_save_path = u.check_path_existence(self.settings["SAVE_PATH"])
if valid_game_path and valid_save_path:
print("Paths validated successfully.")
return
print("Invalid paths detected. Requesting user input.")
self.get_paths()
self.write_settings_file()
def read_settings_file(self):
"""Read settings from the file."""
settings = {}
if os.path.exists(self.settings_file_path):
try:
with open(self.settings_file_path, "r") as file:
for line in file:
if line.startswith("#") or "=" not in line:
continue
key, value = map(str.strip, line.split("=", 1))
if value.startswith('"') and value.endswith('"'):
settings[key] = value[1:-1]
return settings
except Exception as e:
print(f"Error reading settings file: {e}")
print("Settings file does not exist or is invalid.")
return settings
def write_settings_file(self):
"""Write the settings dictionary to the file."""
print("Writing settings")
try:
with open(self.settings_file_path, "w") as file:
file.write("# This file is rewritten before program closes. Comments are ignored.\n# Setting AUTO_UPDATE_CHECK_INTERVAL_H = '0' disables automatic update checks.\n# DO NOT modify REPO_URL if you don't know what value to set, no function to fix incorrect input.\n")
for key in self.accepted_settings_keys:
if key in self.settings:
value = self.settings[key]
file.write(f'{key} = "{value}"\n')
except Exception as e:
input(f"Error writing settings file: {e}\nEnter to continue")
def get_paths(self):
"""Request and validate paths from the user."""
self.settings["GAME_PATH"] = self.ask_for_path("Game path", "ae_prof.sav")
self.settings["SAVE_PATH"] = self.ask_for_path("Save path")
def ask_for_path(self, description, required_file = None):
"""Prompt the user for a valid path."""
while True:
path = input(f"Please enter the {description}: ")
if u.check_path_existence(path, required_file):
return path
print("Invalid path. Please try again.")
class Utility():
@staticmethod
def check_path_existence(path, required_file = None):
"""Check if a path exists and optionally if it contains a specific file."""
if required_file:
full_path = os.path.join(path, required_file)
return os.path.isfile(full_path)
return os.path.isdir(path)
@staticmethod
def get_difference_in_hours(dt1, dt2):
diff = abs(dt1 - dt2)
hours_difference = int(diff.total_seconds() / 3600)
return hours_difference
@staticmethod
def yes_no(str): # improvment: have two yes_no function, should merge them
"""Ask user y/n question"""
while True:
choice = input(f"{str} (y/n): ")
if choice == "y":
return True
elif choice == "n":
return False
else:
print(f"Not a valid option, try again.")
@staticmethod
def copy_file(src, dst):
""""Simply copies a file from a to b."""
if not os.path.isfile(src):
print(f"File to copy {src} does not exist.")
return False
os.system(f"cp {src} {dst}")
print(f"Wrote: {dst}")
@staticmethod
def copy_file_overwrite_if_newer(source, destination):
# Main part arrived from opencoder ollama, modified to fit my needs
# check if source file exists
if not os.path.exists(source):
print(f"Source file {source} does not exist.")
return False
# get modification times of source and destination files
src_mtime = os.path.getmtime(source)
dest_mtime = 0
if os.path.exists(destination):
dest_mtime = os.path.getmtime(destination)
if src_mtime < dest_mtime:
print(f"Destination file is newer than the source.\n({destination} vs {source})")
# ask user permission to overwrite
if u.yes_no("Do you want to replace?"):
os.system(f"cp {source} {destination}")
print(f"File {source} has been copied to {destination}")
else:
print(f"User chose not to replace {destination}.")
else:
print(f"Source file is older than or equal to the destination.\n ({source} vs {destination})")
os.system(f"cp {source} {destination}")
print(f"File {source} has been copied to {destination}")
time.sleep(1)
return True
u = Utility()
configuration = SettingManager(settings_location, gitlab_url)
up = Updater(version, "ftl-savemanager.py")
app = BackupApp()
update_key = "[u] Update"
if "LAST_ONLINE_VERSION" in configuration.settings:
if up.compare_str_SemVer(version, configuration.settings["LAST_ONLINE_VERSION"]):
update_key += f" (v{configuration.settings["LAST_ONLINE_VERSION"]})"
main_menu_options = {
"[s] Save": app.backup,
"[l] Load": "restore_file_menu",
"[d] Delete save files": app.delete_backup_file,
f"{update_key}": up.run_update,
"[p] Profile managment": "backup and restore profile",
"[c] Change settings": "change:_settings_menu",
"[q] Quit": exit
}
title = f"ftl-savemanager v{version} (Press Q or Esc to quit).\n"
tui = TerminalUI(title, main_menu_options)
tui.show_main_menu()