diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e5ce2ff --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,16 @@ +#!/bin/sh +# vim: syn=sh ts=4 sts=4 sw=4 smartindent expandtab ff=unix + +SELF="$(realpath $0)" +GITHOOKS_DIR="$(dirname "$SELF")" ; export GITHOOKS_DIR +PROJECT_ROOT="$(dirname "$(dirname "$SELF")")" ; export PROJECT_ROOT + +pre_commit_d="${GITHOOKS_DIR}/pre-commit-d" + +for i in $(ls "$pre_commit_d"); do + script="${pre_commit_d}/$i" + if [ -x "$script" ]; then + "$script" "$@" + fi +done + diff --git a/.githooks/pre-commit.d/fix_line_endings.sh b/.githooks/pre-commit.d/fix_line_endings.sh new file mode 100755 index 0000000..8b9727c --- /dev/null +++ b/.githooks/pre-commit.d/fix_line_endings.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# vim: syn=sh ts=4 sts=4 sw=4 smartindent expandtab ff=unix + +dos2unix=$(which dos2unix) +unix2dos=$(which unix2dos) + +githooks="pre-commit prepare-commit-msg commit-msg post-commit applypatch-msg pre-applypatch post-applypatch pre-rebase post-rewrite post-checkout post-merge" + +dos2unix_used=NO + +__IFS="$IFS" +IFS=$'\n' +if [ -n "$dos2unix" -a "$unix2dos" ]; then + for line in $(git status -s); do + if [[ line == A* || $line == M* ]]; then + file="${line:3}" + abspath="${PROJECT_ROOT}/$file" + + if [[ $file == *.py || $file == *.sh || file == *.rst ]]; then + $dos2unix "$abspath" + git add "$file" + dos2unix_used=YES + continue + fi + + #check if we are updating a githook + for githook in $githooks; do + if [ "$file" = ".githooks/$githook" ]; then + $dos2unix "$abspath" + git add "$file" + dos2unix_used=YES + break + fi + done + fi + + if [ "$dos2unix_used" = "YES" ]; then + continue + fi + if [[ "$file" == *.txt ]]; then + $unix2dos "$abspath" + git add "$file" + fi + done +else + echo "\"dos2unix\" and/or \"unix2dos\" not found!" >&2 +fi +IFS="$__IFS" diff --git a/.gitignore b/.gitignore index 15201ac..9a3a5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,19 @@ cython_debug/ # PyPI configuration file .pypirc + +.vscode/ +.vsccode/* + +# logfiles +*.log +*.LOG +*.[Ll][Oo][Gg] + +# editor files +*~ +*.swp +*.tmp +*.temp + +apidoc/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..02bf8f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +builbackend = 'setuptools.build_meta' +requires = ['setuptools >= 61.0'] + +[project] +dynamic = ["version"] +name = 'sgbackup' +version = '0.0.0' +requires_python = '>= 3.11' +description = 'Savegame Backup Tool' +readme = 'README.md' +license = {file = 'LICENSE'} +authors = [ + {name = 'Christian Moser', email = 'christian@mydevel.at'}, +] +dependencies = ['gi','yaml'] + +[project.scripts] +sgbackup = 'sgbackup:cli_main' +csgbackup = 'sgbackup:curses_main' + +[project.gui-scripts] +gsgbackup = 'sgbackup:gui_main' + diff --git a/sgbackup/__init__.py b/sgbackup/__init__.py new file mode 100644 index 0000000..a417072 --- /dev/null +++ b/sgbackup/__init__.py @@ -0,0 +1,40 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +import gi +gi.require_version('Gtk','4.0') + +__version__ = "0.0.1" +from .settings import settings +from . import _logging +from .main import cli_main,curses_main,gui_main +from . import game +from .command import Command +from . import commands +from . import archiver + +__ALL__ = [ + "settings" + "cli_main", + "gui_main", + "curses_main", + 'game', + "Command", + "commands", + "archiver", +] diff --git a/sgbackup/__main__.py b/sgbackup/__main__.py new file mode 100644 index 0000000..968c883 --- /dev/null +++ b/sgbackup/__main__.py @@ -0,0 +1,23 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from .main import cli_main +import sys + +if __name__ == '__main__': + sys.exit(cli_main()) diff --git a/sgbackup/_logging.py b/sgbackup/_logging.py new file mode 100644 index 0000000..0cabc60 --- /dev/null +++ b/sgbackup/_logging.py @@ -0,0 +1,28 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +import os + +import logging +import logging.config +from .settings import settings + +if os.path.isfile(settings.logger_conf): + logging.config.fileConfig(settings.logger_conf) +else: + logging.config.fileConfig(os.path.join(os.path.dirname(__file__),"logger.conf")) diff --git a/sgbackup/archiver.py b/sgbackup/archiver.py new file mode 100644 index 0000000..b44bf74 --- /dev/null +++ b/sgbackup/archiver.py @@ -0,0 +1,20 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +class Archiver: + pass \ No newline at end of file diff --git a/sgbackup/command.py b/sgbackup/command.py new file mode 100644 index 0000000..21710f5 --- /dev/null +++ b/sgbackup/command.py @@ -0,0 +1,43 @@ + +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +class Command: + def __init__(self,id:str,name:str,description:str): + self.__id = id + self.__name = name + self.__description = description + + def get_name(self): + return self.__name + + def get_id(self): + return self.__id + + def get_description(self): + return self.__description + + def get_help(self): + raise NotImplementedError("Command.get_help() is not implemented!") + + def get_synopsis(self): + raise NotImplementedError("Command.get_synopsis() is not implemented!") + + def execute(self,argv:list): + raise NotImplementedError("Command.execute is not implemented!") + diff --git a/sgbackup/commands/__init__.py b/sgbackup/commands/__init__.py new file mode 100644 index 0000000..bf0d13c --- /dev/null +++ b/sgbackup/commands/__init__.py @@ -0,0 +1,48 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +import os + +COMMANDS = {} + +_mods = ['commandbase'] + +for _f in os.listdir(os.path.dirname(__file__)): + if _f.startswith('_'): + continue + + if _f.endswith('.py') or _f.endswith('.pyc'): + if (_f.endswith('py')): + _m = _f[0:-3] + else: + _m = _f[0:-4] + + if _m not in _mods: + exec("\n".join([ + "from . import " + _m, + "_mods += _m", + "_mod = " + _m])) + if hasattr(_mod,"COMMANDS") and len(_mod.COMMANDS) > 0: + for _cmd in _mod.COMMANDS: + COMMANDS[_cmd.get_id()] = _cmd + del _cmd + +del _mods +del _f +del _m +del _mod diff --git a/sgbackup/commands/help.py b/sgbackup/commands/help.py new file mode 100644 index 0000000..1d56c5c --- /dev/null +++ b/sgbackup/commands/help.py @@ -0,0 +1,38 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from sgbackup import __version__ as VERSION, Command + +class VersionCommand(Command): + def __init__(self): + super().__init__('version', 'Version', 'Show version information.') + + def get_synopsis(self): + return 'sgbackup version' + + def get_help(self): + return super().get_help() + + def execute(self, argv): + print("sgbackup - {}".format(VERSION)) + return 0 +# VersionCommand class + +COMMANDS = [ + VersionCommand(), +] diff --git a/sgbackup/curses/__init__.py b/sgbackup/curses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sgbackup/curses/__main__.py b/sgbackup/curses/__main__.py new file mode 100644 index 0000000..e69de29 diff --git a/sgbackup/game.py b/sgbackup/game.py new file mode 100644 index 0000000..b8e9be5 --- /dev/null +++ b/sgbackup/game.py @@ -0,0 +1,1015 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from gi.repository.GObject import Property,GObject,Signal,SignalFlags +from gi.repository import GLib + +import os +import json +import re +import fnmatch + +from enum import StrEnum +import sys +import logging +import pathlib + +logger = logging.getLogger(__name__) + +from .settings import settings + +if sys.platform.lower() == "win32": + PLATFORM_WIN32 = True + import winreg +else: + PLATFORM_WIN32 = False + +if sys.platform.lower() in ['linux','freebsd','netbsd','openbsd','dragonfly']: + PLATFORM_LINUX = True +else: + PLATFORM_LINUX = False + + +class SavegameType(StrEnum): + UNSET = "unset" + OTHER = "other" + WINDOWS = "windows" + LINUX = "linux" + MACOS = "macos" + STEAM_WINDOWS = "steam_windows" + STEAM_LINUX = "steam_linux" + STEAM_MACOS = "steam_macos" + GOG_WINDOWS = "gog_windows" + GOG_LINUX = "gog_linux" + EPIC_WINDOWS = "epic_windows" + EPIC_LINUX = "epic_linux" + + @staticmethod + def from_string(typestring:str): + st=SavegameType + s=typestring.lower() + if (s == 'other'): + return st.OTHER + elif (s == 'windows'): + return st.WINDOWS + elif (s == 'linux'): + return st.LINUX + elif (s == 'macos'): + return st.MACOS + elif (s == 'steam_windows' or s == 'steamwindows' or s == 'steam.windows'): + return st.STEAM_WINDOWS + elif (s == 'steam_linux' or s == 'steamlinux' or s == 'steam.linux'): + return st.STEAM_LINUX + elif (s == 'steam_macos' or s == 'steammacos' or s == 'steam.macos'): + return st.STEAM_MACOS + elif (s == 'gog_winows' or s == 'gogwindows' or s == 'gog.windows'): + return st.GOG_WINDOWS + elif (s == 'gog_linux' or s == 'goglinux' or s == 'gog.linux'): + return st.GOG_LINUX + elif (s == 'epic_windows' or s == 'epicwindows' or s == 'epic.windows'): + return st.EPIC_WINDOWS + elif (s == 'epic_linux' or s == 'epiclinux' or s == 'epic.linux'): + return st.EPIC_LINUX + + return st.UNSET + + +class GameFileType(StrEnum): + GLOB = "glob" + REGEX = "regex" + FILENAME = "filename" + + @staticmethod + def from_string(typestring:str): + s = typestring.lower() + if (s == 'glob'): + return GameFileType.GLOB + elif (s == 'regex'): + return GameFileType.REGEX + elif (s == 'filename'): + return GameFileType.FILENAME + + raise ValueError("Unknown GameFileType \"{}\"!".fomrat(typestring)) + +class GameFileMatcher(GObject): + def __init__(self,match_type:GameFileType,match_file:str): + GObject.__init__(self) + self.match_type = type + self.match_file = match_file + + @Property + def match_type(self)->GameFileType: + return self.__match_type + @match_type.setter + def match_type(self,type:GameFileType): + if not isinstance(type,GameFileType): + raise TypeError("match_type is not a GameFileType instance!") + self.__match_type = type + + @Property(type=str) + def match_file(self)->str: + return self.__match_file + @match_file.setter + def match_file(self,file:str): + self.__match_file = file + + ## @} + + def match(self,rel_filename:str): + def match_glob(filename): + return fnmatch.fnmatch(filename,self.match_file) + # match_glob() + + def match_filename(filename): + if (PLATFORM_WIN32): + fn = filename.replace("/","\\") + if (self.match_file.endswith("\\")): + if fn == self.match_file[:-1] or fn.startswith(self.match_file): + return True + elif fn == self.match_file: + return True + else: + if (self.match_file.endswith('/')): + if fn == self.match_file[:-1] or fn.startswith(self.match_file): + return True + elif fn == self.match_file: + return True + return False + # match_filename() + + def match_regex(filename): + return (re.search(self.match_file,filename) is not None) + # match_filename() + + if (self.match_type == GameFileType.FILENAME): + return match_filename(rel_filename) + elif (self.match_type == GameFileType.GLOB): + return match_glob(rel_filename) + elif (self.match_type == GameFileType.REGEX): + return match_regex(rel_filename) + return False + +class GameData(GObject): + """ + :class: GameData + :brief: Base class for platform specific data. + """ + def __init__(self, + savegame_type:SavegameType, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + GObject.__init__(self) + self.__savegame_type = savegame_type + self.__savegame_root = savegame_root + self.__savegame_dir = savegame_dir + self.__variables = {} + self.__filematch = [] + self.__ignorematch = [] + + if variables is not None: + variables.update(variables) + + if file_match is not None: + for fm in file_match: + self.add_file_match(fm) + + if ignore_match is not None: + for fm in ignore_match: + self.add_ignore_match(fm) + + @Property + def savegame_type(self)->SavegameType: + """ + :attr: savegame_type + :brief: Type of the class. + """ + return self.__savegame_type + + @Property(type=str) + def savegame_root(self)->str: + """ + :attr: savegame_root + """ + return self.__savegame_root + + @savegame_root.setter + def savegame_root(self,sgroot:str): + self.__savegame_root = sgroot + + @Property + def savegame_dir(self)->str: + """ + :attr: savegame_dir + """ + return self.__savegame_dir + + @savegame_dir.setter + def savegame_dir(self,sgdir:str): + self.__savegame_dir = sgdir + + @Property + def variables(self): + return self.__variables + + @Property + def file_match(self): + return self.__filematch + + @Property + def ignore_match(self): + return self.__ignorematch + + def has_variable(self,name:str)->bool: + return (name in self.__variables) + + def get_variable(self,name:str)->str: + if name not in self.__variables: + return "" + return self.__variables[name] + + def set_variable(self,name:str,value:str): + self.__variables[name] = value + + def delete_variable(self,name:str): + if name in self.__variables: + del self.__variables[name] + + def get_variables(self): + return self.variables + + def match_file(self,rel_filename:str): + if not self.__filematch: + return True + + for fm in self.__filematch: + if fm.match(rel_filename): + return True + return False + + + def match_ignore(self,rel_filename:str): + if not self.__ignorematch: + return False + + for fm in self.__ignorematch: + if fm.match(rel_filename): + return True + return False + + def match(self,rel_filename:str): + if self.match_file(rel_filename) and not self.match_ignore(rel_filename): + return True + return False + + def add_file_match(self,matcher:GameFileMatcher): + if not isinstance(matcher,GameFileMatcher): + raise TypeError("matcher is not a \"GameFileMatcher\" instance!") + self.__filematch.append(matcher) + + def remove_file_match(self,matcher:GameFileMatcher): + for i in reversed(range(len(self.__filematch))): + if (matcher == self.__filematch[i]): + del self.__filematch[i] + + def add_ignore_match(self,matcher:GameFileMatcher): + if not isinstance(matcher,GameFileMatcher): + raise TypeError("matcher is not a \"GameFileMatcher\" instance!") + self.__ignorematch.append(matcher) + + def remove_ignore_match(self,matcher:GameFileMatcher): + for i in reversed(range(len(self.__ignorematch))): + if (matcher == self.__ignorematch[i]): + del self.__ignorematch[i] + + def serialize(self)->dict: + ret = { + 'savegame_root': self.savegame_root, + 'savegame_dir': self.savegame_dir, + } + if (self.__variables): + ret['variables'] = self.variables + if (self.file_match): + fm = [] + for matcher in self.file_match: + fm.append({'type':matcher.match_type.value,'match':matcher.match_file}) + ret['file_match'] = fm + + if (self.add_ignore_match): + im = [] + for matcher in self.ignore_match: + im.append({'type':matcher.match_type.value,'match':matcher.match_file}) + ret['ignore_match'] = im + + return ret + +class WindowsGame(GameData): + def __init__(self, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + installdir:str|None=None, + game_registry_keys:str|list|None=None, + installdir_registry_keys:str|list|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + + GameData.__init__(self, + SavegameType.WINDOWS, + savegame_root, + savegame_dir, + variables, + file_match, + ignore_match) + if not installdir: + self.__installdir = None + else: + self.__installdir = installdir + + if not game_registry_keys: + self.__game_registry_keys = [] + elif isinstance(game_registry_keys,str): + self.__game_registry_keys = [game_registry_keys] + else: + self.__game_registry_keys = list(game_registry_keys) + + if not installdir_registry_keys: + self.__installdir_registry_keys = [] + elif isinstance(installdir_registry_keys,str): + self.__installdir_registry_keys = [installdir_registry_keys] + else: + self.__installdir_registry_keys = list(installdir_registry_keys) + + def __get_hkey(self,regkey:str): + regvec = regkey.split("\\") + hkstr = regvec[0] + if (hkstr == 'HKLM' or hkstr == 'HKEY_LOCAL_MACHINE'): + return winreg.HKEY_LOCAL_MACHINE + elif (hkstr == 'HKCU' or hkstr == 'HKEY_CURRENT_USER'): + return winreg.HKEY_CURRENT_USER + elif (hkstr == 'HKCC' or hkstr == 'HKEY_CURRENT_CONFIG'): + return winreg.HKEY_CURRENT_CONFIG + elif (hkstr == 'HKCR' or hkstr == 'HKEY_CLASSES_ROOT'): + return winreg.HKEY_CLASSES_ROOT + elif (hkstr == 'HKU' or hkstr == 'HKEY_USERS'): + return winreg.HKEY_USERS + return None + + @Property + def installdir(self)->str|None: + return self.__installdir + + @installdir.setter + def installdir(self,installdir:str|None): + self.__installdir = installdir + + @Property + def game_registry_keys(self)->list: + return self.__game_registry_keys + + @Property + def installdir_registry_keys(self)->list: + return self.__installdir_registry_keys + + @Property + def is_installed(self)->bool|None: + if not PLATFORM_WIN32 or not self.game_registry_keys: + return None + for regkey in self.__game_registry_keys: + hkey = self.__get_hkey(regkey) + regvec = regkey.split('\\') + if (regvec > 1): + key = '\\'.join(regvec[1:]) + try: + rkey = winreg.OpenKeyEx(hkey,key) + winreg.CloseKey(rkey) + return True + except OSError as ex: + continue + + return False + + @Property + def registry_installdir(self)->str|None: + if not PLATFORM_WIN32 or not (self.installdir_registry_keys): + return None + for regkey in self.__game_registry_keys: + hkey = self.__get_hkey(regkey) + regvec = regkey.split('\\') + if (regvec > 2): + key = '\\'.join(regvec[1:-1]) + try: + rkey = None + rkey = winreg.OpenKeyEx(hkey,key) + retval = winreg.QueryValue(rkey,regvec[-1]) + winreg.CloseKey(rkey) + if retval: + return str(retval[0]) + except OSError as ex: + if (rkey): + winreg.CloseKey(rkey) + continue + return None + + def get_variables(self): + variables = super().get_variables() + variables["INSTALLDIR"] = self.installdir if self.installdir else "" + + def serialize(self): + ret = super().serialize() + if (self.installdir): + ret['installdir'] = self.installdir + if (self.game_registry_keys): + ret['game_registry_keys'] = self.game_registry_keys + if (self.installdir_registry_keys): + ret['installdir_registry_keys'] = self.installdir_registry_keys + return ret + +class LinuxGame(GameData): + def __init__(self, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + binary:str|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + GameData.__init__(self, + SavegameType.LINUX, + savegame_root, + savegame_dir, + variables, + file_match, + ignore_match) + self.__binary = binary + + @Property + def binary(self)->str|None: + return self.__binary + @binary.setter + def binary(self,bin:str): + self.__binary = bin + + @Property + def is_installed(self)->bool|None: + if PLATFORM_LINUX and self.binary: + return bool(GLib.find_program_in_path(self.binary)) + else: + return None + + def serialize(self): + ret = super().serialize() + if self.binary: + ret['binary'] = self.binary + return ret + + +class MacOSGame(GameData): + def __init__(self, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + binary:str|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + GameData.__init__(self, + SavegameType.MACOS, + savegame_root, + savegame_dir, + variables, + file_match, + ignore_match) + self.__binary = binary + + @Property + def binary(self)->str|None: + return self.__binary + @binary.setter + def binary(self,bin:str): + self.__binary = bin + + @Property + def is_installed(self)->bool|None: + if PLATFORM_LINUX and self.binary: + return bool(GLib.find_program_in_path(self.binary)) + else: + return None + + def serialize(self): + ret = super().serialize() + if self.binary: + ret['binary'] = self.binary + return ret + + +class SteamGame(GameData): + def __init__(self, + sgtype:SavegameType, + appid:int, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + installdir:str|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + if sgtype not in (SavegameType.STEAM_WINDOWS, + SavegameType.STEAM_LINUX, + SavegameType.STEAM_MACOS): + raise TypeError("SaveGameType") + + GameData.__init__(self, + sgtype, + appid, + savegame_root, + savegame_dir, + variables, + file_match, + ignore_match) + self.__installdir = installdir + + def get_variables(self): + vars = super().get_variables() + vars["INSTALLDIR"] = self.installdir if self.installdir else "" + + @Property + def installdir(self): + return self.__installdir + @installdir.setter + def installdir(self,installdir:str|None): + self.__installdir = installdir + + def serialize(self): + ret = super().serialize() + ret['appid'] = self.appid + + if self.installdir: + ret['installdir'] = self.installdir + + return ret + +class SteamWindowsGame(SteamGame): + def __init__(self, + appid:int, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + installdir:str|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + SteamGame.__init__(self, + SavegameType.STEAM_WINDOWS, + appid, + savegame_root, + savegame_dir, + variables, + installdir, + file_match, + ignore_match) + +class SteamLinuxGame(SteamGame): + def __init__(self, + appid:int, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + installdir:str|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + SteamGame.__init__(self, + SavegameType.STEAM_LINUX, + appid, + savegame_root, + savegame_dir, + variables, + installdir, + file_match, + ignore_match) + +class SteamMacOSGame(SteamGame): + def __init__(self, + appid:int, + savegame_root:str, + savegame_dir:str, + variables:dict|None=None, + installdir:str|None=None, + file_match:list|None=None, + ignore_match:list|None=None): + SteamGame.__init__(self, + SavegameType.STEAM_MACOS, + appid, + savegame_root, + savegame_dir, + variables, + installdir, + file_match, + ignore_match) + + +class Game(GObject): + @staticmethod + def new_from_dict(config:str): + logger = logger.getChild("Game.new_from_dict()") + + def get_file_match(conf:dict): + conf_fm = conf['file_match'] if 'file_match' in conf else None + conf_im = conf['ignore_match'] if 'ignore_match' in conf else None + + if (conf_fm): + file_match = [] + for cfm in conf_fm: + if ('type' in cfm and 'match' in cfm): + try: + file_match.append(GameFileMatcher(GameFileType.from_string(cfm['type'],cfm['match']))) + except Exception as ex: + logger.error("Adding GameFileMatcher to file_match failed! ({})!".format(ex)) + else: + logger.error("Illegal file_match settings! (\"type\" or \"match\" missing!)") + + else: + file_match = None + + if (conf_im): + ignore_match = [] + for cim in conf_im: + if ('type' in cim and 'match' in cim): + try: + file_match.append(GameFileMatcher(GameFileType.from_string(cim['type'],cim['match']))) + except Exception as ex: + logger.error("Adding GameFileMatcher to ignore_match failed! ({})!".format(ex)) + else: + logger.error("Illegal ignore_match settings! (\"type\" or \"match\" missing!)") + else: + ignore_match = None + + return (file_match,ignore_match) + + def new_steam_game(conf,cls:SteamGame): + appid = conf['appid'] if 'appid' in conf else None + sgroot = conf['savegame_root'] if 'savegame_root' in conf else None + sgdir = conf['savegame_dir'] if 'savegame_dir' in conf else None + vars = conf['variables'] if 'variables' in conf else None + installdir = conf['installdir'] if 'installdir' in conf else None + file_match,ignore_match = get_file_match(conf) + + if appid is not None and sgroot and sgdir: + cls(appid,sgroot,sgdir,vars,installdir,file_match,ignore_match) + return None + # new_steam_game() + + if not 'id' in config or not 'name' in config: + return None + + id = config['id'] + name = config['name'] + sgname = config['savegame_name'] if 'savegame_name' in config else id + sgtype = config['savegame_type'] if 'savegame_type' in config else SavegameType.UNSET + + game = Game(id,name,sgname) + game.savegame_type = sgtype + game.is_active = config['is_active'] if 'is_active' in config else False + game.is_live = config['is_live'] if 'is_live' in config else True + + if 'windows' in config: + winconf = config['windows'] + sgroot = winconf['savegame_root'] if 'savegame_root' in winconf else None + sgdir = winconf['savegame_dir'] if 'savegame_dir' in winconf else None + vars = winconf['variables'] if 'variables' in winconf else None + installdir = winconf['installdir'] if 'installdir' in winconf else None + game_regkeys = winconf['game_registry_keys'] if 'game_registry_keys' in winconf else None + installdir_regkeys = winconf['installdir_registry_keys'] if 'installdir_registry_keys' in winconf else None + file_match,ignore_match = get_file_match(winconf) + if (sgroot and sgdir): + game.windows = WindowsGame(sgroot, + sgdir, + vars, + installdir, + game_regkeys, + installdir_regkeys, + file_match, + ignore_match) + + if 'linux' in config: + linconf = config['linux'] + sgroot = linconf['savegame_root'] if 'savegame_root' in linconf else None + sgdir = linconf['savegame_dir'] if 'savegame_dir' in linconf else None + vars = linconf['variables'] if 'variables' in linconf else None + binary = linconf['binary'] if 'binary' in linconf else None + file_match,ignore_match = get_file_match(linconf) + if (sgroot and sgdir): + game.linux = LinuxGame(sgroot,sgdir,vars,binary,file_match,ignore_match) + + if 'macos' in config: + macconf = config['macos'] + sgroot = macconf['savegame_root'] if 'savegame_root' in macconf else None + sgdir = macconf['savegame_dir'] if 'savegame_dir' in macconf else None + vars = macconf['variables'] if 'variables' in macconf else None + binary = macconf['binary'] if 'binary' in macconf else None + file_match,ignore_match = get_file_match(macconf) + if (sgroot and sgdir): + game.macos = MacOSGame(sgroot,sgdir,vars,binary,file_match,ignore_match) + + if 'steam_windows' in config: + game.steam_windows = new_steam_game(config['steam_windows'],SteamWindowsGame) + if 'steam_linux' in config: + game.steam_linux = new_steam_game(config['steam_linux'],SteamLinuxGame) + if 'steam_macos' in config: + game.steam_macos = new_steam_game(config['steam_macos'],SteamMacOSGame) + + return game + + @staticmethod + def new_from_json_file(filename:str): + if not os.path.isfile(filename): + raise FileNotFoundError("Filename \"{filename}\" not found!".format(filename=filename)) + with open(filename,'rt',encoding="UTF-8") as ifile: + return Game.new_from_dict(json.loads(ifile.read())) + + def __init__(self,id:str,name:str,savegame_name:str): + GObject.__init__(self) + self.__id = id + self.__name = name + self.__filename = None + self.__savegame_name = savegame_name + self.__savegame_type = SavegameType.UNSET + self.__active = False + self.__live = True + + self.__windows = None + self.__linux = None + self.__macos = None + self.__steam_windows = None + self.__steam_linux = None + self.__steam_macos = None + self.__gog_windows = None + self.__gog_linux = None + self.__epic_windows = None + self.__epic_linux = None + + @Property(type=str) + def id(self)->str: + return self.__id + @id.setter + def id(self,id:str): + self.__id = id + + + @Property(type=str) + def name(self)->str: + return self.__name + @name.setter + def name(self,name:str): + self.__name = name + + @Property(type=str) + def savegame_name(self)->str: + return self.__savegame_name + @savegame_name.setter + def savegame_name(self,sgname:str): + self.__savegame_name = sgname + + @Property + def savegame_type(self)->SavegameType: + return self.__savegame_type + @savegame_type.setter + def savegame_type(self,sgtype:SavegameType): + self.__savegame_type = sgtype + + @Property(type=bool,default=False) + def is_active(self)->bool: + return self.__active + @is_active.setter + def is_active(self,active:bool): + self.__active = bool(active) + + @Property(type=bool,default=True) + def is_live(self)->bool: + return self.__live + @is_live.setter + def is_live(self,live:bool): + self.__live = bool(live) + + @Property + def filename(self)->str|None: + if not self.__id: + return None + + if not self.__filename: + GLib.build_filename(settings.gameconf_dir,'.'.join((self.id,'gameconf'))) + + return self.__filename + @filename.setter + def filename(self,fn:str): + if not os.path.isabs(fn): + self.__filename = GLib.build_filename(settings.gameconf_dir,fn) + else: + self.__filename = fn + + @Property + def game_data(self): + sgtype = self.savegame_type + if (sgtype == SavegameType.WINDOWS): + return self.windows + elif (sgtype == SavegameType.LINUX): + return self.linux + elif (sgtype == SavegameType.MACOS): + return self.macos + elif (sgtype == SavegameType.STEAM_WINDOWS): + return self.steam_windows + elif (sgtype == SavegameType.STEAM_LINUX): + return self.steam_linux + elif (sgtype == SavegameType.STEAM_MACOS): + return self.steam_macos + elif (sgtype == SavegameType.GOG_WINDOWS): + return self.__gog_windows + elif (sgtype == SavegameType.GOG_LINUX): + return self.__gog_linux + elif (sgtype == SavegameType.EPIC_WINDOWS): + return self.__epic_windows + elif (sgtype == SavegameType.EPIC_LINUX): + return self.__epic_linux + return None + + @Property + def windows(self)->WindowsGame|None: + return self.__windows + @windows.setter + def windows(self,data:WindowsGame|None): + if not data: + self.__windows = None + else: + if not isinstance(data,WindowsGame): + raise TypeError("WindowsGame") + self.__windows = data + + @Property + def linux(self)->LinuxGame|None: + return self.__linux + @linux.setter + def linux(self,data:LinuxGame): + if not data: + self.__linux = None + else: + if not isinstance(data,LinuxGame): + raise TypeError("LinuxGame") + self.__linux = data + + @Property + def macos(self)->MacOSGame|None: + return self.__macos + @macos.setter + def macos(self,data:MacOSGame|None): + if not data: + self.__macos = None + else: + if not isinstance(data,MacOSGame): + raise TypeError("MacOSGame") + self.__macos = data + + @Property + def steam_windows(self)->SteamWindowsGame|None: + return self.__steam_windows + @steam_windows.setter + def steam_windows(self,data:SteamWindowsGame|None): + if not data: + self.__steam_windows = None + else: + if not isinstance(data,SteamWindowsGame): + raise TypeError("SteamWindowsGame") + self.__steam_windows = data + + @Property + def steam_linux(self)->SteamLinuxGame|None: + return self.__steam_linux + @steam_linux.setter + def steam_windows(self,data:SteamLinuxGame|None): + if not data: + self.__steam_linux = None + else: + if not isinstance(data,SteamLinuxGame): + raise TypeError("SteamWindowsGame") + self.__steam_linux = data + + @Property + def steam_macos(self)->SteamMacOSGame|None: + return self.__steam_macos + @steam_macos.setter + def steam_macos(self,data:SteamMacOSGame|None): + if not data: + self.__steam_macos = None + else: + if not isinstance(data,SteamMacOSGame): + raise TypeError("SteamWindowsGame") + self.__steam_macos = data + + def serialize(self)->dict: + ret = { + 'id': self.id, + 'name': self.name, + 'savegame_name': self.savegame_name, + 'savegame_type': self.savegame_type.value, + 'is_active': self.is_active, + 'is_live': self.is_live, + } + + if (self.windows): + ret['windows'] = self.windows.serialize() + if (self.linux): + ret['linux'] = self.linux.serialize() + if (self.macos): + ret['macos'] = self.macos.serialize() + if (self.steam_windows): + ret['steam_windows'] = self.steam_windows.serialize() + if (self.steam_linux): + ret['steam_linux'] = self.steam_linux.serialize() + if (self.steam_macos): + ret['steam_macos'] = self.steam_macos.serialize() + #if self.gog_windows: + # ret['gog_windows'] = self.gog_windows.serialize() + #if self.gog_linux: + # ret['gog_linux'] = self.gog_linux.serialize() + #if self.epic_windows: + # ret['epic_windows'] = self.epic_windows.serialize() + #if self.epic_linux: + # ret['epic_linux'] = self.epic_linux.serialize() + + return ret + + def save(self): + data = self.serialize() + old_path = pathlib.Path(self.filename).resolve() + new_path = pathlib.Path(settings.gameconf_dir / '.'.join(self.id,'gameconf')).resolve() + if (str(old_path) != str(new_path)) and old_path.is_file(): + os.unlink(old_path) + if not new_path.parent.is_dir(): + os.makedirs(new_path.parent) + + with open(new_path,'wt',encoding='UTF-8') as ofile: + ofile.write(json.dumps(self.serialize(),ensure_ascii=False,indent=4)) + +GAMES={} +STEAM_GAMES={} +STEAM_LINUX_GAMES={} +STEAM_WINDOWS_GAMES={} +STEAM_MACOS_GAMES={} + +def __init_games(): + gameconf_dir = settings.gameconf_dir + if not os.path.isdir(gameconf_dir): + return + + for gcf in (os.path.join(gameconf_dir,i) for i in os.path.listdir(gameconf_dir)): + if not os.path.isfile(gcf) or not gcf.endswith('.gameconf'): + continue + + try: + game = Game.new_from_json_file(gcf) + if not game: + continue + except: + continue + + GAMES[game.id] = game + if (game.steam_windows): + if not game.steam_windows.appid in STEAM_GAMES: + STEAM_GAMES[game.steam_windows.appid] = game + STEAM_WINDOWS_GAMES[game.steam_windows.appid] = game + if (game.steam_linux): + if not game.steam_linux.appid in STEAM_GAMES: + STEAM_GAMES[game.steam_linux.appid] = game + STEAM_LINUX_GAMES[game.steam_linux.appid] = game + if (game.steam_macos): + if not game.steam_macos.appid in STEAM_GAMES: + STEAM_GAMES[game.steam_macos.appid] = game + STEAM_MACOS_GAMES[game.steam_macos.appid] = game +__init_games() + +def add_game(game:Game): + GAMES[game.id] = game + if game.steam_windows: + if not game.steam_windows.appid in STEAM_GAMES: + STEAM_GAMES[game.steam_windows.appid] = game + STEAM_WINDOWS_GAMES[game.steam_windows.appid] = game + if (game.steam_linux): + if not game.steam_linux.appid in STEAM_GAMES: + STEAM_GAMES[game.steam_linux.appid] = game + STEAM_LINUX_GAMES[game.steam_linux.appid] = game + if (game.steam_macos): + if not game.steam_macos.appid in STEAM_GAMES: + STEAM_GAMES[game.steam_macos.appid] = game + STEAM_MACOS_GAMES[game.steam_macos.appid] = game \ No newline at end of file diff --git a/sgbackup/gui/__init__.py b/sgbackup/gui/__init__.py new file mode 100644 index 0000000..9049356 --- /dev/null +++ b/sgbackup/gui/__init__.py @@ -0,0 +1,18 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### +from .application import Application diff --git a/sgbackup/gui/__main__.py b/sgbackup/gui/__main__.py new file mode 100644 index 0000000..d96b0f8 --- /dev/null +++ b/sgbackup/gui/__main__.py @@ -0,0 +1,24 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +import sys +from ..main import gui_main + +if __name__ == '__main__': + sys.exit(gui_main()) + \ No newline at end of file diff --git a/sgbackup/gui/application.py b/sgbackup/gui/application.py new file mode 100644 index 0000000..26075df --- /dev/null +++ b/sgbackup/gui/application.py @@ -0,0 +1,83 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from gi.repository import Gtk,GObject,Gio +from .appwindow import AppWindow + +import logging; logger=logging.getLogger(__name__) + +class Application(Gtk.Application): + def __init__(self,*args,**kwargs): + AppFlags = Gio.ApplicationFlags + kwargs['application_id'] = 'org.sgbackup.sgbackup' + kwargs['flags'] = AppFlags.FLAGS_NONE + Gtk.Application.__init__(self,*args,**kwargs) + + self.__logger = logger.getChild('Application') + self.__builder = None + self.__appwindow = None + + @property + def _logger(self): + return self.__logger + + @GObject.Property + def appwindow(self): + return self.__appwindow + + def do_startup(self): + self._logger.debug('do_startup()') + if not self.__builder: + self.__builder = Gtk.Builder.new() + + Gtk.Application.do_startup(self) + + action_about = Gio.SimpleAction.new('about',None) + action_about.connect('activate',self.on_action_about) + self.add_action(action_about) + + action_quit = Gio.SimpleAction.new('quit',None) + action_quit.connect('activate',self.on_action_quit) + self.add_action(action_quit) + + action_settings = Gio.SimpleAction.new('settings',None) + action_settings.connect('activate',self.on_action_settings) + self.add_action(action_settings) + + # add accels + self.set_accels_for_action('app.quit',["q"]) + + @GObject.Property + def builder(self): + return self.__builder + + def do_activate(self): + self._logger.debug('do_activate()') + if not (self.__appwindow): + self.__appwindow = AppWindow(application=self) + + self.appwindow.present() + + def on_action_about(self,action,param): + pass + + def on_action_settings(self,action,param): + pass + + def on_action_quit(self,action,param): + self.quit() \ No newline at end of file diff --git a/sgbackup/gui/appmenu.ui b/sgbackup/gui/appmenu.ui new file mode 100644 index 0000000..acb558e --- /dev/null +++ b/sgbackup/gui/appmenu.ui @@ -0,0 +1,32 @@ + + + +
+ + _Settings + app.settings + +
+ +
+ + _Help +
+ +
+ + _About SGBackup + app.about + +
+
+
+ +
+ + _Quit + app.quit + +
+
+
\ No newline at end of file diff --git a/sgbackup/gui/appwindow.py b/sgbackup/gui/appwindow.py new file mode 100644 index 0000000..cec6ced --- /dev/null +++ b/sgbackup/gui/appwindow.py @@ -0,0 +1,86 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from gi.repository import Gtk,Gio,GObject + +import os +from .gameview import GameView +from .backupview import BackupView + +class AppWindow(Gtk.ApplicationWindow): + def __init__(self,application=None,**kwargs): + kwargs['title'] = "SGBackup" + + if (application is not None): + kwargs['application']=application + if (hasattr(application,'builder')): + builder = application.builder + else: + builder = Gtk.Builder.new() + + Gtk.ApplicationWindow.__init__(self,**kwargs) + self.set_default_size(800,600) + + self.__builder = builder + self.builder.add_from_file(os.path.join(os.path.dirname(__file__),'appmenu.ui')) + gmenu = self.builder.get_object('appmenu') + appmenu_popover = Gtk.PopoverMenu.new_from_model(gmenu) + image = Gtk.Image.new_from_icon_name('open-menu-symbolic') + menubutton = Gtk.MenuButton.new() + menubutton.set_popover(appmenu_popover) + menubutton.set_child(image) + headerbar = Gtk.HeaderBar.new() + headerbar.pack_start(menubutton) + self.set_titlebar(headerbar) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.__vpaned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) + self.__vpaned.set_hexpand(True) + self.__vpaned.set_vexpand(True) + self.__vpaned.set_wide_handle(True) + self.__gameview = GameView() + self.__vpaned.set_start_child(self.gameview) + self.__backupview = BackupView(self.gameview) + self.__vpaned.set_end_child(self.backupview) + self.__vpaned.set_resize_start_child(True) + self.__vpaned.set_resize_end_child(True) + + vbox.append(self.__vpaned) + + statusbar = Gtk.Statusbar() + statusbar.set_hexpand(True) + statusbar.set_vexpand(False) + statusbar.push(0,'Running ...') + vbox.append(statusbar) + + self.set_child(vbox) + + @GObject.Property + def builder(self): + return self.__builder + + @GObject.Property + def backupview(self): + return self.__backupview + + @GObject.Property + def gameview(self): + return self.__gameview + + \ No newline at end of file diff --git a/sgbackup/gui/backupview.py b/sgbackup/gui/backupview.py new file mode 100644 index 0000000..1afec6d --- /dev/null +++ b/sgbackup/gui/backupview.py @@ -0,0 +1,30 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from gi.repository import Gtk,Gio,GObject + +from .gameview import GameView + +class BackupView(Gtk.ScrolledWindow): + def __init__(self,gameview:GameView): + Gtk.ScrolledWindow.__init__(self) + self.__gameview = GameView + + @GObject.Property + def gameview(self): + return self.__gameview diff --git a/sgbackup/gui/gameview.py b/sgbackup/gui/gameview.py new file mode 100644 index 0000000..1f4b623 --- /dev/null +++ b/sgbackup/gui/gameview.py @@ -0,0 +1,24 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from gi.repository import Gtk,Gio,GObject + +class GameView(Gtk.ScrolledWindow): + def __init__(self): + Gtk.ScrolledWindow.__init__(self) + \ No newline at end of file diff --git a/sgbackup/gui/settingsdialog.py b/sgbackup/gui/settingsdialog.py new file mode 100644 index 0000000..35212c4 --- /dev/null +++ b/sgbackup/gui/settingsdialog.py @@ -0,0 +1,17 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### diff --git a/sgbackup/logger.conf b/sgbackup/logger.conf new file mode 100644 index 0000000..6c7d75f --- /dev/null +++ b/sgbackup/logger.conf @@ -0,0 +1,40 @@ +[loggers] +keys=root,console,file + +[handlers] +keys=consoleHandler,fileHandler + +[formatters] +keys=consoleFormatter,fileFormatter + +[logger_root] +level=DEBUG +handlers=consoleHandler,fileHandler + +[logger_console] +level=DEBUG +handlers=consoleHandler +propagate=0 +qualname=console + +[logger_file] +level=DEBUG +handlers=fileHandler +propagate=0 +qualname=file + +[handler_consoleHandler] +class=StreamHandler +formatter=consoleFormatter +level=DEBUG + +[handler_fileHandler] +class=handlers.RotatingFileHandler +args=('sgbackup.log','a',10485760,10,'UTF-8',False,) +formatter=fileFormatter + +[formatter_consoleFormatter] +format=[%(levelname)s:%(name)s] %(message)s + +[formatter_fileFormatter] +format=[%(asctime)s-%(levelname)s:%(name)s] %(message)s diff --git a/sgbackup/logger.yaml b/sgbackup/logger.yaml new file mode 100644 index 0000000..8892f04 --- /dev/null +++ b/sgbackup/logger.yaml @@ -0,0 +1,20 @@ +version: 1 +formatters: + console: + format: '[%(levelname)s:%(name)s] %(message)s' + file: + format: '[%(asctime)s - %(levelname)s:%(name)s] %(message)' +handlers: + console: + class: logging.StreamHandler + level: DEBUG + formatter: console + stream: 'ext://sys.stdout' +loggers: + console: + handlers: [console] + level: DEBUG + propagate: no +root: + level: DEBUG + handlers: [console] \ No newline at end of file diff --git a/sgbackup/main.py b/sgbackup/main.py new file mode 100644 index 0000000..50e2d0c --- /dev/null +++ b/sgbackup/main.py @@ -0,0 +1,42 @@ +#enconding: utf-8 +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +import logging +from . import gui +from .gui.application import Application +from .steam import SteamLibrary +import sys + +logger=logging.getLogger(__name__) + + +def cli_main(): + logger.debug("Running cli_main()") + return 0 + +def curses_main(): + logger.debug("Running curses_main()") + return 0 + + +def gui_main(): + logger.debug("Running gui_main()") + gui.app = Application() + gui.app.run() + return 0 \ No newline at end of file diff --git a/sgbackup/settings.py b/sgbackup/settings.py new file mode 100644 index 0000000..f249405 --- /dev/null +++ b/sgbackup/settings.py @@ -0,0 +1,85 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +from configparser import ConfigParser +import os +import sys + +from gi.repository import GLib,GObject + +class Settings(GObject.GObject): + def __init__(self): + super().__init__() + + self.__configparser = ConfigParser() + self.__config_dir = os.path.join(GLib.get_user_config_dir(),'sgbackup') + self.__gameconf_dir = os.path.join(self.__config_dir,'games') + self.__logger_conf = os.path.join(self.__config_dir,'logger.conf') + + self.__config_file = os.path.join(self.__config_dir,'sgbackup.conf') + if (os.path.isfile(self.__config_file)): + with open(self.__config_file,'r') as conf: + self.__configparser.read_file(conf) + + @GObject.Property(nick="parser") + def parser(self)->ConfigParser: + return self.__configparser + + @GObject.Property(type=str,nick="config-dir") + def config_dir(self)->str: + return self.__config_dir + + @GObject.Property(type=str,nick="config-file") + def config_file(self)->str: + return self.__config_file + + @GObject.Property(type=str,nick="gameconf-dir") + def gameconf_dir(self)->str: + return self.__gameconf_dir + + @GObject.Property(type=str,nick="logger-conf") + def logger_conf(self)->str: + return self.__logger_conf + + + @GObject.Property(type=str,nick="backup-dir") + def backup_dir(self)->str: + if self.parser.has_option('sgbackup','backupDirectory'): + return self.parser.get('sgbackup','backupDirectory') + return GLib.build_filename(GLib.build_filename(GLib.get_home_dir(),'SavagameBackups')) + @backup_dir.setter + def backup_dir(self,directory:str): + if not os.path.isabs(directory): + raise ValueError("\"backup_dir\" needs to be an absolute path!") + return self.parser.set('sgbackup','backupDirectory',directory) + + @GObject.Property(type=str) + def loglevel(self)->str: + if self.parser.has_option('sgbackup','logLevel'): + return self.parser.get('sgbackup','logLevel') + return "INFO" + + def save(self): + self.emit('save') + + @GObject.Signal(name='save',flags=GObject.SIGNAL_RUN_LAST,return_type=None,arg_types=()) + def do_save(self): + with open(self.config_file,'w') as ofile: + self.__configparser.write(ofile) + +settings = Settings() diff --git a/sgbackup/steam.py b/sgbackup/steam.py new file mode 100644 index 0000000..2161f9d --- /dev/null +++ b/sgbackup/steam.py @@ -0,0 +1,296 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 Christian Moser # +# # +# 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 3 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, see . # +############################################################################### + +import os +import re +from pathlib import Path +import sys +import json + +from .settings import settings + +PLATFORM_WINDOWS = (sys.platform.lower() == 'win32') + + +from gi.repository.GObject import GObject,Property,Signal + + +class AcfFileParser(object): + """ + Parses steam acf files to a dict. + """ + def __init__(self): + pass + + def __parse_section(self,lines:list): + option_pattern = re.compile("^\"(.+)\"([ \\t]+)\"(.*)\"$") + section_pattern = re.compile("^\"(.+)\"") + line_count = 0 + ret = {} + + line_count=0 + while line_count < len(lines): + line = lines[line_count] + line_count+=1 + + if line == '}': + break + + s=line.strip() + match = option_pattern.fullmatch(line) + if match: + name=line[match.start(1):match.end(1)] + value=line[match.start(3):match.end(3)] + ret[name] = value + else: + match2 = section_pattern.fullmatch(line) + if match2: + name=line[match2.start(1):match2.end(1)] + if lines[line_count] == '{': + line_count += 1 + n_lines,sect = self.__parse_section(lines[line_count:]) + line_count += n_lines + ret[name]=sect + + + return line_count,ret + + + + def parse_file(self,acf_file)->dict: + if not os.path.isfile(acf_file): + raise FileNotFoundError("File \"{s}\" does not exist!") + + with open(acf_file,'r') as ifile: + buffer = ifile.read() + lines = [l.strip() for l in buffer.split('\n')] + if lines[0] == "\"AppState\"" and lines[1] == "{": + n_lines,sect = self.__parse_section(lines[2:]) + return sect + + raise RuntimeError("Not a acf file!") + +class IgnoreSteamApp(GObject): + @staticmethod + def new_from_dict(conf:dict): + if ('appid' in conf and 'name' in conf): + appid = conf['appid'] + name = conf['name'] + reason = conf['reason'] if 'reason' in conf else "" + return SteamIgnoreApp(appid,name,reason) + + return None + + def __init__(self,appid:int,name:str,reason:str): + GObject.__init__(self) + self.__appid = int(appid) + self.__name = name + self.__reason = reason + + @Property(type=int) + def appid(self)->str: + return self.__appid + + @Property(type=str) + def name(self)->str: + return self.__name + @name.setter + def name(self,name:str): + self.__name = name + + @Property(type=str) + def reason(self)->str: + return self.__reason + @reason.setter + def reason(self,reason:str): + self.__reason = reason + + + def serialize(self): + return { + 'appid': self.appid, + 'name': self.name, + 'reason': self.reason, + } + +class SteamApp(GObject): + def __init__(self,appid:int,name:str,installdir:str): + GObject.__init__(self) + self.__appid = int(appid) + self.__name = name + self.__installdir = installdir + + @Property(type=int) + def appid(self): + return self.__appid + + @Property + def name(self): + return self.__name + + @Property + def installdir(self): + return self.__installdir + + def __str__(self): + return '{}: {}'.format(self.appid,self.name) + + def __gt__(self,other): + return self.appid > other.appid + + def __lt__(self,other): + return self.appid < other.appid + + def __eq__(self,other): + return self.appid == other.appid + + +class SteamLibrary(GObject): + def __init__(self,library_path:str): + GObject.__init__(self) + self.directory = library_path + + @Property(type=str) + def directory(self): + return self.__directory + + @directory.setter + def directory(self,directory:str): + if not os.path.isabs(directory): + raise ValueError("\"directory\" is not an absolute path!") + if not os.path.isdir(directory): + raise NotADirectoryError("\"{}\" is not a directory or does not exist!".format(directory)) + + self.__directory = directory + + @Property + def path(self)->Path: + return Path(self.directory).resolve() + + @Property + def steam_apps(self)->list: + parser = AcfFileParser() + appdir = self.path / "steamapps" + commondir = appdir / "common" + + ret = [] + for acf_file in appdir.glob('appmanifest_*.acf'): + if not acf_file.is_file(): + continue + try: + data = parser.parse_file(str(acf_file)) + app = SteamApp(data['appid'],data['name'],str(commondir/data['installdir'])) + ret.append(app) + except: + pass + + return sorted(ret) + +class Steam(GObject): + def __init__(self): + GObject.__init__(self) + self.__libraries = [] + self.__ignore_apps = [] + + if not self.steamlib_list_file.is_file(): + if (PLATFORM_WINDOWS): + libdirs=[ + "C:\\Program Files (x86)\\steam", + "C:\\Program Files\\steam", + ] + for i in libdirs: + if (os.path.isdir(i)): + self.__libraries.append(SteamLibrary(i)) + break + else: + with open(str(self.steamlib_list_file),'r',encoding="utf-8") as ifile: + for line in (i.strip() for i in ifile.readlines()): + if not line or line.startswith('#'): + continue + libdir = Path(line).resolve() + if libdir.is_dir(): + try: + self.add_library(str(libdir)) + except: + pass + + ignore_apps = [] + if self.ignore_apps_file.is_file(): + with open(str(self.ignore_apps_file),'r',encoding="utf-8") as ifile: + ignore_list = json.loads(ifile.read()) + for ignore in ignore_list: + try: + ignore_app = IgnoreSteamApp.new_from_dict(ignore) + except: + continue + if ignore_app: + self.__ignore_apps.append(ignore_app) + self.__ignore_apps = sorted(ignore_apps) + #__init__() + + @Property + def steamlib_list_file(self)->Path: + return Path(settings.config_dir).resolve() / 'steamlib.lst' + + @Property + def ignore_apps_file(self)->Path: + return Path(settings.config_dir).resolve / 'ignore_steamapps.json' + + @Property + def libraries(self): + return self.__libraries + + @Property + def ignore_apps(self): + return self.__ignore_apps + + def __write_steamlib_list_file(self): + with open(self.steamlib_list_file,'w',encoding='utf-8') as ofile: + ofile.write('\n'.join(str(sl.directory) for sl in self.libraries)) + + def add_library(self,steamlib:SteamLibrary|str): + if isinstance(steamlib,SteamLibrary): + lib = steamlib + else: + lib = SteamLibrary(steamlib) + + lib_exists = False + for i in self.libraries: + if i.derctory == lib.directory: + lib_exists = True + break + + if not lib_exists: + self.__libraries.append(lib) + self.__write_steamlib_list_file() + + def remove_library(self,steamlib:SteamLibrary|str): + if isinstance(steamlib,SteamLibrary): + libdir = steamlib.directory + else: + libdir = str(steamlib) + + delete_libs=[] + for i in range(len(self.__libraries)): + if self.__libraries[i].directory == libdir: + delete_libs.append(i) + + if delete_libs: + for i in sorted(delete_libs,reverse=True): + del self.__libraries[i] + self.__write_steamlib_list_file() diff --git a/sphinx/conf.py b/sphinx/conf.py new file mode 100644 index 0000000..75f9607 --- /dev/null +++ b/sphinx/conf.py @@ -0,0 +1,44 @@ +# vim: syn=python ts=4 sts=4 sw=4 smartindent autoindent expandtab + +import sys,os + +PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__)) +sys.path.insert(0,PROJECT_ROOT) +import sgbackup + +project = 'sgbackup' +copyright = '2024-2025, Christian Moser' +author = 'Christian Moser' +version = sgbackup.__version__ + +exclude_patterns = [ + '_build', + 'Thumbs.db', + '.DS_Store', + '*~', + '*.swp', + '*.tmp', + '*.temp', + '*.log', +] + +extensions = [ + 'sphinx.ext.autodoc' +] +language = 'en' +master_doc = 'index' +source_suffix = '.rst' +templates_path = ['templates'] + +html_theme = 'sphinx_rtd_theme' +html_show_sourcelink = False +#html_static_path = ['_static'] + +autoclass_content='both' +autodoc_class_signature='mixed' +autodoc_default_options={ + 'member_order':'alphabetical', + 'undoc_members':'true', + 'exclude_memebers':'__weakref__', +} + diff --git a/sphinx/index.rst b/sphinx/index.rst new file mode 100644 index 0000000..b06ebab --- /dev/null +++ b/sphinx/index.rst @@ -0,0 +1,29 @@ +========================== +sgbackup API documentation +========================== + +.. title:: sgbackup API + +Descritpion +----------- + +Sgbackup is a savegame backup tool written in Python. It has a commandline and a graphical +interface using *Gtk4*. + +The minimum Python version is **3.11**. + +It has\ *Gtk4*\ and\ *GObject Introspection*\ as a dependency. + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + install.rst + + modules/sgbackup.rst + modules/sgbackup.settings.rst + modules/sgbackup.game.rst + modules/sgbackup.archiver.rst + diff --git a/sphinx/install.rst b/sphinx/install.rst new file mode 100644 index 0000000..1d11d33 --- /dev/null +++ b/sphinx/install.rst @@ -0,0 +1,3 @@ +======================== +Installing sgbackup +======================== diff --git a/sphinx/modules.rst b/sphinx/modules.rst new file mode 100644 index 0000000..c79bd17 --- /dev/null +++ b/sphinx/modules.rst @@ -0,0 +1,5 @@ +.. title:: Modules + +.. toctree:: 2 + modules/sgbackup + modules/game diff --git a/sphinx/modules/sgbackup.rst b/sphinx/modules/sgbackup.rst new file mode 100644 index 0000000..aa668e1 --- /dev/null +++ b/sphinx/modules/sgbackup.rst @@ -0,0 +1,10 @@ +============ +sgbackup API +============ + +.. title:: sgbackup API + + +.. automodule:: sgbackup + :imported-mebers: + :undoc-members: