diff --git a/CHANGES b/CHANGES index 5350cf963..dc5bdd2a4 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,8 @@ Back In Time Version 1.6.0-dev (development of upcoming release) +* Changed: Move several values from config file into new introduce state file ($XDG_STATE_HOME/backintime.json) +* Fix: The width of the fourth column in files view is now saved * Feature: Open user manual (local if available otherwise online) via Help menu * Fix: Snapshot compare copy symlink as symlink (#1902) (Peter Sevens @sevens) * Fix: Crash when comparing a snapshot with a symlink pointing to a nonexistent target (Peter Sevens @sevens) diff --git a/common/backintime.py b/common/backintime.py index b45dc6ebd..20126205a 100644 --- a/common/backintime.py +++ b/common/backintime.py @@ -21,7 +21,6 @@ # Workaround for situations where startApp() is not invoked. # E.g. when using --diagnostics and other argparse.Action tools.initiate_translation(None) - import config import logger import snapshots @@ -30,6 +29,7 @@ import password import encfstools import cli +from statedata import StateData from diagnostics import collect_diagnostics, collect_minimal_diagnostics from exceptions import MountException from applicationinstance import ApplicationInstance @@ -41,7 +41,8 @@ parsers = {} -def takeSnapshotAsync(cfg, checksum = False): + +def takeSnapshotAsync(cfg, checksum=False): """ Fork a new backintime process with 'backup' command which will take a new snapshot in background. @@ -75,7 +76,8 @@ def takeSnapshotAsync(cfg, checksum = False): pass subprocess.Popen(cmd, env = env) -def takeSnapshot(cfg, force = True): + +def takeSnapshot(cfg, force=True): """ Take a new snapshot. @@ -91,6 +93,7 @@ def takeSnapshot(cfg, force = True): ret = snapshots.Snapshots(cfg).backup(force) return ret + def _mount(cfg): """ Mount external filesystems. @@ -106,6 +109,7 @@ def _mount(cfg): else: cfg.setCurrentHashId(hash_id) + def _umount(cfg): """ Unmount external filesystems. @@ -118,7 +122,8 @@ def _umount(cfg): except MountException as ex: logger.error(str(ex)) -def createParsers(app_name = 'backintime'): + +def createParsers(app_name='backintime'): """ Define parsers for commandline arguments. @@ -521,6 +526,9 @@ def startApp(app_name='backintime'): f"{config.Config.APP_NAME}. This will cause some trouble. " f"Please use either 'sudo -i {app_name}' or 'pkexec {app_name}'.") + # State data + load_state_data(args) + # Call commands if 'func' in dir(args): args.func(args) @@ -615,6 +623,7 @@ def join(args, subArgs): return args + def printHeader(): """ Print application name, version and legal notes. @@ -628,6 +637,7 @@ def printHeader(): print("under certain conditions; type `backintime --license' for details.") print('') + class PseudoAliasAction(argparse.Action): """ Translate '--COMMAND' into 'COMMAND' for backwards compatibility. @@ -654,6 +664,7 @@ def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, 'replace', replace) setattr(namespace, 'alias', alias) + def aliasParser(args): """ Call commands which where given with leading -- for backwards @@ -671,7 +682,8 @@ def aliasParser(args): if 'func' in dir(newArgs): newArgs.func(newArgs) -def getConfig(args, check = True): + +def getConfig(args, check=True): """ Load config and change to profile selected on commandline. @@ -715,6 +727,175 @@ def getConfig(args, check = True): return cfg +def _get_state_data_from_config(cfg: config.Config) -> StateData: + """Get data related to application state from the config instance. + + It migrates state data from the config file to an instance of + `StateData` which later is saved in a separate file. + + This function is a temporary workaround. See PR #1850. + + Args: + cfg: The config instance. + + Returns: + dict: The state data. + """ + + data = StateData() + + # internal.manual_starts_countdown + data['manual_starts_countdown'] \ + = cfg.intValue('internal.manual_starts_countdown', 10) + + # internal.msg_rc + val = cfg.strValue('internal.msg_rc', None) + if val: + data.msg_release_candidate = val + + # internal.msg_shown_encfs + val = cfg.boolValue('internal.msg_shown_encfs', None) + if val: + data.msg_encfs_global = val + + # qt.show_hidden_files + data.mainwindow_show_hidden = cfg.boolValue('qt.show_hidden_files', False) + + # Coordinates and dimensions + val = ( + cfg.intValue('qt.main_window.x', None), + cfg.intValue('qt.main_window.y', None) + ) + if all(val): + data.mainwindow_coords = val + + val = ( + cfg.intValue('qt.main_window.width', None), + cfg.intValue('qt.main_window.height', None) + ) + if all(val): + data.mainwindow_dims = val + + val = ( + cfg.intValue('qt.logview.width', None), + cfg.intValue('qt.logview.height', None) + ) + if all(val): + data.logview_dims = val + + # files view + # Dev note (buhtz, 2024-12): Ignore the column width values because of a + # bug. Three columns are tracked but the widget has four columns. The "Typ" + # column is treated as "Date" and the width of the real "Date" column (4th) + # was never stored. + # The new state file will load and store width values for all existing + # columns. + # qt.main_window.files_view.name_width + # qt.main_window.files_view.size_width + # qt.main_window.files_view.date_width + + col = cfg.intValue('qt.main_window.files_view.sort.column', 0) + order = cfg.boolValue('qt.main_window.files_view.sort.ascending', True) + data.files_view_sorting = (col, 0 if order else 1) + + # splitter width + widths = ( + cfg.intValue('qt.main_window.main_splitter_left_w', None), + cfg.intValue('qt.main_window.main_splitter_right_w', None) + ) + if all(widths): + data.mainwindow_main_splitter_widths = widths + + widths = ( + cfg.intValue('qt.main_window.second_splitter_left_w', None), + cfg.intValue('qt.main_window.second_splitter_right_w', None) + ) + if all(widths): + data.mainwindow_second_splitter_widths = widths + + # each profile + for profile_id in cfg.profiles(): + profile_state = data.profile(profile_id) + + # profile specific encfs warning + val = cfg.profileBoolValue('msg_shown_encfs', None, profile_id) + if val is not None: + profile_state.msg_encfs = val + + # qt.last_path + if cfg.hasProfileKey('qt.last_path', profile_id): + profile_state.last_path \ + = cfg.profileStrValue('qt.last_path', None, profile_id) + + # Places: sorting + sorting = ( + cfg.profileIntValue('qt.places.SortColumn', None, profile_id), + cfg.profileIntValue('qt.places.SortOrder', None, profile_id) + ) + if all(sorting): + profile_state.places_sorting = sorting + + # Manage profiles - Exclude tab: sorting + sorting = ( + cfg.profileIntValue( + 'qt.settingsdialog.exclude.SortColumn', None, profile_id), + cfg.profileIntValue( + 'qt.settingsdialog.exclude.SortOrder', None, profile_id) + ) + if all(sorting): + profile_state.exclude_sorting = sorting + + # Manage profiles - Include tab: sorting + sorting = ( + cfg.profileIntValue( + 'qt.settingsdialog.include.SortColumn', None, profile_id), + cfg.profileIntValue( + 'qt.settingsdialog.include.SortOrder', None, profile_id) + ) + if all(sorting): + profile_state.include_sorting = sorting + + return data + + +def load_state_data(args: argparse.Namespace) -> None: + """Initiate the `State` instance. + + The state file is loaded and its data stored in `State`. The later is a + singleton and can be used everywhere. + + Dev note (buhtz, 2024-12): The args argument is a workaround and will be + removed. Currently it is needed to know where to load the config file + from. Related to PR #1850. In the future that function can be moved into + the StateData class as load() method. + + Args: + args: Arguments given from command line. + """ + fp = StateData.file_path() + + try: + # load file + state_data = StateData(json.loads(fp.read_text(encoding='utf-8'))) + + except FileNotFoundError: + logger.debug('State file not found. Using config file and migrate it' + 'into a state file.') + fp.parent.mkdir(parents=True, exist_ok=True) + # extract data from the config file (for migration) + state_data = _get_state_data_from_config(getConfig(args)) + + except json.decoder.JSONDecodeError as exc: + logger.warning(f'Unable to read and decode state file "{fp}". ' + 'Ignnoring it.') + logger.debug(f'{exc=}') + # Empty state data with default values + state_data = StateData() + + # Register close callback. This will save the state file when the BIT ends. + atexit.register(state_data.save) + + def setQuiet(args): """ Redirect :py:data:`sys.stdout` to ``/dev/null`` if ``--quiet`` was set on @@ -736,10 +917,12 @@ def setQuiet(args): atexit.register(force_stdout.close) return force_stdout + class printLicense(argparse.Action): """ Print custom license """ + def __init__(self, *args, **kwargs): super(printLicense, self).__init__(*args, **kwargs) @@ -748,6 +931,7 @@ def __call__(self, *args, **kwargs): print(license_path.read_text('utf-8')) sys.exit(RETURN_OK) + class printDiagnostics(argparse.Action): """ Print information that is helpful for the support team @@ -766,7 +950,8 @@ def __call__(self, *args, **kwargs): sys.exit(RETURN_OK) -def backup(args, force = True): + +def backup(args, force=True): """ Command for force taking a new snapshot. @@ -785,6 +970,7 @@ def backup(args, force = True): ret = takeSnapshot(cfg, force) sys.exit(int(ret)) + def backupJob(args): """ Command for taking a new snapshot in background. Mainly used for cronjobs. @@ -800,6 +986,7 @@ def backupJob(args): """ cli.BackupJobDaemon(backup, args).start() + def shutdown(args): """ Command for shutting down the computer after the current snapshot has @@ -819,12 +1006,14 @@ def shutdown(args): cfg = getConfig(args) sd = tools.ShutDown() + if not sd.canShutdown(): logger.warning('Shutdown is not supported.') sys.exit(RETURN_ERR) instance = ApplicationInstance(cfg.takeSnapshotInstanceFile(), False) profile = '='.join((cfg.currentProfile(), cfg.profileName())) + if not instance.busy(): logger.info('There is no active snapshot for profile %s. Skip shutdown.' %profile) @@ -833,17 +1022,22 @@ def shutdown(args): print('Shutdown is waiting for the snapshot in profile %s to end.\nPress CTRL+C to interrupt shutdown.\n' %profile) sd.activate_shutdown = True + try: while instance.busy(): logger.debug('Snapshot is still active. Wait for shutdown.') sleep(5) + except KeyboardInterrupt: print('Shutdown interrupted.') + else: logger.info('Shutdown now.') sd.shutdown() + sys.exit(RETURN_OK) + def snapshotsPath(args): """ Command for printing the full snapshot path of current profile. @@ -866,6 +1060,7 @@ def snapshotsPath(args): print(msg.format(cfg.snapshotsFullPath()), file=force_stdout) sys.exit(RETURN_OK) + def snapshotsList(args): """ Command for printing a list of all snapshots in current profile. @@ -896,6 +1091,7 @@ def snapshotsList(args): _umount(cfg) sys.exit(RETURN_OK) + def snapshotsListPath(args): """ Command for printing a list of all snapshots paths in current profile. @@ -926,6 +1122,7 @@ def snapshotsListPath(args): _umount(cfg) sys.exit(RETURN_OK) + def lastSnapshot(args): """ Command for printing the very last snapshot in current profile. @@ -952,6 +1149,7 @@ def lastSnapshot(args): _umount(cfg) sys.exit(RETURN_OK) + def lastSnapshotPath(args): """ Command for printing the path of the very last snapshot in @@ -980,6 +1178,7 @@ def lastSnapshotPath(args): _umount(cfg) sys.exit(RETURN_OK) + def unmount(args): """ Command for unmounting all filesystems. @@ -997,6 +1196,7 @@ def unmount(args): _umount(cfg) sys.exit(RETURN_OK) + def benchmarkCipher(args): """ Command for transferring a file with scp to remote host with all @@ -1020,6 +1220,7 @@ def benchmarkCipher(args): logger.error("SSH is not configured for profile '%s'!" % cfg.profileName()) sys.exit(RETURN_ERR) + def pwCache(args): """ Command for starting password cache daemon. @@ -1050,6 +1251,7 @@ def pwCache(args): daemon.run() sys.exit(ret) + def decode(args): """ Command for decoding paths given paths with 'encfsctl'. @@ -1084,7 +1286,8 @@ def decode(args): _umount(cfg) sys.exit(RETURN_OK) -def remove(args, force = False): + +def remove(args, force=False): """ Command for removing snapshots. @@ -1104,6 +1307,7 @@ def remove(args, force = False): _umount(cfg) sys.exit(RETURN_OK) + def removeAndDoNotAskAgain(args): """ Command for removing snapshots without asking before remove @@ -1118,6 +1322,7 @@ def removeAndDoNotAskAgain(args): """ remove(args, True) + def smartRemove(args): """ Command for running Smart-Removal from Terminal. @@ -1151,6 +1356,7 @@ def smartRemove(args): logger.error('Smart Removal is not configured.') sys.exit(RETURN_NO_CFG) + def restore(args): """ Command for restoring files from snapshots. @@ -1180,6 +1386,7 @@ def restore(args): _umount(cfg) sys.exit(RETURN_OK) + def checkConfig(args): """ Command for checking the config file. @@ -1207,5 +1414,6 @@ def checkConfig(args): file = force_stdout) sys.exit(RETURN_ERR) + if __name__ == '__main__': startApp() diff --git a/common/config.py b/common/config.py index 133619fdf..bc36e89da 100644 --- a/common/config.py +++ b/common/config.py @@ -447,27 +447,6 @@ def language(self) -> str: def setLanguage(self, language: str): self.setStrValue('global.language', language if language else '') - def manual_starts_countdown(self) -> int: - """Countdown value about how often the users started the Back In Time - GUI. - - It is an internal variable not meant to be used or manipulated be the - users. At the end of the countown the - :py:class:`ApproachTranslatorDialog` is presented to the user. - - """ - return self.intValue('internal.manual_starts_countdown', 10) - - def decrement_manual_starts_countdown(self): - """Counts down to -1. - - See :py:func:`manual_starts_countdown()` for details. - """ - val = self.manual_starts_countdown() - - if val > -1: - self.setIntValue('internal.manual_starts_countdown', val - 1) - # SSH def sshSnapshotsPath(self, profile_id = None): #?Snapshot path on remote host. If the path is relative (no leading '/') diff --git a/common/singleton.py b/common/singleton.py new file mode 100644 index 000000000..a03e859ec --- /dev/null +++ b/common/singleton.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: © 2022 Mars Landis +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: CC0-1.0 +# +# This file is released under Creative Commons Zero 1.0 (CC0-1.0) and part of +# the program "Back In Time". The program as a whole is released under GNU +# General Public License v2 or any later version (GPL-2.0-or-later). +# See file/folder LICENSE or +# go to +# and . +# +# Credits to Mr. Mars Landis describing that solution and comparing it to +# alternatives in his article 'Better Python Singleton with a Metaclass' at +# +# himself referring to this Stack Overflow +# question as his inspiration. +# +# Original code adapted by Christian Buhtz. + +"""Flexible and pythonic singleton implementation. + +Support inheritance and multiple classes. Multilevel inheritance is +theoretically possible if the '__allow_reinitialization' approach would be +implemented as described in the original article. + +Example :: + + >>> from singleton import Singleton + >>> + >>> class Foo(metaclass=Singleton): + ... def __init__(self): + ... self.value = 'Alyssa Ogawa' + >>> + >>> class Bar(metaclass=Singleton): + ... def __init__(self): + ... self.value = 'Naomi Wildmann' + >>> + >>> f = Foo() + >>> ff = Foo() + >>> f'{f.value=} :: {ff.value=}' + "f.value='Alyssa Ogawa' :: ff.value='Alyssa Ogawa'" + >>> ff.value = 'Who?' + >>> f'{f.value=} :: {ff.value=}' + "f.value='Who?' :: ff.value='Who?'" + >>> + >>> b = Bar() + >>> bb = Bar() + >>> f'{b.value=} :: {bb.value=}' + "b.value='Naomi Wildmann' :: bb.value='Naomi Wildmann'" + >>> b.value = 'thinking ...' + >>> f'{b.value=} :: {bb.value=}' + "b.value='thinking ...' :: bb.value='thinking ...'" + >>> + >>> id(f) == id(ff) + True + >>> id(b) == id(bb) + True + >>> id(f) == id(b) + False +""" + + +class Singleton(type): + """Singleton implementation supporting inheritance and multiple classes.""" + + _instances = {} + """Hold single instances of multiple classes.""" + + def __call__(cls, *args, **kwargs): + + try: + # Reuse existing instance + return cls._instances[cls] + + except KeyError: + # Create new instance + cls._instances[cls] = super().__call__(*args, **kwargs) + + return cls._instances[cls] diff --git a/common/statedata.py b/common/statedata.py new file mode 100644 index 000000000..4c267688f --- /dev/null +++ b/common/statedata.py @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: © 2024 Christian Buhtz +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). See LICENSES directory or go to +# . +"""Management of the state file.""" +from __future__ import annotations +import os +import json +from pathlib import Path +from datetime import datetime, timezone +import singleton +import logger +from version import __version__ + + +class StateData(dict, metaclass=singleton.Singleton): + """Manage state data for Back In Time. + + Dev note (buhtz, 2024-12): It is usually recommended and preferred to + derive from `collections.UserDict` instead of just `dict`. But this + conflicts with the ``metaclass=``. To my current knowledge this is not a + big deal and won't introduce any problems. + + """ + # The default structure. All properties do rely on them and assuming + # it is there. + _EMPTY_STRUCT = { + 'gui': { + 'mainwindow': { + 'files_view': {}, + }, + 'manage_profiles': { + 'incl_sorting': {}, + 'excl_sorting': {}, + }, + 'logview': {}, + }, + 'message': { + 'encfs': {} + }, + } + + class Profile: + """A surrogate to access profile-specific state data.""" + + def __init__(self, profile_id: str, state: StateData): + self._state = state + self._profile_id = profile_id + + @property + def msg_encfs(self) -> bool: + """If message box about EncFS deprecation was shown already.""" + return self._state['message']['encfs'][self._profile_id] + + @msg_encfs.setter + def msg_encfs(self, val: bool) -> None: + self._state['message']['encfs'][self._profile_id] = val + + @property + def last_path(self) -> Path: + """Last path used in the GUI.""" + return Path(self._state['gui']['mainwindow'][ + 'last_path'][self._profile_id]) + + @last_path.setter + def last_path(self, path: Path) -> None: + self._state['gui']['mainwindow'][ + 'last_path'][self._profile_id] = str(path) + + @property + def places_sorting(self) -> tuple[int, int]: + """Column index and sort order. + + Returns: + Tuple with column index and its sorting order (0=ascending). + """ + return self._state['gui']['mainwindow'][ + 'places_sorting'][self._profile_id] + + @places_sorting.setter + def places_sorting(self, vals: tuple[int, int]) -> None: + self._state['gui']['mainwindow'][ + 'places_sorting'][self._profile_id] = vals + + @property + def exclude_sorting(self) -> tuple[int, int]: + """Column index and sort order. + + Returns: + Tuple with column index and its sorting order (0=ascending). + """ + return self._state['gui']['manage_profiles'][ + 'excl_sorting'][self._profile_id] + + @exclude_sorting.setter + def exclude_sorting(self, vals: tuple[int, int]) -> None: + self._state['gui']['manage_profiles'][ + 'excl_sorting'][self._profile_id] = vals + + @property + def include_sorting(self) -> tuple[int, int]: + """Column index and sort order. + + Returns: + Tuple with column index and its sorting order (0=ascending). + """ + return self._state['gui']['manage_profiles'][ + 'incl_sorting'][self._profile_id] + + @include_sorting.setter + def include_sorting(self, vals: tuple[int, int]) -> None: + self._state['gui']['manage_profiles'][ + 'incl_sorting'][self._profile_id] = vals + + @staticmethod + def file_path() -> Path: + """Returns the state file path.""" + xdg_state = os.environ.get('XDG_STATE_HOME', + Path.home() / '.local' / 'state') + fp = xdg_state / 'backintime.json' + + logger.debug(f'State file path: {fp}') + + return fp + + def __init__(self, data: dict = None): + """Constructor.""" + + if data: + self._EMPTY_STRUCT.update(data) + else: + data = self._EMPTY_STRUCT + + super().__init__(data) + + def __str__(self): + return json.dumps(self, indent=4) + + def _set_save_meta_data(self): + meta = { + 'saved': datetime.now().isoformat(), + 'saved_utc': datetime.now(timezone.utc).isoformat(), + 'bitversion': __version__, + } + + self['_meta'] = meta + + def save(self): + """Store application state data to a file.""" + logger.debug('Save state data.') + + self._set_save_meta_data() + + with self.file_path().open('w', encoding='utf-8') as handle: + handle.write(str(self)) + + def profile(self, profile_id: str) -> StateData.Profile: + """Return a `Profile` object related to the given id. + + Args: + profile_id: A profile_id of a snapshot profile. + + Returns: + A profile surrogate. + """ + return StateData.Profile(profile_id=profile_id, state=self) + + def manual_starts_countdown(self) -> int: + """Countdown value about how often the users started the Back In Time + GUI. + + At the end of the countown the `ApproachTranslatorDialog` is presented + to the user. + """ + return self.get('manual_starts_countdown', 10) + + def decrement_manual_starts_countdown(self): + """Counts down to -1. + + See :py:func:`manual_starts_countdown()` for details. + """ + val = self.manual_starts_countdown() + + if val > -1: + self['manual_starts_countdown'] = val - 1 + + @property + def msg_release_candidate(self) -> str: + """Last version of Back In Time in which the release candidate message + box was displayed. + """ + return self['message'].get('release_candidate', None) + + @msg_release_candidate.setter + def msg_release_candidate(self, val: str) -> None: + self['message']['release_candidate'] = val + + @property + def msg_encfs_global(self) -> bool: + """If global EncFS deprecation message box was displayed already.""" + return self['message']['encfs'].get('global', False) + + @msg_encfs_global.setter + def msg_encfs_global(self, val: bool) -> None: + self['message']['encfs']['global'] = val + + @property + def mainwindow_show_hidden(self) -> bool: + """Show hidden files in files view.""" + return self['gui']['mainwindow'].get('show_hidden', False) + + @mainwindow_show_hidden.setter + def mainwindow_show_hidden(self, val: bool) -> None: + self['gui']['mainwindow']['show_hidden'] = val + + @property + def mainwindow_dims(self) -> tuple[int, int]: + """Dimensions of the main window.""" + return self['gui']['mainwindow']['dims'] + + @mainwindow_dims.setter + def mainwindow_dims(self, vals: tuple[int, int]) -> None: + self['gui']['mainwindow']['dims'] = vals + + @property + def mainwindow_coords(self) -> tuple[int, int]: + """Coordinates (position) of the main window.""" + return self['gui']['mainwindow']['coords'] + + @mainwindow_coords.setter + def mainwindow_coords(self, vals: tuple[int, int]) -> None: + self['gui']['mainwindow']['coords'] = vals + + @property + def logview_dims(self) -> tuple[int, int]: + """Dimensions of the log view dialog.""" + return self['gui']['logview'].get('dims', (800, 500)) + + @logview_dims.setter + def logview_dims(self, vals: tuple[int, int]) -> None: + self['gui']['logview']['dims'] = vals + + @property + def files_view_sorting(self) -> tuple[int, int]: + """Column index and sort order. + + Returns: + Tuple with column index and its sorting order (0=ascending). + """ + return self['gui']['mainwindow']['files_view'].get('sorting', (0, 0)) + + @files_view_sorting.setter + def files_view_sorting(self, vals: tuple[int, int]) -> None: + self['gui']['mainwindow']['files_view']['sorting'] = vals + + @property + def files_view_col_widths(self) -> tuple: + """Widths of columns in the files view.""" + return self['gui']['mainwindow']['files_view']['col_widths'] + + @files_view_col_widths.setter + def files_view_col_widths(self, widths: tuple) -> None: + self['gui']['mainwindow']['files_view']['col_widths'] = widths + + @property + def mainwindow_main_splitter_widths(self) -> tuple[int, int]: + """Left and right width of main splitter in main window. + + Returns: + Two entry tuple with right and left widths. + """ + return self['gui']['mainwindow'] \ + .get('splitter_main_widths', (150, 450)) + + @mainwindow_main_splitter_widths.setter + def mainwindow_main_splitter_widths(self, vals: tuple[int, int]) -> None: + self['gui']['mainwindow']['splitter_main_widths'] = vals + + @property + def mainwindow_second_splitter_widths(self) -> tuple[int, int]: + """Left and right width of second splitter in main window. + + Returns: + Two entry tuple with right and left widths. + """ + return self['gui']['mainwindow'] \ + .get('splitter_second_widths', (150, 300)) + + @mainwindow_second_splitter_widths.setter + def mainwindow_second_splitter_widths(self, vals: tuple[int, int]) -> None: + self['gui']['mainwindow']['splitter_second_widths'] = vals diff --git a/common/test/test_lint.py b/common/test/test_lint.py index a57e3f31a..737d0b2d2 100644 --- a/common/test/test_lint.py +++ b/common/test/test_lint.py @@ -45,10 +45,13 @@ 'bitbase.py', 'languages.py', 'schedule.py', + 'singleton.py', 'ssh_max_arg.py', + 'statedata.py', 'version.py', 'test/test_lint.py', 'test/test_mount.py', + 'test/test_singleton.py', 'test/test_uniquenessset.py', )] diff --git a/common/test/test_singleton.py b/common/test/test_singleton.py new file mode 100644 index 000000000..8cd00e410 --- /dev/null +++ b/common/test/test_singleton.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In time" which is released under GNU +# General Public License v2 (GPLv2). +# See file LICENSE or go to . +"""Tests about singleton module.""" +# pylint: disable=missing-class-docstring,too-few-public-methods +import unittest +import singleton + + +class Test(unittest.TestCase): + class Foo(metaclass=singleton.Singleton): + def __init__(self): + self.value = 'Ogawa' + + class Bar(metaclass=singleton.Singleton): + def __init__(self): + self.value = 'Naomi' + + def setUp(self): + # Clean up all instances + singleton.Singleton._instances = {} # pylint: disable=protected-access + + def test_twins(self): + """Identical id and values.""" + a = self.Foo() + b = self.Foo() + + self.assertEqual(id(a), id(b)) + self.assertEqual(a.value, b.value) + + def test_share_value(self): + """Modify value""" + a = self.Foo() + b = self.Foo() + a.value = 'foobar' + + self.assertEqual(a.value, 'foobar') + self.assertEqual(a.value, b.value) + + def test_multi_class(self): + """Two different singleton classes.""" + a = self.Foo() + b = self.Foo() + x = self.Bar() + y = self.Bar() + + self.assertEqual(id(a), id(b)) + self.assertEqual(id(x), id(y)) + self.assertNotEqual(id(a), id(y)) + + self.assertEqual(a.value, 'Ogawa') + self.assertEqual(x.value, 'Naomi') + + a.value = 'who' + self.assertEqual(b.value, 'who') + self.assertEqual(x.value, 'Naomi') + self.assertEqual(x.value, y.value) diff --git a/qt/app.py b/qt/app.py index 0624efb5c..5b0a19565 100644 --- a/qt/app.py +++ b/qt/app.py @@ -22,17 +22,13 @@ import signal from contextlib import contextmanager from tempfile import TemporaryDirectory - # We need to import common/tools.py import qttools_path qttools_path.registerBackintimePath('common') - # Workaround until the codebase is rectified/equalized. import tools tools.initiate_translation(None) - import qttools - import backintime import bitbase import tools @@ -43,7 +39,7 @@ import progress import encfsmsgbox from exceptions import MountException - +from statedata import StateData from PyQt6.QtGui import (QAction, QShortcut, QDesktopServices, @@ -131,6 +127,8 @@ def __init__(self, config, appInstance, qapp): self._create_menubar() self._create_main_toolbar() + state_data = StateData() + # timeline (left widget) self.timeLine = qttools.TimeLine(self) self.timeLine.updateFilesView.connect(self.updateFilesView) @@ -225,13 +223,10 @@ def __init__(self, config, appInstance, qapp): self.filesViewDelegate = QStyledItemDelegate(self) self.filesView.setItemDelegate(self.filesViewDelegate) - sortColumn = self.config.intValue( - 'qt.main_window.files_view.sort.column', 0) - sortOrder = self.config.boolValue( - 'qt.main_window.files_view.sort.ascending', True) - sortOrder = Qt.SortOrder.AscendingOrder if sortOrder else Qt.SortOrder.DescendingOrder + sortColumn, sortOrder = state_data.files_view_sorting - self.filesView.header().setSortIndicator(sortColumn, sortOrder) + self.filesView.header().setSortIndicator( + sortColumn, Qt.SortOrder(sortOrder)) self.filesViewModel.sort( self.filesView.header().sortIndicatorSection(), self.filesView.header().sortIndicatorOrder()) @@ -296,43 +291,26 @@ def __init__(self, config, appInstance, qapp): self.widget_current_path.setText(self.path) self.path_history = tools.PathHistory(self.path) - # restore size and position - x = self.config.intValue('qt.main_window.x', -1) - y = self.config.intValue('qt.main_window.y', -1) - if x >= 0 and y >= 0: - self.move(x, y) - - w = self.config.intValue('qt.main_window.width', 800) - h = self.config.intValue('qt.main_window.height', 500) - self.resize(w, h) - - mainSplitterLeftWidth = self.config.intValue( - 'qt.main_window.main_splitter_left_w', 150) - mainSplitterRightWidth = self.config.intValue( - 'qt.main_window.main_splitter_right_w', 450) - sizes = [mainSplitterLeftWidth, mainSplitterRightWidth] - self.mainSplitter.setSizes(sizes) - - secondSplitterLeftWidth = self.config.intValue( - 'qt.main_window.second_splitter_left_w', 150) - secondSplitterRightWidth = self.config.intValue( - 'qt.main_window.second_splitter_right_w', 300) - sizes = [secondSplitterLeftWidth, secondSplitterRightWidth] - self.secondSplitter.setSizes(sizes) - - filesViewColumnNameWidth = self.config.intValue( - 'qt.main_window.files_view.name_width', -1) - filesViewColumnSizeWidth = self.config.intValue( - 'qt.main_window.files_view.size_width', -1) - filesViewColumnDateWidth = self.config.intValue( - 'qt.main_window.files_view.date_width', -1) - - if (filesViewColumnNameWidth > 0 - and filesViewColumnSizeWidth > 0 - and filesViewColumnDateWidth > 0): - self.filesView.header().resizeSection(0, filesViewColumnNameWidth) - self.filesView.header().resizeSection(1, filesViewColumnSizeWidth) - self.filesView.header().resizeSection(2, filesViewColumnDateWidth) + # restore position and size + try: + self.move(*state_data.mainwindow_coords) + self.resize(*state_data.mainwindow_dims) + except KeyError: + pass + + self.mainSplitter.setSizes( + state_data.mainwindow_main_splitter_widths) + self.secondSplitter.setSizes( + state_data.mainwindow_second_splitter_widths) + + # FilesView: Column width + try: + files_view_col_widths = state_data.files_view_col_widths + except KeyError: + pass + else: + for idx, width in enumerate(files_view_col_widths): + self.filesView.header().resizeSection(idx, width) # Force dialog to import old configuration if not config.isConfigured(): @@ -406,8 +384,8 @@ def __init__(self, config, appInstance, qapp): SetupCron(self).start() - # Finished countdown of manual GUI starts - if 0 == self.config.manual_starts_countdown(): + # Countdown of manual GUI starts finished? + if 0 == state_data.manual_starts_countdown(): # Do nothing if English is the current used language if self.config.language_used != 'en': @@ -419,11 +397,10 @@ def __init__(self, config, appInstance, qapp): # BIT counts down how often the GUI was started. Until the end of that # countdown a dialog with a text about contributing to translating # BIT is presented to the users. - self.config.decrement_manual_starts_countdown() + state_data.decrement_manual_starts_countdown() # If the encfs-deprecation warning was never shown before - if self.config.boolValue('internal.msg_shown_encfs') == False: - + if state_data.msg_encfs_global is False: # Are there profiles using EncFS? encfs_profiles = [] for pid in self.config.profiles(): @@ -435,23 +412,24 @@ def __init__(self, config, appInstance, qapp): if encfs_profiles: dlg = encfsmsgbox.EncfsExistsWarning(self, encfs_profiles) dlg.exec() - self.config.setBoolValue('internal.msg_shown_encfs', True) + state_data.msg_encfs_global = True # Release Candidate if version.is_release_candidate(): - last_vers = self.config.strValue('internal.msg_rc') + last_vers = state_data.msg_release_candidate if last_vers != version.__version__: - self.config.setStrValue('internal.msg_rc', version.__version__) + state_data.msg_release_candidate = version.__version__ self._open_release_candidate_dialog() @property def showHiddenFiles(self): - return self.config.boolValue('qt.show_hidden_files', False) + state_data = StateData() + return state_data.mainwindow_show_hidden - # TODO The qt.show_hidden_files key should be a constant instead of a duplicated string @showHiddenFiles.setter def showHiddenFiles(self, value): - self.config.setBoolValue('qt.show_hidden_files', value) + state_data = StateData() + state_data.mainwindow_show_hidden = value def _create_actions(self): """Create all action objects used by this main window. @@ -849,6 +827,9 @@ def _create_and_get_filesview_toolbar(self): return toolbar def closeEvent(self, event): + state_data = StateData() + profile_state = state_data.profile(self.config.current_profile_id) + if self.shutdown.askBeforeQuit(): msg = _('If you close this window, Back In Time will not be able ' 'to shut down your system when the snapshot is finished.') @@ -858,40 +839,32 @@ def closeEvent(self, event): if answer != QMessageBox.StandardButton.Yes: return event.ignore() - self.config.setProfileStrValue('qt.last_path', self.path) - - self.config.setProfileIntValue( - 'qt.places.SortColumn', - self.places.header().sortIndicatorSection()) - self.config.setProfileIntValue( - 'qt.places.SortOrder', - self.places.header().sortIndicatorOrder()) - - self.config.setIntValue('qt.main_window.x', self.x()) - self.config.setIntValue('qt.main_window.y', self.y()) - self.config.setIntValue('qt.main_window.width', self.width()) - self.config.setIntValue('qt.main_window.height', self.height()) - - sizes = self.mainSplitter.sizes() - self.config.setIntValue('qt.main_window.main_splitter_left_w', sizes[0]) - self.config.setIntValue('qt.main_window.main_splitter_right_w', sizes[1]) - - sizes = self.secondSplitter.sizes() - self.config.setIntValue('qt.main_window.second_splitter_left_w', sizes[0]) - self.config.setIntValue('qt.main_window.second_splitter_right_w', sizes[1]) - - self.config.setIntValue('qt.main_window.files_view.name_width', self.filesView.header().sectionSize(0)) - self.config.setIntValue('qt.main_window.files_view.size_width', self.filesView.header().sectionSize(1)) - self.config.setIntValue('qt.main_window.files_view.date_width', self.filesView.header().sectionSize(2)) + profile_state.last_path = pathlib.Path(self.path) + profile_state.places_sorting = ( + self.places.header().sortIndicatorSection(), + self.places.header().sortIndicatorOrder().value, + ) - self.config.setIntValue('qt.main_window.files_view.sort.column', self.filesView.header().sortIndicatorSection()) - self.config.setBoolValue('qt.main_window.files_view.sort.ascending', self.filesView.header().sortIndicatorOrder() == Qt.SortOrder.AscendingOrder) + state_data.mainwindow_coords = (self.x(), self.y()) + state_data.mainwindow_dims = (self.width(), self.height()) + state_data.mainwindow_main_splitter_widths = self.mainSplitter.sizes() + state_data.mainwindow_second_splitter_widths \ + = self.secondSplitter.sizes() + state_data.files_view_col_widths = [ + self.filesView.header().sectionSize(idx) + for idx + in range(self.filesView.header().count()) + ] + state_data.files_view_sorting = ( + self.filesView.header().sortIndicatorSection(), + self.filesView.header().sortIndicatorOrder().value + ) self.filesViewModel.deleteLater() - #umount + # umount try: - mnt = mount.Mount(cfg = self.config, parent = self) + mnt = mount.Mount(cfg=self.config, parent=self) mnt.umount(self.config.current_hash_id) except MountException as ex: messagebox.critical(self, str(ex)) @@ -1738,7 +1711,7 @@ def tmpCopy(self, full_path, sid=None): if sid: sid = '_' + sid.sid - d = TemporaryDirectory(prefix='backintime_', suffix = sid) + d = TemporaryDirectory(prefix='backintime_', suffix=sid) tmp_file = os.path.join(d.name, os.path.basename(full_path)) if os.path.isdir(full_path): @@ -1775,13 +1748,24 @@ def openPath(self, rel_path): self.run = QDesktopServices.openUrl(file_url) @pyqtSlot(int) - def updateFilesView(self, changed_from, selected_file = None, show_snapshots = False): #0 - files view change directory, 1 - files view, 2 - time_line, 3 - places + def updateFilesView(self, + changed_from, + selected_file=None, + show_snapshots=False): + """ + changed_from? WTF! + 0 - files view change directory, + 1 - files view, + 2 - time_line, + 3 - places + """ if 0 == changed_from or 3 == changed_from: selected_file = '' if 0 == changed_from: # update places self.places.setCurrentItem(None) + for place_index in range(self.places.topLevelItemCount()): item = self.places.topLevelItem(place_index) if self.path == str(item.data(0, Qt.ItemDataRole.UserRole)): @@ -1791,6 +1775,7 @@ def updateFilesView(self, changed_from, selected_file = None, show_snapshots = F text = '' if self.sid.isRoot: text = _('Now') + else: name = self.sid.displayName # buhtz (2023-07)3 blanks at the end of that string as a @@ -1811,8 +1796,10 @@ def updateFilesView(self, changed_from, selected_file = None, show_snapshots = F full_path = self.sid.pathBackup(self.path) if os.path.isdir(full_path): + if self.showHiddenFiles: self.filesViewProxyModel.setFilterRegularExpression(r'') + else: self.filesViewProxyModel.setFilterRegularExpression(r'^[^\.]') @@ -2155,6 +2142,7 @@ def eventFilter(self, receiver, event): return super(ExtraMouseButtonEventFilter, self) \ .eventFilter(receiver, event) + class RemoveSnapshotThread(QThread): """ remove snapshots in background thread so GUI will not freeze diff --git a/qt/logviewdialog.py b/qt/logviewdialog.py index 0ec3c220e..54d603d99 100644 --- a/qt/logviewdialog.py +++ b/qt/logviewdialog.py @@ -25,6 +25,7 @@ import snapshotlog import tools import qttools +from statedata import StateData class LogViewDialog(QDialog): @@ -50,9 +51,8 @@ def __init__(self, parent, sid=None, systray=False): self.enableUpdate = False self.decode = None - w = self.config.intValue('qt.logview.width', 800) - h = self.config.intValue('qt.logview.height', 500) - self.resize(w, h) + state_data = StateData() + self.resize(*state_data.logview_dims) import icon self.setWindowIcon(icon.VIEW_SNAPSHOT_LOG) @@ -102,13 +102,17 @@ def __init__(self, parent, sid=None, systray=False): # "Few" in Polish. # Research in translation community indicate this as the best fit to # the meaning of "all". - self.comboFilter.addItem(' + '.join((_('Errors'), _('Changes'))), snapshotlog.LogFilter.ERROR_AND_CHANGES) + self.comboFilter.addItem( + ' + '.join((_('Errors'), _('Changes'))), + snapshotlog.LogFilter.ERROR_AND_CHANGES) self.comboFilter.setCurrentIndex(self.comboFilter.count() - 1) self.comboFilter.addItem(_('Errors'), snapshotlog.LogFilter.ERROR) self.comboFilter.addItem(_('Changes'), snapshotlog.LogFilter.CHANGES) self.comboFilter.addItem(ngettext('Information', 'Information', 2), snapshotlog.LogFilter.INFORMATION) - self.comboFilter.addItem(_('rsync transfer failures (experimental)'), snapshotlog.LogFilter.RSYNC_TRANSFER_FAILURES) + self.comboFilter.addItem( + _('rsync transfer failures (experimental)'), + snapshotlog.LogFilter.RSYNC_TRANSFER_FAILURES) # text view self.txtLogView = QPlainTextEdit(self) @@ -224,29 +228,37 @@ def updateLog(self, watchPath=None): mode = self.comboFilter.itemData(self.comboFilter.currentIndex()) - # TODO This expressions is hard to understand (watchPath is not a boolean!) + # TODO This expressions is hard to understand (watchPath is not a + # boolean!) if watchPath and self.sid is None: - # remove path from watch to prevent multiple updates at the same time + # remove path from watch to prevent multiple updates at the same + # time self.watcher.removePath(watchPath) # append only new lines to txtLogView - log = snapshotlog.SnapshotLog(self.config, self.comboProfiles.currentProfileID()) - for line in log.get(mode = mode, - decode = self.decode, - skipLines = self.txtLogView.document().lineCount() - 1): + log = snapshotlog.SnapshotLog( + self.config, self.comboProfiles.currentProfileID()) + for line in log.get(mode=mode, + decode=self.decode, + skipLines=self.txtLogView.document().lineCount()-1): self.txtLogView.appendPlainText(line) # re-add path to watch after 5sec delay - alarm = tools.Alarm(callback = lambda: self.watcher.addPath(watchPath), - overwrite = False) + alarm = tools.Alarm( + callback=lambda: self.watcher.addPath(watchPath), + overwrite=False) alarm.start(5) elif self.sid is None: - log = snapshotlog.SnapshotLog(self.config, self.comboProfiles.currentProfileID()) - self.txtLogView.setPlainText('\n'.join(log.get(mode = mode, decode = self.decode))) + log = snapshotlog.SnapshotLog( + self.config, self.comboProfiles.currentProfileID()) + self.txtLogView.setPlainText( + '\n'.join(log.get(mode=mode, decode=self.decode))) + else: - self.txtLogView.setPlainText('\n'.join(self.sid.log(mode, decode = self.decode))) + self.txtLogView.setPlainText( + '\n'.join(self.sid.log(mode, decode=self.decode))) def closeEvent(self, event): - self.config.setIntValue('qt.logview.width', self.width()) - self.config.setIntValue('qt.logview.height', self.height()) + state_data = StateData() + state_data.logview_dims = (self.width(), self.height()) event.accept() diff --git a/qt/manageprofiles/__init__.py b/qt/manageprofiles/__init__.py index 65494ae09..eb83ab40c 100644 --- a/qt/manageprofiles/__init__.py +++ b/qt/manageprofiles/__init__.py @@ -35,6 +35,7 @@ import tools import qttools import messagebox +from statedata import StateData from manageprofiles.tab_general import GeneralTab from manageprofiles.tab_auto_remove import AutoRemoveTab from manageprofiles.tab_options import OptionsTab @@ -382,6 +383,8 @@ def updateProfile(self): self.btnRemoveProfile.setEnabled(True) self.btnAddProfile.setEnabled(self.config.isConfigured('1')) + profile_state = StateData().profile(self.config.currentProfile()) + # TAB: General self._tab_general.load_values() @@ -391,16 +394,6 @@ def updateProfile(self): for include in self.config.include(): self.addInclude(include) - includeSortColumn = int( - self.config.profileIntValue('qt.settingsdialog.include.SortColumn', - 1) - ) - includeSortOrder = Qt.SortOrder( - self.config.profileIntValue('qt.settingsdialog.include.SortOrder', - Qt.SortOrder.AscendingOrder) - ) - self.listInclude.sortItems(includeSortColumn, includeSortOrder) - # TAB: Exclude self.listExclude.clear() @@ -409,13 +402,17 @@ def updateProfile(self): self.cbExcludeBySize.setChecked(self.config.excludeBySizeEnabled()) self.spbExcludeBySize.setValue(self.config.excludeBySize()) - excludeSortColumn = int(self.config.profileIntValue( - 'qt.settingsdialog.exclude.SortColumn', 1)) - excludeSortOrder = Qt.SortOrder( - self.config.profileIntValue('qt.settingsdialog.exclude.SortOrder', - Qt.SortOrder.AscendingOrder) - ) - self.listExclude.sortItems(excludeSortColumn, excludeSortOrder) + try: + incl_sort = profile_state.include_sorting + excl_sort = profile_state.exclude_sorting + self.listInclude.sortItems( + incl_sort[0], Qt.SortOrder(incl_sort[1]) + ) + self.listExclude.sortItems( + excl_sort[0], Qt.SortOrder(excl_sort[1])) + except KeyError: + pass + self._update_exclude_recommend_label() self._tab_auto_remove.load_values() @@ -437,13 +434,14 @@ def saveProfile(self): if success is False: return False + profile_state = StateData().profile(self.config.currentProfile()) + # include list - self.config.setProfileIntValue( - 'qt.settingsdialog.include.SortColumn', - self.listInclude.header().sortIndicatorSection()) - self.config.setProfileIntValue( - 'qt.settingsdialog.include.SortOrder', - self.listInclude.header().sortIndicatorOrder()) + profile_state.include_sorting = ( + self.listInclude.header().sortIndicatorSection(), + self.listInclude.header().sortIndicatorOrder().value + ) + # Why? self.listInclude.sortItems(1, Qt.SortOrder.AscendingOrder) include_list = [] @@ -455,12 +453,11 @@ def saveProfile(self): self.config.setInclude(include_list) # exclude patterns - self.config.setProfileIntValue( - 'qt.settingsdialog.exclude.SortColumn', - self.listExclude.header().sortIndicatorSection()) - self.config.setProfileIntValue( - 'qt.settingsdialog.exclude.SortOrder', - self.listExclude.header().sortIndicatorOrder()) + profile_state.exclude_sorting = ( + self.listExclude.header().sortIndicatorSection(), + self.listExclude.header().sortIndicatorOrder().value + ) + # Why? self.listExclude.sortItems(1, Qt.SortOrder.AscendingOrder) exclude_list = []