Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exclude If Present #2016

Merged
merged 13 commits into from
Jun 28, 2024
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())
Loading