From 0d4807202356ff3b915f50fda2e852d8d35b5222 Mon Sep 17 00:00:00 2001 From: Bob Swift Date: Thu, 7 Apr 2022 16:37:41 -0600 Subject: [PATCH 1/4] Add maintenance options to backup and restore configuration INI files --- picard/config.py | 32 ++++++-- picard/ui/options/dialog.py | 13 +++- picard/ui/options/maintenance.py | 111 +++++++++++++++++++++++++++- picard/ui/ui_options_maintenance.py | 18 ++++- ui/options_maintenance.ui | 34 +++++++++ 5 files changed, 197 insertions(+), 11 deletions(-) diff --git a/picard/config.py b/picard/config.py index 159d68e09..ce0fdbfed 100644 --- a/picard/config.py +++ b/picard/config.py @@ -12,7 +12,7 @@ # Copyright (C) 2017 Sophist-UK # Copyright (C) 2018 Vishal Choudhary # Copyright (C) 2020-2021 Gabriel Ferreira -# Copyright (C) 2021 Bob Swift +# Copyright (C) 2021-2022 Bob Swift # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -241,7 +241,8 @@ class Config(QtCore.QSettings): self.setting = SettingConfigSection(self, "setting") self.persist = ConfigSection(self, "persist") - TextOption("application", "version", '0.0.0dev0') + if 'version' not in self.application or not self.application['version']: + TextOption("application", "version", '0.0.0dev0') self._version = Version.from_string(self.application["version"]) self._upgrade_hooks = dict() @@ -368,11 +369,16 @@ class Config(QtCore.QSettings): def _backup_settings(self): if Version(0, 0, 0) < self._version < PICARD_VERSION: backup_path = self._versioned_config_filename() - log.info('Backing up config file to %s', backup_path) - try: - shutil.copyfile(self.fileName(), backup_path) - except OSError: - log.error('Failed backing up config file to %s', backup_path) + self._save_backup(backup_path) + + def _save_backup(self, backup_path): + log.info('Backing up config file to %s', backup_path) + try: + shutil.copyfile(self.fileName(), backup_path) + except OSError: + log.error('Failed backing up config file to %s', backup_path) + return False + return True def _write_version(self): self.application["version"] = self._version.to_string() @@ -384,6 +390,12 @@ class Config(QtCore.QSettings): return os.path.join(os.path.dirname(self.fileName()), '%s-%s.ini' % ( self.applicationName(), version.to_string(short=True))) + def save_user_backup(self, backup_path): + if backup_path == self.fileName(): + log.warning("Attempt to backup configuration file to the same path.") + return False + return self._save_backup(backup_path) + class Option(QtCore.QObject): @@ -479,3 +491,9 @@ def get_config(): Config objects for threads are created on demand and cached for later use. """ return config + + +def load_new_config(filename=None): + ini_file = get_config().fileName() + shutil.copy(filename, ini_file) + setup_config(QtCore.QObject.tagger, ini_file) diff --git a/picard/ui/options/dialog.py b/picard/ui/options/dialog.py index 799c94f64..a1771a53b 100644 --- a/picard/ui/options/dialog.py +++ b/picard/ui/options/dialog.py @@ -13,7 +13,7 @@ # Copyright (C) 2016-2017 Sambhav Kothari # Copyright (C) 2017 Suhas # Copyright (C) 2018 Vishal Choudhary -# Copyright (C) 2021 Bob Swift +# Copyright (C) 2021-2022 Bob Swift # Copyright (C) 2021 Gabriel Ferreira # # This program is free software; you can redistribute it and/or @@ -185,6 +185,9 @@ class OptionsDialog(PicardDialog, SingletonDialog): self.profile_page = self.get_page('profiles') self.profile_page.signal_refresh.connect(self.update_from_profile_changes) + self.maintenance_page = self.get_page('maintenance') + self.maintenance_page.signal_reload.connect(self.reload_all_pages) + self.first_enter = True self.installEventFilter(self) @@ -192,6 +195,14 @@ class OptionsDialog(PicardDialog, SingletonDialog): current_page = self.item_to_page[self.ui.pages_tree.currentItem()] self.set_profiles_button_and_highlight(current_page) + def reload_all_pages(self): + for page in self.pages: + try: + page.load() + except Exception: + log.exception('Failed loading options page %r', page) + self.disable_page(page.NAME) + def page_has_profile_options(self, page): try: name = page.PARENT if page.PARENT in UserProfileGroups.SETTINGS_GROUPS else page.NAME diff --git a/picard/ui/options/maintenance.py b/picard/ui/options/maintenance.py index f08895641..e847ace95 100644 --- a/picard/ui/options/maintenance.py +++ b/picard/ui/options/maintenance.py @@ -2,7 +2,7 @@ # # Picard, the next-generation MusicBrainz tagger # -# Copyright (C) 2021 Bob Swift +# Copyright (C) 2021-2022 Bob Swift # Copyright (C) 2021 Laurent Monin # Copyright (C) 2021-2022 Philipp Wolfer # @@ -21,6 +21,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import datetime from os import path from PyQt5 import ( @@ -33,7 +34,9 @@ from picard import log from picard.config import ( Option, get_config, + load_new_config, ) +from picard.config_upgrade import upgrade_config from picard.ui.options import ( OptionsPage, @@ -65,6 +68,8 @@ class MaintenanceOptionsPage(OptionsPage): options = [] + signal_reload = QtCore.pyqtSignal() + def __init__(self, parent=None): super().__init__(parent) self.ui = Ui_MaintenanceOptionsPage() @@ -88,6 +93,8 @@ class MaintenanceOptionsPage(OptionsPage): self.ui.select_all.stateChanged.connect(self.select_all_changed) self.ui.enable_cleanup.stateChanged.connect(self.enable_cleanup_changed) self.ui.open_folder_button.clicked.connect(self.open_config_dir) + self.ui.save_backup_button.clicked.connect(self.save_backup) + self.ui.load_backup_button.clicked.connect(self.load_backup) # Set the palette of the config file QLineEdit widget to inactive. palette_normal = self.ui.config_file.palette() @@ -154,6 +161,108 @@ class MaintenanceOptionsPage(OptionsPage): config_dir = path.split(config.fileName())[0] QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(config_dir)) + def _get_dialog_filetypes(self, _ext='.ini'): + return ";;".join(( + _("Configuration files") + " (*{0})".format(_ext,), + _("All files") + " (*)", + )) + + def _make_backup_filename(self, auto=False): + config = get_config() + _filename = path.split(config.fileName())[1] + _root, _ext = path.splitext(_filename) + return "{0}_{1}_Backup_{2}{3}".format( + _root, + 'Auto' if auto else 'User', + datetime.datetime.now().strftime("%Y%m%d_%H%M"), + _ext, + ) + + def _backup_error(self, dialog_title=None): + if not dialog_title: + dialog_title = _("Backup Configuration File") + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Critical, + dialog_title, + _("There was a problem backing up the configuration file. Please see the logs for more details."), + QtWidgets.QMessageBox.StandardButton.Ok, + self + ) + dialog.exec_() + + def save_backup(self): + config = get_config() + directory = path.normpath(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.StandardLocation.DocumentsLocation)) + filename = self._make_backup_filename() + ext = path.splitext(filename)[1] + default_path = path.normpath(path.join(directory, filename)) + + dialog_title = _("Backup Configuration File") + dialog_file_types = self._get_dialog_filetypes(ext) + options = QtWidgets.QFileDialog.Options() + filename, file_type = QtWidgets.QFileDialog.getSaveFileName(self, dialog_title, default_path, dialog_file_types, options=options) + if not filename: + return + # Fix issue where Qt may set the extension twice + (name, ext) = path.splitext(filename) + if ext and str(name).endswith('.' + ext): + filename = name + + if config.save_user_backup(filename): + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Information, + dialog_title, + _("Configuration successfully backed up to %s") % filename, + QtWidgets.QMessageBox.StandardButton.Ok, + self + ) + dialog.exec_() + else: + self._backup_error(dialog_title) + + def load_backup(self): + dialog_title = _("Load Backup Configuration File") + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Warning, + dialog_title, + _("Loading a backup configuration file will replace the current configuration settings. " + "A backup copy of the current file will be saved automatically.\n\nDo you want to continue?"), + QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel, + self + ) + dialog.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + if not dialog.exec_() == QtWidgets.QMessageBox.StandardButton.Ok: + return + + config = get_config() + directory = path.normpath(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.StandardLocation.DocumentsLocation)) + filename = path.join(directory, self._make_backup_filename(auto=True)) + if not config.save_user_backup(filename): + self._backup_error() + return + + ext = path.splitext(filename)[1] + dialog_file_types = self._get_dialog_filetypes(ext) + directory = path.normpath(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.StandardLocation.DocumentsLocation)) + options = QtWidgets.QFileDialog.Options() + filename, file_type = QtWidgets.QFileDialog.getOpenFileName(self, dialog_title, directory, dialog_file_types, options=options) + if not filename: + return + log.warning('Loading configuration from %s' % filename) + load_new_config(filename) + config = get_config() + upgrade_config(config) + QtCore.QObject.config = get_config() + self.signal_reload.emit() + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Information, + dialog_title, + _("Configuration successfully loaded from %s") % filename, + QtWidgets.QMessageBox.StandardButton.Ok, + self + ) + dialog.exec_() + def column_items(self, column): for idx in range(self.ui.tableWidget.rowCount()): yield self.ui.tableWidget.item(idx, column) diff --git a/picard/ui/ui_options_maintenance.py b/picard/ui/ui_options_maintenance.py index 1a807398b..3dbf44402 100644 --- a/picard/ui/ui_options_maintenance.py +++ b/picard/ui/ui_options_maintenance.py @@ -27,6 +27,18 @@ class Ui_MaintenanceOptionsPage(object): self.open_folder_button.setObjectName("open_folder_button") self.horizontalLayout_3.addWidget(self.open_folder_button) self.vboxlayout.addLayout(self.horizontalLayout_3) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setContentsMargins(-1, -1, -1, 0) + self.horizontalLayout.setObjectName("horizontalLayout") + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem) + self.load_backup_button = QtWidgets.QToolButton(MaintenanceOptionsPage) + self.load_backup_button.setObjectName("load_backup_button") + self.horizontalLayout.addWidget(self.load_backup_button) + self.save_backup_button = QtWidgets.QToolButton(MaintenanceOptionsPage) + self.save_backup_button.setObjectName("save_backup_button") + self.horizontalLayout.addWidget(self.save_backup_button) + self.vboxlayout.addLayout(self.horizontalLayout) self.option_counts = QtWidgets.QLabel(MaintenanceOptionsPage) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -46,8 +58,8 @@ class Ui_MaintenanceOptionsPage(object): self.description.setIndent(0) self.description.setObjectName("description") self.vboxlayout.addWidget(self.description) - spacerItem = QtWidgets.QSpacerItem(20, 8, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - self.vboxlayout.addItem(spacerItem) + spacerItem1 = QtWidgets.QSpacerItem(20, 8, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.vboxlayout.addItem(spacerItem1) self.line = QtWidgets.QFrame(MaintenanceOptionsPage) self.line.setFrameShape(QtWidgets.QFrame.HLine) self.line.setFrameShadow(QtWidgets.QFrame.Sunken) @@ -73,5 +85,7 @@ class Ui_MaintenanceOptionsPage(object): _translate = QtCore.QCoreApplication.translate self.label.setText(_("Configuration File:")) self.open_folder_button.setText(_("Open folder")) + self.load_backup_button.setText(_("Load Backup")) + self.save_backup_button.setText(_("Save Backup")) self.enable_cleanup.setText(_("Remove selected options")) self.select_all.setText(_("Select all")) diff --git a/ui/options_maintenance.ui b/ui/options_maintenance.ui index ac4fd98c7..45b971c40 100644 --- a/ui/options_maintenance.ui +++ b/ui/options_maintenance.ui @@ -39,6 +39,40 @@ + + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Load Backup + + + + + + + Save Backup + + + + + From 84b3793dd664a4203ef45effe69a7d048d3f32b8 Mon Sep 17 00:00:00 2001 From: Bob Swift Date: Fri, 8 Apr 2022 08:13:03 -0600 Subject: [PATCH 2/4] Add check to ensure restore file is copied before processing --- picard/config.py | 7 ++++++- picard/ui/options/maintenance.py | 32 ++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/picard/config.py b/picard/config.py index ce0fdbfed..048a648af 100644 --- a/picard/config.py +++ b/picard/config.py @@ -495,5 +495,10 @@ def get_config(): def load_new_config(filename=None): ini_file = get_config().fileName() - shutil.copy(filename, ini_file) + try: + shutil.copy(filename, ini_file) + except OSError: + log.error('Failed restoring config file from %s', filename) + return False setup_config(QtCore.QObject.tagger, ini_file) + return True diff --git a/picard/ui/options/maintenance.py b/picard/ui/options/maintenance.py index e847ace95..faaf18609 100644 --- a/picard/ui/options/maintenance.py +++ b/picard/ui/options/maintenance.py @@ -249,18 +249,26 @@ class MaintenanceOptionsPage(OptionsPage): if not filename: return log.warning('Loading configuration from %s' % filename) - load_new_config(filename) - config = get_config() - upgrade_config(config) - QtCore.QObject.config = get_config() - self.signal_reload.emit() - dialog = QtWidgets.QMessageBox( - QtWidgets.QMessageBox.Icon.Information, - dialog_title, - _("Configuration successfully loaded from %s") % filename, - QtWidgets.QMessageBox.StandardButton.Ok, - self - ) + if load_new_config(filename): + config = get_config() + upgrade_config(config) + QtCore.QObject.config = get_config() + self.signal_reload.emit() + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Information, + dialog_title, + _("Configuration successfully loaded from %s") % filename, + QtWidgets.QMessageBox.StandardButton.Ok, + self + ) + else: + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Critical, + dialog_title, + _("There was a problem restoring the configuration file. Please see the logs for more details."), + QtWidgets.QMessageBox.StandardButton.Ok, + self + ) dialog.exec_() def column_items(self, column): From 9aaf1a18b76bb0c314e9287c6bab4b59b774cd91 Mon Sep 17 00:00:00 2001 From: Bob Swift Date: Fri, 8 Apr 2022 08:26:21 -0600 Subject: [PATCH 3/4] Refactor to remove duplicate code --- picard/ui/options/dialog.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/picard/ui/options/dialog.py b/picard/ui/options/dialog.py index a1771a53b..fb576e4c7 100644 --- a/picard/ui/options/dialog.py +++ b/picard/ui/options/dialog.py @@ -174,19 +174,14 @@ class OptionsDialog(PicardDialog, SingletonDialog): self.restoreWindowState() self.finished.connect(self.saveWindowState) - for page in self.pages: - try: - page.load() - except Exception: - log.exception('Failed loading options page %r', page) - self.disable_page(page.NAME) + self.load_all_pages() self.ui.pages_tree.setCurrentItem(self.default_item) self.profile_page = self.get_page('profiles') self.profile_page.signal_refresh.connect(self.update_from_profile_changes) self.maintenance_page = self.get_page('maintenance') - self.maintenance_page.signal_reload.connect(self.reload_all_pages) + self.maintenance_page.signal_reload.connect(self.load_all_pages) self.first_enter = True self.installEventFilter(self) @@ -195,7 +190,7 @@ class OptionsDialog(PicardDialog, SingletonDialog): current_page = self.item_to_page[self.ui.pages_tree.currentItem()] self.set_profiles_button_and_highlight(current_page) - def reload_all_pages(self): + def load_all_pages(self): for page in self.pages: try: page.load() From a30b821649e23eda0c79863a9087a7c283e950a4 Mon Sep 17 00:00:00 2001 From: Bob Swift Date: Fri, 8 Apr 2022 09:52:03 -0600 Subject: [PATCH 4/4] Use file format-agnostic variable name --- picard/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/picard/config.py b/picard/config.py index 048a648af..46b3215f3 100644 --- a/picard/config.py +++ b/picard/config.py @@ -494,11 +494,11 @@ def get_config(): def load_new_config(filename=None): - ini_file = get_config().fileName() + config_file = get_config().fileName() try: - shutil.copy(filename, ini_file) + shutil.copy(filename, config_file) except OSError: log.error('Failed restoring config file from %s', filename) return False - setup_config(QtCore.QObject.tagger, ini_file) + setup_config(QtCore.QObject.tagger, config_file) return True