From a63453fd87566bac827f318a5c3f06ad3535d309 Mon Sep 17 00:00:00 2001 From: Christian Moser Date: Mon, 13 Jan 2025 02:08:06 +0100 Subject: [PATCH] 2025.01.13 02:08:06 --- sgbackup/archiver/__init__.py | 28 ++++ .../{archiver.py => archiver/_archiver.py} | 68 +++++++- sgbackup/archiver/zipfilearchiver.py | 71 ++++++++ sgbackup/game.py | 16 ++ sgbackup/gui/_app.py | 154 +++++++++++++++--- sgbackup/gui/_settingsdialog.py | 8 +- sgbackup/settings.py | 48 ++++++ sphinx/modules/sgbackup.gui-app.rst | 1 - 8 files changed, 361 insertions(+), 33 deletions(-) create mode 100644 sgbackup/archiver/__init__.py rename sgbackup/{archiver.py => archiver/_archiver.py} (54%) create mode 100644 sgbackup/archiver/zipfilearchiver.py diff --git a/sgbackup/archiver/__init__.py b/sgbackup/archiver/__init__.py new file mode 100644 index 0000000..6196f40 --- /dev/null +++ b/sgbackup/archiver/__init__.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 . # +############################################################################### + +from ._archiver import Archiver,ArchiverManager +import importlib + +archiver = ArchiverManager() + +__ALL__ = [ + "Archiver", + "AchiverManager", + "archiver", +] diff --git a/sgbackup/archiver.py b/sgbackup/archiver/_archiver.py similarity index 54% rename from sgbackup/archiver.py rename to sgbackup/archiver/_archiver.py index fc63281..ebcd853 100644 --- a/sgbackup/archiver.py +++ b/sgbackup/archiver/_archiver.py @@ -24,7 +24,11 @@ from gi.repository.GObject import ( signal_accumulator_true_handled, ) -from .game import Game +import datetime +import os + +from ..game import Game +from ..settings import settings class Archiver: def __init__(self,key:str,name:str,extensions:list[str],decription:str|None=None): @@ -35,6 +39,8 @@ class Archiver: else: self.__description = "" + self.__extensions = list(extensions) + @Property(type=str) def name(self)->str: return self.__name @@ -47,20 +53,68 @@ class Archiver: def description(self)->str: return self.__description - def backup(self,game)->bool: - pass + @Property + def extensions(self)->list[str]: + return self.__extensions + + def backup(self,game:Game)->bool: + if not game.get_backup_files(): + return + filename = self.generate_new_backup_filename() + dirname = os.path.dirname(filename) + if not os.path.isdir(dirname): + os.makedirs(dirname) + + self.emit('backup',game,filename) def restore(self,game,file)->bool: pass + def generate_new_backup_filename(self,game:Game)->str: + dt = datetime.datetime.now() + basename = '.'.join(game.savegame_name, + game.savegame_subdir, + dt.strftime("%Y%m%d-%H%M%S"), + "sgbackup", + self.extensions[0]) + return os.path.join(settings.backup_dir,game.savegame_name,game.subdir,basename) + + + + @Signal(name="backup",flags=SignalFlags.RUN_FIRST, return_type=bool, arg_types=(GObject,str), accumulator=signal_accumulator_true_handled) def do_backup(self,game:Game,filename:str): - pass + raise NotImplementedError("{_class}.{function}() is not implemented!",_class=__class__,function="do_backup") @Signal(name="restore",flags=SignalFlags.RUN_FIRST, - return_type=bool,arg_types=(GObject,str), + return_type=bool,arg_types=(str,), accumulator=signal_accumulator_true_handled) - def do_backup(self,game:Game,filanme:str): - pass \ No newline at end of file + def do_restore(self,filanme:str): + raise NotImplementedError("{_class}.{function}() is not implemented!",_class=__class__,function="do_restore") + +class ArchiverManager(GObject): + __global_archiver_manager = None + + def __init__(self): + GObject.__init__(self) + self.__archivers = {} + + + @staticmethod + def get_global(): + if ArchiverManager.__global_archiver_manager is None: + ArchiverManager.__global_archiver_manager = ArchiverManager() + + return ArchiverManager.__global_archiver_manager + + @property + def standard_archiver(self)->Archiver: + try: + return self.__archivers[settings.archiver] + except: + return self.__archivers["zipfile"] + + + \ No newline at end of file diff --git a/sgbackup/archiver/zipfilearchiver.py b/sgbackup/archiver/zipfilearchiver.py new file mode 100644 index 0000000..37a1e47 --- /dev/null +++ b/sgbackup/archiver/zipfilearchiver.py @@ -0,0 +1,71 @@ +############################################################################### +# 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 ._archiver import Archiver +import zipfile +import json +import os +from ..game import Game,GameManager +from settings import settings + +class ZipfileArchiver(Archiver): + def __init__(self): + Archiver.__init__(self,"zipfile","ZipFile",[".zip"],"Archiver for .zip files.") + + def do_backup(self, game:Game, filename:str): + files = game.get_backup_files() + game_data = json.dumps(game.serialize(),ensure_ascii=False,indent=4) + with zipfile.ZipFile(filename,mode="w", + compression=settings.zipfile_compression, + compresslevel=settings.zipfile_compresslevel) as zf: + zf.writestr("game.conf",game_data) + for path,arcname in files.items(): + zf.write(path,arcname) + + def is_archive(self,filename:str)->bool: + if zipfile.is_zipfile(filename): + with zipfile.ZipFile(filename,"r") as zf: + if 'game.conf' in zf.filelist(): + return True + return False + + def do_restore(self,filename:str): + # TODO: convert savegame dir if not the same SvaegameType!!! + + if not zipfile.is_zipfile(filename): + raise RuntimeError("\"{filename}\" is not a valid sgbackup zipfile archive!") + + with zipfile.ZipFile(filename,"r") as zf: + zip_game = Game.new_from_dict(json.loads(zf.read('game.conf').decode("utf-8"))) + try: + game = GameManager.get_global().games[zip_game.key] + except: + game = zip_game + + if not game.savegame_root: + os.makedirs(game.savegame_root) + + extract_files = [i for i in zf.filelist if i.startswith(zip_game.savegame_dir + "/")] + for file in extract_files: + zf.extract(file,game.savegame_root) + + return True + +ARCHIVERS = [ + ZipfileArchiver(), +] \ No newline at end of file diff --git a/sgbackup/game.py b/sgbackup/game.py index b554ea4..29e1b54 100644 --- a/sgbackup/game.py +++ b/sgbackup/game.py @@ -19,6 +19,7 @@ from gi.repository.GObject import Property,GObject,Signal,SignalFlags from gi.repository import GLib + import os import json import re @@ -28,6 +29,7 @@ from enum import StrEnum import sys import logging import pathlib +import datetime logger = logging.getLogger(__name__) @@ -1303,6 +1305,19 @@ class Game(GObject): backup_files = get_backup_files_recursive(sgroot,sgdir) + @Property(type=str) + def savegame_subdir(self)->str: + """ + savegame_subdir The subdir for the savegame backup. + + If `is_live` results to `True`, *"live"* is returned. Else *"finished"* is returned. + + :type: str + """ + if (self.is_live): + return "live" + return "finished" + class GameManager(GObject): __global_gamemanager = None @@ -1379,3 +1394,4 @@ class GameManager(GObject): if (game.steam_windows): self.__steam_games[game.steam_windows.appid] = game self.__steam_windows_games[game.steam_windows.appid] = game + diff --git a/sgbackup/gui/_app.py b/sgbackup/gui/_app.py index 11313a9..f8a60b5 100644 --- a/sgbackup/gui/_app.py +++ b/sgbackup/gui/_app.py @@ -200,6 +200,10 @@ class GameView(Gtk.ScrolledWindow): # GameView class class BackupViewData(GObject): + """ + BackupViewData The data class for BackupView + """ + def __init__(self,_game:Game,filename:str): GObject.GObject.__init__(self) self.__game = _game @@ -215,34 +219,75 @@ class BackupViewData(GObject): @property def game(self)->Game: + """ + game The `Game` the data belong to + + :type: Game + """ return self.__game - @Property - def savegame_name(self): + @Property(type=str) + def savegame_name(self)->str: + """ + savegame_name The savegame_name of the file. + + :type: str + """ return self.__savegame_name @Property(type=str) def filename(self)->str: + """ + filename The full filename of the savegame backup. + + :type: str + """ return self.__filename @Property(type=bool,default=False) def is_live(self)->bool: + """ + is_live `True` if the savegame backup is from a live game. + + :type: bool + """ pass - @Property - def extension(self): + @Property(type=str) + def extension(self)->str: + """ + extension The extension of the file. + + :type: str + """ return self.__extension @Property - def timestamp(self): + def timestamp(self)->DateTime: + """ + timestamp The timestamp of the file. + + DateTime is the alias for `datetime.datetime`. + + :type: DateTime + """ return self.__timestamp def _on_selection_changed(self,selection): pass class BackupView(Gtk.ScrolledWindow): + """ + BackupView This view displays the backup for the selected `Game`. + """ __gtype_name__ = "BackupView" def __init__(self,gameview:GameView): + """ + BackupView + + :param gameview: The `GameView` to connect this class to. + :type gameview: GameView + """ Gtk.ScrolledWindow.__init__(self) self.__gameview = gameview @@ -277,6 +322,11 @@ class BackupView(Gtk.ScrolledWindow): @property def gameview(self)->GameView: + """ + gameview The GameView this class is connected to. + + :type: GameView + """ return self.__gameview def _on_live_column_setup(self,factory,item): @@ -338,8 +388,17 @@ class BackupView(Gtk.ScrolledWindow): class AppWindow(Gtk.ApplicationWindow): + """ + AppWindow The applications main window. + """ __gtype_name__ = "AppWindow" def __init__(self,application=None,**kwargs): + """ + AppWindow + + :param application: The `Application` this window belongs to, defaults to `None`. + :type application: Application, optional + """ kwargs['title'] = "SGBackup" if (application is not None): @@ -389,18 +448,39 @@ class AppWindow(Gtk.ApplicationWindow): self.set_child(vbox) @property - def builder(self): + def builder(self)->Gtk.Builder: + """ + builder The Builder for this Window. + + If application is set and it has an attriubte *builder*, The applications builder + is used else a new `Gtk.Builder` instance is created. + + :type: Gtk.Builder + """ return self.__builder @property - def backupview(self): + def backupview(self)->BackupView: + """ + backupview The `BackupView` of this window. + + :type: BackupView + """ return self.__backupview @property - def gameview(self): + def gameview(self)->GameView: + """ + gameview The `GameView` for this window. + + :type: GameView + """ return self.__gameview def refresh(self): + """ + refresh Refresh the views of this window. + """ self.gameview.refresh() #self.backupview.refresh() @@ -411,7 +491,7 @@ class Application(Gtk.Application): Signals _______ - + `settings-dialog-init` + + **settings-dialog-init** - Called when the application creates a new `SettingsDialog`. """ __gtype_name__ = "Application" @@ -433,10 +513,18 @@ class Application(Gtk.Application): return self.__logger @property - def appwindow(self): + def appwindow(self)->AppWindow: + """ + appwindow The main `AppWindow` of this app. + + :type: AppWindow + """ return self.__appwindow def do_startup(self): + """ + do_startup The startup method for this application. + """ self._logger.debug('do_startup()') if not self.__builder: self.__builder = Gtk.Builder.new() @@ -451,29 +539,37 @@ class Application(Gtk.Application): theme.add_search_path(str(icons_path)) action_about = Gio.SimpleAction.new('about',None) - action_about.connect('activate',self.on_action_about) + action_about.connect('activate',self._on_action_about) self.add_action(action_about) action_new_game = Gio.SimpleAction.new('new-game',None) - action_new_game.connect('activate',self.on_action_new_game) + action_new_game.connect('activate',self._on_action_new_game) self.add_action(action_new_game) action_quit = Gio.SimpleAction.new('quit',None) - action_quit.connect('activate',self.on_action_quit) + 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) + action_settings.connect('activate',self._on_action_settings) self.add_action(action_settings) # add accels self.set_accels_for_action('app.quit',["q"]) - @Property - def builder(self): + @property + def builder(self)->Gtk.Builder: + """ + builder Get the builder for the application. + + :type: Gtk.Builder + """ return self.__builder def do_activate(self): + """ + do_activate This method is called, when the application is activated. + """ self._logger.debug('do_activate()') if not (self.__appwindow): self.__appwindow = AppWindow(application=self) @@ -481,28 +577,34 @@ class Application(Gtk.Application): self.appwindow.present() - def on_action_about(self,action,param): + def _on_action_about(self,action,param): pass - def on_action_settings(self,action,param): + def _on_action_settings(self,action,param): dialog = self.new_settings_dialog() dialog.present() - def on_action_quit(self,action,param): + def _on_action_quit(self,action,param): self.quit() def _on_dialog_response_refresh(self,dialog,response,check_response): if response == check_response: self.appwindow.refresh() - def on_action_new_game(self,action,param): + def _on_action_new_game(self,action,param): dialog = GameDialog(self.appwindow) dialog.connect('response', self._on_dialog_response_refresh, Gtk.ResponseType.APPLY) dialog.present() - def new_settings_dialog(self): + def new_settings_dialog(self)->SettingsDialog: + """ + new_settings_dialog Create a new `SettingsDialog`. + + :return: The new dialog. + :rtype: `SettingsDialog` + """ dialog = SettingsDialog(self.appwindow) self.emit('settings-dialog-init',dialog) return dialog @@ -511,6 +613,14 @@ class Application(Gtk.Application): flags=SignalFlags.RUN_LAST, return_type=None, arg_types=(SettingsDialog,)) - def do_settings_dialog_init(self,dialog): + def do_settings_dialog_init(self,dialog:SettingsDialog): + """ + do_settings_dialog_init The **settings-dialog-init** signal callback for initializing the `SettingsDialog`. + + This signal is ment to add pages to the `SettingsDialog`. + + :param dialog: The dialog to initialize. + :type dialog: SettingsDialog + """ pass diff --git a/sgbackup/gui/_settingsdialog.py b/sgbackup/gui/_settingsdialog.py index 93ac5d9..ffd1ede 100644 --- a/sgbackup/gui/_settingsdialog.py +++ b/sgbackup/gui/_settingsdialog.py @@ -17,7 +17,7 @@ ############################################################################### from gi.repository import Gtk,GLib,Gio -from gi.repository.GObject import GObject,Signal,Property +from gi.repository.GObject import GObject,Signal,Property,SignalFlags from ..settings import settings @@ -81,7 +81,6 @@ class SettingsDialog(Gtk.Dialog): dialog.select_folder(self,None,self._on_backupdir_dialog_select_folder) - def add_page(self,page,name,title): self.__stack.add_titled(page,name,title) @@ -91,7 +90,10 @@ class SettingsDialog(Gtk.Dialog): settings.save() self.destroy() - @Signal(name='save') + @Signal(name='save', + flags=SignalFlags.RUN_FIRST, + return_type=None, + arg_types=()) def do_save(self): settings.backup_dir = self.__backupdir_label.get_text() \ No newline at end of file diff --git a/sgbackup/settings.py b/sgbackup/settings.py index 8265bf1..5d35c62 100644 --- a/sgbackup/settings.py +++ b/sgbackup/settings.py @@ -21,6 +21,27 @@ import os import sys from gi.repository import GLib,GObject +import zipfile + +ZIPFILE_COMPRESSION_STR = { + zipfile.ZIP_STORED: "stored", + zipfile.ZIP_DEFLATED: "deflated", + zipfile.ZIP_BZIP2: "bzip2", + zipfile.ZIP_LZMA: "lzma", +} + +ZIPFILE_COMPRESSLEVEL_MAX = { + zipfile.ZIP_STORED: 0, + zipfile.ZIP_DEFLATED: 9, + zipfile.ZIP_BZIP2: 9, + zipfile.ZIP_LZMA: 0, +} + +ZIPFILE_STR_COMPRESSION = {} +for _zc,_zs in ZIPFILE_COMPRESSION_STR.items(): + ZIPFILE_STR_COMPRESSION[_zs] = _zc +del _zc +del _zs class Settings(GObject.GObject): __gtype_name__ = "Settings" @@ -84,6 +105,33 @@ class Settings(GObject.GObject): return self.parser.get('sgbackup','logLevel') return "INFO" + @GObject.Property(type=str) + def zipfile_compression(self)->str: + if self.parser.has_option('zipfile','compression'): + try: + ZIPFILE_STR_COMPRESSION[self.parser.get('zipfile','compression')] + except: + pass + return ZIPFILE_STR_COMPRESSION["deflated"] + + @zipfile_compression.setter + def zipfile_compression(self,compression): + try: + self.parser.set('zipfile','compression',ZIPFILE_COMPRESSION_STR[compression]) + except: + self.parser.set('zipfile','compression',ZIPFILE_STR_COMPRESSION[zipfile.ZIP_DEFLATED]) + + @GObject.Property(type=int) + def zipfile_compresslevel(self)->int: + if self.parser.has_option('zipfile','compressLevel'): + cl = self.parser.getint('zipfile','compressLevel') + return cl if cl <= ZIPFILE_COMPRESSLEVEL_MAX[self.zipfile_compression] else ZIPFILE_COMPRESSLEVEL_MAX[self.zipfile_compression] + return ZIPFILE_COMPRESSLEVEL_MAX[self.zipfile_compression] + + @zipfile_compresslevel.setter + def zipfile_compresslevel(self,cl:int): + self.parser.set('zipfile','compressLevel',cl) + def save(self): self.emit('save') diff --git a/sphinx/modules/sgbackup.gui-app.rst b/sphinx/modules/sgbackup.gui-app.rst index 254457d..aeace03 100644 --- a/sphinx/modules/sgbackup.gui-app.rst +++ b/sphinx/modules/sgbackup.gui-app.rst @@ -5,5 +5,4 @@ Applicaction .. autoclass:: sgbackup.gui.Application :members: :undoc-members: - \ No newline at end of file