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