2025.01.13 02:08:06

This commit is contained in:
Christian Moser 2025-01-13 02:08:06 +01:00
parent 538f70c8b8
commit a63453fd87
Failed to extract signature
8 changed files with 361 additions and 33 deletions

View File

@ -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 <https://www.gnu.org/licenses/>. #
###############################################################################
from ._archiver import Archiver,ArchiverManager
import importlib
archiver = ArchiverManager()
__ALL__ = [
"Archiver",
"AchiverManager",
"archiver",
]

View File

@ -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
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"]

View File

@ -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 <https://www.gnu.org/licenses/>. #
###############################################################################
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(),
]

View File

@ -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

View File

@ -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',["<Primary>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

View File

@ -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()

View File

@ -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')

View File

@ -5,5 +5,4 @@ Applicaction
.. autoclass:: sgbackup.gui.Application
:members:
:undoc-members: