mirror of https://github.com/borgbase/vorta
Exclude GUI. By @diivi (#1846)
This commit is contained in:
parent
c9f170aecf
commit
b502fc3fd3
|
@ -0,0 +1,153 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Dialog</class>
|
||||
<widget class="QDialog" name="Dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>504</width>
|
||||
<height>426</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Add patterns to exclude</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<property name="topMargin">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab">
|
||||
<attribute name="title">
|
||||
<string>Custom</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="customPresetsHelpText">
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListView" name="customExclusionsList"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<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="bAddPattern"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="bRemovePattern"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>Presets</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="exclusionPresetsHelpText">
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListView" name="exclusionPresetsList"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_4">
|
||||
<attribute name="title">
|
||||
<string>Raw</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QLabel" name="rawExclusionsHelpText">
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="rawExclusionsText"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_3">
|
||||
<attribute name="title">
|
||||
<string>Preview</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QLabel" name="exclusionsPreviewHelpText">
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="exclusionsPreviewText"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<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="QPushButton" name="bPreviewCopy">
|
||||
<property name="text">
|
||||
<string>Copy to Clipboard</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Close</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
|
@ -13,7 +13,7 @@
|
|||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="2,1">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="2">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
|
@ -112,6 +112,16 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="bExclude">
|
||||
<property name="text">
|
||||
<string>Manage Excluded Items…</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
|
@ -147,65 +157,6 @@
|
|||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout" rowstretch="0,1">
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string><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></string>
|
||||
</property>
|
||||
<property name="openExternalLinks">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Exclude If Present (exclude folders with these files):</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPlainTextEdit" name="excludePatternsField">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QAbstractScrollArea::AdjustToContentsOnFirstShow</enum>
|
||||
</property>
|
||||
<property name="plainText">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>E.g. */.cache</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QPlainTextEdit" name="excludeIfPresentField">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QAbstractScrollArea::AdjustToContentsOnFirstShow</enum>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>E.g. .nobackup</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
[
|
||||
{
|
||||
"name": "Chromium cache and config files",
|
||||
"slug": "chromium-cache",
|
||||
"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",
|
||||
"slug": "google-chrome-cache",
|
||||
"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",
|
||||
"slug": "brave-cache",
|
||||
"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",
|
||||
"slug": "firefox-cache",
|
||||
"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"
|
||||
}
|
||||
]
|
|
@ -0,0 +1,37 @@
|
|||
[
|
||||
{
|
||||
"name": "Node Modules and package manager cache",
|
||||
"slug": "node-cache",
|
||||
"patterns":
|
||||
[
|
||||
"fm:*/node_modules",
|
||||
"fm:*/.npm"
|
||||
],
|
||||
"tags": ["type:dev", "lang:javascript", "os:linux", "os:darwin"],
|
||||
"author": "Divi"
|
||||
},
|
||||
{
|
||||
"name": "Python cache and virtualenv",
|
||||
"slug": "python-cache",
|
||||
"patterns":
|
||||
[
|
||||
"fm:*/__pycache__",
|
||||
"fm:*.pyc",
|
||||
"fm:*.pyo",
|
||||
"fm:*/.virtualenvs"
|
||||
],
|
||||
"tags": ["type:dev", "lang:python", "os:linux", "os:darwin"],
|
||||
"author": "Divi"
|
||||
},
|
||||
{
|
||||
"name": "Rust artefacts",
|
||||
"slug": "rust-artefacts",
|
||||
"patterns":
|
||||
[
|
||||
"fm:*/.cargo",
|
||||
"fm:*/.rustup"
|
||||
],
|
||||
"tags": ["type:dev", "lang:rust", "os:linux", "os:darwin"],
|
||||
"author": "Divi"
|
||||
}
|
||||
]
|
|
@ -164,24 +164,24 @@ class BorgCreateJob(BorgJob):
|
|||
|
||||
# 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)
|
||||
exclude_dirs = []
|
||||
for p in profile.get_combined_exclusion_string().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 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()])
|
||||
# Currently not in use, but may be added back to the UI later.
|
||||
# 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)
|
||||
|
|
|
@ -14,6 +14,7 @@ from .models import (
|
|||
ArchiveModel,
|
||||
BackupProfileModel,
|
||||
EventLogModel,
|
||||
ExclusionModel,
|
||||
RepoModel,
|
||||
RepoPassword,
|
||||
SchemaVersion,
|
||||
|
@ -54,6 +55,7 @@ def init_db(con=None):
|
|||
WifiSettingModel,
|
||||
EventLogModel,
|
||||
SchemaVersion,
|
||||
ExclusionModel,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -5,14 +5,18 @@ At the bottom there is a simple schema migration system.
|
|||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
import peewee as pw
|
||||
from playhouse import signals
|
||||
|
||||
from vorta.utils import slugify
|
||||
from vorta.views.utils import get_exclusion_presets
|
||||
|
||||
DB = pw.Proxy()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JSONField(pw.TextField):
|
||||
|
@ -105,6 +109,69 @@ class BackupProfileModel(BaseModel):
|
|||
def slug(self):
|
||||
return slugify(self.name)
|
||||
|
||||
def get_combined_exclusion_string(self):
|
||||
allPresets = get_exclusion_presets()
|
||||
excludes = ""
|
||||
|
||||
if (
|
||||
ExclusionModel.select()
|
||||
.where(
|
||||
ExclusionModel.profile == self,
|
||||
ExclusionModel.enabled,
|
||||
ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value,
|
||||
)
|
||||
.count()
|
||||
> 0
|
||||
):
|
||||
excludes = "# custom added rules\n"
|
||||
|
||||
for exclude in ExclusionModel.select().where(
|
||||
ExclusionModel.profile == self,
|
||||
ExclusionModel.enabled,
|
||||
ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value,
|
||||
):
|
||||
excludes += f"{exclude.name}\n"
|
||||
|
||||
raw_excludes = self.exclude_patterns
|
||||
if raw_excludes:
|
||||
excludes += "\n# raw exclusions\n"
|
||||
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,
|
||||
ExclusionModel.enabled,
|
||||
ExclusionModel.source == ExclusionModel.SourceFieldOptions.PRESET.value,
|
||||
):
|
||||
if exclude.name not in allPresets:
|
||||
logger.warning("Exclusion preset %s not found in built-in presets.", exclude.name)
|
||||
continue
|
||||
excludes += f"\n# {exclude.name}\n"
|
||||
for pattern in allPresets[exclude.name]['patterns']:
|
||||
excludes += f"{pattern}\n"
|
||||
|
||||
return excludes
|
||||
|
||||
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. Duplicate patterns are already handled by Borg.
|
||||
"""
|
||||
|
||||
class SourceFieldOptions(Enum):
|
||||
CUSTOM = 'custom'
|
||||
PRESET = 'preset'
|
||||
|
||||
profile = pw.ForeignKeyField(BackupProfileModel, backref='exclusions')
|
||||
name = pw.CharField()
|
||||
enabled = pw.BooleanField(default=True)
|
||||
source = pw.CharField(default=SourceFieldOptions.CUSTOM.value)
|
||||
|
||||
class Meta:
|
||||
database = DB
|
||||
|
||||
|
|
|
@ -0,0 +1,330 @@
|
|||
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 ExclusionModel
|
||||
from vorta.utils import get_asset
|
||||
from vorta.views.utils import get_colored_icon, get_exclusion_presets
|
||||
|
||||
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, profile, parent=None):
|
||||
super().__init__(parent)
|
||||
self.profile = profile
|
||||
|
||||
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(name=value, profile=self.profile):
|
||||
self.removeRow(index.row())
|
||||
QMessageBox.critical(
|
||||
self.parent(),
|
||||
'Error',
|
||||
'This exclusion already exists.',
|
||||
)
|
||||
return False
|
||||
|
||||
return super().setData(index, value, role)
|
||||
|
||||
|
||||
class ExcludeDialog(ExcludeDialogBase, ExcludeDialogUi):
|
||||
def __init__(self, profile, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setupUi(self)
|
||||
self.profile = profile
|
||||
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.allPresets = get_exclusion_presets()
|
||||
self.buttonBox.rejected.connect(self.close)
|
||||
|
||||
self.customExclusionsModel = MandatoryInputItemModel(profile=profile)
|
||||
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)
|
||||
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.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",
|
||||
"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 <a href=\"https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-patterns\">documentation</a>. To add multiple patterns at once, use the \"Raw\" tab.", # noqa: E501
|
||||
)
|
||||
)
|
||||
self.exclusionPresetsHelpText.setText(
|
||||
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(
|
||||
translate(
|
||||
"RawExclusionsHelp",
|
||||
"You can use this field to add multiple patterns at once. Each pattern should be on a separate line.",
|
||||
)
|
||||
)
|
||||
self.exclusionsPreviewHelpText.setText(
|
||||
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()
|
||||
self.populate_presets_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.exclusions.select()
|
||||
.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.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):
|
||||
for preset_slug in self.allPresets.keys():
|
||||
item = QStandardItem(self.allPresets[preset_slug]['name'])
|
||||
item.setCheckable(True)
|
||||
item.setData(preset_slug, Qt.ItemDataRole.UserRole)
|
||||
preset_model = ExclusionModel.get_or_none(
|
||||
name=preset_slug,
|
||||
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)
|
||||
|
||||
self.exclusionPresetsModel.appendRow(item)
|
||||
|
||||
def populate_raw_exclusions_text(self):
|
||||
raw_excludes = self.profile.exclude_patterns
|
||||
if raw_excludes:
|
||||
self.rawExclusionsText.setPlainText(raw_excludes)
|
||||
|
||||
def populate_preview_tab(self):
|
||||
excludes = self.profile.get_combined_exclusion_string()
|
||||
self.exclusionsPreviewText.setPlainText(excludes)
|
||||
|
||||
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)):
|
||||
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,
|
||||
ExclusionModel.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 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 == ExclusionModel.SourceFieldOptions.CUSTOM.value,
|
||||
ExclusionModel.profile == self.profile,
|
||||
).execute()
|
||||
|
||||
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.data(Qt.ItemDataRole.UserRole),
|
||||
source=ExclusionModel.SourceFieldOptions.PRESET.value,
|
||||
profile=self.profile,
|
||||
)
|
||||
if preset:
|
||||
preset.enabled = item.checkState() == Qt.CheckState.Checked
|
||||
preset.save()
|
||||
else:
|
||||
ExclusionModel.create(
|
||||
name=item.data(Qt.ItemDataRole.UserRole),
|
||||
source=ExclusionModel.SourceFieldOptions.PRESET.value,
|
||||
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.
|
||||
'''
|
||||
raw_excludes = self.rawExclusionsText.toPlainText()
|
||||
self.profile.exclude_patterns = 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)
|
|
@ -21,6 +21,7 @@ from vorta.utils import (
|
|||
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,8 +102,7 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
|
|||
# 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.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present)
|
||||
self.bExclude.clicked.connect(self.show_exclude_dialog)
|
||||
header.sortIndicatorChanged.connect(self.update_sort_order)
|
||||
|
||||
# Connect to palette change
|
||||
|
@ -251,11 +251,7 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
|
|||
|
||||
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):
|
||||
self.add_source_to_table(source, False)
|
||||
|
@ -267,11 +263,6 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
|
|||
# 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):
|
||||
"""Save selected sort by column and order to settings"""
|
||||
SettingsModel.update({SettingsModel.str_value: str(column)}).where(
|
||||
|
@ -351,15 +342,11 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
|
|||
|
||||
logger.debug(f"Removed source in row {index.row()}")
|
||||
|
||||
def save_exclude_patterns(self):
|
||||
profile = self.profile()
|
||||
profile.exclude_patterns = self.excludePatternsField.toPlainText()
|
||||
profile.save()
|
||||
|
||||
def save_exclude_if_present(self):
|
||||
profile = self.profile()
|
||||
profile.exclude_if_present = self.excludeIfPresentField.toPlainText()
|
||||
profile.save()
|
||||
def show_exclude_dialog(self):
|
||||
window = ExcludeDialog(self.profile(), self)
|
||||
window.setParent(self, QtCore.Qt.WindowType.Sheet)
|
||||
self._window = window # for testing
|
||||
window.show()
|
||||
|
||||
def paste_text(self):
|
||||
sources = QApplication.clipboard().text().splitlines()
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt6.QtGui import QIcon, QImage, QPixmap
|
||||
|
||||
from vorta.utils import get_asset, uses_dark_mode
|
||||
|
@ -17,3 +21,31 @@ def get_colored_icon(icon_name, scaled_height=128, return_qpixmap=False):
|
|||
return QPixmap(svg_img)
|
||||
else:
|
||||
return QIcon(QPixmap(svg_img))
|
||||
|
||||
|
||||
def get_exclusion_presets():
|
||||
"""
|
||||
Loads exclusion presets from JSON files in assets/exclusion_presets.
|
||||
|
||||
Currently the preset name is used as identifier.
|
||||
"""
|
||||
allPresets = {}
|
||||
os_tag = f"os:{sys.platform}"
|
||||
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 sorted(os.listdir(bundle_dir)):
|
||||
with open(os.path.join(bundle_dir, preset_file), 'r') as f:
|
||||
preset_list = json.load(f)
|
||||
for preset in preset_list:
|
||||
if os_tag in preset['tags']:
|
||||
allPresets[preset['slug']] = {
|
||||
'name': preset['name'],
|
||||
'patterns': preset['patterns'],
|
||||
'tags': preset['tags'],
|
||||
}
|
||||
return allPresets
|
||||
|
|
|
@ -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())
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue