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.
491 lines
22 KiB
Python
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()
|