Skip to content

Commit

Permalink
Add back Exclude If Present. By @shivansh02 (#2016)
Browse files Browse the repository at this point in the history
  • Loading branch information
shivansh02 authored Jun 28, 2024
1 parent a1cbca2 commit 7ab769e
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 31 deletions.
42 changes: 41 additions & 1 deletion src/vorta/assets/UI/excludedialog.ui
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,47 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_3">
<widget class="QWidget" name="tab_5">
<attribute name="title">
<string>Exclude If Present</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QLabel" name="excludeIfPresentHelpText">
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListView" name="excludeIfPresentList"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="bAddPatternExcludeIfPresent"/>
</item>
<item>
<widget class="QToolButton" name="bRemovePatternExcludeIfPresent"/>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_3">
<attribute name="title">
<string>Preview</string>
</attribute>
Expand Down
11 changes: 11 additions & 0 deletions src/vorta/borg/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
2 changes: 1 addition & 1 deletion src/vorta/store/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
173 changes: 146 additions & 27 deletions src/vorta/views/exclude_dialog.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand All @@ -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():
Expand All @@ -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()
Expand Down Expand Up @@ -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):
'''
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions tests/test_excludes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

0 comments on commit 7ab769e

Please sign in to comment.