From 7ab769e11736a36eb58297faf608f5fc6c10d109 Mon Sep 17 00:00:00 2001 From: Shivansh Singh <89853707+shivansh02@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:13:51 +0530 Subject: [PATCH] Add back Exclude If Present. By @shivansh02 (#2016) --- src/vorta/assets/UI/excludedialog.ui | 42 ++++++- src/vorta/borg/create.py | 11 ++ src/vorta/store/models.py | 2 +- src/vorta/views/exclude_dialog.py | 173 ++++++++++++++++++++++----- tests/test_excludes.py | 11 +- 5 files changed, 208 insertions(+), 31 deletions(-) diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui index f1b4bab6..793ad3fd 100644 --- a/src/vorta/assets/UI/excludedialog.ui +++ b/src/vorta/assets/UI/excludedialog.ui @@ -96,7 +96,47 @@ - + + + Exclude If Present + + + + + + true + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Preview diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 4b2e8b84..b1601137 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -162,6 +162,17 @@ def prepare(cls, profile): ] cmd += extra_cmd_options + # Function to extend command with exclude-if-present patterns + if profile.exclude_if_present is not None: + patterns = [] + for f in profile.exclude_if_present.split('\n'): + f = f.strip() + if f.startswith('[x]'): + patterns.append(f[3:].strip()) # Remove the '[x]' prefix + + for pattern in patterns: + cmd.extend(['--exclude-if-present', pattern]) + # Add excludes # Partly inspired by borgmatic/borgmatic/borg/create.py exclude_dirs = [] diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 400299fb..d2117a10 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -79,7 +79,7 @@ class BackupProfileModel(BaseModel): ssh_key = pw.CharField(default=None, null=True) compression = pw.CharField(default='lz4') exclude_patterns = pw.TextField(null=True) - exclude_if_present = pw.TextField(null=True) + exclude_if_present = pw.TextField(null=True, default="[] .nobackup\n[] CACHEDIR.TAG") 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 ee058de9..1c87439b 100644 --- a/src/vorta/views/exclude_dialog.py +++ b/src/vorta/views/exclude_dialog.py @@ -1,6 +1,6 @@ from PyQt6 import uic from PyQt6.QtCore import QModelIndex, QObject, Qt -from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtGui import QCursor, QStandardItem, QStandardItemModel from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, @@ -85,8 +85,12 @@ def __init__(self, profile, parent=None): 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.clicked.connect(self.add_pattern_custom_exclusion) self.bAddPattern.setIcon(get_colored_icon('plus')) + self.bAddPatternExcludeIfPresent.setIcon(get_colored_icon('plus')) + self.bAddPatternExcludeIfPresent.clicked.connect(self.add_pattern_exclude_if_present) + self.bRemovePatternExcludeIfPresent.setIcon(get_colored_icon('minus')) + self.bRemovePatternExcludeIfPresent.clicked.connect(self.remove_pattern_exclude_if_present) # help text self.customPresetsHelpText.setOpenExternalLinks(True) @@ -114,11 +118,33 @@ def __init__(self, profile, parent=None): "This is a preview of the patterns that will be used to exclude files and folders from the backup.", ) ) + self.excludeIfPresentHelpText.setText( + translate( + "ExcludeIfPresentHelp", + "Folders that contain the following files will be excluded from backups.", + ) + ) + + # Add the Exclude If Present tab + self.excludeIfPresentModel = MandatoryInputItemModel(profile=profile) + self.excludeIfPresentList.setModel(self.excludeIfPresentModel) + self.excludeIfPresentList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.excludeIfPresentList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.excludeIfPresentList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.excludeIfPresentList.setAlternatingRowColors(True) + self.excludeIfPresentListDelegate = QStyledItemDelegate() + self.excludeIfPresentList.setItemDelegate(self.excludeIfPresentListDelegate) + self.excludeIfPresentListDelegate.closeEditor.connect(self.exclude_if_present_pattern_editing_finished) + self.excludeIfPresentList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.excludeIfPresentList.customContextMenuRequested.connect(self.exclude_if_present_context_menu) + + self.excludeIfPresentModel.itemChanged.connect(self.exclude_if_present_item_changed) self.populate_custom_exclusions_list() self.populate_presets_list() self.populate_raw_exclusions_text() self.populate_preview_tab() + self.populate_exclude_if_present_patterns() def populate_custom_exclusions_list(self): user_excluded_patterns = { @@ -134,37 +160,41 @@ 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) + def create_context_menu(self, list_widget, remove_method, toggle_method=None): + pos = list_widget.viewport().mapFromGlobal(QCursor.pos()) + index = list_widget.indexAt(pos) if not index.isValid(): return - selected_rows = self.customExclusionsList.selectedIndexes() + selected_rows = list_widget.selectedIndexes() if selected_rows and index not in selected_rows: - return # popup only for selected items + return - menu = QMenu(self.customExclusionsList) + menu = QMenu(list_widget) 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), + lambda: remove_method(index if not selected_rows else None), ) - menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) + if toggle_method: + menu.addAction( + get_colored_icon('check-circle'), + self.tr('Toggle'), + lambda: toggle_method(index if not selected_rows else None), + ) + + menu.popup(list_widget.viewport().mapToGlobal(pos)) + + def custom_exclusions_context_menu(self, pos): + self.create_context_menu(self.customExclusionsList, self.remove_pattern, self.toggle_custom_pattern) def populate_presets_list(self): for preset_slug in self.allPresets.keys(): @@ -190,8 +220,14 @@ def populate_raw_exclusions_text(self): self.rawExclusionsText.setPlainText(raw_excludes) def populate_preview_tab(self): - excludes = self.profile.get_combined_exclusion_string() - self.exclusionsPreviewText.setPlainText(excludes) + preview = self.profile.get_combined_exclusion_string() + if self.profile.exclude_if_present: + preview += '\n# Exclude if present' + for f in self.profile.exclude_if_present.split('\n'): + f = f.strip() + if f.startswith('[x]'): + preview += '\n' + f[3:].strip() + self.exclusionsPreviewText.setPlainText(preview) def copy_preview_to_clipboard(self): cb = QApplication.clipboard() @@ -242,29 +278,35 @@ def toggle_custom_pattern(self, index=None): else: item.setCheckState(Qt.CheckState.Checked) - def add_pattern(self): + def add_pattern(self, list_widget, model): ''' 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: + if list_widget.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() + model.appendRow(item) + list_widget.edit(item.index()) + list_widget.scrollToBottom() - def custom_pattern_editing_finished(self, editor): + def add_pattern_custom_exclusion(self): + self.add_pattern(self.customExclusionsList, self.customExclusionsModel) + + def text_editing_finished(self, model): ''' 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) + for row in range(model.rowCount()): + item = model.item(row) if item.text() == '': - self.customExclusionsModel.removeRow(row) + model.removeRow(row) + + def custom_pattern_editing_finished(self, editor): + self.text_editing_finished(self.customExclusionsModel) def custom_item_changed(self, item): ''' @@ -310,6 +352,83 @@ def preset_item_changed(self, item): self.populate_preview_tab() + def exclude_if_present_item_changed(self, item): + self.save_exclude_if_present_patterns() + + def exclude_if_present_context_menu(self, pos): + self.create_context_menu(self.excludeIfPresentList, self.remove_pattern_exclude_if_present) + + def remove_pattern_exclude_if_present(self, index=None): + if not index: + indexes = self.excludeIfPresentList.selectedIndexes() + for index in reversed(sorted(indexes)): + self.excludeIfPresentModel.removeRow(index.row()) + else: + self.excludeIfPresentModel.removeRow(index.row()) + self.save_exclude_if_present_patterns() + + def add_pattern_exclude_if_present(self): + self.add_pattern(self.excludeIfPresentList, self.excludeIfPresentModel) + self.save_exclude_if_present_patterns() + + def exclude_if_present_pattern_editing_finished(self, editor): + self.text_editing_finished(self.customExclusionsModel) + self.save_exclude_if_present_patterns() + + def save_exclude_if_present_patterns(self): + ''' + Save Exclude If Present files to profile. + ''' + patterns = [] + for row in range(self.excludeIfPresentModel.rowCount()): + item = self.excludeIfPresentModel.item(row) + text = item.text().strip() + + # Remove any existing prefix + if text.startswith('[x] '): + text = text[4:] + elif text.startswith('[] '): + text = text[3:] + + # Add the correct prefix based on the check state + prefix = '[x] ' if item.checkState() == Qt.CheckState.Checked else '[] ' + patterns.append(prefix + text) + + self.profile.exclude_if_present = '\n'.join(patterns) + self.profile.save() + self.populate_preview_tab() + + def normalize_exclude_if_present_patterns(self): + ''' + Ensure all patterns in exclude_if_present start with either [x] or []. + ''' + patterns = self.profile.exclude_if_present.split('\n') if self.profile.exclude_if_present else [] + normalized_patterns = [] + for pattern in patterns: + if not pattern.startswith('[x]') and not pattern.startswith('[]'): + pattern = '[x] ' + pattern + normalized_patterns.append(pattern) + self.profile.exclude_if_present = '\n'.join(normalized_patterns) + self.profile.save() + + def populate_exclude_if_present_patterns(self): + ''' + Populate the 'Exclude If Present' list from the profile. + ''' + self.normalize_exclude_if_present_patterns() + patterns = self.profile.exclude_if_present.split('\n') if self.profile.exclude_if_present else [] + for pattern in patterns: + item = QStandardItem() + if pattern.startswith('[x]'): + item.setText(pattern[4:]) + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked) + elif pattern.startswith('[]'): + item.setText(pattern[3:]) + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Unchecked) + self.excludeIfPresentModel.appendRow(item) + def raw_exclusions_saved(self): ''' When the user saves changes in the raw exclusions text box, add it to the database. diff --git a/tests/test_excludes.py b/tests/test_excludes.py index 30359402..69bd5148 100644 --- a/tests/test_excludes.py +++ b/tests/test_excludes.py @@ -11,16 +11,23 @@ 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: "custom pattern" in tab._window.exclusionsPreviewText.toPlainText()) 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()) + qtbot.waitUntil(lambda: "# chromium-cache" 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()) + + qtbot.mouseClick(tab.bExclude, QtCore.Qt.MouseButton.LeftButton) + qtbot.mouseClick(tab._window.bAddPatternExcludeIfPresent, QtCore.Qt.MouseButton.LeftButton) + + qtbot.keyClicks(tab._window.excludeIfPresentList.viewport().focusWidget(), "exclude_if_present_file") + qtbot.keyClick(tab._window.excludeIfPresentList.viewport().focusWidget(), QtCore.Qt.Key.Key_Enter) + qtbot.waitUntil(lambda: "exclude_if_present_file" in tab._window.exclusionsPreviewText.toPlainText())