491 lines
22 KiB
Python
491 lines
22 KiB
Python
import os
|
|
import time
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
version = "0.5.4"
|
|
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": "168" # checks once a week
|
|
}
|
|
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": 168} # 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()
|