import os import time import sys from datetime import datetime version = "0.5.3" # 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()