diff --git a/src/vorta/assets/UI/diffresult.ui b/src/vorta/assets/UI/diffresult.ui index 0907fe0b6..97fb25e39 100644 --- a/src/vorta/assets/UI/diffresult.ui +++ b/src/vorta/assets/UI/diffresult.ui @@ -115,6 +115,54 @@ + + + + + 4 + + + 0 + + + + + + Search Pattern + + + Search Pattern + + + + + + + + + Qt::NoFocus + + + Submit Search + + + + + + + + Qt::NoFocus + + + Help + + + + + + + + diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui index a6113aafd..7f7e8e452 100644 --- a/src/vorta/assets/UI/extractdialog.ui +++ b/src/vorta/assets/UI/extractdialog.ui @@ -106,6 +106,55 @@ + + + + + 4 + + + 0 + + + + + + Search Pattern + + + Search Pattern + + + + + + + + + Qt::NoFocus + + + Submit Search + + + + + + + + Qt::NoFocus + + + Help + + + + + + + + + diff --git a/src/vorta/assets/icons/search.svg b/src/vorta/assets/icons/search.svg new file mode 100644 index 000000000..79a4a3a43 --- /dev/null +++ b/src/vorta/assets/icons/search.svg @@ -0,0 +1,2 @@ + + diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 6ed8841a0..07caf8204 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -2,6 +2,7 @@ import json import logging import re +import webbrowser from dataclasses import dataclass from pathlib import PurePath from typing import List, Optional, Tuple @@ -10,26 +11,24 @@ from PyQt6.QtCore import ( QDateTime, QLocale, - QMimeData, QModelIndex, - QPoint, Qt, QThread, - QUrl, ) -from PyQt6.QtGui import QColor, QKeySequence, QShortcut -from PyQt6.QtWidgets import QApplication, QHeaderView, QMenu, QTreeView +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import QHeaderView from vorta.store.models import SettingsModel from vorta.utils import get_asset, pretty_bytes, uses_dark_mode +from vorta.views.partials.file_dialog import BaseFileDialog from vorta.views.partials.treemodel import ( FileSystemItem, FileTreeModel, - FileTreeSortProxyModel, + FileTreeSortFilterProxyModel, path_to_str, relative_path, ) -from vorta.views.utils import get_colored_icon +from vorta.views.utils import compare_values_with_sign, get_colored_icon uifile = get_asset('UI/diffresult.ui') DiffResultUI, DiffResultBase = uic.loadUiType(uifile) @@ -65,121 +64,40 @@ def run(self) -> None: parse_diff_lines(lines, self.model) -class DiffResultDialog(DiffResultBase, DiffResultUI): +class DiffResultDialog(BaseFileDialog, DiffResultBase, DiffResultUI): """Display the results of `borg diff`.""" - def __init__(self, archive_newer, archive_older, model: 'DiffTree'): - """Init.""" - super().__init__() - self.setupUi(self) - - self.model = model - self.model.setParent(self) - - self.treeView: QTreeView - self.treeView.setUniformRowHeights(True) # Allows for scrolling optimizations. - self.treeView.setAlternatingRowColors(True) - self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # to better see name of paths - - # custom context menu - self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.treeView.customContextMenuRequested.connect(self.treeview_context_menu) - - # shortcuts - shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView) - shortcut_copy.activated.connect(self.diff_item_copy) + def __init__(self, archive_newer, archive_older, model): + super().__init__(model) - # add sort proxy model - self.sortproxy = DiffSortProxyModel(self) - self.sortproxy.setSourceModel(self.model) - self.treeView.setModel(self.sortproxy) - self.sortproxy.sorted.connect(self.slot_sorted) - - self.treeView.setSortingEnabled(True) - - # header header = self.treeView.header() header.setStretchLastSection(False) # stretch only first section header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - # signals - self.archiveNameLabel_1.setText(f'{archive_newer.name}') self.archiveNameLabel_2.setText(f'{archive_older.name}') - self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode) - diff_result_display_mode = SettingsModel.get(key='diff_files_display_mode').str_value - self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode)) - self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop) - self.bCollapseAll.clicked.connect(self.treeView.collapseAll) + self.bHelp.clicked.connect(lambda: webbrowser.open('https://vorta.borgbase.com/usage/search/')) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) + def get_sort_proxy_model(self): + """Return the sort proxy model for the tree view.""" + return DiffSortFilterProxyModel(self) - self.set_icons() - - # Connect to palette change - QApplication.instance().paletteChanged.connect(lambda p: self.set_icons()) + def get_diff_result_display_mode(self): + return SettingsModel.get(key='diff_files_display_mode').str_value def set_icons(self): """Set or update the icons in the right color scheme.""" self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid')) self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top')) + self.bSearch.setIcon(get_colored_icon('search')) + self.bHelp.setIcon(get_colored_icon('help-about')) self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree")) self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree")) self.comboBoxDisplayMode.setItemIcon(2, get_colored_icon("view-list-details")) - def treeview_context_menu(self, pos: QPoint): - """Display a context menu for `treeView`.""" - index = self.treeView.indexAt(pos) - if not index.isValid(): - # popup only for items - return - - menu = QMenu(self.treeView) - - menu.addAction( - get_colored_icon('copy'), - self.tr("Copy"), - lambda: self.diff_item_copy(index), - ) - - if self.model.getMode() != self.model.DisplayMode.FLAT: - menu.addSeparator() - menu.addAction( - get_colored_icon('angle-down-solid'), - self.tr("Expand recursively"), - lambda: self.treeView.expandRecursively(index), - ) - - menu.popup(self.treeView.viewport().mapToGlobal(pos)) - - def diff_item_copy(self, index: QModelIndex = None): - """ - Copy a diff item path to the clipboard. - - Copies the first selected item if no index is specified. - """ - if index is None or (not index.isValid()): - indexes = self.treeView.selectionModel().selectedRows() - - if not indexes: - return - - index = indexes[0] - - index = self.sortproxy.mapToSource(index) - item: DiffItem = index.internalPointer() - path = PurePath('/', *item.path) - - data = QMimeData() - data.setUrls([QUrl(path.as_uri())]) - data.setText(str(path)) - - QApplication.clipboard().setMimeData(data) - def change_display_mode(self, selection: int): """ Change the display mode of the tree view @@ -206,13 +124,6 @@ def change_display_mode(self, selection: int): self.model.setMode(mode) - def slot_sorted(self, column, order): - """React the tree view being sorted.""" - # reveal selection - selectedRows = self.treeView.selectionModel().selectedRows() - if selectedRows: - self.treeView.scrollTo(selectedRows[0]) - # ---- Output parsing -------------------------------------------------------- @@ -488,11 +399,53 @@ def size_to_byte(significand: str, unit: str) -> int: # ---- Sorting --------------------------------------------------------------- -class DiffSortProxyModel(FileTreeSortProxyModel): +class DiffSortFilterProxyModel(FileTreeSortFilterProxyModel): """ Sort a DiffTree model. """ + def __init__(self, parent=None) -> None: + super().__init__(parent) + + def get_parser(self): + """Add Diff view specific arguments to the parser.""" + parser = super().get_parser() + parser.add_argument( + "-b", "--balance", type=FileTreeSortFilterProxyModel.valid_size, help="Match by balance size." + ) + parser.add_argument("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.") + + return parser + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + """ + Return whether the row should be accepted. + """ + + if not self.searchPattern: + return True + + if not super().filterAcceptsRow(sourceRow, sourceParent): + return False + + model = self.sourceModel() + item = model.index(sourceRow, 0, sourceParent).internalPointer() + + if self.searchPattern.balance: + item_balance = item.data.size + + for filter_balance in self.searchPattern.balance: + if not compare_values_with_sign(item_balance, filter_balance[1], filter_balance[0]): + return False + + if self.searchPattern.change: + item_change = item.data.change_type.short() + + if item_change != self.searchPattern.change: + return False + + return True + def choose_data(self, index: QModelIndex): """Choose the data of index used for comparison.""" item: DiffItem = index.internalPointer() diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index 8c3874048..127bd05cc 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -1,39 +1,37 @@ +import argparse import enum import json import logging +import webbrowser from dataclasses import dataclass from datetime import datetime from pathlib import PurePath -from typing import Optional, Union +from typing import List, Optional, Tuple, Union from PyQt6 import uic from PyQt6.QtCore import ( QDateTime, QLocale, - QMimeData, QModelIndex, - QPoint, Qt, QThread, - QUrl, ) -from PyQt6.QtGui import QColor, QKeySequence, QShortcut +from PyQt6.QtGui import QColor from PyQt6.QtWidgets import ( - QApplication, QDialogButtonBox, QHeaderView, - QMenu, QPushButton, ) from vorta.store.models import SettingsModel from vorta.utils import borg_compat, get_asset, pretty_bytes, uses_dark_mode -from vorta.views.utils import get_colored_icon +from vorta.views.partials.file_dialog import BaseFileDialog +from vorta.views.utils import compare_values_with_sign, get_colored_icon from .partials.treemodel import ( FileSystemItem, FileTreeModel, - FileTreeSortProxyModel, + FileTreeSortFilterProxyModel, path_to_str, relative_path, ) @@ -64,70 +62,43 @@ def run(self) -> None: parse_json_lines(lines, self.model) -class ExtractDialog(ExtractDialogBase, ExtractDialogUI): +class ExtractDialog(BaseFileDialog, ExtractDialogBase, ExtractDialogUI): """ Show the contents of an archive and allow choosing what to extract. """ def __init__(self, archive, model): """Init.""" - super().__init__() - self.setupUi(self) - - self.model = model - self.model.setParent(self) - - view = self.treeView - view.setAlternatingRowColors(True) - view.setUniformRowHeights(True) # Allows for scrolling optimizations. - - # custom context menu - self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.treeView.customContextMenuRequested.connect(self.treeview_context_menu) - - # add sort proxy model - self.sortproxy = ExtractSortProxyModel(self) - self.sortproxy.setSourceModel(self.model) - view.setModel(self.sortproxy) - self.sortproxy.sorted.connect(self.slot_sorted) - - view.setSortingEnabled(True) + super().__init__(model) # header - header = view.header() + header = self.treeView.header() header.setStretchLastSection(False) header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - # shortcuts - shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView) - shortcut_copy.activated.connect(self.copy_item) - # add extract button to button box self.extractButton = QPushButton(self) self.extractButton.setObjectName("extractButton") self.extractButton.setText(self.tr("Extract")) - self.buttonBox.addButton(self.extractButton, QDialogButtonBox.ButtonRole.AcceptRole) self.archiveNameLabel.setText(f"{archive.name}, {archive.time}") - diff_result_display_mode = SettingsModel.get(key='extract_files_display_mode').str_value - - # connect signals - self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode) - self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode)) - self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop) - self.bCollapseAll.clicked.connect(self.treeView.collapseAll) + self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.close) - self.buttonBox.accepted.connect(self.accept) - self.set_icons() + self.bHelp.clicked.connect(lambda: webbrowser.open('https://vorta.borgbase.com/usage/search/')) + + def get_sort_proxy_model(self): + """Get the sort proxy model for this dialog.""" + return ExtractSortFilterProxyModel() - # Connect to palette change - QApplication.instance().paletteChanged.connect(lambda p: self.set_icons()) + def get_diff_result_display_mode(self): + """Get the display mode for this dialog.""" + return SettingsModel.get(key='extract_files_display_mode').str_value def retranslateUi(self, dialog): """Retranslate strings in ui.""" @@ -139,42 +110,13 @@ def retranslateUi(self, dialog): def set_icons(self): """Set or update the icons in the right color scheme.""" + self.bSearch.setIcon(get_colored_icon('search')) self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top')) self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid')) + self.bHelp.setIcon(get_colored_icon('help-about')) self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree")) self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree")) - def slot_sorted(self, column, order): - """React to the tree view being sorted.""" - # reveal selection - selectedRows = self.treeView.selectionModel().selectedRows() - if selectedRows: - self.treeView.scrollTo(selectedRows[0]) - - def copy_item(self, index: QModelIndex = None): - """ - Copy an item path to the clipboard. - - Copies the first selected item if no index is specified. - """ - if index is None or (not index.isValid()): - indexes = self.treeView.selectionModel().selectedRows() - - if not indexes: - return - - index = indexes[0] - - index = self.sortproxy.mapToSource(index) - item: ExtractFileItem = index.internalPointer() - path = PurePath('/', *item.path) - - data = QMimeData() - data.setUrls([QUrl(path.as_uri())]) - data.setText(str(path)) - - QApplication.clipboard().setMimeData(data) - def change_display_mode(self, selection: int): """ Change the display mode of the tree view @@ -198,27 +140,6 @@ def change_display_mode(self, selection: int): self.model.setMode(mode) - def treeview_context_menu(self, pos: QPoint): - """Display a context menu for `treeView`.""" - index = self.treeView.indexAt(pos) - if not index.isValid(): - # popup only for items - return - - menu = QMenu(self.treeView) - - menu.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.copy_item(index)) - - if self.model.getMode() != self.model.DisplayMode.FLAT: - menu.addSeparator() - menu.addAction( - get_colored_icon('angle-down-solid'), - self.tr("Expand recursively"), - lambda: self.treeView.expandRecursively(index), - ) - - menu.popup(self.treeView.viewport().mapToGlobal(pos)) - def parse_json_lines(lines, model: "ExtractTree"): """Parse json output of `borg list`.""" @@ -256,11 +177,100 @@ def parse_json_lines(lines, model: "ExtractTree"): # ---- Sorting --------------------------------------------------------------- -class ExtractSortProxyModel(FileTreeSortProxyModel): +class ExtractSortFilterProxyModel(FileTreeSortFilterProxyModel): """ Sort a ExtractTree model. """ + def __init__(self, parent=None) -> None: + super().__init__(parent) + + @staticmethod + def parse_date(value: str) -> Tuple[str, datetime]: + """ + Parse the date string into a tuple of two values. + """ + + comparison_sign = ['<', '>', '<=', '>=', '='] + + if not any(value.startswith(unit) for unit in comparison_sign): + raise argparse.ArgumentTypeError("Invalid date format. Supported comparison signs: <, >, <=, >=, =") + + if value[1] == '=': + date = value[2:] + sign = value[:2] + else: + date = value[1:] + sign = value[:1] + + try: + date = datetime.strptime(date, '%Y-%m-%d') + except ValueError: + raise argparse.ArgumentTypeError("Invalid date format. Must be YYYY-MM-DD.") + + return (sign, date) + + @classmethod + def valid_date_range(cls, value: str) -> List[Tuple[str, datetime]]: + """Parse the date range string.""" + date = value.split(',') + + if len(date) == 1: + return [cls.parse_date(date[0])] + elif len(date) == 2: + return [ + cls.parse_date(date[0]), + cls.parse_date(date[1]), + ] + else: + raise argparse.ArgumentTypeError("Invalid date format. Can only accept two values.") + + def get_parser(self): + """Add Extract view specific arguments to the parser.""" + parser = super().get_parser() + + health_group = parser.add_mutually_exclusive_group() + health_group.add_argument( + "--healthy", default=None, action="store_true", dest="healthy", help="Match only healthy items." + ) + health_group.add_argument( + "--unhealthy", default=None, action="store_false", dest="healthy", help="Match only unhealthy items." + ) + + parser.add_argument( + "--last-modified", + type=self.valid_date_range, + help="Match by last modified date.", + ) + + return parser + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + """ + Return whether the row should be accepted. + """ + + if not self.searchPattern: + return True + + if not super().filterAcceptsRow(sourceRow, sourceParent): + return False + + model = self.sourceModel() + item = model.index(sourceRow, 0, sourceParent).internalPointer() + + if self.searchPattern.healthy is not None: + return item.data.health == self.searchPattern.healthy + + if self.searchPattern.last_modified: + item_last_modified = item.data.last_modified + + for filter_last_modified in self.searchPattern.last_modified: + if not compare_values_with_sign(item_last_modified, filter_last_modified[1], filter_last_modified[0]): + return False + + return True + def choose_data(self, index: QModelIndex): """Choose the data of index used for comparison.""" item: ExtractFileItem = index.internalPointer() diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py new file mode 100644 index 000000000..05cef229c --- /dev/null +++ b/src/vorta/views/partials/file_dialog.py @@ -0,0 +1,156 @@ +from abc import ABCMeta, abstractmethod +from pathlib import PurePath + +from PyQt6.QtCore import ( + QMimeData, + QModelIndex, + QPoint, + Qt, + QUrl, +) +from PyQt6.QtGui import QKeySequence, QShortcut +from PyQt6.QtWidgets import QApplication, QDialog, QMenu, QTreeView + +from vorta.views.utils import get_colored_icon + + +class BaseFileDialog(QDialog): + """ + Base class for all file view dialogs. + Attributes: + model: The model to use for the file view. + """ + + __metaclass__ = ABCMeta + + def __init__(self, model): + """ + Initialize the file view dialog with model, setup UI and connect signals. + """ + + super().__init__() + self.setupUi(self) + + self.model = model + self.model.setParent(self) + + self.treeView: QTreeView + self.treeView.setUniformRowHeights(True) # Allows for scrolling optimizations. + self.treeView.setAlternatingRowColors(True) + self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # to better see name of paths + + # custom context menu + self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.treeView.customContextMenuRequested.connect(self.treeview_context_menu) + + # shortcuts + shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView) + shortcut_copy.activated.connect(self.copy_item) + + # add sort proxy model + self.sortproxy = self.get_sort_proxy_model() + self.sortproxy.setSourceModel(self.model) + self.treeView.setModel(self.sortproxy) + self.sortproxy.sorted.connect(self.slot_sorted) + + self.treeView.setSortingEnabled(True) + + # signals + + self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode) + diff_result_display_mode = self.get_diff_result_display_mode() + self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode)) + self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop) + self.bCollapseAll.clicked.connect(self.treeView.collapseAll) + + self.bSearch.clicked.connect(self.submitSearchPattern) + self.sortproxy.searchStringError.connect(self.searchStringError) + + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + # Add a cross icon inside the search field to clear the search string + self.searchWidget.setClearButtonEnabled(True) + # self.searchLineEdit.textChanged.connect(self.searchLineEditChanged) + + self.set_icons() + + # Connect to palette change + QApplication.instance().paletteChanged.connect(lambda p: self.set_icons()) + + @abstractmethod + def get_sort_proxy_model(self): + """Return a sort proxy model for the file view.""" + pass + + @abstractmethod + def get_diff_result_display_mode(self): + """Return the display mode for the diff result.""" + pass + + def copy_item(self, index: QModelIndex = None): + """ + Copy a diff item path to the clipboard. + + Copies the first selected item if no index is specified. + """ + if index is None or (not index.isValid()): + indexes = self.treeView.selectionModel().selectedRows() + + if not indexes: + return + + index = indexes[0] + + index = self.sortproxy.mapToSource(index) + item = index.internalPointer() + path = PurePath('/', *item.path) + + data = QMimeData() + data.setUrls([QUrl(path.as_uri())]) + data.setText(str(path)) + + QApplication.clipboard().setMimeData(data) + + def treeview_context_menu(self, pos: QPoint): + """Display a context menu for `treeView`.""" + index = self.treeView.indexAt(pos) + if not index.isValid(): + # popup only for items + return + + menu = QMenu(self.treeView) + + menu.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.copy_item(index)) + + if self.model.getMode() != self.model.DisplayMode.FLAT: + menu.addSeparator() + menu.addAction( + get_colored_icon('angle-down-solid'), + self.tr("Expand recursively"), + lambda: self.treeView.expandRecursively(index), + ) + + menu.popup(self.treeView.viewport().mapToGlobal(pos)) + + def slot_sorted(self, column, order): + """React to the tree view being sorted.""" + # reveal selection + selectedRows = self.treeView.selectionModel().selectedRows() + if selectedRows: + self.treeView.scrollTo(selectedRows[0]) + + def keyPressEvent(self, event): + """React to Enter key press in search field.""" + if event.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter] and self.searchWidget.hasFocus(): + self.submitSearchPattern() + else: + super().keyPressEvent(event) + + def submitSearchPattern(self): + """Submit the search pattern to the sort proxy model.""" + self.sortproxy.setSearchString(self.searchWidget.text()) + + def searchStringError(self, error: bool): + """Handle search string errors.""" + self.searchWidget.setStyleSheet("QLineEdit { border: 2px solid red; }" if error else "") diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index ceff3eb46..7be5da84d 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -2,10 +2,12 @@ Implementation of a tree model for use with `QTreeView` based on (file) paths. """ - +import argparse import bisect import enum import os.path as osp +import re +from fnmatch import fnmatch from functools import reduce from pathlib import PurePath from typing import Generic, List, Optional, Sequence, Tuple, TypeVar, Union, overload @@ -19,6 +21,8 @@ pyqtSignal, ) +from vorta.views.utils import compare_values_with_sign + #: A representation of a path Path = Tuple[str, ...] PathLike = Union[Path, Sequence[str]] @@ -896,17 +900,60 @@ def headerData( return super().headerData(section, orientation, role) -class FileTreeSortProxyModel(QSortFilterProxyModel): +class FileTreeSortFilterProxyModel(QSortFilterProxyModel): """ - Sort a FileTreeModel. + Sort and Filter a FileTreeModel. """ sorted = pyqtSignal(int, Qt.SortOrder) + searchStringError = pyqtSignal(bool) def __init__(self, parent=None) -> None: """Init.""" super().__init__(parent) + + self.setRecursiveFilteringEnabled(True) + self.setAutoAcceptChildRows(True) + self.folders_on_top = False + self.searchPattern = None + + @staticmethod + def parse_size(size: str) -> Tuple[str, int]: + """ + Parse the size string into a tuple of two values. + """ + + comparison_sign = ['<', '>', '<=', '>=', '='] + size_units = ['KB', 'MB', 'GB'] + + # TODO: Should we just use regex? ^([<>]=?|)(\d+(\.\d+)?)([KMG]B)?$ + if not any(size.startswith(unit) for unit in comparison_sign): + raise argparse.ArgumentTypeError("Invalid size format. Supported comparison signs: <, >, <=, >=") + + if not any(size.endswith(unit) for unit in size_units): + raise argparse.ArgumentTypeError("Invalid size format. Supported units: KB, MB, GB") + + try: + unit = size[-2:] + if size[1] == '=': + return (size[:2], int(size[2:-2]) * 1024 ** (size_units.index(unit) + 1)) + else: + return (size[0], int(size[1:-2]) * 1024 ** (size_units.index(unit) + 1)) + except ValueError: + raise argparse.ArgumentTypeError("Invalid size format. Must be a number.") + + @classmethod + def valid_size(cls, value: str) -> List[Tuple[str, int]]: + """Validate the size string.""" + size = value.split(',') + + if len(size) == 1: + return [cls.parse_size(size[0])] + elif len(size) == 2: + return [cls.parse_size(size[0]), cls.parse_size(size[1])] + else: + raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.") @overload def keepFoldersOnTop(self) -> bool: @@ -963,7 +1010,7 @@ def extract_path(self, index: QModelIndex): def choose_data(self, index: QModelIndex): """Choose the data of index used for comparison.""" raise NotImplementedError( - "Method `choose_data` of " + "FileTreeSortProxyModel" + " must be implemented by subclasses." + "Method `choose_data` of " + "FileTreeSortFilterProxyModel" + " must be implemented by subclasses." ) def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: @@ -994,3 +1041,107 @@ def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: data1 = self.choose_data(left) data2 = self.choose_data(right) return data1 < data2 + + def setSearchString(self, pattern: str): + """ + Set the pattern to filter for. + """ + + try: + self.searchPattern = self.parse_search_string(pattern) + except SystemExit: + self.searchStringError.emit(True) + return None + + self.searchStringError.emit(False) + self.invalidateRowsFilter() + + def get_parser(self): + """ + Creates and returns the parser for the search string. + """ + parser = argparse.ArgumentParser(description="Search files and folders based on various options.") + parser.add_argument("search_string", nargs="*", default=[], help="String to search in the name.") + parser.add_argument( + "-m", "--match", choices=["in", "ex", "re", "fm"], default=None, help="Type of match query." + ) + parser.add_argument("-i", "--ignore-case", action="store_true", help="Ignore case.") + parser.add_argument("-p", "--path", action="store_true", help="Match by path.") + parser.add_argument("-s", "--size", type=FileTreeSortFilterProxyModel.valid_size, help="Match by size.") + parser.add_argument("--exclude-parents", action="store_true", help="Match only items without children.") + + return parser + + def parse_search_string(self, pattern: str): + """ + Parse the search string into a list of tokens. + + May raise `SystemExit`. + """ + + parser = self.get_parser() + return parser.parse_args(pattern.split()) + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + """ + Return whether the row should be accepted. + """ + + if not self.searchPattern: + return True + + model = self.sourceModel() + item = model.index(sourceRow, 0, sourceParent).internalPointer() + + item_path = path_to_str(item.path) + item_name = item.subpath + + # Exclude Parents + if self.searchPattern.exclude_parents: + if item.children: + return False + + # Set default values + if self.searchPattern.match is None: + self.searchPattern.match = "fm" if self.searchPattern.path else "in" + + if self.searchPattern.path: + search_item = item_path + else: + search_item = item_name + + if self.searchPattern.search_string: + search_string = " ".join(self.searchPattern.search_string) + + # Ignore Case? + if self.searchPattern.ignore_case: + search_item = search_item.lower() + search_string = search_string.lower() + + if self.searchPattern.match == "in" and search_string not in search_item: + return False + elif self.searchPattern.match == "ex" and search_string != search_item: + return False + elif self.searchPattern.match == "re": + try: + if not re.search(search_string, search_item): + return False + except re.error: + self.searchStringError.emit(True) + return False + elif self.searchPattern.match == "fm" and not fnmatch(search_item, search_string): + return False + + if self.searchPattern.size: + # Diff view has size column corresponding to the changed_size while + # Extract view has size column corresponding to the size + if hasattr(item.data, 'changed_size'): + item_size = item.data.changed_size + else: + item_size = item.data.size + + for filter_size in self.searchPattern.size: + if not compare_values_with_sign(item_size, filter_size[1], filter_size[0]): + return False + + return True diff --git a/src/vorta/views/utils.py b/src/vorta/views/utils.py index 5a9697a73..02249b102 100644 --- a/src/vorta/views/utils.py +++ b/src/vorta/views/utils.py @@ -49,3 +49,19 @@ def get_exclusion_presets(): 'tags': preset['tags'], } return allPresets + + +def compare_values_with_sign(item_value: int, filter_value: int, comparison_sign: str) -> bool: + """ + Compare two values with a comparison sign. + """ + if comparison_sign == '<': + return item_value < filter_value + elif comparison_sign == '>': + return item_value > filter_value + elif comparison_sign == '<=': + return item_value <= filter_value + elif comparison_sign == '>=': + return item_value >= filter_value + elif comparison_sign == '=': + return item_value == filter_value diff --git a/tests/unit/borg_json_output/diff_archives_search_stderr.json b/tests/unit/borg_json_output/diff_archives_search_stderr.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/borg_json_output/diff_archives_search_stdout.json b/tests/unit/borg_json_output/diff_archives_search_stdout.json new file mode 100644 index 000000000..a5beee78d --- /dev/null +++ b/tests/unit/borg_json_output/diff_archives_search_stdout.json @@ -0,0 +1,5 @@ +{"changes": [{"new_ctime": "2023-09-25T15:15:45.857656", "old_ctime": "2023-08-08T13:29:52.873997", "type": "ctime"}, {"new_mtime": "2023-09-25T15:15:45.857656", "old_mtime": "2023-08-08T13:29:52.873997", "type": "mtime"}], "path": "home/kali/vorta/source1"} +{"changes": [{"added": 5061, "removed": 2530, "type": "modified"}, {"new_ctime": "2023-09-25T15:15:03.824651", "old_ctime": "2023-08-08T13:29:52.873997", "type": "ctime"}, {"new_mtime": "2023-09-25T15:15:03.824651", "old_mtime": "2023-08-08T13:29:52.869997", "type": "mtime"}], "path": "home/kali/vorta/source1/hello.txt"} +{"changes": [{"size": 0, "type": "added"}], "path": "home/kali/vorta/source1/emptyfile.bin"} +{"changes": [{"size": 56, "type": "added"}], "path": "home/kali/vorta/source1/notemptyfile.bin"} +{"changes": [{"size": 0, "type": "removed"}], "path": "home/kali/vorta/source1/file1.txt"} diff --git a/tests/unit/borg_json_output/extract_archives_search_stderr.json b/tests/unit/borg_json_output/extract_archives_search_stderr.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/borg_json_output/extract_archives_search_stdout.json b/tests/unit/borg_json_output/extract_archives_search_stdout.json new file mode 100644 index 000000000..338f6812b --- /dev/null +++ b/tests/unit/borg_json_output/extract_archives_search_stdout.json @@ -0,0 +1,5 @@ +{"type": "d", "mode": "drwx------", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "home/kali/vorta/source1", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-08-08T13:29:52.873997", "size": 0} +{"type": "-", "mode": "-rw-r--r--", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "home/kali/vorta/source1/file1.txt", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-03-27T10:36:25.418000", "size": 0} +{"type": "-", "mode": "-rw-r--r--", "user": "root", "group": "root", "uid": 0, "gid": 0, "path": "home/kali/vorta/source1/file.txt", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2030-08-15T21:20:12.188002", "size": 0} +{"type": "-", "mode": "-rw-r--r--", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "home/kali/vorta/source1/hello.txt", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-08-08T13:29:52.869997", "size": 2530} +{"type": "-", "mode": "-rw-r--r--", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "work/abigfile.pdf", "healthy": false, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-08-17T00:00:00.000000", "size": 10000000} diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e622a2118..b72be081a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -6,6 +6,7 @@ import vorta.application import vorta.borg.jobs_manager from peewee import SqliteDatabase +from PyQt6.QtCore import Qt from vorta.store.models import ( ArchiveModel, BackupProfileModel, @@ -68,11 +69,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() @@ -118,3 +120,26 @@ def archive_env(qapp, qtbot): tab.populate_from_profile() qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2, **pytest._wait_defaults) return main, tab + + +@pytest.fixture +def search_visible_items_in_tree(): + """ " + Returns a function that searches for visible items in a QTreeView. + """ + + def inner_search_visible_items_in_tree(model, parent_index): + filtered_items = [] + + def recursive_search_visible_items_in_tree(model, parent_index): + for row in range(model.rowCount(parent_index)): + index = model.index(row, 0, parent_index) + if model.data(index, Qt.ItemDataRole.DisplayRole) is not None: + if model.rowCount(index) == 0: + filtered_items.append(model.data(index, Qt.ItemDataRole.DisplayRole)) + recursive_search_visible_items_in_tree(model, index) + + recursive_search_visible_items_in_tree(model, parent_index) + return filtered_items + + return inner_search_visible_items_in_tree diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index e7ef9fd9b..bcd846537 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -4,7 +4,7 @@ import vorta.borg import vorta.utils import vorta.views.archive_tab -from PyQt6.QtCore import QDateTime, QItemSelectionModel, Qt +from PyQt6.QtCore import QDateTime, QItemSelectionModel, QModelIndex, Qt from PyQt6.QtWidgets import QMenu from vorta.store.models import ArchiveModel from vorta.views.diff_result import ( @@ -75,7 +75,7 @@ def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output, archive_env): # test 'diff_item_copy()' by passing it an item to copy index = tab._resultwindow.treeView.model().index(0, 0) assert index is not None - tab._resultwindow.diff_item_copy(index) + tab._resultwindow.copy_item(index) clipboard_data = clipboard_spy.call_args[0][0] assert clipboard_data.hasText() assert clipboard_data.text() == "/test" @@ -86,7 +86,7 @@ def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output, archive_env): flags = QItemSelectionModel.SelectionFlag.Rows flags |= QItemSelectionModel.SelectionFlag.Select tab._resultwindow.treeView.selectionModel().select(tab._resultwindow.treeView.model().index(0, 0), flags) - tab._resultwindow.diff_item_copy() + tab._resultwindow.copy_item() clipboard_data = clipboard_spy.call_args[0][0] assert clipboard_data.hasText() assert clipboard_data.text() == "/test" @@ -476,3 +476,104 @@ def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAl assert dialog.model.mode == expected_mode assert dialog.bCollapseAll.isEnabled() == expected_bCollapseAllEnabled + + +@pytest.mark.parametrize( + 'search_string,expected_search_results,emit_error', + [ + # Normal "in" search + ('txt', ['hello.txt', 'file1.txt'], False), + # Ignore Case + ('HELLO.txt -i', ['hello.txt'], False), + ('HELLO.txt', [], False), + # Size Match + ('--size >=15MB', [], False), + ('--size >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin'], False), + ('--size >1KB,<1MB --exclude-parents', ['hello.txt'], False), + # Path Match Type + ('home/kali/vorta/source1/hello.txt --path', ['hello.txt'], False), + ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt'], False), + ('home/kali/vorta/source1/*.bin --path -m fm', ['notemptyfile.bin', 'emptyfile.bin'], False), + # Regex Match Type + ("file[^/]*\\.txt|\\.bin -m re", ['file1.txt', 'notemptyfile.bin', 'emptyfile.bin'], False), + ("[ -m re", [], True), + # Exact Match Type + ('hello', ['hello.txt'], False), + ('hello -m ex', [], False), + # Diff Specific Filters # + # Balance Match + ('--balance >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin'], False), + ('--balance >1KB,<1MB --exclude-parents', ['hello.txt'], False), + ('--balance >10GB', [], False), + # Change Type + ('--change A', ['notemptyfile.bin', 'emptyfile.bin'], False), + ('--change D', ['file1.txt'], False), + ('--change M', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin'], False), + ], +) +def test_archive_diff_filters( + qtbot, + mocker, + borg_json_output, + search_visible_items_in_tree, + archive_env, + search_string, + expected_search_results, + emit_error, +): + """ + Tests the supported search filters for the diff window. + """ + + vorta.utils.borg_compat.version = '1.2.4' + + # _, tab = archive_env + main, tab = archive_env + main.show() + tab.archiveTable.selectRow(0) + + selection_model: QItemSelectionModel = tab.archiveTable.selectionModel() + model = tab.archiveTable.model() + + flags = QItemSelectionModel.SelectionFlag.Rows + flags |= QItemSelectionModel.SelectionFlag.Select + + selection_model.select(model.index(0, 0), flags) + selection_model.select(model.index(1, 0), flags) + + stdout, stderr = borg_json_output('diff_archives_search') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + # click on diff button + qtbot.mouseClick(tab.bDiff, Qt.MouseButton.LeftButton) + + # Wait for window to open + qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults) + qtbot.waitUntil(lambda: tab._resultwindow.treeView.model().rowCount(QModelIndex()) > 0, **pytest._wait_defaults) + + tab._resultwindow.searchWidget.setText(search_string) + qtbot.mouseClick(tab._resultwindow.bSearch, Qt.MouseButton.LeftButton) + + qtbot.waitUntil( + lambda: (tab._resultwindow.treeView.model().rowCount(QModelIndex()) > 0) or (len(expected_search_results) == 0), + **pytest._wait_defaults, + ) + + proxy_model = tab._resultwindow.treeView.model() + + filtered_items = search_visible_items_in_tree(proxy_model, QModelIndex()) + + # sort both lists to make sure the order is not important + filtered_items.sort() + expected_search_results.sort() + + assert filtered_items == expected_search_results + + # Check if error is emitted + if emit_error: + assert tab._resultwindow.searchWidget.styleSheet() == 'QLineEdit { border: 2px solid red; }' + else: + assert tab._resultwindow.searchWidget.styleSheet() == '' + + vorta.utils.borg_compat.version = '1.1.0' diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index e68da0d28..7f8442947 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -30,6 +30,7 @@ def prepare_borg(mocker, borg_json_output): "linktarget": "", "flags": None, "mtime": "2022-05-13T14:33:57.305797", + "isomtime": "2023-08-17T00:00:00.000000", "size": 0, } @@ -197,3 +198,90 @@ def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAl assert dialog.model.mode == expected_mode assert dialog.bCollapseAll.isEnabled() == expected_bCollapseAllEnabled + + +@pytest.mark.parametrize( + 'search_string,expected_search_results,emit_error', + [ + # Normal "in" search + ('txt', ['hello.txt', 'file1.txt', 'file.txt'], False), + # Ignore Case + ('HELLO.txt -i', ['hello.txt'], False), + ('HELLO.txt', [], False), + # Size Match + ('--size >=15MB', [], False), + ('--size >9MB,<11MB', ['abigfile.pdf'], False), + ('--size >1KB,<4KB --exclude-parents', ['hello.txt'], False), + # Path Match Type + ('home/kali/vorta/source1/hello.txt --path', ['hello.txt'], False), + ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt', 'file.txt'], False), + # Regex Match Type + ("file[^/]*\\.txt|\\.pdf -m re", ['file1.txt', 'file.txt', 'abigfile.pdf'], False), + ("[ -m re", [], True), + # Exact Match Type + ('hello', ['hello.txt'], False), + ('hello -m ex', [], False), + # Extract Specific Filters # + # Date Filter + ('--last-modified >2025-01-01', ['file.txt'], False), + ('--last-modified <2025-01-01 --exclude-parents', ['hello.txt', 'file1.txt', 'abigfile.pdf'], False), + # Health match + ('--unhealthy', ['abigfile.pdf'], False), + ('--healthy', ['hello.txt', 'file1.txt', 'file.txt', 'abigfile.pdf'], False), + ], +) +def test_archive_extract_filters( + qtbot, + mocker, + borg_json_output, + search_visible_items_in_tree, + archive_env, + search_string, + expected_search_results, + emit_error, +): + """ + Tests the supported search filters for the extract window. + """ + + vorta.utils.borg_compat.version = '1.2.4' + + _, tab = archive_env + tab.archiveTable.selectRow(0) + + stdout, stderr = borg_json_output('extract_archives_search') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + # click on extract button + qtbot.mouseClick(tab.bExtract, Qt.MouseButton.LeftButton) + + # Wait for window to open + qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults) + qtbot.waitUntil(lambda: tab._window.treeView.model().rowCount(QModelIndex()) > 0, **pytest._wait_defaults) + + tab._window.searchWidget.setText(search_string) + qtbot.mouseClick(tab._window.bSearch, Qt.MouseButton.LeftButton) + + qtbot.waitUntil( + lambda: (tab._window.treeView.model().rowCount(QModelIndex()) > 0) or (len(expected_search_results) == 0), + **pytest._wait_defaults, + ) + + proxy_model = tab._window.treeView.model() + + filtered_items = search_visible_items_in_tree(proxy_model, QModelIndex()) + + # sort both lists to make sure the order is not important + filtered_items.sort() + expected_search_results.sort() + + assert filtered_items == expected_search_results + + # Check if error is emitted + if emit_error: + assert tab._window.searchWidget.styleSheet() == 'QLineEdit { border: 2px solid red; }' + else: + assert tab._window.searchWidget.styleSheet() == '' + + vorta.utils.borg_compat.version = '1.1.0'