From 21c86cd84651084b80ba9d7f3043cf54943d1815 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 23 Jun 2023 23:39:25 +0530 Subject: [PATCH 01/31] feat: exclude gui --- src/vorta/assets/UI/excludedialog.ui | 132 +++++++++++++++++++++++++++ src/vorta/assets/UI/sourcetab.ui | 38 ++------ src/vorta/views/exclude_dialog.py | 46 ++++++++++ src/vorta/views/source_tab.py | 13 +-- 4 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 src/vorta/assets/UI/excludedialog.ui create mode 100644 src/vorta/views/exclude_dialog.py diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui new file mode 100644 index 000000000..3b20063a6 --- /dev/null +++ b/src/vorta/assets/UI/excludedialog.ui @@ -0,0 +1,132 @@ + + + Dialog + + + + 0 + 0 + 784 + 565 + + + + Choose archives for diff + + + + + + 10 + + + + + 0 + + + + Custom + + + + + + Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. + + + true + + + + + + + + + + + + ... + + + + + + + ... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + + + + 50 + 0 + + + + + + + + + + + Presets + + + + + Raw + + + + + Preview + + + + + + + + + + + diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index f0af8c385..9b672700c 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -148,20 +148,10 @@ - + 12 - - - - <html><head/><body><p>Exclude Patterns (<a href="https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns"><span style=" text-decoration: underline; color:#0984e3;">more</span></a>):</p></body></html> - - - true - - - @@ -169,25 +159,6 @@ - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustToContentsOnFirstShow - - - - - - E.g. */.cache - - - @@ -204,6 +175,13 @@ + + + + Select Exclude Patterns... + + + diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py new file mode 100644 index 000000000..9535dd33c --- /dev/null +++ b/src/vorta/views/exclude_dialog.py @@ -0,0 +1,46 @@ +from PyQt6 import uic +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QStandardItem, QStandardItemModel + +from vorta.utils import get_asset + +uifile = get_asset('UI/excludedialog.ui') +ExcludeDialogUi, ExcludeDialogBase = uic.loadUiType(uifile) + + +class ExcludeDialog(ExcludeDialogBase, ExcludeDialogUi): + def __init__(self, profile, parent=None): + super().__init__(parent) + self.setupUi(self) + self.profile = profile + self.setWindowTitle(self.tr('Add patterns to exclude')) + + self.sample_exclusion_data = [ + {'pattern': '*/.DS_Store', 'enabled': True, 'comment': 'Mac OS X Finder metadata file'}, + {'pattern': '*/.TemporaryItems', 'enabled': False, 'comment': 'Mac OS X temporary files'}, + { + 'pattern': '*/.Spotlight-V100', + 'enabled': True, + 'comment': None, + }, + { + 'pattern': '*/.Trashes', + 'enabled': False, + 'comment': None, + }, + ] + self.poupulate_excludes() + + def poupulate_excludes(self): + model = QStandardItemModel() + self.customExclusionsList.setModel(model) + + for exclude in self.sample_exclusion_data: + item = QStandardItem(exclude['pattern']) + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked if exclude['enabled'] else Qt.CheckState.Unchecked) + + if exclude['comment']: + item.setToolTip(exclude['comment']) + + model.appendRow(item) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 13a48f5e4..b2d7d6adf 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -21,6 +21,7 @@ pretty_bytes, sort_sizes, ) +from vorta.views.exclude_dialog import ExcludeDialog from vorta.views.utils import get_colored_icon uifile = get_asset('UI/sourcetab.ui') @@ -101,7 +102,7 @@ def __init__(self, parent=None): # Connect signals self.removeButton.clicked.connect(self.source_remove) self.updateButton.clicked.connect(self.sources_update) - self.excludePatternsField.textChanged.connect(self.save_exclude_patterns) + self.bExclude.clicked.connect(self.show_exclude_dialog) self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) header.sortIndicatorChanged.connect(self.update_sort_order) @@ -251,10 +252,8 @@ def add_source_to_table(self, source, update_data=None): def populate_from_profile(self): profile = self.profile() - self.excludePatternsField.textChanged.disconnect() self.excludeIfPresentField.textChanged.disconnect() self.sourceFilesWidget.setRowCount(0) # Clear rows - self.excludePatternsField.clear() self.excludeIfPresentField.clear() for source in SourceFileModel.select().where(SourceFileModel.profile == profile): @@ -267,9 +266,7 @@ def populate_from_profile(self): # Sort items as per settings self.sourceFilesWidget.sortItems(sourcetab_sort_column, Qt.SortOrder(sourcetab_sort_order)) - self.excludePatternsField.appendPlainText(profile.exclude_patterns) self.excludeIfPresentField.appendPlainText(profile.exclude_if_present) - self.excludePatternsField.textChanged.connect(self.save_exclude_patterns) self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) def update_sort_order(self, column: int, order: int): @@ -351,9 +348,13 @@ def source_remove(self): logger.debug(f"Removed source in row {index.row()}") + def show_exclude_dialog(self): + window = ExcludeDialog(self.profile(), self) + window.show() + def save_exclude_patterns(self): profile = self.profile() - profile.exclude_patterns = self.excludePatternsField.toPlainText() + profile.exclude_patterns = "" profile.save() def save_exclude_if_present(self): From e46e1f181e6ff022448cb7fe5321a56e82aecacf Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 27 Jun 2023 23:36:42 +0530 Subject: [PATCH 02/31] add and remove patterns from custom table --- src/vorta/assets/UI/excludedialog.ui | 39 +-------- src/vorta/assets/UI/sourcetab.ui | 63 ++++++--------- src/vorta/views/exclude_dialog.py | 117 ++++++++++++++++++++------- src/vorta/views/source_tab.py | 11 --- 4 files changed, 119 insertions(+), 111 deletions(-) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index 3b20063a6..bb9459df5 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -6,8 +6,8 @@ 0 0 - 784 - 565 + 504 + 426 @@ -44,20 +44,6 @@ - - - - ... - - - - - - - ... - - - @@ -72,14 +58,14 @@ - + ... - + ... @@ -87,23 +73,6 @@ - - - - - - - - - - 50 - 0 - - - - - - diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index 9b672700c..24db13701 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -13,7 +13,7 @@ Form - + 12 @@ -103,6 +103,30 @@ + + + + + + Select Exclude Patterns... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + @@ -147,43 +171,6 @@ - - - - 12 - - - - - Exclude If Present (exclude folders with these files): - - - - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustToContentsOnFirstShow - - - E.g. .nobackup - - - - - - - Select Exclude Patterns... - - - - - diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 9535dd33c..b710e88d1 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -1,13 +1,26 @@ from PyQt6 import uic -from PyQt6.QtCore import Qt +from PyQt6.QtCore import QModelIndex, Qt from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtWidgets import ( + QAbstractItemView, +) from vorta.utils import get_asset +from vorta.views.utils import get_colored_icon uifile = get_asset('UI/excludedialog.ui') ExcludeDialogUi, ExcludeDialogBase = uic.loadUiType(uifile) +class QCustomItemModel(QStandardItemModel): + # When a user-added item in edit mode has no text, remove it from the list. + def setData(self, index: QModelIndex, value, role: int = ...) -> bool: + if role == Qt.ItemDataRole.EditRole and value == '': + self.removeRow(index.row()) + return True + return super().setData(index, value, role) + + class ExcludeDialog(ExcludeDialogBase, ExcludeDialogUi): def __init__(self, profile, parent=None): super().__init__(parent) @@ -15,32 +28,82 @@ def __init__(self, profile, parent=None): self.profile = profile self.setWindowTitle(self.tr('Add patterns to exclude')) - self.sample_exclusion_data = [ - {'pattern': '*/.DS_Store', 'enabled': True, 'comment': 'Mac OS X Finder metadata file'}, - {'pattern': '*/.TemporaryItems', 'enabled': False, 'comment': 'Mac OS X temporary files'}, - { - 'pattern': '*/.Spotlight-V100', - 'enabled': True, - 'comment': None, - }, - { - 'pattern': '*/.Trashes', - 'enabled': False, - 'comment': None, - }, - ] - self.poupulate_excludes() - - def poupulate_excludes(self): - model = QStandardItemModel() - self.customExclusionsList.setModel(model) - - for exclude in self.sample_exclusion_data: - item = QStandardItem(exclude['pattern']) + self.customExcludesModel = QCustomItemModel() + self.customExclusionsList.setModel(self.customExcludesModel) + self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) + self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.customExclusionsList.setAlternatingRowColors(True) + self.customExclusionsList.setStyleSheet( + ''' + QListView::item { + padding: 20px 0px; + border-bottom: .5px solid black; + } + QListView::item:selected { + background-color: palette(highlight); + } + + ''' + ) + + self.customExcludesModel.itemChanged.connect(self.item_changed) + + self.bRemovePattern.clicked.connect(self.remove_pattern) + self.bRemovePattern.setIcon(get_colored_icon('minus')) + self.bAddPattern.clicked.connect(self.add_pattern) + self.bAddPattern.setIcon(get_colored_icon('plus')) + + self.exclusion_list = set() + self.user_excluded_patterns = set() + + # Complete set of all exclusions selected by the user. + self.exclusion_list = { + "./.DS_Store": None, + "./.Spotlight-V100": None, + "./.Trashes": None, + "./.fseventsd": None, + "./.TemporaryItems": None, + } + # Custom patterns added by the user to exclude. + self.user_excluded_patterns = { + "./.DS_Store": None, + "./.TemporaryItems": None, + "node_modules": None, + "env": None, + } + + self.poupulate_custom_excludes() + + def poupulate_custom_excludes(self): + for exclude in self.user_excluded_patterns: + item = QStandardItem(exclude) item.setCheckable(True) - item.setCheckState(Qt.CheckState.Checked if exclude['enabled'] else Qt.CheckState.Unchecked) + item.setCheckState(Qt.CheckState.Checked if exclude in self.exclusion_list else Qt.CheckState.Unchecked) + + self.customExcludesModel.appendRow(item) + + def remove_pattern(self): + indexes = self.customExclusionsList.selectedIndexes() + for index in reversed(indexes): + self.user_excluded_patterns.pop(index.data()) + self.customExclusionsList.model().removeRow(index.row()) - if exclude['comment']: - item.setToolTip(exclude['comment']) + def add_pattern(self): + ''' + Add an empty item to the list in editable mode + ''' + item = QStandardItem('') + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked) + self.customExclusionsList.model().appendRow(item) + self.customExclusionsList.edit(item.index()) - model.appendRow(item) + def item_changed(self, item): + ''' + When the user checks or unchecks an item, add or remove it from the exclusion list. + ''' + if item.checkState() == Qt.CheckState.Checked: + self.exclusion_list[item.text()] = None + else: + self.exclusion_list.pop(item.text(), None) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index b2d7d6adf..935759312 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -103,7 +103,6 @@ def __init__(self, parent=None): self.removeButton.clicked.connect(self.source_remove) self.updateButton.clicked.connect(self.sources_update) self.bExclude.clicked.connect(self.show_exclude_dialog) - self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) header.sortIndicatorChanged.connect(self.update_sort_order) # Connect to palette change @@ -252,9 +251,7 @@ def add_source_to_table(self, source, update_data=None): def populate_from_profile(self): profile = self.profile() - self.excludeIfPresentField.textChanged.disconnect() self.sourceFilesWidget.setRowCount(0) # Clear rows - self.excludeIfPresentField.clear() for source in SourceFileModel.select().where(SourceFileModel.profile == profile): self.add_source_to_table(source, False) @@ -266,9 +263,6 @@ def populate_from_profile(self): # Sort items as per settings self.sourceFilesWidget.sortItems(sourcetab_sort_column, Qt.SortOrder(sourcetab_sort_order)) - self.excludeIfPresentField.appendPlainText(profile.exclude_if_present) - self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) - def update_sort_order(self, column: int, order: int): """Save selected sort by column and order to settings""" SettingsModel.update({SettingsModel.str_value: str(column)}).where( @@ -357,11 +351,6 @@ def save_exclude_patterns(self): profile.exclude_patterns = "" profile.save() - def save_exclude_if_present(self): - profile = self.profile() - profile.exclude_if_present = self.excludeIfPresentField.toPlainText() - profile.save() - def paste_text(self): sources = QApplication.clipboard().text().splitlines() invalidSources = "" From a2cd061b156a8dd4ce3f860a296363142f8100b6 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 28 Jun 2023 02:07:00 +0530 Subject: [PATCH 03/31] database integration for adding and removing custom patterns --- src/vorta/assets/UI/excludedialog.ui | 159 +++++++++++++++------------ src/vorta/store/connection.py | 2 + src/vorta/store/models.py | 16 +++ src/vorta/views/exclude_dialog.py | 76 ++++++++----- 4 files changed, 155 insertions(+), 98 deletions(-) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index bb9459df5..065d1e698 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -11,88 +11,109 @@ - Choose archives for diff + Add patterns to exclude + + 10 + - - - 10 - - - - - 0 - - - - Custom - - + + + + Custom + + + + + + Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. + + + true + + + + + + + + - - - Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. + + + Qt::Horizontal - - true + + + 40 + 20 + + + + + + + + + + + + + + + + Presets + + + + + Raw + + + + + + Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. + + + true + + + + + + + + - + + + Qt::Horizontal + + + + 40 + 20 + + + - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - ... - - - - - - - ... - - - - + + + Save + + - - - - Presets - - - - - Raw - - - - - Preview - - - - - + + + + diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index 21e6ec258..e7756f157 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -12,6 +12,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + ExclusionModel, RepoModel, RepoPassword, SchemaVersion, @@ -52,6 +53,7 @@ def init_db(con=None): WifiSettingModel, EventLogModel, SchemaVersion, + ExclusionModel, ] ) diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index f0c32938a..5bd442b48 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -108,6 +108,22 @@ class Meta: database = DB +class ExclusionModel(BaseModel): + """ + If this is a user created exclusion, the name will be the same as the pattern added. For exclusions added from + presets, the name will be the same as the preset name. + """ + + profile = pw.ForeignKeyField(BackupProfileModel, backref='exclusions') + name = pw.CharField(unique=True) + enabled = pw.BooleanField(default=True) + source = pw.CharField(default='user') + date_added = pw.DateTimeField(default=datetime.now) + + class Meta: + database = DB + + class SourceFileModel(BaseModel): """A folder to be backed up, related to a Backup Configuration.""" diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index b710e88d1..9dea6f233 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -5,6 +5,7 @@ QAbstractItemView, ) +from vorta.store.models import ExclusionModel from vorta.utils import get_asset from vorta.views.utils import get_colored_icon @@ -14,10 +15,17 @@ class QCustomItemModel(QStandardItemModel): # When a user-added item in edit mode has no text, remove it from the list. + def __init__(self, profile, parent=None): + super().__init__(parent) + self.profile = profile + def setData(self, index: QModelIndex, value, role: int = ...) -> bool: - if role == Qt.ItemDataRole.EditRole and value == '': - self.removeRow(index.row()) - return True + if role == Qt.ItemDataRole.EditRole: + if value == '': + self.removeRow(index.row()) + return True + ExclusionModel.create(name=value, source='user', profile=self.profile) + return super().setData(index, value, role) @@ -26,9 +34,12 @@ def __init__(self, profile, parent=None): super().__init__(parent) self.setupUi(self) self.profile = profile - self.setWindowTitle(self.tr('Add patterns to exclude')) + # Complete set of all exclusions selected by the user, these are finally passed to Borg. + self.exclusion_set = {e.name for e in self.profile.exclusions.select().where(ExclusionModel.enabled)} + # Custom patterns added by the user to exclude. + self.user_excluded_patterns = [] - self.customExcludesModel = QCustomItemModel() + self.customExcludesModel = QCustomItemModel(self.profile) self.customExclusionsList.setModel(self.customExcludesModel) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) @@ -54,40 +65,40 @@ def __init__(self, profile, parent=None): self.bAddPattern.clicked.connect(self.add_pattern) self.bAddPattern.setIcon(get_colored_icon('plus')) - self.exclusion_list = set() - self.user_excluded_patterns = set() + self.populate_custom_exclusions_list() + self.populate_raw_excludes() - # Complete set of all exclusions selected by the user. - self.exclusion_list = { - "./.DS_Store": None, - "./.Spotlight-V100": None, - "./.Trashes": None, - "./.fseventsd": None, - "./.TemporaryItems": None, - } - # Custom patterns added by the user to exclude. + def populate_custom_exclusions_list(self): self.user_excluded_patterns = { - "./.DS_Store": None, - "./.TemporaryItems": None, - "node_modules": None, - "env": None, + e.name: e.enabled + for e in self.profile.exclusions.select() + .where(ExclusionModel.source == 'user') + .order_by(ExclusionModel.date_added.desc()) } - self.poupulate_custom_excludes() - - def poupulate_custom_excludes(self): - for exclude in self.user_excluded_patterns: + for (exclude, enabled) in self.user_excluded_patterns.items(): item = QStandardItem(exclude) item.setCheckable(True) - item.setCheckState(Qt.CheckState.Checked if exclude in self.exclusion_list else Qt.CheckState.Unchecked) - + item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) self.customExcludesModel.appendRow(item) + def populate_raw_excludes(self): + raw_excludes = "" + for exclude in self.exclusion_set: + raw_excludes += f"{exclude}\n" + self.rawExclusions.setPlainText(raw_excludes) + def remove_pattern(self): indexes = self.customExclusionsList.selectedIndexes() for index in reversed(indexes): self.user_excluded_patterns.pop(index.data()) - self.customExclusionsList.model().removeRow(index.row()) + self.exclusion_set.remove(index.data()) + ExclusionModel.delete().where( + ExclusionModel.name == index.data(), + ExclusionModel.source == 'user', + ExclusionModel.profile == self.profile, + ).execute() + self.customExcludesModel.removeRow(index.row()) def add_pattern(self): ''' @@ -98,12 +109,19 @@ def add_pattern(self): item.setCheckState(Qt.CheckState.Checked) self.customExclusionsList.model().appendRow(item) self.customExclusionsList.edit(item.index()) + self.customExclusionsList.scrollToBottom() def item_changed(self, item): ''' When the user checks or unchecks an item, add or remove it from the exclusion list. ''' + ExclusionModel.update(enabled=item.checkState() == Qt.CheckState.Checked).where( + ExclusionModel.name == item.text(), ExclusionModel.source == 'user', ExclusionModel.profile == self.profile + ).execute() + if item.checkState() == Qt.CheckState.Checked: - self.exclusion_list[item.text()] = None + self.exclusion_set.add(item.text()) else: - self.exclusion_list.pop(item.text(), None) + self.exclusion_set.remove(item.text()) + + self.populate_raw_excludes() From e4b2640848601ecc85998bc25b6bc6548f60e588 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 28 Jun 2023 02:37:34 +0530 Subject: [PATCH 04/31] complete custom presets tab frontend --- src/vorta/views/exclude_dialog.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 9dea6f233..2aef1f05e 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -15,16 +15,13 @@ class QCustomItemModel(QStandardItemModel): # When a user-added item in edit mode has no text, remove it from the list. - def __init__(self, profile, parent=None): + def __init__(self, parent=None): super().__init__(parent) - self.profile = profile def setData(self, index: QModelIndex, value, role: int = ...) -> bool: - if role == Qt.ItemDataRole.EditRole: - if value == '': - self.removeRow(index.row()) - return True - ExclusionModel.create(name=value, source='user', profile=self.profile) + if role == Qt.ItemDataRole.EditRole and value == '': + self.removeRow(index.row()) + return True return super().setData(index, value, role) @@ -37,9 +34,9 @@ def __init__(self, profile, parent=None): # Complete set of all exclusions selected by the user, these are finally passed to Borg. self.exclusion_set = {e.name for e in self.profile.exclusions.select().where(ExclusionModel.enabled)} # Custom patterns added by the user to exclude. - self.user_excluded_patterns = [] + self.user_excluded_patterns = {} - self.customExcludesModel = QCustomItemModel(self.profile) + self.customExcludesModel = QCustomItemModel() self.customExclusionsList.setModel(self.customExcludesModel) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) @@ -92,7 +89,10 @@ def remove_pattern(self): indexes = self.customExclusionsList.selectedIndexes() for index in reversed(indexes): self.user_excluded_patterns.pop(index.data()) - self.exclusion_set.remove(index.data()) + try: + self.exclusion_set.remove(index.data()) # the pattern will be here only if it was checked. + except KeyError: + pass ExclusionModel.delete().where( ExclusionModel.name == index.data(), ExclusionModel.source == 'user', @@ -100,6 +100,8 @@ def remove_pattern(self): ).execute() self.customExcludesModel.removeRow(index.row()) + self.populate_raw_excludes() + def add_pattern(self): ''' Add an empty item to the list in editable mode @@ -114,10 +116,11 @@ def add_pattern(self): def item_changed(self, item): ''' When the user checks or unchecks an item, add or remove it from the exclusion list. + When the user adds a new item, add it to the custom exclusion list and the database. ''' - ExclusionModel.update(enabled=item.checkState() == Qt.CheckState.Checked).where( - ExclusionModel.name == item.text(), ExclusionModel.source == 'user', ExclusionModel.profile == self.profile - ).execute() + if item.text() not in self.user_excluded_patterns: + self.user_excluded_patterns[item.text()] = True + ExclusionModel.create(name=item.text(), source='user', profile=self.profile) if item.checkState() == Qt.CheckState.Checked: self.exclusion_set.add(item.text()) From 28c082d3c065e93259a9bf4a604169b0c611c36e Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 30 Jun 2023 00:52:43 +0530 Subject: [PATCH 05/31] remove unnecessary array dependency --- src/vorta/assets/UI/excludedialog.ui | 35 ++++++++-- src/vorta/store/connection.py | 2 + src/vorta/store/models.py | 16 ++++- src/vorta/views/exclude_dialog.py | 97 ++++++++++++++++++---------- 4 files changed, 108 insertions(+), 42 deletions(-) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index 065d1e698..f6aa38365 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -19,6 +19,9 @@ + + 0 + Custom @@ -53,12 +56,10 @@ - - + - - + @@ -69,7 +70,7 @@ Presets - + Raw @@ -85,7 +86,7 @@ - + @@ -103,7 +104,7 @@ - + Save @@ -113,6 +114,26 @@ + + + Preview + + + + + + Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. + + + true + + + + + + + + diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index e7756f157..48105b893 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -13,6 +13,7 @@ BackupProfileModel, EventLogModel, ExclusionModel, + RawExclusionModel, RepoModel, RepoPassword, SchemaVersion, @@ -54,6 +55,7 @@ def init_db(con=None): EventLogModel, SchemaVersion, ExclusionModel, + RawExclusionModel, ] ) diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 5bd442b48..f60f648d5 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -111,7 +111,7 @@ class Meta: class ExclusionModel(BaseModel): """ If this is a user created exclusion, the name will be the same as the pattern added. For exclusions added from - presets, the name will be the same as the preset name. + presets, the name will be the same as the preset name. Duplicate patterns are already handled by Borg. """ profile = pw.ForeignKeyField(BackupProfileModel, backref='exclusions') @@ -124,6 +124,20 @@ class Meta: database = DB +class RawExclusionModel(BaseModel): + """ + The raw exclusion patterns that a user adds to a profile will be added here as plaintext. + Each profile will have a single associated RawExclusionModel. + """ + + profile = pw.ForeignKeyField(BackupProfileModel, backref='raw_exclusions') + patterns = pw.TextField(default='') + date_added = pw.DateTimeField(default=datetime.now) + + class Meta: + database = DB + + class SourceFileModel(BaseModel): """A folder to be backed up, related to a Backup Configuration.""" diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 2aef1f05e..10c0d2f2f 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -1,11 +1,9 @@ from PyQt6 import uic from PyQt6.QtCore import QModelIndex, Qt from PyQt6.QtGui import QStandardItem, QStandardItemModel -from PyQt6.QtWidgets import ( - QAbstractItemView, -) +from PyQt6.QtWidgets import QAbstractItemView, QMessageBox -from vorta.store.models import ExclusionModel +from vorta.store.models import ExclusionModel, RawExclusionModel from vorta.utils import get_asset from vorta.views.utils import get_colored_icon @@ -22,6 +20,14 @@ def setData(self, index: QModelIndex, value, role: int = ...) -> bool: if role == Qt.ItemDataRole.EditRole and value == '': self.removeRow(index.row()) return True + if role == Qt.ItemDataRole.EditRole and ExclusionModel.get_or_none(ExclusionModel.name == value): + QMessageBox.critical( + self.parent(), + 'Error', + 'This exclusion already exists.', + ) + self.removeRow(index.row()) + return False return super().setData(index, value, role) @@ -31,10 +37,6 @@ def __init__(self, profile, parent=None): super().__init__(parent) self.setupUi(self) self.profile = profile - # Complete set of all exclusions selected by the user, these are finally passed to Borg. - self.exclusion_set = {e.name for e in self.profile.exclusions.select().where(ExclusionModel.enabled)} - # Custom patterns added by the user to exclude. - self.user_excluded_patterns = {} self.customExcludesModel = QCustomItemModel() self.customExclusionsList.setModel(self.customExcludesModel) @@ -55,6 +57,9 @@ def __init__(self, profile, parent=None): ''' ) + self.exclusionsPreviewText.setReadOnly(True) + self.rawExclusionsSaveButton.clicked.connect(self.raw_exclusions_saved) + self.customExcludesModel.itemChanged.connect(self.item_changed) self.bRemovePattern.clicked.connect(self.remove_pattern) @@ -63,48 +68,58 @@ def __init__(self, profile, parent=None): self.bAddPattern.setIcon(get_colored_icon('plus')) self.populate_custom_exclusions_list() - self.populate_raw_excludes() + self.populate_raw_exclusions_text() + self.populate_preview_tab() def populate_custom_exclusions_list(self): - self.user_excluded_patterns = { + user_excluded_patterns = { e.name: e.enabled for e in self.profile.exclusions.select() - .where(ExclusionModel.source == 'user') + .where(ExclusionModel.source == 'custom') .order_by(ExclusionModel.date_added.desc()) } - for (exclude, enabled) in self.user_excluded_patterns.items(): + for (exclude, enabled) in user_excluded_patterns.items(): item = QStandardItem(exclude) item.setCheckable(True) item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) self.customExcludesModel.appendRow(item) - def populate_raw_excludes(self): - raw_excludes = "" - for exclude in self.exclusion_set: - raw_excludes += f"{exclude}\n" - self.rawExclusions.setPlainText(raw_excludes) + def populate_raw_exclusions_text(self): + raw_excludes = RawExclusionModel.get_or_none(profile=self.profile) + if raw_excludes: + self.rawExclusionsText.setPlainText(raw_excludes.patterns) + + def populate_preview_tab(self): + excludes = "" + for exclude in ExclusionModel.select().where( + ExclusionModel.profile == self.profile, + ExclusionModel.enabled, + ExclusionModel.source == 'custom', + ): + excludes += f"{exclude.name}\n" + + raw_excludes = RawExclusionModel.get_or_none(profile=self.profile) + if raw_excludes: + excludes += raw_excludes.patterns + + self.exclusionsPreviewText.setPlainText(excludes) def remove_pattern(self): indexes = self.customExclusionsList.selectedIndexes() for index in reversed(indexes): - self.user_excluded_patterns.pop(index.data()) - try: - self.exclusion_set.remove(index.data()) # the pattern will be here only if it was checked. - except KeyError: - pass ExclusionModel.delete().where( ExclusionModel.name == index.data(), - ExclusionModel.source == 'user', + ExclusionModel.source == 'custom', ExclusionModel.profile == self.profile, ).execute() self.customExcludesModel.removeRow(index.row()) - self.populate_raw_excludes() + self.populate_preview_tab() def add_pattern(self): ''' - Add an empty item to the list in editable mode + Add an empty item to the list in editable mode. ''' item = QStandardItem('') item.setCheckable(True) @@ -115,16 +130,30 @@ def add_pattern(self): def item_changed(self, item): ''' - When the user checks or unchecks an item, add or remove it from the exclusion list. - When the user adds a new item, add it to the custom exclusion list and the database. + When the user checks or unchecks an item, update the database. + When the user adds a new item, add it to the database. ''' - if item.text() not in self.user_excluded_patterns: - self.user_excluded_patterns[item.text()] = True - ExclusionModel.create(name=item.text(), source='user', profile=self.profile) + if not ExclusionModel.get_or_none(name=item.text(), source='custom', profile=self.profile): + ExclusionModel.create(name=item.text(), source='custom', profile=self.profile) - if item.checkState() == Qt.CheckState.Checked: - self.exclusion_set.add(item.text()) + ExclusionModel.update(enabled=item.checkState() == Qt.CheckState.Checked).where( + ExclusionModel.name == item.text(), + ExclusionModel.source == 'custom', + ExclusionModel.profile == self.profile, + ).execute() + + self.populate_preview_tab() + + def raw_exclusions_saved(self): + ''' + When the user saves changes in the raw exclusions text box, add it to the database. + ''' + raw_excludes = self.rawExclusionsText.toPlainText() + raw_excludes_model = RawExclusionModel.get_or_none(profile=self.profile) + if raw_excludes_model: + raw_excludes_model.patterns = raw_excludes + raw_excludes_model.save() else: - self.exclusion_set.remove(item.text()) + RawExclusionModel.create(profile=self.profile, patterns=raw_excludes) - self.populate_raw_excludes() + self.populate_preview_tab() From bd2367d9a74d8fd4deff963f6be05782586bf6d6 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Thu, 6 Jul 2023 11:12:44 +0530 Subject: [PATCH 06/31] custom presets --- src/vorta/assets/UI/excludedialog.ui | 15 +++ .../assets/exclusion_presets/browsers.json | 67 +++++++++++ src/vorta/assets/exclusion_presets/dev.json | 35 ++++++ src/vorta/store/models.py | 2 +- src/vorta/views/exclude_dialog.py | 107 +++++++++++++++++- 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/vorta/assets/exclusion_presets/browsers.json create mode 100644 src/vorta/assets/exclusion_presets/dev.json diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index f6aa38365..f40b654de 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -69,6 +69,21 @@ Presets + + + + + Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. + + + true + + + + + + + diff --git a/src/vorta/assets/exclusion_presets/browsers.json b/src/vorta/assets/exclusion_presets/browsers.json new file mode 100644 index 000000000..95f89c993 --- /dev/null +++ b/src/vorta/assets/exclusion_presets/browsers.json @@ -0,0 +1,67 @@ +[ + { + "name": "Chromium cache and config files", + "patterns": + [ + "fm:/.config/chromium/*/Local Storage", + "fm:/.config/chromium/*/Session Storage", + "fm:/.config/chromium/*/Service Worker/CacheStorage", + "fm:/.config/chromium/*/Application Cache", + "fm:/.config/chromium/*/History Index *", + "fm:/snap/chromium/common/.cache", + "fm:/snap/chromium/*/.config/chromium/*/Service Worker/CacheStorage", + "fm:/snap/chromium/*/.local/share/" + ], + "tags":["application:chromium", "type:browser", "os:linux"], + "author": "Divi" + }, + { + "name": "Google Chrome cache and config files", + "patterns": + [ + "fm:.config/google-chrome/ShaderCache", + "fm:.config/google-chrome/*/Local Storage", + "fm:.config/google-chrome/*/Session Storage", + "fm:.config/google-chrome/*/Application Cache", + "fm:.config/google-chrome/*/History Index *", + "fm:.config/google-chrome/*/Service Worker/CacheStorage" + ], + "tags": ["application:chrome", "type:browser", "os:linux"], + "author": "Divi" + }, + { + "name": "Brave cache and config files", + "patterns":[ + "fm:.config/BraveSoftware/Brave-Browser/*/Feature Engagement Tracker/", + "fm:.config/BraveSoftware/Brave-Browser/*/Local Storage/", + "fm:.config/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/", + "fm:.config/BraveSoftware/Brave-Browser/*/Session Storage/", + "fm:.config/BraveSoftware/Brave-Browser/Safe Browsing/", + "fm:.config/BraveSoftware/Brave-Browser/ShaderCache/" + ], + "tags": ["application:brave", "type:browser", "os:linux"], + "author": "Divi" + }, + { + "name": "Mozilla Firefox cache and config files", + "patterns":[ + "fm:.mozilla/firefox/*/Cache", + "fm:.mozilla/firefox/*/minidumps", + "fm:.mozilla/firefox/*/.parentlock", + "fm:.mozilla/firefox/*/urlclassifier3.sqlite", + "fm:.mozilla/firefox/*/blocklist.xml", + "fm:.mozilla/firefox/*/extensions.sqlite", + "fm:./mozilla/firefox/*/extensions.sqlite-journal", + "fm:./mozilla/firefox/*/extensions.rdf", + "fm:./mozilla/firefox/*/extensions.ini", + "fm:./mozilla/firefox/*/extensions.cache", + "fm:./mozilla/firefox/*/XUL.mfasl", + "fm:./mozilla/firefox/*/XPC.mfasl", + "fm:./mozilla/firefox/*/xpti.dat", + "fm:./mozilla/firefox/*/compreg.dat", + "fm:./mozilla/firefox/*/pluginreg.dat" + ], + "tags": ["application:firefox", "type:browser", "os:linux"], + "author": "Divi" + } +] diff --git a/src/vorta/assets/exclusion_presets/dev.json b/src/vorta/assets/exclusion_presets/dev.json new file mode 100644 index 000000000..dc565c4ec --- /dev/null +++ b/src/vorta/assets/exclusion_presets/dev.json @@ -0,0 +1,35 @@ +[ + { + "name": "Node Modules and package manager cache", + "patterns": + [ + "fm:/node_modules", + "fm:.npm" + ], + "tags": ["type:dev", "lang:javascript", "os:linux", "os:macos"], + "author": "Divi" + }, + { + "name": "Python cache and virtualenv", + "patterns": + [ + "fm:__pycache__", + "fm:.pyc", + "fm:.pyo", + "fm:.env", + "fm:/.virtualenvs" + ], + "tags": ["type:dev", "lang:python", "os:linux", "os:macos"], + "author": "Divi" + }, + { + "name": "Rust artefacts", + "patterns": + [ + "fm:.cargo", + "fm:.rustup" + ], + "tags": ["type:dev", "lang:rust", "os:linux", "os:macos"], + "author": "Divi" + } +] diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index f60f648d5..33faa6bbd 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -117,7 +117,7 @@ class ExclusionModel(BaseModel): profile = pw.ForeignKeyField(BackupProfileModel, backref='exclusions') name = pw.CharField(unique=True) enabled = pw.BooleanField(default=True) - source = pw.CharField(default='user') + source = pw.CharField(default='custom') # custom or preset date_added = pw.DateTimeField(default=datetime.now) class Meta: diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 10c0d2f2f..e87a02879 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -1,7 +1,14 @@ +import json +import os +import sys + from PyQt6 import uic from PyQt6.QtCore import QModelIndex, Qt from PyQt6.QtGui import QStandardItem, QStandardItemModel -from PyQt6.QtWidgets import QAbstractItemView, QMessageBox +from PyQt6.QtWidgets import ( + QAbstractItemView, + QMessageBox, +) from vorta.store.models import ExclusionModel, RawExclusionModel from vorta.utils import get_asset @@ -37,9 +44,11 @@ def __init__(self, profile, parent=None): super().__init__(parent) self.setupUi(self) self.profile = profile + self.allPresets = {} self.customExcludesModel = QCustomItemModel() self.customExclusionsList.setModel(self.customExcludesModel) + self.customExcludesModel.itemChanged.connect(self.custom_item_changed) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) @@ -57,10 +66,30 @@ def __init__(self, profile, parent=None): ''' ) + self.exclusionPresetsModel = QStandardItemModel() + self.exclusionPresetsList.setModel(self.exclusionPresetsModel) + self.exclusionPresetsModel.itemChanged.connect(self.preset_item_changed) + self.exclusionPresetsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.exclusionPresetsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.exclusionPresetsList.setAlternatingRowColors(True) + self.exclusionPresetsList.setStyleSheet( + ''' + QListView::item { + padding: 20px 0px; + border-bottom: .5px solid black; + } + QListView::item:selected { + background-color: palette(highlight); + } + QListView::item::icon { + padding-right: 10px; + } + ''' + ) + self.exclusionsPreviewText.setReadOnly(True) - self.rawExclusionsSaveButton.clicked.connect(self.raw_exclusions_saved) - self.customExcludesModel.itemChanged.connect(self.item_changed) + self.rawExclusionsSaveButton.clicked.connect(self.raw_exclusions_saved) self.bRemovePattern.clicked.connect(self.remove_pattern) self.bRemovePattern.setIcon(get_colored_icon('minus')) @@ -68,6 +97,7 @@ def __init__(self, profile, parent=None): self.bAddPattern.setIcon(get_colored_icon('plus')) self.populate_custom_exclusions_list() + self.populate_presets_list() self.populate_raw_exclusions_text() self.populate_preview_tab() @@ -85,13 +115,44 @@ def populate_custom_exclusions_list(self): item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) self.customExcludesModel.appendRow(item) + def populate_presets_list(self): + if getattr(sys, 'frozen', False): + # we are running in a bundle + bundle_dir = os.path.join(sys._MEIPASS, 'assets/exclusion_presets') + else: + # we are running in a normal Python environment + bundle_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../assets/exclusion_presets') + + for preset_file in os.listdir(bundle_dir): + with open(os.path.join(bundle_dir, preset_file), 'r') as f: + preset_data = json.load(f) + for preset in preset_data: + item = QStandardItem(preset['name']) + item.setCheckable(True) + preset_model = ExclusionModel.get_or_none( + name=preset['name'], + source='preset', + profile=self.profile, + ) + if preset_model: + item.setCheckState(Qt.CheckState.Checked if preset_model.enabled else Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + # add a link icon to the end of the item + self.exclusionPresetsModel.appendRow(item) + self.allPresets[preset['name']] = { + 'patterns': preset['patterns'], + 'tags': preset['tags'], + 'filename': preset_file, + } + def populate_raw_exclusions_text(self): raw_excludes = RawExclusionModel.get_or_none(profile=self.profile) if raw_excludes: self.rawExclusionsText.setPlainText(raw_excludes.patterns) def populate_preview_tab(self): - excludes = "" + excludes = "# custom added rules\n" for exclude in ExclusionModel.select().where( ExclusionModel.profile == self.profile, ExclusionModel.enabled, @@ -101,7 +162,19 @@ def populate_preview_tab(self): raw_excludes = RawExclusionModel.get_or_none(profile=self.profile) if raw_excludes: + excludes += "\n# raw exclusions\n" excludes += raw_excludes.patterns + excludes += "\n" + + # go through all source=='preset' exclusions, find the name in the allPresets dict, and add the patterns + for exclude in ExclusionModel.select().where( + ExclusionModel.profile == self.profile, + ExclusionModel.enabled, + ExclusionModel.source == 'preset', + ): + excludes += f"\n#{exclude.name}\n" + for pattern in self.allPresets[exclude.name]['patterns']: + excludes += f"{pattern}\n" self.exclusionsPreviewText.setPlainText(excludes) @@ -128,7 +201,7 @@ def add_pattern(self): self.customExclusionsList.edit(item.index()) self.customExclusionsList.scrollToBottom() - def item_changed(self, item): + def custom_item_changed(self, item): ''' When the user checks or unchecks an item, update the database. When the user adds a new item, add it to the database. @@ -144,6 +217,30 @@ def item_changed(self, item): self.populate_preview_tab() + def preset_item_changed(self, item): + ''' + Create or update the preset in the database. + If the user unchecks the preset, set enabled to False, otherwise set it to True. + If the preset doesn't exist, create it and set enabled to True. + ''' + preset = ExclusionModel.get_or_none( + name=item.text(), + source='preset', + profile=self.profile, + ) + if preset: + preset.enabled = item.checkState() == Qt.CheckState.Checked + preset.save() + else: + ExclusionModel.create( + name=item.text(), + source='preset', + profile=self.profile, + enabled=item.checkState() == Qt.CheckState.Checked, + ) + + self.populate_preview_tab() + def raw_exclusions_saved(self): ''' When the user saves changes in the raw exclusions text box, add it to the database. From 1b4548f6c5eee04b2ba39c517ea5cb9ff8b47c43 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 18 Jul 2023 22:47:03 +0530 Subject: [PATCH 07/31] remove all leading slashes and work on code review --- .../assets/exclusion_presets/browsers.json | 34 ++++---- src/vorta/assets/exclusion_presets/dev.json | 4 +- src/vorta/store/connection.py | 2 - src/vorta/store/models.py | 24 ++---- src/vorta/views/exclude_dialog.py | 81 +++++++++++-------- 5 files changed, 75 insertions(+), 70 deletions(-) diff --git a/src/vorta/assets/exclusion_presets/browsers.json b/src/vorta/assets/exclusion_presets/browsers.json index 95f89c993..a94882181 100644 --- a/src/vorta/assets/exclusion_presets/browsers.json +++ b/src/vorta/assets/exclusion_presets/browsers.json @@ -3,14 +3,14 @@ "name": "Chromium cache and config files", "patterns": [ - "fm:/.config/chromium/*/Local Storage", - "fm:/.config/chromium/*/Session Storage", - "fm:/.config/chromium/*/Service Worker/CacheStorage", - "fm:/.config/chromium/*/Application Cache", - "fm:/.config/chromium/*/History Index *", - "fm:/snap/chromium/common/.cache", - "fm:/snap/chromium/*/.config/chromium/*/Service Worker/CacheStorage", - "fm:/snap/chromium/*/.local/share/" + "fm:.config/chromium/*/Local Storage", + "fm:.config/chromium/*/Session Storage", + "fm:.config/chromium/*/Service Worker/CacheStorage", + "fm:.config/chromium/*/Application Cache", + "fm:.config/chromium/*/History Index *", + "fm:snap/chromium/common/.cache", + "fm:snap/chromium/*/.config/chromium/*/Service Worker/CacheStorage", + "fm:snap/chromium/*/.local/share/" ], "tags":["application:chromium", "type:browser", "os:linux"], "author": "Divi" @@ -51,15 +51,15 @@ "fm:.mozilla/firefox/*/urlclassifier3.sqlite", "fm:.mozilla/firefox/*/blocklist.xml", "fm:.mozilla/firefox/*/extensions.sqlite", - "fm:./mozilla/firefox/*/extensions.sqlite-journal", - "fm:./mozilla/firefox/*/extensions.rdf", - "fm:./mozilla/firefox/*/extensions.ini", - "fm:./mozilla/firefox/*/extensions.cache", - "fm:./mozilla/firefox/*/XUL.mfasl", - "fm:./mozilla/firefox/*/XPC.mfasl", - "fm:./mozilla/firefox/*/xpti.dat", - "fm:./mozilla/firefox/*/compreg.dat", - "fm:./mozilla/firefox/*/pluginreg.dat" + "fm:.mozilla/firefox/*/extensions.sqlite-journal", + "fm:.mozilla/firefox/*/extensions.rdf", + "fm:.mozilla/firefox/*/extensions.ini", + "fm:.mozilla/firefox/*/extensions.cache", + "fm:.mozilla/firefox/*/XUL.mfasl", + "fm:.mozilla/firefox/*/XPC.mfasl", + "fm:.mozilla/firefox/*/xpti.dat", + "fm:.mozilla/firefox/*/compreg.dat", + "fm:.mozilla/firefox/*/pluginreg.dat" ], "tags": ["application:firefox", "type:browser", "os:linux"], "author": "Divi" diff --git a/src/vorta/assets/exclusion_presets/dev.json b/src/vorta/assets/exclusion_presets/dev.json index dc565c4ec..4269604cb 100644 --- a/src/vorta/assets/exclusion_presets/dev.json +++ b/src/vorta/assets/exclusion_presets/dev.json @@ -3,7 +3,7 @@ "name": "Node Modules and package manager cache", "patterns": [ - "fm:/node_modules", + "fm:node_modules", "fm:.npm" ], "tags": ["type:dev", "lang:javascript", "os:linux", "os:macos"], @@ -17,7 +17,7 @@ "fm:.pyc", "fm:.pyo", "fm:.env", - "fm:/.virtualenvs" + "fm:.virtualenvs" ], "tags": ["type:dev", "lang:python", "os:linux", "os:macos"], "author": "Divi" diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index 48105b893..e7756f157 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -13,7 +13,6 @@ BackupProfileModel, EventLogModel, ExclusionModel, - RawExclusionModel, RepoModel, RepoPassword, SchemaVersion, @@ -55,7 +54,6 @@ def init_db(con=None): EventLogModel, SchemaVersion, ExclusionModel, - RawExclusionModel, ] ) diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 33faa6bbd..d8c884111 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -6,6 +6,7 @@ import json from datetime import datetime +from enum import Enum import peewee as pw from playhouse import signals @@ -73,8 +74,8 @@ class BackupProfileModel(BaseModel): repo = pw.ForeignKeyField(RepoModel, default=None, null=True) ssh_key = pw.CharField(default=None, null=True) compression = pw.CharField(default='lz4') + raw_exclusions = pw.TextField(default='') exclude_patterns = pw.TextField(null=True) - exclude_if_present = pw.TextField(null=True) schedule_mode = pw.CharField(default='off') schedule_interval_count = pw.IntegerField(default=3) schedule_interval_unit = pw.CharField(default='hours') @@ -114,25 +115,14 @@ class ExclusionModel(BaseModel): presets, the name will be the same as the preset name. Duplicate patterns are already handled by Borg. """ + class SourceFieldOptions(Enum): + CUSTOM = 'custom' + PRESET = 'preset' + profile = pw.ForeignKeyField(BackupProfileModel, backref='exclusions') name = pw.CharField(unique=True) enabled = pw.BooleanField(default=True) - source = pw.CharField(default='custom') # custom or preset - date_added = pw.DateTimeField(default=datetime.now) - - class Meta: - database = DB - - -class RawExclusionModel(BaseModel): - """ - The raw exclusion patterns that a user adds to a profile will be added here as plaintext. - Each profile will have a single associated RawExclusionModel. - """ - - profile = pw.ForeignKeyField(BackupProfileModel, backref='raw_exclusions') - patterns = pw.TextField(default='') - date_added = pw.DateTimeField(default=datetime.now) + source = pw.CharField(default=SourceFieldOptions.CUSTOM.value) class Meta: database = DB diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index e87a02879..996ec81b1 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -10,7 +10,7 @@ QMessageBox, ) -from vorta.store.models import ExclusionModel, RawExclusionModel +from vorta.store.models import ExclusionModel from vorta.utils import get_asset from vorta.views.utils import get_colored_icon @@ -18,12 +18,16 @@ ExcludeDialogUi, ExcludeDialogBase = uic.loadUiType(uifile) -class QCustomItemModel(QStandardItemModel): - # When a user-added item in edit mode has no text, remove it from the list. +class MandatoryInputItemModel(QStandardItemModel): + ''' + A model that prevents the user from adding an empty item to the list. + ''' + def __init__(self, parent=None): super().__init__(parent) def setData(self, index: QModelIndex, value, role: int = ...) -> bool: + # When a user-added item in edit mode has no text, remove it from the list. if role == Qt.ItemDataRole.EditRole and value == '': self.removeRow(index.row()) return True @@ -46,9 +50,9 @@ def __init__(self, profile, parent=None): self.profile = profile self.allPresets = {} - self.customExcludesModel = QCustomItemModel() - self.customExclusionsList.setModel(self.customExcludesModel) - self.customExcludesModel.itemChanged.connect(self.custom_item_changed) + self.customExclusionsModel = MandatoryInputItemModel() + self.customExclusionsList.setModel(self.customExclusionsModel) + self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) @@ -105,15 +109,15 @@ def populate_custom_exclusions_list(self): user_excluded_patterns = { e.name: e.enabled for e in self.profile.exclusions.select() - .where(ExclusionModel.source == 'custom') - .order_by(ExclusionModel.date_added.desc()) + .where(ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value) + .order_by(ExclusionModel.name) } for (exclude, enabled) in user_excluded_patterns.items(): item = QStandardItem(exclude) item.setCheckable(True) item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) - self.customExcludesModel.appendRow(item) + self.customExclusionsModel.appendRow(item) def populate_presets_list(self): if getattr(sys, 'frozen', False): @@ -131,46 +135,59 @@ def populate_presets_list(self): item.setCheckable(True) preset_model = ExclusionModel.get_or_none( name=preset['name'], - source='preset', + source=ExclusionModel.SourceFieldOptions.PRESET.value, profile=self.profile, ) + if preset_model: item.setCheckState(Qt.CheckState.Checked if preset_model.enabled else Qt.CheckState.Unchecked) else: item.setCheckState(Qt.CheckState.Unchecked) - # add a link icon to the end of the item + self.exclusionPresetsModel.appendRow(item) self.allPresets[preset['name']] = { 'patterns': preset['patterns'], 'tags': preset['tags'], - 'filename': preset_file, } def populate_raw_exclusions_text(self): - raw_excludes = RawExclusionModel.get_or_none(profile=self.profile) + raw_excludes = self.profile.raw_exclusions if raw_excludes: - self.rawExclusionsText.setPlainText(raw_excludes.patterns) + self.rawExclusionsText.setPlainText(raw_excludes) def populate_preview_tab(self): - excludes = "# custom added rules\n" + excludes = "" + + if ( + ExclusionModel.select() + .where( + ExclusionModel.profile == self.profile, + ExclusionModel.enabled, + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ) + .count() + > 0 + ): + excludes = "# custom added rules\n" + for exclude in ExclusionModel.select().where( ExclusionModel.profile == self.profile, ExclusionModel.enabled, - ExclusionModel.source == 'custom', + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, ): excludes += f"{exclude.name}\n" - raw_excludes = RawExclusionModel.get_or_none(profile=self.profile) + raw_excludes = self.profile.raw_exclusions if raw_excludes: excludes += "\n# raw exclusions\n" - excludes += raw_excludes.patterns + excludes += raw_excludes excludes += "\n" # go through all source=='preset' exclusions, find the name in the allPresets dict, and add the patterns for exclude in ExclusionModel.select().where( ExclusionModel.profile == self.profile, ExclusionModel.enabled, - ExclusionModel.source == 'preset', + ExclusionModel.source == ExclusionModel.SourceFieldOptions.PRESET.value, ): excludes += f"\n#{exclude.name}\n" for pattern in self.allPresets[exclude.name]['patterns']: @@ -183,10 +200,10 @@ def remove_pattern(self): for index in reversed(indexes): ExclusionModel.delete().where( ExclusionModel.name == index.data(), - ExclusionModel.source == 'custom', + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, ExclusionModel.profile == self.profile, ).execute() - self.customExcludesModel.removeRow(index.row()) + self.customExclusionsModel.removeRow(index.row()) self.populate_preview_tab() @@ -206,12 +223,16 @@ def custom_item_changed(self, item): When the user checks or unchecks an item, update the database. When the user adds a new item, add it to the database. ''' - if not ExclusionModel.get_or_none(name=item.text(), source='custom', profile=self.profile): - ExclusionModel.create(name=item.text(), source='custom', profile=self.profile) + if not ExclusionModel.get_or_none( + name=item.text(), source=ExclusionModel.SourceFieldOptions.CUSTOM.value, profile=self.profile + ): + ExclusionModel.create( + name=item.text(), source=ExclusionModel.SourceFieldOptions.CUSTOM.value, profile=self.profile + ) ExclusionModel.update(enabled=item.checkState() == Qt.CheckState.Checked).where( ExclusionModel.name == item.text(), - ExclusionModel.source == 'custom', + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, ExclusionModel.profile == self.profile, ).execute() @@ -225,7 +246,7 @@ def preset_item_changed(self, item): ''' preset = ExclusionModel.get_or_none( name=item.text(), - source='preset', + source=ExclusionModel.SourceFieldOptions.PRESET.value, profile=self.profile, ) if preset: @@ -234,7 +255,7 @@ def preset_item_changed(self, item): else: ExclusionModel.create( name=item.text(), - source='preset', + source=ExclusionModel.SourceFieldOptions.PRESET.value, profile=self.profile, enabled=item.checkState() == Qt.CheckState.Checked, ) @@ -246,11 +267,7 @@ def raw_exclusions_saved(self): When the user saves changes in the raw exclusions text box, add it to the database. ''' raw_excludes = self.rawExclusionsText.toPlainText() - raw_excludes_model = RawExclusionModel.get_or_none(profile=self.profile) - if raw_excludes_model: - raw_excludes_model.patterns = raw_excludes - raw_excludes_model.save() - else: - RawExclusionModel.create(profile=self.profile, patterns=raw_excludes) + self.profile.raw_exclusions = raw_excludes + self.profile.save() self.populate_preview_tab() From 62d8e108ce5d64eaef9498f63bf584b3322d5666 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 19 Jul 2023 21:16:21 +0530 Subject: [PATCH 08/31] pass the exclusions to borg! --- .../assets/exclusion_presets/browsers.json | 70 +++++++++---------- src/vorta/assets/exclusion_presets/dev.json | 19 ++--- src/vorta/borg/create.py | 22 ++---- src/vorta/views/exclude_dialog.py | 2 + 4 files changed, 52 insertions(+), 61 deletions(-) diff --git a/src/vorta/assets/exclusion_presets/browsers.json b/src/vorta/assets/exclusion_presets/browsers.json index a94882181..7dae5a4c6 100644 --- a/src/vorta/assets/exclusion_presets/browsers.json +++ b/src/vorta/assets/exclusion_presets/browsers.json @@ -3,14 +3,14 @@ "name": "Chromium cache and config files", "patterns": [ - "fm:.config/chromium/*/Local Storage", - "fm:.config/chromium/*/Session Storage", - "fm:.config/chromium/*/Service Worker/CacheStorage", - "fm:.config/chromium/*/Application Cache", - "fm:.config/chromium/*/History Index *", - "fm:snap/chromium/common/.cache", - "fm:snap/chromium/*/.config/chromium/*/Service Worker/CacheStorage", - "fm:snap/chromium/*/.local/share/" + "*.config/chromium/*/Local Storage", + "*.config/chromium/*/Session Storage", + "*.config/chromium/*/Service Worker/CacheStorage", + "*.config/chromium/*/Application Cache", + "*.config/chromium/*/History Index *", + "snap/chromium/common/.cache", + "snap/chromium/*/.config/chromium/*/Service Worker/CacheStorage", + "snap/chromium/*/.local/share/" ], "tags":["application:chromium", "type:browser", "os:linux"], "author": "Divi" @@ -19,12 +19,12 @@ "name": "Google Chrome cache and config files", "patterns": [ - "fm:.config/google-chrome/ShaderCache", - "fm:.config/google-chrome/*/Local Storage", - "fm:.config/google-chrome/*/Session Storage", - "fm:.config/google-chrome/*/Application Cache", - "fm:.config/google-chrome/*/History Index *", - "fm:.config/google-chrome/*/Service Worker/CacheStorage" + "*.config/google-chrome/ShaderCache", + "*.config/google-chrome/*/Local Storage", + "*.config/google-chrome/*/Session Storage", + "*.config/google-chrome/*/Application Cache", + "*.config/google-chrome/*/History Index *", + "*.config/google-chrome/*/Service Worker/CacheStorage" ], "tags": ["application:chrome", "type:browser", "os:linux"], "author": "Divi" @@ -32,12 +32,12 @@ { "name": "Brave cache and config files", "patterns":[ - "fm:.config/BraveSoftware/Brave-Browser/*/Feature Engagement Tracker/", - "fm:.config/BraveSoftware/Brave-Browser/*/Local Storage/", - "fm:.config/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/", - "fm:.config/BraveSoftware/Brave-Browser/*/Session Storage/", - "fm:.config/BraveSoftware/Brave-Browser/Safe Browsing/", - "fm:.config/BraveSoftware/Brave-Browser/ShaderCache/" + "*.config/BraveSoftware/Brave-Browser/*/Feature Engagement Tracker/", + "*.config/BraveSoftware/Brave-Browser/*/Local Storage/", + "*.config/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/", + "*.config/BraveSoftware/Brave-Browser/*/Session Storage/", + "*.config/BraveSoftware/Brave-Browser/Safe Browsing/", + "*.config/BraveSoftware/Brave-Browser/ShaderCache/" ], "tags": ["application:brave", "type:browser", "os:linux"], "author": "Divi" @@ -45,21 +45,21 @@ { "name": "Mozilla Firefox cache and config files", "patterns":[ - "fm:.mozilla/firefox/*/Cache", - "fm:.mozilla/firefox/*/minidumps", - "fm:.mozilla/firefox/*/.parentlock", - "fm:.mozilla/firefox/*/urlclassifier3.sqlite", - "fm:.mozilla/firefox/*/blocklist.xml", - "fm:.mozilla/firefox/*/extensions.sqlite", - "fm:.mozilla/firefox/*/extensions.sqlite-journal", - "fm:.mozilla/firefox/*/extensions.rdf", - "fm:.mozilla/firefox/*/extensions.ini", - "fm:.mozilla/firefox/*/extensions.cache", - "fm:.mozilla/firefox/*/XUL.mfasl", - "fm:.mozilla/firefox/*/XPC.mfasl", - "fm:.mozilla/firefox/*/xpti.dat", - "fm:.mozilla/firefox/*/compreg.dat", - "fm:.mozilla/firefox/*/pluginreg.dat" + "*.mozilla/firefox/*/Cache", + "*.mozilla/firefox/*/minidumps", + "*.mozilla/firefox/*/.parentlock", + "*.mozilla/firefox/*/urlclassifier3.sqlite", + "*.mozilla/firefox/*/blocklist.xml", + "*.mozilla/firefox/*/extensions.sqlite", + "*.mozilla/firefox/*/extensions.sqlite-journal", + "*.mozilla/firefox/*/extensions.rdf", + "*.mozilla/firefox/*/extensions.ini", + "*.mozilla/firefox/*/extensions.cache", + "*.mozilla/firefox/*/XUL.mfasl", + "*.mozilla/firefox/*/XPC.mfasl", + "*.mozilla/firefox/*/xpti.dat", + "*.mozilla/firefox/*/compreg.dat", + "*.mozilla/firefox/*/pluginreg.dat" ], "tags": ["application:firefox", "type:browser", "os:linux"], "author": "Divi" diff --git a/src/vorta/assets/exclusion_presets/dev.json b/src/vorta/assets/exclusion_presets/dev.json index 4269604cb..c48930ed0 100644 --- a/src/vorta/assets/exclusion_presets/dev.json +++ b/src/vorta/assets/exclusion_presets/dev.json @@ -3,8 +3,8 @@ "name": "Node Modules and package manager cache", "patterns": [ - "fm:node_modules", - "fm:.npm" + "*/node_modules", + "*.npm" ], "tags": ["type:dev", "lang:javascript", "os:linux", "os:macos"], "author": "Divi" @@ -13,11 +13,11 @@ "name": "Python cache and virtualenv", "patterns": [ - "fm:__pycache__", - "fm:.pyc", - "fm:.pyo", - "fm:.env", - "fm:.virtualenvs" + "*/__pycache__", + "*.pyc", + "*.pyo", + "*.env", + "*.virtualenvs" ], "tags": ["type:dev", "lang:python", "os:linux", "os:macos"], "author": "Divi" @@ -26,8 +26,9 @@ "name": "Rust artefacts", "patterns": [ - "fm:.cargo", - "fm:.rustup" + "*/target", + "*.cargo", + "*.rustup" ], "tags": ["type:dev", "lang:rust", "os:linux", "os:macos"], "author": "Divi" diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 69dc8b3f1..8fbe54ef8 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -165,23 +165,11 @@ def prepare(cls, profile): # Add excludes # Partly inspired by borgmatic/borgmatic/borg/create.py if profile.exclude_patterns is not None: - exclude_dirs = [] - for p in profile.exclude_patterns.split('\n'): - if p.strip(): - expanded_directory = os.path.expanduser(p.strip()) - exclude_dirs.append(expanded_directory) - - if exclude_dirs: - pattern_file = tempfile.NamedTemporaryFile('w', delete=True) - pattern_file.write('\n'.join(exclude_dirs)) - pattern_file.flush() - cmd.extend(['--exclude-from', pattern_file.name]) - ret['cleanup_files'].append(pattern_file) - - if profile.exclude_if_present is not None: - for f in profile.exclude_if_present.split('\n'): - if f.strip(): - cmd.extend(['--exclude-if-present', f.strip()]) + pattern_file = tempfile.NamedTemporaryFile('w', delete=True) + pattern_file.write(profile.exclude_patterns) + pattern_file.flush() + cmd.extend(['--exclude-from', pattern_file.name]) + ret['cleanup_files'].append(pattern_file) # Add repo url and source dirs. new_archive_name = format_archive_name(profile, profile.new_archive_name) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 996ec81b1..bcee5baa5 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -194,6 +194,8 @@ def populate_preview_tab(self): excludes += f"{pattern}\n" self.exclusionsPreviewText.setPlainText(excludes) + self.profile.exclude_patterns = excludes + self.profile.save() def remove_pattern(self): indexes = self.customExclusionsList.selectedIndexes() From fccea565ba4fac61ef1fe1d7b74c217e4823585c Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 21 Jul 2023 20:35:21 +0530 Subject: [PATCH 09/31] add migration for raw exclusions field --- src/vorta/store/connection.py | 2 +- src/vorta/store/migrations.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index b3a713acd..75e8e07cb 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -22,7 +22,7 @@ ) from .settings import get_misc_settings -SCHEMA_VERSION = 22 +SCHEMA_VERSION = 23 @signals.post_save(sender=SettingsModel) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index a26892597..85d811fa2 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -250,6 +250,17 @@ def run_migrations(current_schema, db_connection): ), ) + if current_schema.version < 23: + _apply_schema_update( + current_schema, + 23, + migrator.add_column( + BackupProfileModel._meta.table_name, + 'raw_exclusions', + pw.CharField(default=''), + ), + ) + def _apply_schema_update(current_schema, version_after, *operations): with DB.atomic(): From b8899ab0e4ed97658381b96293def9209e41c7e2 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sun, 23 Jul 2023 23:43:09 +0530 Subject: [PATCH 10/31] review changes: context menu, remove save button for raw text, copy to clipboard... --- src/vorta/assets/UI/excludedialog.ui | 48 +++++++------- src/vorta/assets/UI/sourcetab.ui | 32 +++------- src/vorta/views/exclude_dialog.py | 94 ++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 52 deletions(-) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index f40b654de..c831a4650 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -103,30 +103,6 @@ - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Save - - - - - @@ -147,6 +123,30 @@ + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy to Clipboard + + + + + diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index 24db13701..d02b54eec 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -104,37 +104,23 @@ - + - - - Select Exclude Patterns... + + + Recalculate source size and file count - - - Qt::Horizontal - - - - 40 - 20 - + + + Select Exclude Patterns... - + - - - - - - - Recalculate source size and file count - - + diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index bcee5baa5..8392e0233 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -7,7 +7,10 @@ from PyQt6.QtGui import QStandardItem, QStandardItemModel from PyQt6.QtWidgets import ( QAbstractItemView, + QApplication, + QMenu, QMessageBox, + QStyledItemDelegate, ) from vorta.store.models import ExclusionModel @@ -69,6 +72,14 @@ def __init__(self, profile, parent=None): ''' ) + self.customExclusionsListDelegate = QStyledItemDelegate() + self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) + self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) + # allow removing items with the delete key (remove_pattern is called in keyPressEvent) + self.customExclusionsList.keyPressEvent = self.customPatternKeyPressEvent + # context menu + self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) self.exclusionPresetsModel = QStandardItemModel() self.exclusionPresetsList.setModel(self.exclusionPresetsModel) @@ -93,10 +104,12 @@ def __init__(self, profile, parent=None): self.exclusionsPreviewText.setReadOnly(True) - self.rawExclusionsSaveButton.clicked.connect(self.raw_exclusions_saved) + self.rawExclusionsText.textChanged.connect(self.raw_exclusions_saved) self.bRemovePattern.clicked.connect(self.remove_pattern) self.bRemovePattern.setIcon(get_colored_icon('minus')) + self.bPreviewCopy.clicked.connect(self.copy_preview_to_clipboard) + self.bPreviewCopy.setIcon(get_colored_icon('copy')) self.bAddPattern.clicked.connect(self.add_pattern) self.bAddPattern.setIcon(get_colored_icon('plus')) @@ -119,6 +132,31 @@ def populate_custom_exclusions_list(self): item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) self.customExclusionsModel.appendRow(item) + def custom_exclusions_context_menu(self, pos): + # index under cursor + index = self.customExclusionsList.indexAt(pos) + if not index.isValid(): + return + + menu = QMenu(self.customExclusionsList) + menu.addAction( + get_colored_icon('minus'), + self.tr('Remove'), + lambda: self.remove_pattern(index), + ) + menu.addAction( + get_colored_icon('check-circle'), + self.tr('Toggle'), + lambda: self.toggle_custom_pattern(index), + ) + menu.addAction( + get_colored_icon('copy'), + self.tr('Copy'), + lambda: QApplication.clipboard().setText(index.data()), + ) + + menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) + def populate_presets_list(self): if getattr(sys, 'frozen', False): # we are running in a bundle @@ -189,7 +227,7 @@ def populate_preview_tab(self): ExclusionModel.enabled, ExclusionModel.source == ExclusionModel.SourceFieldOptions.PRESET.value, ): - excludes += f"\n#{exclude.name}\n" + excludes += f"\n# {exclude.name}\n" for pattern in self.allPresets[exclude.name]['patterns']: excludes += f"{pattern}\n" @@ -197,9 +235,32 @@ def populate_preview_tab(self): self.profile.exclude_patterns = excludes self.profile.save() - def remove_pattern(self): - indexes = self.customExclusionsList.selectedIndexes() - for index in reversed(indexes): + def copy_preview_to_clipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Mode.Clipboard) + cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) + + def customPatternKeyPressEvent(self, event): + if event.key() == Qt.Key.Key_Delete: + self.remove_pattern() + else: + super().keyPressEvent(event) + + def remove_pattern(self, index=None): + ''' + Remove the selected item(s) from the list and the database. + If there is no selection, this was called from the context menu and the index is passed in. + ''' + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in reversed(sorted(indexes)): + ExclusionModel.delete().where( + ExclusionModel.name == index.data(), + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ExclusionModel.profile == self.profile, + ).execute() + self.customExclusionsModel.removeRow(index.row()) + else: ExclusionModel.delete().where( ExclusionModel.name == index.data(), ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, @@ -209,10 +270,23 @@ def remove_pattern(self): self.populate_preview_tab() + def toggle_custom_pattern(self, index): + ''' + Toggle the check state of the selected item. + ''' + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + def add_pattern(self): ''' Add an empty item to the list in editable mode. + Don't add an item if the user is already editing an item. ''' + if self.customExclusionsList.state() == QAbstractItemView.State.EditingState: + return item = QStandardItem('') item.setCheckable(True) item.setCheckState(Qt.CheckState.Checked) @@ -220,6 +294,16 @@ def add_pattern(self): self.customExclusionsList.edit(item.index()) self.customExclusionsList.scrollToBottom() + def custom_pattern_editing_finished(self, editor): + ''' + Go through all items in the list and if any of them are empty, remove them. + Handles the case where the user presses the escape key to cancel editing. + ''' + for row in range(self.customExclusionsModel.rowCount()): + item = self.customExclusionsModel.item(row) + if item.text() == '': + self.customExclusionsModel.removeRow(row) + def custom_item_changed(self, item): ''' When the user checks or unchecks an item, update the database. From f8f437275bfea0f5767d2d1f2847955b3be13e58 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Mon, 24 Jul 2023 19:39:52 +0530 Subject: [PATCH 11/31] qdialogbuttonbox close --- src/vorta/assets/UI/excludedialog.ui | 7 +++++++ src/vorta/views/exclude_dialog.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index c831a4650..05b3e1325 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -151,6 +151,13 @@ + + + + QDialogButtonBox::Close + + + diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 8392e0233..b6dbb0fd9 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -53,6 +53,8 @@ def __init__(self, profile, parent=None): self.profile = profile self.allPresets = {} + self.buttonBox.rejected.connect(self.close) + self.customExclusionsModel = MandatoryInputItemModel() self.customExclusionsList.setModel(self.customExclusionsModel) self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) From c9c6ede9f6c41fad95bfcaa4a541976be3388ea5 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Mon, 24 Jul 2023 23:13:00 +0530 Subject: [PATCH 12/31] test --- src/vorta/views/source_tab.py | 1 + tests/profile_exports/valid.json | 1 - tests/test_excludes.py | 26 ++++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/test_excludes.py diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 935759312..dd0e41d83 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -344,6 +344,7 @@ def source_remove(self): def show_exclude_dialog(self): window = ExcludeDialog(self.profile(), self) + self._window = window # for testing window.show() def save_exclude_patterns(self): diff --git a/tests/profile_exports/valid.json b/tests/profile_exports/valid.json index 9b8fe32f4..ee252a74b 100644 --- a/tests/profile_exports/valid.json +++ b/tests/profile_exports/valid.json @@ -15,7 +15,6 @@ "ssh_key": null, "compression": "zstd,8", "exclude_patterns": null, - "exclude_if_present": ".nobackup", "schedule_mode": "off", "schedule_interval_unit": "hours", "schedule_interval_count": 2, diff --git a/tests/test_excludes.py b/tests/test_excludes.py new file mode 100644 index 000000000..303594023 --- /dev/null +++ b/tests/test_excludes.py @@ -0,0 +1,26 @@ +from PyQt6 import QtCore + + +def test_exclusion_preview_populated(qapp, qtbot): + main = qapp.main_window + tab = main.sourceTab + main.tabWidget.setCurrentIndex(1) + + qtbot.mouseClick(tab.bExclude, QtCore.Qt.MouseButton.LeftButton) + qtbot.mouseClick(tab._window.bAddPattern, QtCore.Qt.MouseButton.LeftButton) + + qtbot.keyClicks(tab._window.customExclusionsList.viewport().focusWidget(), "custom pattern") + qtbot.keyClick(tab._window.customExclusionsList.viewport().focusWidget(), QtCore.Qt.Key.Key_Enter) + qtbot.waitUntil(lambda: tab._window.exclusionsPreviewText.toPlainText() == "# custom added rules\ncustom pattern\n") + + tab._window.tabWidget.setCurrentIndex(1) + + tab._window.exclusionPresetsModel.itemFromIndex(tab._window.exclusionPresetsModel.index(0, 0)).setCheckState( + QtCore.Qt.CheckState.Checked + ) + qtbot.waitUntil(lambda: "# Chromium cache and config files" in tab._window.exclusionsPreviewText.toPlainText()) + + tab._window.tabWidget.setCurrentIndex(2) + + qtbot.keyClicks(tab._window.rawExclusionsText, "test raw pattern 1") + qtbot.waitUntil(lambda: "test raw pattern 1\n" in tab._window.exclusionsPreviewText.toPlainText()) From 59551135c86c9f8ba7d1964e78e43852a7dbc953 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 25 Jul 2023 01:25:17 +0530 Subject: [PATCH 13/31] sort presets --- src/vorta/views/exclude_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index b6dbb0fd9..e7d996473 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -167,7 +167,7 @@ def populate_presets_list(self): # we are running in a normal Python environment bundle_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../assets/exclusion_presets') - for preset_file in os.listdir(bundle_dir): + for preset_file in sorted(os.listdir(bundle_dir)): with open(os.path.join(bundle_dir, preset_file), 'r') as f: preset_data = json.load(f) for preset in preset_data: From 5e255ddeffe6092fe028a3fcbe73613b6c22b03e Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 2 Aug 2023 03:24:20 +0530 Subject: [PATCH 14/31] change help texts --- src/vorta/assets/UI/excludedialog.ui | 20 ++++---------------- src/vorta/views/exclude_dialog.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index 05b3e1325..f1b4bab64 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -28,10 +28,7 @@ - - - Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. - + true @@ -71,10 +68,7 @@ - - - Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. - + true @@ -91,10 +85,7 @@ - - - Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. - + true @@ -111,10 +102,7 @@ - - - Some instruction text here maybe to tell the user what they are doing. ignore the non SFPro font. more lorem ipsum. - + true diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index e7d996473..e5dbbe9e9 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -115,6 +115,28 @@ def __init__(self, profile, parent=None): self.bAddPattern.clicked.connect(self.add_pattern) self.bAddPattern.setIcon(get_colored_icon('plus')) + # help text + self.customPresetsHelpText.setOpenExternalLinks(True) + self.customPresetsHelpText.setOpenExternalLinks(True) + self.customPresetsHelpText.setText( + "Patterns that you add here will be used to exclude files and folders from the backup. " + "For more info on how to use patterns, see the " + "documentation. " + "To add multiple patterns at once, use the \"Raw\" tab." + ) + self.exclusionPresetsHelpText.setText( + "These presets are provided by the community and are a good starting point", + "for excluding certain types of files. You can enable or disable them as you see fit. " + "To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it.", + ) + + self.rawExclusionsHelpText.setText( + "You can use this field to add multiple patterns at once. Each pattern should be on a separate line." + ) + self.exclusionsPreviewHelpText.setText( + "This is a preview of the patterns that will be used to exclude files and folders from the backup." + ) + self.populate_custom_exclusions_list() self.populate_presets_list() self.populate_raw_exclusions_text() From a2f8d8baabfe14560334d68f1f0bd1b66593b266 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 2 Aug 2023 03:31:23 +0530 Subject: [PATCH 15/31] help text formatting to pass tests --- src/vorta/views/exclude_dialog.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index e5dbbe9e9..854ed8b80 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -117,19 +117,12 @@ def __init__(self, profile, parent=None): # help text self.customPresetsHelpText.setOpenExternalLinks(True) - self.customPresetsHelpText.setOpenExternalLinks(True) self.customPresetsHelpText.setText( - "Patterns that you add here will be used to exclude files and folders from the backup. " - "For more info on how to use patterns, see the " - "documentation. " - "To add multiple patterns at once, use the \"Raw\" tab." + "Patterns that you add here will be used to exclude files and folders from the backup. For more info on how to use patterns, see the documentation. To add multiple patterns at once, use the \"Raw\" tab." # noqa: E501 ) self.exclusionPresetsHelpText.setText( - "These presets are provided by the community and are a good starting point", - "for excluding certain types of files. You can enable or disable them as you see fit. " - "To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it.", + "These presets are provided by the community and are a good starting point for excluding certain types of files. You can enable or disable them as you see fit. To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it." # noqa: E501 ) - self.rawExclusionsHelpText.setText( "You can use this field to add multiple patterns at once. Each pattern should be on a separate line." ) From 9fe6b7ed3c15e8d750360bd7ff560c123a791d87 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sat, 5 Aug 2023 23:19:36 +0530 Subject: [PATCH 16/31] yf-projects Help text suggestions Co-authored-by: yfprojects <62463991+real-yfprojects@users.noreply.github.com> --- src/vorta/views/exclude_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 854ed8b80..332b95daa 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -121,13 +121,13 @@ def __init__(self, profile, parent=None): "Patterns that you add here will be used to exclude files and folders from the backup. For more info on how to use patterns, see the documentation. To add multiple patterns at once, use the \"Raw\" tab." # noqa: E501 ) self.exclusionPresetsHelpText.setText( - "These presets are provided by the community and are a good starting point for excluding certain types of files. You can enable or disable them as you see fit. To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it." # noqa: E501 + "These presets are provided by the community and are a good starting point for excluding certain types of files. You can enable or disable them as you see fit. To see the patterns that comprise a preset, switch to the \"Preview\" tab after enabling it." # noqa: E501 ) self.rawExclusionsHelpText.setText( "You can use this field to add multiple patterns at once. Each pattern should be on a separate line." ) self.exclusionsPreviewHelpText.setText( - "This is a preview of the patterns that will be used to exclude files and folders from the backup." + "This is a preview of the patterns that will be passed to borg for excluding files and folders from the backup." ) self.populate_custom_exclusions_list() From ae2e948d174a4655d411a49190374b0bbb564179 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 15 Aug 2023 16:22:45 +0530 Subject: [PATCH 17/31] use translate method --- src/vorta/assets/UI/sourcetab.ui | 2 +- src/vorta/views/exclude_dialog.py | 21 +++++++++++++++++---- src/vorta/views/source_tab.py | 5 +++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index d02b54eec..7d5850910 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -115,7 +115,7 @@ - Select Exclude Patterns... + Select Exclude Patterns… diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 854ed8b80..a10cc9935 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -13,6 +13,7 @@ QStyledItemDelegate, ) +from vorta.i18n import translate from vorta.store.models import ExclusionModel from vorta.utils import get_asset from vorta.views.utils import get_colored_icon @@ -118,16 +119,28 @@ def __init__(self, profile, parent=None): # help text self.customPresetsHelpText.setOpenExternalLinks(True) self.customPresetsHelpText.setText( - "Patterns that you add here will be used to exclude files and folders from the backup. For more info on how to use patterns, see the documentation. To add multiple patterns at once, use the \"Raw\" tab." # noqa: E501 + translate( + "CustomPresetsHelp", + "Patterns that you add here will be used to exclude files and folders from the backup. For more info on how to use patterns, see the documentation. To add multiple patterns at once, use the \"Raw\" tab.", # noqa: E501 + ) ) self.exclusionPresetsHelpText.setText( - "These presets are provided by the community and are a good starting point for excluding certain types of files. You can enable or disable them as you see fit. To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it." # noqa: E501 + translate( + "ExclusionPresetsHelp", + "These presets are provided by the community and are a good starting point for excluding certain types of files. You can enable or disable them as you see fit. To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it.", # noqa: E501 + ) ) self.rawExclusionsHelpText.setText( - "You can use this field to add multiple patterns at once. Each pattern should be on a separate line." + translate( + "RawExclusionsHelp", + "You can use this field to add multiple patterns at once. Each pattern should be on a separate line.", + ) ) self.exclusionsPreviewHelpText.setText( - "This is a preview of the patterns that will be used to exclude files and folders from the backup." + translate( + "ExclusionsPreviewHelp", + "This is a preview of the patterns that will be used to exclude files and folders from the backup.", + ) ) self.populate_custom_exclusions_list() diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index dd0e41d83..696e98d65 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -343,6 +343,11 @@ def source_remove(self): logger.debug(f"Removed source in row {index.row()}") def show_exclude_dialog(self): + # Close existing window + if hasattr(self, '_window') and self._window is not None: + self._window.activateWindow() + self._window.raise_() + return window = ExcludeDialog(self.profile(), self) self._window = window # for testing window.show() From 9dbfe1a21e3d8d0184b430088706c69347200893 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 15 Aug 2023 16:46:40 +0530 Subject: [PATCH 18/31] make context menu work for multiple items --- src/vorta/views/exclude_dialog.py | 45 +++++++++++++++++++++---------- src/vorta/views/source_tab.py | 4 +-- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index d99c2396d..c97d4ba82 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -168,21 +168,28 @@ def custom_exclusions_context_menu(self, pos): if not index.isValid(): return + selected_rows = self.customExclusionsList.selectedIndexes() + + if selected_rows and index not in selected_rows: + return # popup only for selected items + menu = QMenu(self.customExclusionsList) + menu.addAction( + get_colored_icon('copy'), + self.tr('Copy'), + lambda: QApplication.clipboard().setText(index.data()), + ) + + # Remove and Toggle can work with multiple items selected menu.addAction( get_colored_icon('minus'), self.tr('Remove'), - lambda: self.remove_pattern(index), + lambda: self.remove_pattern(index if not selected_rows else None), ) menu.addAction( get_colored_icon('check-circle'), self.tr('Toggle'), - lambda: self.toggle_custom_pattern(index), - ) - menu.addAction( - get_colored_icon('copy'), - self.tr('Copy'), - lambda: QApplication.clipboard().setText(index.data()), + lambda: self.toggle_custom_pattern(index if not selected_rows else None), ) menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) @@ -279,7 +286,7 @@ def customPatternKeyPressEvent(self, event): def remove_pattern(self, index=None): ''' Remove the selected item(s) from the list and the database. - If there is no selection, this was called from the context menu and the index is passed in. + If there is no index, this was called from the context menu and the indexes are passed in. ''' if not index: indexes = self.customExclusionsList.selectedIndexes() @@ -300,15 +307,25 @@ def remove_pattern(self, index=None): self.populate_preview_tab() - def toggle_custom_pattern(self, index): + def toggle_custom_pattern(self, index=None): ''' - Toggle the check state of the selected item. + Toggle the check state of the selected item(s). + If there is no index, this was called from the context menu and the indexes are passed in. ''' - item = self.customExclusionsModel.itemFromIndex(index) - if item.checkState() == Qt.CheckState.Checked: - item.setCheckState(Qt.CheckState.Unchecked) + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in indexes: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) else: - item.setCheckState(Qt.CheckState.Checked) + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) def add_pattern(self): ''' diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 696e98d65..fb793ca2a 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -345,9 +345,7 @@ def source_remove(self): def show_exclude_dialog(self): # Close existing window if hasattr(self, '_window') and self._window is not None: - self._window.activateWindow() - self._window.raise_() - return + self._window.close() window = ExcludeDialog(self.profile(), self) self._window = window # for testing window.show() From 96e3fa7c2721afa58df0f09104ad79acd2fbc43c Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 15 Aug 2023 17:19:43 +0530 Subject: [PATCH 19/31] delete custom items with the delete key --- src/vorta/views/exclude_dialog.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index c97d4ba82..f6fd68274 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -3,7 +3,7 @@ import sys from PyQt6 import uic -from PyQt6.QtCore import QModelIndex, Qt +from PyQt6.QtCore import QModelIndex, QObject, Qt from PyQt6.QtGui import QStandardItem, QStandardItemModel from PyQt6.QtWidgets import ( QAbstractItemView, @@ -78,8 +78,8 @@ def __init__(self, profile, parent=None): self.customExclusionsListDelegate = QStyledItemDelegate() self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) - # allow removing items with the delete key (remove_pattern is called in keyPressEvent) - self.customExclusionsList.keyPressEvent = self.customPatternKeyPressEvent + # allow removing items with the delete key with event filter + self.installEventFilter(self) # context menu self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) @@ -277,12 +277,6 @@ def copy_preview_to_clipboard(self): cb.clear(mode=cb.Mode.Clipboard) cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) - def customPatternKeyPressEvent(self, event): - if event.key() == Qt.Key.Key_Delete: - self.remove_pattern() - else: - super().keyPressEvent(event) - def remove_pattern(self, index=None): ''' Remove the selected item(s) from the list and the database. @@ -404,3 +398,12 @@ def raw_exclusions_saved(self): self.profile.save() self.populate_preview_tab() + + def eventFilter(self, source, event): + ''' + When the user presses the delete key, remove the selected items. + ''' + if event.type() == event.Type.KeyPress and event.key() == Qt.Key.Key_Delete: + self.remove_pattern() + return True + return QObject.eventFilter(self, source, event) From 3bcd08b6ef47a8896a458a3b89f869074fc13512 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Thu, 17 Aug 2023 00:44:56 +0530 Subject: [PATCH 20/31] make main window uninteractable when the exclude dialog opens --- src/vorta/views/exclude_dialog.py | 1 + src/vorta/views/source_tab.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index f6fd68274..75bf0690a 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -53,6 +53,7 @@ def __init__(self, profile, parent=None): self.setupUi(self) self.profile = profile self.allPresets = {} + self.setWindowModality(Qt.WindowModality.ApplicationModal) self.buttonBox.rejected.connect(self.close) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index fb793ca2a..dd0e41d83 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -343,9 +343,6 @@ def source_remove(self): logger.debug(f"Removed source in row {index.row()}") def show_exclude_dialog(self): - # Close existing window - if hasattr(self, '_window') and self._window is not None: - self._window.close() window = ExcludeDialog(self.profile(), self) self._window = window # for testing window.show() From 328b46f3fef3336ac0558ec05a5deda463b81fc7 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sat, 19 Aug 2023 23:56:28 +0530 Subject: [PATCH 21/31] show a default exclusion --- src/vorta/store/connection.py | 2 +- src/vorta/store/migrations.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index 75e8e07cb..de0e08085 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -22,7 +22,7 @@ ) from .settings import get_misc_settings -SCHEMA_VERSION = 23 +SCHEMA_VERSION = 24 @signals.post_save(sender=SettingsModel) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index 85d811fa2..ec095fb70 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -8,6 +8,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + ExclusionModel, RepoModel, SettingsModel, SourceFileModel, @@ -261,6 +262,16 @@ def run_migrations(current_schema, db_connection): ), ) + # add a default exclusion to help the user understand how to use the new exclude GUI + if current_schema.version < 24: + if ExclusionModel.select().count() == 0: + for profile in BackupProfileModel: + ExclusionModel.create( + profile=profile, + name='*/node_modules', + enabled=True, + ) + def _apply_schema_update(current_schema, version_after, *operations): with DB.atomic(): From f27c6536adc49b512725543def4e971e6dabe938 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sun, 20 Aug 2023 00:00:13 +0530 Subject: [PATCH 22/31] convert old exclude patterns to new objects --- src/vorta/store/migrations.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index ec095fb70..7ee633fc2 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -262,8 +262,20 @@ def run_migrations(current_schema, db_connection): ), ) + # convert every pattern in the old exclude_patterns string to a new ExclusionModel object # add a default exclusion to help the user understand how to use the new exclude GUI if current_schema.version < 24: + for profile in BackupProfileModel: + previous_exclusions = profile.exclude_patterns.splitlines() + for pattern in previous_exclusions: + ExclusionModel.create( + profile=profile, + name=pattern, + enabled=True, + ) + profile.exclude_patterns = '' + profile.save() + if ExclusionModel.select().count() == 0: for profile in BackupProfileModel: ExclusionModel.create( From b00779c9350e439aa638e3f7b533a6be0e6eb950 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Mon, 21 Aug 2023 20:20:49 +0530 Subject: [PATCH 23/31] handle integrity error during migration and allow selecting multiple patterns using shift --- src/vorta/store/migrations.py | 15 +++++++++------ src/vorta/views/exclude_dialog.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index 7ee633fc2..49db3dd05 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -266,13 +266,16 @@ def run_migrations(current_schema, db_connection): # add a default exclusion to help the user understand how to use the new exclude GUI if current_schema.version < 24: for profile in BackupProfileModel: - previous_exclusions = profile.exclude_patterns.splitlines() + previous_exclusions = profile.exclude_patterns.splitlines() if profile.exclude_patterns else [] for pattern in previous_exclusions: - ExclusionModel.create( - profile=profile, - name=pattern, - enabled=True, - ) + try: + ExclusionModel.create( + profile=profile, + name=pattern, + enabled=True, + ) + except pw.IntegrityError: + pass profile.exclude_patterns = '' profile.save() diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 75bf0690a..ee98f60f3 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -61,7 +61,7 @@ def __init__(self, profile, parent=None): self.customExclusionsList.setModel(self.customExclusionsModel) self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) + self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.customExclusionsList.setAlternatingRowColors(True) self.customExclusionsList.setStyleSheet( From 9188265d6c77dd9c1d836e7407e1066a7677b5f9 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 22 Aug 2023 10:55:10 +0530 Subject: [PATCH 24/31] reduce item padding --- src/vorta/views/exclude_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index ee98f60f3..34ba62914 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -67,7 +67,7 @@ def __init__(self, profile, parent=None): self.customExclusionsList.setStyleSheet( ''' QListView::item { - padding: 20px 0px; + padding: 10px 0px; border-bottom: .5px solid black; } QListView::item:selected { @@ -94,7 +94,7 @@ def __init__(self, profile, parent=None): self.exclusionPresetsList.setStyleSheet( ''' QListView::item { - padding: 20px 0px; + padding: 10px 0px; border-bottom: .5px solid black; } QListView::item:selected { From 3c2e4c2fef247540b81c9189e640346d6bb8544c Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 23 Aug 2023 16:14:12 +0530 Subject: [PATCH 25/31] remove styling from elements and add back exclude_if_present box --- src/vorta/assets/UI/sourcetab.ui | 27 +++++++++++++++++++++++++++ src/vorta/borg/create.py | 5 +++++ src/vorta/store/models.py | 1 + src/vorta/views/exclude_dialog.py | 26 -------------------------- src/vorta/views/source_tab.py | 10 ++++++++-- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index 7d5850910..b8ffd40b5 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -157,6 +157,33 @@ + + + + + + Exclude If Present (exclude folders with these files): + + + + + + + + 0 + 0 + + + + QAbstractScrollArea::AdjustToContentsOnFirstShow + + + E.g. .nobackup + + + + + diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 8fbe54ef8..17296dd84 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -171,6 +171,11 @@ def prepare(cls, profile): cmd.extend(['--exclude-from', pattern_file.name]) ret['cleanup_files'].append(pattern_file) + if profile.exclude_if_present is not None: + for f in profile.exclude_if_present.split('\n'): + if f.strip(): + cmd.extend(['--exclude-if-present', f.strip()]) + # Add repo url and source dirs. new_archive_name = format_archive_name(profile, profile.new_archive_name) diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 6aa92c642..76690cc37 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -77,6 +77,7 @@ class BackupProfileModel(BaseModel): compression = pw.CharField(default='lz4') raw_exclusions = pw.TextField(default='') exclude_patterns = pw.TextField(null=True) + exclude_if_present = pw.TextField(null=True) schedule_mode = pw.CharField(default='off') schedule_interval_count = pw.IntegerField(default=3) schedule_interval_unit = pw.CharField(default='hours') diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 34ba62914..571215f5d 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -64,18 +64,6 @@ def __init__(self, profile, parent=None): self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.customExclusionsList.setAlternatingRowColors(True) - self.customExclusionsList.setStyleSheet( - ''' - QListView::item { - padding: 10px 0px; - border-bottom: .5px solid black; - } - QListView::item:selected { - background-color: palette(highlight); - } - - ''' - ) self.customExclusionsListDelegate = QStyledItemDelegate() self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) @@ -91,20 +79,6 @@ def __init__(self, profile, parent=None): self.exclusionPresetsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.exclusionPresetsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.exclusionPresetsList.setAlternatingRowColors(True) - self.exclusionPresetsList.setStyleSheet( - ''' - QListView::item { - padding: 10px 0px; - border-bottom: .5px solid black; - } - QListView::item:selected { - background-color: palette(highlight); - } - QListView::item::icon { - padding-right: 10px; - } - ''' - ) self.exclusionsPreviewText.setReadOnly(True) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 12a55d283..471342a8a 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -102,6 +102,7 @@ def __init__(self, parent=None): # Connect signals self.removeButton.clicked.connect(self.source_remove) self.updateButton.clicked.connect(self.sources_update) + self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) self.bExclude.clicked.connect(self.show_exclude_dialog) header.sortIndicatorChanged.connect(self.update_sort_order) @@ -251,7 +252,9 @@ def add_source_to_table(self, source, update_data=None): def populate_from_profile(self): profile = self.profile() + self.excludeIfPresentField.textChanged.disconnect() self.sourceFilesWidget.setRowCount(0) # Clear rows + self.excludeIfPresentField.clear() for source in SourceFileModel.select().where(SourceFileModel.profile == profile): self.add_source_to_table(source, False) @@ -263,6 +266,9 @@ def populate_from_profile(self): # Sort items as per settings self.sourceFilesWidget.sortItems(sourcetab_sort_column, Qt.SortOrder(sourcetab_sort_order)) + self.excludeIfPresentField.appendPlainText(profile.exclude_if_present) + self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) + def update_sort_order(self, column: int, order: int): """Save selected sort by column and order to settings""" SettingsModel.update({SettingsModel.str_value: str(column)}).where( @@ -347,9 +353,9 @@ def show_exclude_dialog(self): self._window = window # for testing window.show() - def save_exclude_patterns(self): + def save_exclude_if_present(self): profile = self.profile() - profile.exclude_patterns = "" + profile.exclude_if_present = self.excludeIfPresentField.toPlainText() profile.save() def paste_text(self): From 4522a5b11fdc7c2ec080a77bf0b5a4bef2b9a57b Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 25 Aug 2023 02:12:38 +0530 Subject: [PATCH 26/31] add exclude if present dialog --- src/vorta/assets/UI/excludeifpresentdialog.ui | 136 ++++++++ src/vorta/assets/UI/sourcetab.ui | 34 +- src/vorta/borg/create.py | 2 +- src/vorta/store/connection.py | 2 + src/vorta/store/migrations.py | 35 ++- src/vorta/store/models.py | 14 + src/vorta/views/exclude_dialog.py | 6 +- src/vorta/views/exclude_if_present_dialog.py | 293 ++++++++++++++++++ src/vorta/views/source_tab.py | 16 +- 9 files changed, 498 insertions(+), 40 deletions(-) create mode 100644 src/vorta/assets/UI/excludeifpresentdialog.ui create mode 100644 src/vorta/views/exclude_if_present_dialog.py diff --git a/src/vorta/assets/UI/excludeifpresentdialog.ui b/src/vorta/assets/UI/excludeifpresentdialog.ui new file mode 100644 index 000000000..040695834 --- /dev/null +++ b/src/vorta/assets/UI/excludeifpresentdialog.ui @@ -0,0 +1,136 @@ + + + Dialog + + + + 0 + 0 + 504 + 426 + + + + Add patterns to exclude + + + + 10 + + + + + 0 + + + + Custom + + + + + + true + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + Raw + + + + + + true + + + + + + + + + + + Preview + + + + + + true + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy to Clipboard + + + + + + + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index b8ffd40b5..3238d4c04 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -122,6 +122,13 @@ + + + + Exclude if Present… + + + @@ -157,33 +164,6 @@ - - - - - - Exclude If Present (exclude folders with these files): - - - - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustToContentsOnFirstShow - - - E.g. .nobackup - - - - - diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 17296dd84..f37965bca 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -173,7 +173,7 @@ def prepare(cls, profile): if profile.exclude_if_present is not None: for f in profile.exclude_if_present.split('\n'): - if f.strip(): + if f.strip() and not f.strip().startswith('#'): cmd.extend(['--exclude-if-present', f.strip()]) # Add repo url and source dirs. diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index de0e08085..027edb019 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -12,6 +12,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + ExcludeIfPresentModel, ExclusionModel, RepoModel, RepoPassword, @@ -54,6 +55,7 @@ def init_db(con=None): EventLogModel, SchemaVersion, ExclusionModel, + ExcludeIfPresentModel, ] ) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index 49db3dd05..b40256676 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -8,6 +8,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + ExcludeIfPresentModel, ExclusionModel, RepoModel, SettingsModel, @@ -262,8 +263,19 @@ def run_migrations(current_schema, db_connection): ), ) + _apply_schema_update( + current_schema, + 23, + migrator.add_column( + BackupProfileModel._meta.table_name, + 'raw_exclude_if_present', + pw.CharField(default=''), + ), + ) + # convert every pattern in the old exclude_patterns string to a new ExclusionModel object - # add a default exclusion to help the user understand how to use the new exclude GUI + # add a default exclusion to help the user understand how to use the new exclude + # do the same with the exclude_if_present text if current_schema.version < 24: for profile in BackupProfileModel: previous_exclusions = profile.exclude_patterns.splitlines() if profile.exclude_patterns else [] @@ -276,6 +288,19 @@ def run_migrations(current_schema, db_connection): ) except pw.IntegrityError: pass + + previous_exclude_if_present = profile.exclude_if_present.splitlines() if profile.exclude_if_present else [] + for pattern in previous_exclude_if_present: + try: + ExcludeIfPresentModel.create( + profile=profile, + name=pattern, + enabled=True, + ) + except pw.IntegrityError: + pass + + profile.exclude_if_present = '' profile.exclude_patterns = '' profile.save() @@ -287,6 +312,14 @@ def run_migrations(current_schema, db_connection): enabled=True, ) + if ExcludeIfPresentModel.select().count() == 0: + for profile in BackupProfileModel: + ExcludeIfPresentModel.create( + profile=profile, + name='.nobackup', + enabled=True, + ) + def _apply_schema_update(current_schema, version_after, *operations): with DB.atomic(): diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 76690cc37..6fee97a7d 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -76,6 +76,7 @@ class BackupProfileModel(BaseModel): ssh_key = pw.CharField(default=None, null=True) compression = pw.CharField(default='lz4') raw_exclusions = pw.TextField(default='') + raw_exclude_if_present = pw.TextField(default='') exclude_patterns = pw.TextField(null=True) exclude_if_present = pw.TextField(null=True) schedule_mode = pw.CharField(default='off') @@ -130,6 +131,19 @@ class Meta: database = DB +class ExcludeIfPresentModel(BaseModel): + """ + These patters can't be presets and are used to exclude directories if a specific file is present. + """ + + profile = pw.ForeignKeyField(BackupProfileModel, backref='exclude_if_present_patterns') + name = pw.CharField(unique=True) + enabled = pw.BooleanField(default=True) + + class Meta: + database = DB + + class SourceFileModel(BaseModel): """A folder to be backed up, related to a Backup Configuration.""" diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 571215f5d..a17513ac3 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -227,9 +227,13 @@ def populate_preview_tab(self): ): excludes += f"{exclude.name}\n" + # add a newline if there are custom exclusions + if excludes: + excludes += "\n" + raw_excludes = self.profile.raw_exclusions if raw_excludes: - excludes += "\n# raw exclusions\n" + excludes += "# raw exclusions\n" excludes += raw_excludes excludes += "\n" diff --git a/src/vorta/views/exclude_if_present_dialog.py b/src/vorta/views/exclude_if_present_dialog.py new file mode 100644 index 000000000..055d1cbd2 --- /dev/null +++ b/src/vorta/views/exclude_if_present_dialog.py @@ -0,0 +1,293 @@ +from PyQt6 import uic +from PyQt6.QtCore import QModelIndex, QObject, Qt +from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtWidgets import ( + QAbstractItemView, + QApplication, + QMenu, + QMessageBox, + QStyledItemDelegate, +) + +from vorta.i18n import translate +from vorta.store.models import ExcludeIfPresentModel +from vorta.utils import get_asset +from vorta.views.utils import get_colored_icon + +uifile = get_asset('UI/excludeifpresentdialog.ui') +ExcludeIfPresentDialogUi, ExcludeIfPresentDialogBase = uic.loadUiType(uifile) + + +class MandatoryInputItemModel(QStandardItemModel): + ''' + A model that prevents the user from adding an empty item to the list. + ''' + + def __init__(self, parent=None): + super().__init__(parent) + + def setData(self, index: QModelIndex, value, role: int = ...) -> bool: + # When a user-added item in edit mode has no text, remove it from the list. + if role == Qt.ItemDataRole.EditRole and value == '': + self.removeRow(index.row()) + return True + if role == Qt.ItemDataRole.EditRole and ExcludeIfPresentModel.get_or_none(ExcludeIfPresentModel.name == value): + QMessageBox.critical( + self.parent(), + 'Error', + 'This exclusion already exists.', + ) + self.removeRow(index.row()) + return False + + return super().setData(index, value, role) + + +class ExcludeIfPresentDialog(ExcludeIfPresentDialogBase, ExcludeIfPresentDialogUi): + def __init__(self, profile, parent=None): + super().__init__(parent) + self.setupUi(self) + self.profile = profile + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.buttonBox.rejected.connect(self.close) + + self.customExclusionsModel = MandatoryInputItemModel() + self.customExclusionsList.setModel(self.customExclusionsModel) + self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) + self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.customExclusionsList.setAlternatingRowColors(True) + self.customExclusionsListDelegate = QStyledItemDelegate() + self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) + self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) + # allow removing items with the delete key with event filter + self.installEventFilter(self) + # context menu + self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) + + self.exclusionsPreviewText.setReadOnly(True) + + self.rawExclusionsText.textChanged.connect(self.raw_exclusions_saved) + + self.bRemovePattern.clicked.connect(self.remove_pattern) + self.bRemovePattern.setIcon(get_colored_icon('minus')) + self.bPreviewCopy.clicked.connect(self.copy_preview_to_clipboard) + self.bPreviewCopy.setIcon(get_colored_icon('copy')) + self.bAddPattern.clicked.connect(self.add_pattern) + self.bAddPattern.setIcon(get_colored_icon('plus')) + + # help text + self.customPresetsHelpText.setOpenExternalLinks(True) + self.customPresetsHelpText.setText( + translate( + "CustomPresetsHelp", + "You can add names of filesystem objects (e.g. a file or folder name) which, when contained within another folder, will prevent the containing folder from being backed up. This option will exclude a directory if a specific file or folder is present in that directory. For more info, see the documentation. To add multiple names at once, use the \"Raw\" tab.", # noqa: E501 + ) + ) + self.rawExclusionsHelpText.setText( + translate( + "RawExclusionsHelp", + "You can use this field to add multiple names at once. Each name should be on a separate line. You can also add comments with #, which will be ignored when the list is parsed.", # noqa: E501 + ) + ) + self.exclusionsPreviewHelpText.setText( + translate( + "ExclusionsPreviewHelp", + "This is a preview of all the names which will be passed to Borg's --exclude-if-present option.", # noqa: E501 + ) + ) + + self.populate_custom_exclusions_list() + self.populate_raw_exclusions_text() + self.populate_preview_tab() + + def populate_custom_exclusions_list(self): + user_excluded_patterns = { + e.name: e.enabled + for e in self.profile.exclude_if_present_patterns.select().order_by(ExcludeIfPresentModel.name) + } + + for (exclude, enabled) in user_excluded_patterns.items(): + item = QStandardItem(exclude) + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) + self.customExclusionsModel.appendRow(item) + + def custom_exclusions_context_menu(self, pos): + # index under cursor + index = self.customExclusionsList.indexAt(pos) + if not index.isValid(): + return + + selected_rows = self.customExclusionsList.selectedIndexes() + + if selected_rows and index not in selected_rows: + return # popup only for selected items + + menu = QMenu(self.customExclusionsList) + menu.addAction( + get_colored_icon('copy'), + self.tr('Copy'), + lambda: QApplication.clipboard().setText(index.data()), + ) + + # Remove and Toggle can work with multiple items selected + menu.addAction( + get_colored_icon('minus'), + self.tr('Remove'), + lambda: self.remove_pattern(index if not selected_rows else None), + ) + menu.addAction( + get_colored_icon('check-circle'), + self.tr('Toggle'), + lambda: self.toggle_custom_pattern(index if not selected_rows else None), + ) + + menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) + + def populate_raw_exclusions_text(self): + raw_excludes = self.profile.raw_exclude_if_present + if raw_excludes: + self.rawExclusionsText.setPlainText(raw_excludes) + + def populate_preview_tab(self): + excludes = "" + + if ( + ExcludeIfPresentModel.select() + .where( + ExcludeIfPresentModel.profile == self.profile, + ExcludeIfPresentModel.enabled, + ) + .count() + > 0 + ): + excludes = "# custom added rules\n" + + for exclude in ExcludeIfPresentModel.select().where( + ExcludeIfPresentModel.profile == self.profile, + ExcludeIfPresentModel.enabled, + ): + excludes += f"{exclude.name}\n" + + # add a newline if there are custom exclusions + if excludes: + excludes += "\n" + + raw_excludes = self.profile.raw_exclude_if_present + if raw_excludes: + excludes += "# raw exclusions\n" + excludes += raw_excludes + excludes += "\n" + + self.exclusionsPreviewText.setPlainText(excludes) + self.profile.exclude_if_present = excludes + self.profile.save() + + def copy_preview_to_clipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Mode.Clipboard) + cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) + + def remove_pattern(self, index=None): + ''' + Remove the selected item(s) from the list and the database. + If there is no index, this was called from the context menu and the indexes are passed in. + ''' + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in reversed(sorted(indexes)): + ExcludeIfPresentModel.delete().where( + ExcludeIfPresentModel.name == index.data(), + ExcludeIfPresentModel.profile == self.profile, + ).execute() + self.customExclusionsModel.removeRow(index.row()) + else: + ExcludeIfPresentModel.delete().where( + ExcludeIfPresentModel.name == index.data(), + ExcludeIfPresentModel.profile == self.profile, + ).execute() + self.customExclusionsModel.removeRow(index.row()) + + self.populate_preview_tab() + + def toggle_custom_pattern(self, index=None): + ''' + Toggle the check state of the selected item(s). + If there is no index, this was called from the context menu and the indexes are passed in. + ''' + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in indexes: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + else: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + + def add_pattern(self): + ''' + Add an empty item to the list in editable mode. + Don't add an item if the user is already editing an item. + ''' + if self.customExclusionsList.state() == QAbstractItemView.State.EditingState: + return + item = QStandardItem('') + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked) + self.customExclusionsList.model().appendRow(item) + self.customExclusionsList.edit(item.index()) + self.customExclusionsList.scrollToBottom() + + def custom_pattern_editing_finished(self, editor): + ''' + Go through all items in the list and if any of them are empty, remove them. + Handles the case where the user presses the escape key to cancel editing. + ''' + for row in range(self.customExclusionsModel.rowCount()): + item = self.customExclusionsModel.item(row) + if item.text() == '': + self.customExclusionsModel.removeRow(row) + + def custom_item_changed(self, item): + ''' + When the user checks or unchecks an item, update the database. + When the user adds a new item, add it to the database. + ''' + if not ExcludeIfPresentModel.get_or_none(name=item.text(), profile=self.profile): + ExcludeIfPresentModel.create(name=item.text(), profile=self.profile) + + ExcludeIfPresentModel.update(enabled=item.checkState() == Qt.CheckState.Checked).where( + ExcludeIfPresentModel.name == item.text(), + ExcludeIfPresentModel.profile == self.profile, + ).execute() + + self.populate_preview_tab() + + def raw_exclusions_saved(self): + ''' + When the user saves changes in the raw exclusions text box, add it to the database. + ''' + raw_excludes = self.rawExclusionsText.toPlainText() + self.profile.raw_exclude_if_present = raw_excludes + self.profile.save() + + self.populate_preview_tab() + + def eventFilter(self, source, event): + ''' + When the user presses the delete key, remove the selected items. + ''' + if event.type() == event.Type.KeyPress and event.key() == Qt.Key.Key_Delete: + self.remove_pattern() + return True + return QObject.eventFilter(self, source, event) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 471342a8a..615eead8e 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -22,6 +22,7 @@ sort_sizes, ) from vorta.views.exclude_dialog import ExcludeDialog +from vorta.views.exclude_if_present_dialog import ExcludeIfPresentDialog from vorta.views.utils import get_colored_icon uifile = get_asset('UI/sourcetab.ui') @@ -102,8 +103,8 @@ def __init__(self, parent=None): # Connect signals self.removeButton.clicked.connect(self.source_remove) self.updateButton.clicked.connect(self.sources_update) - self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) self.bExclude.clicked.connect(self.show_exclude_dialog) + self.bExcludeIfPresent.clicked.connect(self.show_exclude_if_present_dialog) header.sortIndicatorChanged.connect(self.update_sort_order) # Connect to palette change @@ -252,9 +253,7 @@ def add_source_to_table(self, source, update_data=None): def populate_from_profile(self): profile = self.profile() - self.excludeIfPresentField.textChanged.disconnect() self.sourceFilesWidget.setRowCount(0) # Clear rows - self.excludeIfPresentField.clear() for source in SourceFileModel.select().where(SourceFileModel.profile == profile): self.add_source_to_table(source, False) @@ -266,9 +265,6 @@ def populate_from_profile(self): # Sort items as per settings self.sourceFilesWidget.sortItems(sourcetab_sort_column, Qt.SortOrder(sourcetab_sort_order)) - self.excludeIfPresentField.appendPlainText(profile.exclude_if_present) - self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) - def update_sort_order(self, column: int, order: int): """Save selected sort by column and order to settings""" SettingsModel.update({SettingsModel.str_value: str(column)}).where( @@ -353,10 +349,10 @@ def show_exclude_dialog(self): self._window = window # for testing window.show() - def save_exclude_if_present(self): - profile = self.profile() - profile.exclude_if_present = self.excludeIfPresentField.toPlainText() - profile.save() + def show_exclude_if_present_dialog(self): + window = ExcludeIfPresentDialog(self.profile(), self) + self._window = window + window.show() def paste_text(self): sources = QApplication.clipboard().text().splitlines() From 423aa4d9d93077c8854b2a1c6bcb332a3e20766d Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 25 Aug 2023 02:20:08 +0530 Subject: [PATCH 27/31] update exclude if present window title --- src/vorta/assets/UI/excludeifpresentdialog.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vorta/assets/UI/excludeifpresentdialog.ui b/src/vorta/assets/UI/excludeifpresentdialog.ui index 040695834..926ae99c1 100644 --- a/src/vorta/assets/UI/excludeifpresentdialog.ui +++ b/src/vorta/assets/UI/excludeifpresentdialog.ui @@ -11,7 +11,7 @@ - Add patterns to exclude + Exclude if present From cae7ba4e0f754df3136f74d2eb80f264a33045d6 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 25 Aug 2023 02:34:05 +0530 Subject: [PATCH 28/31] add test for exclude if present dialog --- tests/test_exclude_if_present.py | 22 ++++++++++++++++++++++ tests/test_excludes.py | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/test_exclude_if_present.py diff --git a/tests/test_exclude_if_present.py b/tests/test_exclude_if_present.py new file mode 100644 index 000000000..f964791a0 --- /dev/null +++ b/tests/test_exclude_if_present.py @@ -0,0 +1,22 @@ +from PyQt6 import QtCore + + +def test_exclusion_preview_populated(qapp, qtbot): + main = qapp.main_window + tab = main.sourceTab + main.tabWidget.setCurrentIndex(1) + + qtbot.mouseClick(tab.bExcludeIfPresent, QtCore.Qt.MouseButton.LeftButton) + qtbot.mouseClick(tab._window.bAddPattern, QtCore.Qt.MouseButton.LeftButton) + + qtbot.keyClicks(tab._window.customExclusionsList.viewport().focusWidget(), "custom pattern") + qtbot.keyClick(tab._window.customExclusionsList.viewport().focusWidget(), QtCore.Qt.Key.Key_Enter) + + qtbot.waitUntil( + lambda: tab._window.exclusionsPreviewText.toPlainText() == "# custom added rules\ncustom pattern\n\n" + ) + + tab._window.tabWidget.setCurrentIndex(2) + + qtbot.keyClicks(tab._window.rawExclusionsText, "test raw pattern 1") + qtbot.waitUntil(lambda: "test raw pattern 1\n" in tab._window.exclusionsPreviewText.toPlainText()) diff --git a/tests/test_excludes.py b/tests/test_excludes.py index 303594023..23aa3e649 100644 --- a/tests/test_excludes.py +++ b/tests/test_excludes.py @@ -11,7 +11,9 @@ def test_exclusion_preview_populated(qapp, qtbot): qtbot.keyClicks(tab._window.customExclusionsList.viewport().focusWidget(), "custom pattern") qtbot.keyClick(tab._window.customExclusionsList.viewport().focusWidget(), QtCore.Qt.Key.Key_Enter) - qtbot.waitUntil(lambda: tab._window.exclusionsPreviewText.toPlainText() == "# custom added rules\ncustom pattern\n") + qtbot.waitUntil( + lambda: tab._window.exclusionsPreviewText.toPlainText() == "# custom added rules\ncustom pattern\n\n" + ) tab._window.tabWidget.setCurrentIndex(1) From 1cf22238a9d06c2a1a020e3dfc415940474514c8 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Mon, 28 Aug 2023 02:42:49 +0530 Subject: [PATCH 29/31] move tests --- tests/unit/conftest.py | 3 ++- tests/{ => unit}/test_exclude_if_present.py | 0 tests/{ => unit}/test_excludes.py | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename tests/{ => unit}/test_exclude_if_present.py (100%) rename tests/{ => unit}/test_excludes.py (100%) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e622a2118..0f1015465 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -68,11 +68,12 @@ def init_db(qapp, qtbot, tmpdir_factory): del qapp.main_window qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI + qapp.scheduler.schedule_changed.disconnect() + yield qapp.jobs_manager.cancel_all_jobs() qapp.backup_finished_event.disconnect() - qapp.scheduler.schedule_changed.disconnect() qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults) mock_db.close() diff --git a/tests/test_exclude_if_present.py b/tests/unit/test_exclude_if_present.py similarity index 100% rename from tests/test_exclude_if_present.py rename to tests/unit/test_exclude_if_present.py diff --git a/tests/test_excludes.py b/tests/unit/test_excludes.py similarity index 100% rename from tests/test_excludes.py rename to tests/unit/test_excludes.py From 15c87e68bc11750a973a2c862e649038dd07a666 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sat, 2 Sep 2023 02:56:48 +0530 Subject: [PATCH 30/31] remove duplicate MandatoryInputItemModel --- src/vorta/store/migrations.py | 7 +++-- src/vorta/views/exclude_dialog.py | 7 +++-- src/vorta/views/exclude_if_present_dialog.py | 33 +++----------------- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index b40256676..1d1cd1551 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -287,6 +287,8 @@ def run_migrations(current_schema, db_connection): enabled=True, ) except pw.IntegrityError: + # Previously, users could add the same exclusion multiple times, since it was just a string. + # Now, we enforce uniqueness, so we need to catch the IntegrityError and ignore it. pass previous_exclude_if_present = profile.exclude_if_present.splitlines() if profile.exclude_if_present else [] @@ -298,6 +300,7 @@ def run_migrations(current_schema, db_connection): enabled=True, ) except pw.IntegrityError: + # Same as above pass profile.exclude_if_present = '' @@ -309,7 +312,7 @@ def run_migrations(current_schema, db_connection): ExclusionModel.create( profile=profile, name='*/node_modules', - enabled=True, + enabled=False, ) if ExcludeIfPresentModel.select().count() == 0: @@ -317,7 +320,7 @@ def run_migrations(current_schema, db_connection): ExcludeIfPresentModel.create( profile=profile, name='.nobackup', - enabled=True, + enabled=False, ) diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index a17513ac3..7d0fb0f3e 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -27,15 +27,16 @@ class MandatoryInputItemModel(QStandardItemModel): A model that prevents the user from adding an empty item to the list. ''' - def __init__(self, parent=None): + def __init__(self, parent=None, model=None): super().__init__(parent) + self.model = model def setData(self, index: QModelIndex, value, role: int = ...) -> bool: # When a user-added item in edit mode has no text, remove it from the list. if role == Qt.ItemDataRole.EditRole and value == '': self.removeRow(index.row()) return True - if role == Qt.ItemDataRole.EditRole and ExclusionModel.get_or_none(ExclusionModel.name == value): + if role == Qt.ItemDataRole.EditRole and ExclusionModel.get_or_none(self.model.name == value): QMessageBox.critical( self.parent(), 'Error', @@ -57,7 +58,7 @@ def __init__(self, profile, parent=None): self.buttonBox.rejected.connect(self.close) - self.customExclusionsModel = MandatoryInputItemModel() + self.customExclusionsModel = MandatoryInputItemModel(model=ExclusionModel) self.customExclusionsList.setModel(self.customExclusionsModel) self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) diff --git a/src/vorta/views/exclude_if_present_dialog.py b/src/vorta/views/exclude_if_present_dialog.py index 055d1cbd2..f398de598 100644 --- a/src/vorta/views/exclude_if_present_dialog.py +++ b/src/vorta/views/exclude_if_present_dialog.py @@ -1,48 +1,23 @@ from PyQt6 import uic -from PyQt6.QtCore import QModelIndex, QObject, Qt -from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtCore import QObject, Qt +from PyQt6.QtGui import QStandardItem from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, QMenu, - QMessageBox, QStyledItemDelegate, ) from vorta.i18n import translate from vorta.store.models import ExcludeIfPresentModel from vorta.utils import get_asset +from vorta.views.exclude_dialog import MandatoryInputItemModel from vorta.views.utils import get_colored_icon uifile = get_asset('UI/excludeifpresentdialog.ui') ExcludeIfPresentDialogUi, ExcludeIfPresentDialogBase = uic.loadUiType(uifile) -class MandatoryInputItemModel(QStandardItemModel): - ''' - A model that prevents the user from adding an empty item to the list. - ''' - - def __init__(self, parent=None): - super().__init__(parent) - - def setData(self, index: QModelIndex, value, role: int = ...) -> bool: - # When a user-added item in edit mode has no text, remove it from the list. - if role == Qt.ItemDataRole.EditRole and value == '': - self.removeRow(index.row()) - return True - if role == Qt.ItemDataRole.EditRole and ExcludeIfPresentModel.get_or_none(ExcludeIfPresentModel.name == value): - QMessageBox.critical( - self.parent(), - 'Error', - 'This exclusion already exists.', - ) - self.removeRow(index.row()) - return False - - return super().setData(index, value, role) - - class ExcludeIfPresentDialog(ExcludeIfPresentDialogBase, ExcludeIfPresentDialogUi): def __init__(self, profile, parent=None): super().__init__(parent) @@ -52,7 +27,7 @@ def __init__(self, profile, parent=None): self.buttonBox.rejected.connect(self.close) - self.customExclusionsModel = MandatoryInputItemModel() + self.customExclusionsModel = MandatoryInputItemModel(model=ExcludeIfPresentModel) self.customExclusionsList.setModel(self.customExclusionsModel) self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) From c74853d84c9677b58a8b549a15f34f0b9e2ca258 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 13 Oct 2023 20:22:40 +0530 Subject: [PATCH 31/31] partial for file exclusions --- src/vorta/views/exclude_dialog.py | 166 +----------------- src/vorta/views/exclude_if_present_dialog.py | 138 +-------------- src/vorta/views/partials/exclusion_dialog.py | 172 +++++++++++++++++++ 3 files changed, 181 insertions(+), 295 deletions(-) create mode 100644 src/vorta/views/partials/exclusion_dialog.py diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py index 7d0fb0f3e..a98992a60 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -3,76 +3,23 @@ import sys from PyQt6 import uic -from PyQt6.QtCore import QModelIndex, QObject, Qt +from PyQt6.QtCore import Qt from PyQt6.QtGui import QStandardItem, QStandardItemModel -from PyQt6.QtWidgets import ( - QAbstractItemView, - QApplication, - QMenu, - QMessageBox, - QStyledItemDelegate, -) +from PyQt6.QtWidgets import QAbstractItemView from vorta.i18n import translate from vorta.store.models import ExclusionModel from vorta.utils import get_asset -from vorta.views.utils import get_colored_icon +from vorta.views.partials.exclusion_dialog import BaseExclusionDialog uifile = get_asset('UI/excludedialog.ui') ExcludeDialogUi, ExcludeDialogBase = uic.loadUiType(uifile) -class MandatoryInputItemModel(QStandardItemModel): - ''' - A model that prevents the user from adding an empty item to the list. - ''' - - def __init__(self, parent=None, model=None): - super().__init__(parent) - self.model = model - - def setData(self, index: QModelIndex, value, role: int = ...) -> bool: - # When a user-added item in edit mode has no text, remove it from the list. - if role == Qt.ItemDataRole.EditRole and value == '': - self.removeRow(index.row()) - return True - if role == Qt.ItemDataRole.EditRole and ExclusionModel.get_or_none(self.model.name == value): - QMessageBox.critical( - self.parent(), - 'Error', - 'This exclusion already exists.', - ) - self.removeRow(index.row()) - return False - - return super().setData(index, value, role) - - -class ExcludeDialog(ExcludeDialogBase, ExcludeDialogUi): +class ExcludeDialog(BaseExclusionDialog, ExcludeDialogBase, ExcludeDialogUi): def __init__(self, profile, parent=None): - super().__init__(parent) - self.setupUi(self) - self.profile = profile + super().__init__(profile, parent) self.allPresets = {} - self.setWindowModality(Qt.WindowModality.ApplicationModal) - - self.buttonBox.rejected.connect(self.close) - - self.customExclusionsModel = MandatoryInputItemModel(model=ExclusionModel) - self.customExclusionsList.setModel(self.customExclusionsModel) - self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) - self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.customExclusionsList.setAlternatingRowColors(True) - self.customExclusionsListDelegate = QStyledItemDelegate() - self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) - self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) - # allow removing items with the delete key with event filter - self.installEventFilter(self) - # context menu - self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) self.exclusionPresetsModel = QStandardItemModel() self.exclusionPresetsList.setModel(self.exclusionPresetsModel) @@ -83,17 +30,7 @@ def __init__(self, profile, parent=None): self.exclusionsPreviewText.setReadOnly(True) - self.rawExclusionsText.textChanged.connect(self.raw_exclusions_saved) - - self.bRemovePattern.clicked.connect(self.remove_pattern) - self.bRemovePattern.setIcon(get_colored_icon('minus')) - self.bPreviewCopy.clicked.connect(self.copy_preview_to_clipboard) - self.bPreviewCopy.setIcon(get_colored_icon('copy')) - self.bAddPattern.clicked.connect(self.add_pattern) - self.bAddPattern.setIcon(get_colored_icon('plus')) - # help text - self.customPresetsHelpText.setOpenExternalLinks(True) self.customPresetsHelpText.setText( translate( "CustomPresetsHelp", @@ -119,10 +56,7 @@ def __init__(self, profile, parent=None): ) ) - self.populate_custom_exclusions_list() self.populate_presets_list() - self.populate_raw_exclusions_text() - self.populate_preview_tab() def populate_custom_exclusions_list(self): user_excluded_patterns = { @@ -138,38 +72,6 @@ def populate_custom_exclusions_list(self): item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) self.customExclusionsModel.appendRow(item) - def custom_exclusions_context_menu(self, pos): - # index under cursor - index = self.customExclusionsList.indexAt(pos) - if not index.isValid(): - return - - selected_rows = self.customExclusionsList.selectedIndexes() - - if selected_rows and index not in selected_rows: - return # popup only for selected items - - menu = QMenu(self.customExclusionsList) - menu.addAction( - get_colored_icon('copy'), - self.tr('Copy'), - lambda: QApplication.clipboard().setText(index.data()), - ) - - # Remove and Toggle can work with multiple items selected - menu.addAction( - get_colored_icon('minus'), - self.tr('Remove'), - lambda: self.remove_pattern(index if not selected_rows else None), - ) - menu.addAction( - get_colored_icon('check-circle'), - self.tr('Toggle'), - lambda: self.toggle_custom_pattern(index if not selected_rows else None), - ) - - menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) - def populate_presets_list(self): if getattr(sys, 'frozen', False): # we are running in a bundle @@ -252,11 +154,6 @@ def populate_preview_tab(self): self.profile.exclude_patterns = excludes self.profile.save() - def copy_preview_to_clipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Mode.Clipboard) - cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) - def remove_pattern(self, index=None): ''' Remove the selected item(s) from the list and the database. @@ -281,50 +178,6 @@ def remove_pattern(self, index=None): self.populate_preview_tab() - def toggle_custom_pattern(self, index=None): - ''' - Toggle the check state of the selected item(s). - If there is no index, this was called from the context menu and the indexes are passed in. - ''' - if not index: - indexes = self.customExclusionsList.selectedIndexes() - for index in indexes: - item = self.customExclusionsModel.itemFromIndex(index) - if item.checkState() == Qt.CheckState.Checked: - item.setCheckState(Qt.CheckState.Unchecked) - else: - item.setCheckState(Qt.CheckState.Checked) - else: - item = self.customExclusionsModel.itemFromIndex(index) - if item.checkState() == Qt.CheckState.Checked: - item.setCheckState(Qt.CheckState.Unchecked) - else: - item.setCheckState(Qt.CheckState.Checked) - - def add_pattern(self): - ''' - Add an empty item to the list in editable mode. - Don't add an item if the user is already editing an item. - ''' - if self.customExclusionsList.state() == QAbstractItemView.State.EditingState: - return - item = QStandardItem('') - item.setCheckable(True) - item.setCheckState(Qt.CheckState.Checked) - self.customExclusionsList.model().appendRow(item) - self.customExclusionsList.edit(item.index()) - self.customExclusionsList.scrollToBottom() - - def custom_pattern_editing_finished(self, editor): - ''' - Go through all items in the list and if any of them are empty, remove them. - Handles the case where the user presses the escape key to cancel editing. - ''' - for row in range(self.customExclusionsModel.rowCount()): - item = self.customExclusionsModel.item(row) - if item.text() == '': - self.customExclusionsModel.removeRow(row) - def custom_item_changed(self, item): ''' When the user checks or unchecks an item, update the database. @@ -378,12 +231,3 @@ def raw_exclusions_saved(self): self.profile.save() self.populate_preview_tab() - - def eventFilter(self, source, event): - ''' - When the user presses the delete key, remove the selected items. - ''' - if event.type() == event.Type.KeyPress and event.key() == Qt.Key.Key_Delete: - self.remove_pattern() - return True - return QObject.eventFilter(self, source, event) diff --git a/src/vorta/views/exclude_if_present_dialog.py b/src/vorta/views/exclude_if_present_dialog.py index f398de598..8d6f030e9 100644 --- a/src/vorta/views/exclude_if_present_dialog.py +++ b/src/vorta/views/exclude_if_present_dialog.py @@ -1,61 +1,21 @@ from PyQt6 import uic -from PyQt6.QtCore import QObject, Qt +from PyQt6.QtCore import Qt from PyQt6.QtGui import QStandardItem -from PyQt6.QtWidgets import ( - QAbstractItemView, - QApplication, - QMenu, - QStyledItemDelegate, -) from vorta.i18n import translate from vorta.store.models import ExcludeIfPresentModel from vorta.utils import get_asset -from vorta.views.exclude_dialog import MandatoryInputItemModel -from vorta.views.utils import get_colored_icon +from vorta.views.partials.exclusion_dialog import BaseExclusionDialog uifile = get_asset('UI/excludeifpresentdialog.ui') ExcludeIfPresentDialogUi, ExcludeIfPresentDialogBase = uic.loadUiType(uifile) -class ExcludeIfPresentDialog(ExcludeIfPresentDialogBase, ExcludeIfPresentDialogUi): +class ExcludeIfPresentDialog(BaseExclusionDialog, ExcludeIfPresentDialogBase, ExcludeIfPresentDialogUi): def __init__(self, profile, parent=None): - super().__init__(parent) - self.setupUi(self) - self.profile = profile - self.setWindowModality(Qt.WindowModality.ApplicationModal) - - self.buttonBox.rejected.connect(self.close) - - self.customExclusionsModel = MandatoryInputItemModel(model=ExcludeIfPresentModel) - self.customExclusionsList.setModel(self.customExclusionsModel) - self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) - self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.customExclusionsList.setAlternatingRowColors(True) - self.customExclusionsListDelegate = QStyledItemDelegate() - self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) - self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) - # allow removing items with the delete key with event filter - self.installEventFilter(self) - # context menu - self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) - - self.exclusionsPreviewText.setReadOnly(True) - - self.rawExclusionsText.textChanged.connect(self.raw_exclusions_saved) - - self.bRemovePattern.clicked.connect(self.remove_pattern) - self.bRemovePattern.setIcon(get_colored_icon('minus')) - self.bPreviewCopy.clicked.connect(self.copy_preview_to_clipboard) - self.bPreviewCopy.setIcon(get_colored_icon('copy')) - self.bAddPattern.clicked.connect(self.add_pattern) - self.bAddPattern.setIcon(get_colored_icon('plus')) + super().__init__(profile, parent) # help text - self.customPresetsHelpText.setOpenExternalLinks(True) self.customPresetsHelpText.setText( translate( "CustomPresetsHelp", @@ -91,38 +51,6 @@ def populate_custom_exclusions_list(self): item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) self.customExclusionsModel.appendRow(item) - def custom_exclusions_context_menu(self, pos): - # index under cursor - index = self.customExclusionsList.indexAt(pos) - if not index.isValid(): - return - - selected_rows = self.customExclusionsList.selectedIndexes() - - if selected_rows and index not in selected_rows: - return # popup only for selected items - - menu = QMenu(self.customExclusionsList) - menu.addAction( - get_colored_icon('copy'), - self.tr('Copy'), - lambda: QApplication.clipboard().setText(index.data()), - ) - - # Remove and Toggle can work with multiple items selected - menu.addAction( - get_colored_icon('minus'), - self.tr('Remove'), - lambda: self.remove_pattern(index if not selected_rows else None), - ) - menu.addAction( - get_colored_icon('check-circle'), - self.tr('Toggle'), - lambda: self.toggle_custom_pattern(index if not selected_rows else None), - ) - - menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) - def populate_raw_exclusions_text(self): raw_excludes = self.profile.raw_exclude_if_present if raw_excludes: @@ -162,11 +90,6 @@ def populate_preview_tab(self): self.profile.exclude_if_present = excludes self.profile.save() - def copy_preview_to_clipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Mode.Clipboard) - cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) - def remove_pattern(self, index=None): ''' Remove the selected item(s) from the list and the database. @@ -189,50 +112,6 @@ def remove_pattern(self, index=None): self.populate_preview_tab() - def toggle_custom_pattern(self, index=None): - ''' - Toggle the check state of the selected item(s). - If there is no index, this was called from the context menu and the indexes are passed in. - ''' - if not index: - indexes = self.customExclusionsList.selectedIndexes() - for index in indexes: - item = self.customExclusionsModel.itemFromIndex(index) - if item.checkState() == Qt.CheckState.Checked: - item.setCheckState(Qt.CheckState.Unchecked) - else: - item.setCheckState(Qt.CheckState.Checked) - else: - item = self.customExclusionsModel.itemFromIndex(index) - if item.checkState() == Qt.CheckState.Checked: - item.setCheckState(Qt.CheckState.Unchecked) - else: - item.setCheckState(Qt.CheckState.Checked) - - def add_pattern(self): - ''' - Add an empty item to the list in editable mode. - Don't add an item if the user is already editing an item. - ''' - if self.customExclusionsList.state() == QAbstractItemView.State.EditingState: - return - item = QStandardItem('') - item.setCheckable(True) - item.setCheckState(Qt.CheckState.Checked) - self.customExclusionsList.model().appendRow(item) - self.customExclusionsList.edit(item.index()) - self.customExclusionsList.scrollToBottom() - - def custom_pattern_editing_finished(self, editor): - ''' - Go through all items in the list and if any of them are empty, remove them. - Handles the case where the user presses the escape key to cancel editing. - ''' - for row in range(self.customExclusionsModel.rowCount()): - item = self.customExclusionsModel.item(row) - if item.text() == '': - self.customExclusionsModel.removeRow(row) - def custom_item_changed(self, item): ''' When the user checks or unchecks an item, update the database. @@ -257,12 +136,3 @@ def raw_exclusions_saved(self): self.profile.save() self.populate_preview_tab() - - def eventFilter(self, source, event): - ''' - When the user presses the delete key, remove the selected items. - ''' - if event.type() == event.Type.KeyPress and event.key() == Qt.Key.Key_Delete: - self.remove_pattern() - return True - return QObject.eventFilter(self, source, event) diff --git a/src/vorta/views/partials/exclusion_dialog.py b/src/vorta/views/partials/exclusion_dialog.py new file mode 100644 index 000000000..19c86f425 --- /dev/null +++ b/src/vorta/views/partials/exclusion_dialog.py @@ -0,0 +1,172 @@ +from PyQt6.QtCore import QModelIndex, QObject, Qt +from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtWidgets import ( + QAbstractItemView, + QApplication, + QDialog, + QMenu, + QMessageBox, + QStyledItemDelegate, +) + +from vorta.store.models import ExclusionModel +from vorta.views.utils import get_colored_icon + + +class MandatoryInputItemModel(QStandardItemModel): + ''' + A model that prevents the user from adding an empty item to the list. + ''' + + def __init__(self, parent=None, model=None): + super().__init__(parent) + self.model = model + + def setData(self, index: QModelIndex, value, role: int = ...) -> bool: + # When a user-added item in edit mode has no text, remove it from the list. + if role == Qt.ItemDataRole.EditRole and value == '': + self.removeRow(index.row()) + return True + if role == Qt.ItemDataRole.EditRole and ExclusionModel.get_or_none(self.model.name == value): + QMessageBox.critical( + self.parent(), + 'Error', + 'This exclusion already exists.', + ) + self.removeRow(index.row()) + return False + + return super().setData(index, value, role) + + +class BaseExclusionDialog(QDialog): + def __init__(self, profile, parent=None): + super().__init__(parent) + self.setupUi(self) + self.profile = profile + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.buttonBox.rejected.connect(self.close) + + self.customExclusionsModel = MandatoryInputItemModel(model=ExclusionModel) + self.customExclusionsList.setModel(self.customExclusionsModel) + self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) + self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.customExclusionsList.setAlternatingRowColors(True) + self.customExclusionsListDelegate = QStyledItemDelegate() + self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) + self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) + # allow removing items with the delete key with event filter + self.installEventFilter(self) + # context menu + self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) + + self.exclusionsPreviewText.setReadOnly(True) + + self.rawExclusionsText.textChanged.connect(self.raw_exclusions_saved) + + self.bRemovePattern.clicked.connect(self.remove_pattern) + self.bRemovePattern.setIcon(get_colored_icon('minus')) + self.bPreviewCopy.clicked.connect(self.copy_preview_to_clipboard) + self.bPreviewCopy.setIcon(get_colored_icon('copy')) + self.bAddPattern.clicked.connect(self.add_pattern) + self.bAddPattern.setIcon(get_colored_icon('plus')) + + self.customPresetsHelpText.setOpenExternalLinks(True) + + self.populate_custom_exclusions_list() + self.populate_raw_exclusions_text() + self.populate_preview_tab() + + def custom_exclusions_context_menu(self, pos): + # index under cursor + index = self.customExclusionsList.indexAt(pos) + if not index.isValid(): + return + + selected_rows = self.customExclusionsList.selectedIndexes() + + if selected_rows and index not in selected_rows: + return # popup only for selected items + + menu = QMenu(self.customExclusionsList) + menu.addAction( + get_colored_icon('copy'), + self.tr('Copy'), + lambda: QApplication.clipboard().setText(index.data()), + ) + + # Remove and Toggle can work with multiple items selected + menu.addAction( + get_colored_icon('minus'), + self.tr('Remove'), + lambda: self.remove_pattern(index if not selected_rows else None), + ) + menu.addAction( + get_colored_icon('check-circle'), + self.tr('Toggle'), + lambda: self.toggle_custom_pattern(index if not selected_rows else None), + ) + + menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) + + def copy_preview_to_clipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Mode.Clipboard) + cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) + + def toggle_custom_pattern(self, index=None): + ''' + Toggle the check state of the selected item(s). + If there is no index, this was called from the context menu and the indexes are passed in. + ''' + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in indexes: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + else: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + + def add_pattern(self): + ''' + Add an empty item to the list in editable mode. + Don't add an item if the user is already editing an item. + ''' + if self.customExclusionsList.state() == QAbstractItemView.State.EditingState: + return + item = QStandardItem('') + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked) + self.customExclusionsList.model().appendRow(item) + self.customExclusionsList.edit(item.index()) + self.customExclusionsList.scrollToBottom() + + def custom_pattern_editing_finished(self, editor): + ''' + Go through all items in the list and if any of them are empty, remove them. + Handles the case where the user presses the escape key to cancel editing. + ''' + for row in range(self.customExclusionsModel.rowCount()): + item = self.customExclusionsModel.item(row) + if item.text() == '': + self.customExclusionsModel.removeRow(row) + + def eventFilter(self, source, event): + ''' + When the user presses the delete key, remove the selected items. + ''' + if event.type() == event.Type.KeyPress and event.key() == Qt.Key.Key_Delete: + self.remove_pattern() + return True + return QObject.eventFilter(self, source, event)