From 84c5b748ac6f36ba9200114dc4409ce5475375c1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 10 Jul 2023 23:48:07 -0400 Subject: [PATCH 01/27] Initial Search box Signed-off-by: Chirag Aggarwal --- src/vorta/assets/UI/diffresult.ui | 24 +++++++++++++++++++++ src/vorta/views/diff_result.py | 2 ++ src/vorta/views/partials/treemodel.py | 30 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/vorta/assets/UI/diffresult.ui b/src/vorta/assets/UI/diffresult.ui index 0907fe0b6..291756e96 100644 --- a/src/vorta/assets/UI/diffresult.ui +++ b/src/vorta/assets/UI/diffresult.ui @@ -115,6 +115,30 @@ + + + + + 4 + + + 0 + + + + + + Search + + + Search + + + + + + + diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 5d262efa5..70504fcd6 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -114,6 +114,8 @@ def __init__(self, archive_newer, archive_older, model: 'DiffTree'): self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode)) self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop) self.bCollapseAll.clicked.connect(self.treeView.collapseAll) + # Search widget + self.searchWidget.textChanged.connect(self.sortproxy.setFilterFixedString) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index a184a5428..dd37162ff 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -907,6 +907,7 @@ def __init__(self, parent=None) -> None: """Init.""" super().__init__(parent) self.folders_on_top = False + self.searchPattern = "" @overload def keepFoldersOnTop(self) -> bool: @@ -994,3 +995,32 @@ def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: data1 = self.choose_data(left) data2 = self.choose_data(right) return data1 < data2 + + def setFilterFixedString(self, pattern: str): + """ + Set the pattern to filter for. + """ + self.searchPattern = pattern + self.invalidateRowsFilter() + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + """ + Return whether the row should be accepted. + """ + + self.setRecursiveFilteringEnabled(True) + self.setAutoAcceptChildRows(True) + + if self.searchPattern == "": + return True + + sourceModel = self.sourceModel() + sourceIndex = sourceModel.index(sourceRow, 0, sourceParent) + name = self.extract_path(sourceIndex) + + if self.searchPattern.lower() in name.lower(): + return True + + # TODO: Implement path: syntax which builds full path of current item and then compares using startWith + + return False From 384b41e1ce18f02f14dd53af3a8466858b1f3ce5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 17 Jul 2023 04:57:57 -0400 Subject: [PATCH 02/27] Created a base file_dialog class to reuse code in diff and extract dialog Signed-off-by: Chirag Aggarwal --- src/vorta/views/diff_result.py | 117 +++------------------- src/vorta/views/extract_dialog.py | 110 +++------------------ src/vorta/views/partials/file_dialog.py | 126 ++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 202 deletions(-) create mode 100644 src/vorta/views/partials/file_dialog.py diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 70504fcd6..04d3c3bb2 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -10,18 +10,16 @@ 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, @@ -65,65 +63,30 @@ 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) - - # 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) + def __init__(self, archive_newer, archive_older, model): + super().__init__(model) - # 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) - # Search widget + # TODO: Move to BaseFileDialog once it's added to extract dialog UI self.searchWidget.textChanged.connect(self.sortproxy.setFilterFixedString) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - - self.set_icons() + def get_sort_proxy_model(self): + """Return the sort proxy model for the tree view.""" + return DiffSortProxyModel(self) - # 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.""" @@ -133,55 +96,6 @@ def set_icons(self): 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 @@ -205,13 +119,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 -------------------------------------------------------- diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index 0822d0670..57e707870 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -10,24 +10,20 @@ 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.partials.file_dialog import BaseFileDialog from vorta.views.utils import get_colored_icon from .partials.treemodel import ( @@ -64,70 +60,42 @@ 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) + # TODO: disable self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # 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() + def get_sort_proxy_model(self): + """Get the sort proxy model for this dialog.""" + return ExtractSortProxyModel() - # 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.""" @@ -144,37 +112,6 @@ def set_icons(self): 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 @@ -196,27 +133,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`.""" diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py new file mode 100644 index 000000000..fb3f6c204 --- /dev/null +++ b/src/vorta/views/partials/file_dialog.py @@ -0,0 +1,126 @@ +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): + __metaclass__ = ABCMeta + + def __init__(self, model): + 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.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.set_icons() + + # Connect to palette change + QApplication.instance().paletteChanged.connect(lambda p: self.set_icons()) + + @abstractmethod + def get_sort_proxy_model(self): + pass + + @abstractmethod + def get_diff_result_display_mode(self): + pass + + @abstractmethod + def set_archive_names(self): + 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]) From 45f3383f7b57db2314b12bbd8181f855e2778a25 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 21 Jul 2023 04:49:06 -0400 Subject: [PATCH 03/27] Added search syntax with name and path search along with a error signal Signed-off-by: Chirag Aggarwal --- src/vorta/views/diff_result.py | 6 +- src/vorta/views/partials/treemodel.py | 86 +++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 04d3c3bb2..fc469d66a 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -79,7 +79,11 @@ def __init__(self, archive_newer, archive_older, model): self.archiveNameLabel_2.setText(f'{archive_older.name}') # TODO: Move to BaseFileDialog once it's added to extract dialog UI - self.searchWidget.textChanged.connect(self.sortproxy.setFilterFixedString) + self.searchWidget.textChanged.connect(self.sortproxy.setSearchString) + self.sortproxy.searchStringError.connect(self.searchStringError) + + def searchStringError(self, error: bool): + self.searchWidget.setStyleSheet("border: 2px solid red;" if error else "") def get_sort_proxy_model(self): """Return the sort proxy model for the tree view.""" diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index dd37162ff..3cd3105ba 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 import os.path as osp +from fnmatch import fnmatch from functools import reduce from pathlib import PurePath from typing import Generic, List, Optional, Sequence, Tuple, TypeVar, Union, overload @@ -902,12 +904,13 @@ class FileTreeSortProxyModel(QSortFilterProxyModel): """ sorted = pyqtSignal(int, Qt.SortOrder) + searchStringError = pyqtSignal(bool) def __init__(self, parent=None) -> None: """Init.""" super().__init__(parent) self.folders_on_top = False - self.searchPattern = "" + self.searchPattern = None @overload def keepFoldersOnTop(self) -> bool: @@ -996,13 +999,55 @@ def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: data2 = self.choose_data(right) return data1 < data2 - def setFilterFixedString(self, pattern: str): + def setSearchString(self, pattern: str): """ Set the pattern to filter for. """ - self.searchPattern = pattern + self.searchStringError.emit(False) + + if not pattern: + self.searchPattern = None + + self.searchPattern = self.parse_search_string(pattern) self.invalidateRowsFilter() + def parse_search_string(self, pattern: str): + """ + Parse the search string into a list of tokens. + """ + + def valid_size(value): + comparison_sign = ['<', '>'] + size_units = ['KB', 'MB', 'GB'] + + if not any(value.startswith(unit) for unit in comparison_sign): + raise argparse.ArgumentTypeError("Invalid size format. Supported comparison signs: <, >") + + if not any(value.endswith(unit) for unit in size_units): + raise argparse.ArgumentTypeError("Invalid size format. Supported units: KB, MB, GB") + + try: + # TODO: Yet to check how the model stores actual size of the file but assuming in bytes for now + return (value[0], int(value[1:-2]) * 1024 ** (size_units.index(value[-2:]) + 1)) + except ValueError: + raise argparse.ArgumentTypeError("Invalid size format. Must be a number.") + + 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", "exact"], default="in", help="Type of match query." + ) # TODO: Type "regex" + parser.add_argument("-i", "--ignore-case", action="store_true", help="Ignore case.") + parser.add_argument("-p", "--path", nargs="*", default=None, help="Specify path to match.") + parser.add_argument("-c", "--change", choices=["A", "R"], help="Only available in Diff View.") + parser.add_argument("-s", "--size", nargs="+", type=valid_size, help="Match by size.") + + try: + return parser.parse_args(pattern.split()) + except SystemExit: + self.searchStringError.emit(True) + return None + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: """ Return whether the row should be accepted. @@ -1011,16 +1056,33 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: self.setRecursiveFilteringEnabled(True) self.setAutoAcceptChildRows(True) - if self.searchPattern == "": + if not self.searchPattern: return True - sourceModel = self.sourceModel() - sourceIndex = sourceModel.index(sourceRow, 0, sourceParent) - name = self.extract_path(sourceIndex) + model: FileTreeModel = self.sourceModel() + item = model.index(sourceRow, 0, sourceParent).internalPointer() - if self.searchPattern.lower() in name.lower(): - return True + item_path = path_to_str(item.path) + item_name = item.subpath + + if self.searchPattern.search_string: + # TODO: Move operations on search string to setSearchString method + + search_string = " ".join(self.searchPattern.search_string) - # TODO: Implement path: syntax which builds full path of current item and then compares using startWith + # Ignore Case? + if self.searchPattern.ignore_case: + item_name = item_name.lower() + search_string = search_string.lower() - return False + if self.searchPattern.match == "in" and search_string not in item_name: + return False + elif self.searchPattern.match == "exact" and search_string != item_name: + return False + + if self.searchPattern.path: + search_path = os.path.join(*self.searchPattern.path) + if not fnmatch(item_path, search_path): + return False + + return True From 1da2ecd90d1c2c6e6fa90e3369a78771f4830442 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 21 Jul 2023 04:53:47 -0400 Subject: [PATCH 04/27] Use existing method to join path in searchpattern Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 3cd3105ba..5adf8f98d 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -5,7 +5,6 @@ import argparse import bisect import enum -import os import os.path as osp from fnmatch import fnmatch from functools import reduce @@ -1081,7 +1080,7 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: return False if self.searchPattern.path: - search_path = os.path.join(*self.searchPattern.path) + search_path = path_to_str(self.searchPattern.path) if not fnmatch(item_path, search_path): return False From 69649b0011967ceaa8a8f395eca0c248fc39b818 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 21 Jul 2023 13:56:48 -0400 Subject: [PATCH 05/27] Added search button Signed-off-by: Chirag Aggarwal --- src/vorta/assets/UI/diffresult.ui | 18 ++++++++++++++++-- src/vorta/views/diff_result.py | 12 ++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/vorta/assets/UI/diffresult.ui b/src/vorta/assets/UI/diffresult.ui index 291756e96..3b9ddf426 100644 --- a/src/vorta/assets/UI/diffresult.ui +++ b/src/vorta/assets/UI/diffresult.ui @@ -128,17 +128,31 @@ - Search + Search Pattern - Search + Search Pattern + + + + + Submit Search + + + Search + + + + + + diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index fc469d66a..301d002e4 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -78,10 +78,18 @@ def __init__(self, archive_newer, archive_older, model): self.archiveNameLabel_1.setText(f'{archive_newer.name}') self.archiveNameLabel_2.setText(f'{archive_older.name}') - # TODO: Move to BaseFileDialog once it's added to extract dialog UI - self.searchWidget.textChanged.connect(self.sortproxy.setSearchString) + self.bSearch.clicked.connect(self.submitSearchPattern) self.sortproxy.searchStringError.connect(self.searchStringError) + def keyPressEvent(self, event): + 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): + self.sortproxy.setSearchString(self.searchWidget.text()) + def searchStringError(self, error: bool): self.searchWidget.setStyleSheet("border: 2px solid red;" if error else "") From b947858cc86e8faaa1f8fe037c5192f8582f5cd2 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 25 Jul 2023 19:56:03 -0400 Subject: [PATCH 06/27] Added size filter, improved string search Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 84 ++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 5adf8f98d..10a791df5 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -6,6 +6,7 @@ import bisect import enum import os.path as osp +import re from fnmatch import fnmatch from functools import reduce from pathlib import PurePath @@ -1015,29 +1016,47 @@ def parse_search_string(self, pattern: str): Parse the search string into a list of tokens. """ - def valid_size(value): - comparison_sign = ['<', '>'] + def parse_size(size): + """ + Parse the size string into a tuple of two values. + """ + + comparison_sign = ['<', '>', '<=', '>='] size_units = ['KB', 'MB', 'GB'] - if not any(value.startswith(unit) for unit in comparison_sign): - raise argparse.ArgumentTypeError("Invalid size format. Supported comparison signs: <, >") + # 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(value.endswith(unit) for unit in size_units): + if not any(size.endswith(unit) for unit in size_units): raise argparse.ArgumentTypeError("Invalid size format. Supported units: KB, MB, GB") try: - # TODO: Yet to check how the model stores actual size of the file but assuming in bytes for now - return (value[0], int(value[1:-2]) * 1024 ** (size_units.index(value[-2:]) + 1)) + 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.") + def valid_size(value): + size = value.split(',') + + if len(size) == 1: + return parse_size(size[0]) + elif len(size) == 2: + return (parse_size(size[0]), parse_size(size[1])) + else: + raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.") + 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", "exact"], default="in", help="Type of match query." + "-m", "--match", choices=["in", "ex", "re", "fm"], default=None, help="Type of match query." ) # TODO: Type "regex" parser.add_argument("-i", "--ignore-case", action="store_true", help="Ignore case.") - parser.add_argument("-p", "--path", nargs="*", default=None, help="Specify path to match.") + parser.add_argument("-p", "--path", action="store_true", help="Match by path.") parser.add_argument("-c", "--change", choices=["A", "R"], help="Only available in Diff View.") parser.add_argument("-s", "--size", nargs="+", type=valid_size, help="Match by size.") @@ -1058,12 +1077,26 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: if not self.searchPattern: return True + # Match type "fm" is only available with path + if self.searchPattern.match == "fm" and not self.searchPattern.path: + self.searchStringError.emit(True) + return False + model: FileTreeModel = self.sourceModel() item = model.index(sourceRow, 0, sourceParent).internalPointer() item_path = path_to_str(item.path) item_name = item.subpath + # 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: # TODO: Move operations on search string to setSearchString method @@ -1071,17 +1104,36 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: # Ignore Case? if self.searchPattern.ignore_case: - item_name = item_name.lower() + search_item = search_item.lower() search_string = search_string.lower() - if self.searchPattern.match == "in" and search_string not in item_name: + if self.searchPattern.match == "in" and search_string not in search_item: return False - elif self.searchPattern.match == "exact" and search_string != item_name: + elif self.searchPattern.match == "ex" and search_string != search_item: return False - - if self.searchPattern.path: - search_path = path_to_str(self.searchPattern.path) - if not fnmatch(item_path, search_path): + elif self.searchPattern.match == "re" and not re.search(search_string, search_item): return False + elif self.searchPattern.match == "fm" and not fnmatch(search_item, search_string): + return False + + def validate_size_filter(item_size, filter_size): + comparison_sign = filter_size[0] + filter_size = filter_size[1] + + if comparison_sign == '<': + return item_size < filter_size + elif comparison_sign == '>': + return item_size > filter_size + elif comparison_sign == '<=': + return item_size <= filter_size + elif comparison_sign == '>=': + return item_size >= filter_size + + if self.searchPattern.size: + item_size = item.data.size + + for filter_size in self.searchPattern.size: + if not validate_size_filter(item_size, filter_size): + return False return True From f14a1c6867ccd3e6724dedd59e833fbf1a8fd60d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 28 Jul 2023 20:51:49 -0400 Subject: [PATCH 07/27] Added change type filter Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 10a791df5..be3a89f4d 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -1057,7 +1057,7 @@ def valid_size(value): ) # TODO: Type "regex" 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("-c", "--change", choices=["A", "R"], help="Only available in Diff View.") + parser.add_argument("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.") parser.add_argument("-s", "--size", nargs="+", type=valid_size, help="Match by size.") try: @@ -1136,4 +1136,10 @@ def validate_size_filter(item_size, filter_size): if not validate_size_filter(item_size, filter_size): return False + if self.searchPattern.change: + item_change = item.data.change_type.short() + + if item_change != self.searchPattern.change: + return False + return True From 15b2fd466db33e6d6e5aa0af7c7157e0981bb499 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 28 Jul 2023 21:12:34 -0400 Subject: [PATCH 08/27] Added search to extract view Signed-off-by: Chirag Aggarwal --- src/vorta/assets/UI/extractdialog.ui | 38 +++++++++++++++++++++++++ src/vorta/views/diff_result.py | 15 ---------- src/vorta/views/partials/file_dialog.py | 15 ++++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui index a6113aafd..17c5d65a0 100644 --- a/src/vorta/assets/UI/extractdialog.ui +++ b/src/vorta/assets/UI/extractdialog.ui @@ -106,6 +106,44 @@ + + + + + 4 + + + 0 + + + + + + Search Pattern + + + Search Pattern + + + + + + + + + Submit Search + + + Search + + + + + + + + + diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 301d002e4..560eb9edb 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -78,21 +78,6 @@ def __init__(self, archive_newer, archive_older, model): self.archiveNameLabel_1.setText(f'{archive_newer.name}') self.archiveNameLabel_2.setText(f'{archive_older.name}') - self.bSearch.clicked.connect(self.submitSearchPattern) - self.sortproxy.searchStringError.connect(self.searchStringError) - - def keyPressEvent(self, event): - 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): - self.sortproxy.setSearchString(self.searchWidget.text()) - - def searchStringError(self, error: bool): - self.searchWidget.setStyleSheet("border: 2px solid red;" if error else "") - def get_sort_proxy_model(self): """Return the sort proxy model for the tree view.""" return DiffSortProxyModel(self) diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py index fb3f6c204..525b53d9b 100644 --- a/src/vorta/views/partials/file_dialog.py +++ b/src/vorta/views/partials/file_dialog.py @@ -53,6 +53,9 @@ def __init__(self, model): 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) @@ -124,3 +127,15 @@ def slot_sorted(self, column, order): selectedRows = self.treeView.selectionModel().selectedRows() if selectedRows: self.treeView.scrollTo(selectedRows[0]) + + def keyPressEvent(self, event): + 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): + self.sortproxy.setSearchString(self.searchWidget.text()) + + def searchStringError(self, error: bool): + self.searchWidget.setStyleSheet("border: 2px solid red;" if error else "") From 370a4fbd9f6b02dd787f0562e33f2410c3d1f049 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 8 Aug 2023 13:59:18 -0400 Subject: [PATCH 09/27] Added Balance and healthy filter Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 39 ++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index be3a89f4d..3c54ee8e1 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -1060,6 +1060,13 @@ def valid_size(value): parser.add_argument("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.") parser.add_argument("-s", "--size", nargs="+", type=valid_size, help="Match by size.") + # Diff view only + parser.add_argument("-b", "--balance", nargs="+", type=valid_size, help="Match by balance size.") + + # Extract view only + parser.add_argument("--healthy", action="store_true", help="Match only healthy items.") + parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") + try: return parser.parse_args(pattern.split()) except SystemExit: @@ -1130,12 +1137,42 @@ def validate_size_filter(item_size, filter_size): return item_size >= filter_size if self.searchPattern.size: - item_size = item.data.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 validate_size_filter(item_size, filter_size): return False + if self.searchPattern.balance: + # Only available in Diff view + if hasattr(item.data, 'changed_size'): + item_balance = item.data.size + + for filter_balance in self.searchPattern.balance: + if not validate_size_filter(item_balance, filter_balance): + return False + else: + self.searchStringError.emit(True) + + if self.searchPattern.healthy: + if hasattr(item.data, 'health'): + if not item.data.health: + return False + else: + self.searchStringError.emit(True) + + if self.searchPattern.unhealthy: + if hasattr(item.data, 'health'): + if item.data.health: + return False + else: + self.searchStringError.emit(True) + if self.searchPattern.change: item_change = item.data.change_type.short() From 4247e828174fc5605e0030345f6650adb1e34d56 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 11 Aug 2023 20:07:43 -0400 Subject: [PATCH 10/27] Added Last Modified filter Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 77 +++++++++++++++++++++------ 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 3c54ee8e1..e74812c4e 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -7,6 +7,7 @@ import enum import os.path as osp import re +from datetime import datetime from fnmatch import fnmatch from functools import reduce from pathlib import PurePath @@ -1021,7 +1022,7 @@ def parse_size(size): Parse the size string into a tuple of two values. """ - comparison_sign = ['<', '>', '<=', '>='] + comparison_sign = ['<', '>', '<=', '>=', '='] size_units = ['KB', 'MB', 'GB'] # TODO: Should we just use regex? ^([<>]=?|)(\d+(\.\d+)?)([KMG]B)?$ @@ -1040,16 +1041,50 @@ def parse_size(size): except ValueError: raise argparse.ArgumentTypeError("Invalid size format. Must be a number.") + def parse_date(value): + """ + 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) + def valid_size(value): size = value.split(',') if len(size) == 1: - return parse_size(size[0]) + return [parse_size(size[0])] elif len(size) == 2: - return (parse_size(size[0]), parse_size(size[1])) + return [parse_size(size[0]), parse_size(size[1])] else: raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.") + def valid_date_range(value): + date = value.split(',') + + if len(date) == 1: + return [parse_date(date[0])] + elif len(date) == 2: + return [parse_date(date[0]), parse_date(date[1])] + else: + raise argparse.ArgumentTypeError("Invalid date format. Can only accept two values.") + 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( @@ -1058,14 +1093,15 @@ def valid_size(value): 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("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.") - parser.add_argument("-s", "--size", nargs="+", type=valid_size, help="Match by size.") + parser.add_argument("-s", "--size", type=valid_size, help="Match by size.") # Diff view only - parser.add_argument("-b", "--balance", nargs="+", type=valid_size, help="Match by balance size.") + parser.add_argument("-b", "--balance", type=valid_size, help="Match by balance size.") # Extract view only parser.add_argument("--healthy", action="store_true", help="Match only healthy items.") parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") + parser.add_argument("--last-modified", type=valid_date_range, help="Match by last modified date.") try: return parser.parse_args(pattern.split()) @@ -1123,18 +1159,17 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: elif self.searchPattern.match == "fm" and not fnmatch(search_item, search_string): return False - def validate_size_filter(item_size, filter_size): - comparison_sign = filter_size[0] - filter_size = filter_size[1] - + def compare_values_with_sign(item_value, filter_value, comparison_sign): if comparison_sign == '<': - return item_size < filter_size + return item_value < filter_value elif comparison_sign == '>': - return item_size > filter_size + return item_value > filter_value elif comparison_sign == '<=': - return item_size <= filter_size + return item_value <= filter_value elif comparison_sign == '>=': - return item_size >= filter_size + return item_value >= filter_value + elif comparison_sign == '=': + return item_value == filter_value if self.searchPattern.size: # Diff view has size column corresponding to the changed_size while @@ -1145,7 +1180,7 @@ def validate_size_filter(item_size, filter_size): item_size = item.data.size for filter_size in self.searchPattern.size: - if not validate_size_filter(item_size, filter_size): + if not compare_values_with_sign(item_size, filter_size[1], filter_size[0]): return False if self.searchPattern.balance: @@ -1154,7 +1189,7 @@ def validate_size_filter(item_size, filter_size): item_balance = item.data.size for filter_balance in self.searchPattern.balance: - if not validate_size_filter(item_balance, filter_balance): + if not compare_values_with_sign(item_balance, filter_balance[1], filter_balance[0]): return False else: self.searchStringError.emit(True) @@ -1173,6 +1208,18 @@ def validate_size_filter(item_size, filter_size): else: self.searchStringError.emit(True) + if self.searchPattern.last_modified: + if hasattr(item.data, '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 + else: + self.searchStringError.emit(True) + if self.searchPattern.change: item_change = item.data.change_type.short() From a1cbdfc461c920f479449d4b5169d9e294595443 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 16 Aug 2023 17:38:52 -0400 Subject: [PATCH 11/27] Added Search Icon Signed-off-by: Chirag Aggarwal --- src/vorta/assets/UI/diffresult.ui | 7 +++---- src/vorta/assets/UI/extractdialog.ui | 6 +++--- src/vorta/assets/icons/search.svg | 1 + src/vorta/views/diff_result.py | 1 + src/vorta/views/extract_dialog.py | 1 + 5 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/vorta/assets/icons/search.svg diff --git a/src/vorta/assets/UI/diffresult.ui b/src/vorta/assets/UI/diffresult.ui index 3b9ddf426..91c933b4b 100644 --- a/src/vorta/assets/UI/diffresult.ui +++ b/src/vorta/assets/UI/diffresult.ui @@ -139,16 +139,15 @@ + + Qt::NoFocus + Submit Search - - Search - - diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui index 17c5d65a0..82344e916 100644 --- a/src/vorta/assets/UI/extractdialog.ui +++ b/src/vorta/assets/UI/extractdialog.ui @@ -130,12 +130,12 @@ + + Qt::NoFocus + Submit Search - - Search - diff --git a/src/vorta/assets/icons/search.svg b/src/vorta/assets/icons/search.svg new file mode 100644 index 000000000..15d3b909f --- /dev/null +++ b/src/vorta/assets/icons/search.svg @@ -0,0 +1 @@ + diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 560eb9edb..20aa625fd 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -89,6 +89,7 @@ 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.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")) diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index 57e707870..6115bda32 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -107,6 +107,7 @@ 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.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree")) From ba80f2ebd8a6afb3b09ba506e8927e1f70136885 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 19 Aug 2023 10:52:57 -0400 Subject: [PATCH 12/27] Added search clear icon and minor fixes with borg outputs Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/file_dialog.py | 4 ++++ .../diff_extract_archives_search_stderr.json | 0 .../diff_extract_archives_search_stdout.json | 8 ++++++++ .../borg_json_output/extract_archives_search_stderr.json | 0 .../borg_json_output/extract_archives_search_stdout.json | 5 +++++ 5 files changed, 17 insertions(+) create mode 100644 tests/borg_json_output/diff_extract_archives_search_stderr.json create mode 100644 tests/borg_json_output/diff_extract_archives_search_stdout.json create mode 100644 tests/borg_json_output/extract_archives_search_stderr.json create mode 100644 tests/borg_json_output/extract_archives_search_stdout.json diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py index 525b53d9b..38e9c690f 100644 --- a/src/vorta/views/partials/file_dialog.py +++ b/src/vorta/views/partials/file_dialog.py @@ -59,6 +59,10 @@ def __init__(self, model): 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 diff --git a/tests/borg_json_output/diff_extract_archives_search_stderr.json b/tests/borg_json_output/diff_extract_archives_search_stderr.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/borg_json_output/diff_extract_archives_search_stdout.json b/tests/borg_json_output/diff_extract_archives_search_stdout.json new file mode 100644 index 000000000..ac3fdad60 --- /dev/null +++ b/tests/borg_json_output/diff_extract_archives_search_stdout.json @@ -0,0 +1,8 @@ +added directory Users/manu/Downloads +added 122 B Users/manu/Downloads/.transifexrc +added 9.22 kB Users/manu/Downloads/12042021 ORDER COD NUMBER.doc +added 1000 kB Users/manu/Documents/abigfile.pdf +removed directory Users/manu/Downloads/test-diff/bar +removed 0 B Users/manu/Downloads/test-diff/bar/2.txt +removed directory Users/manu/Downloads/test-diff/foo +removed 0 B Users/manu/Downloads/test-diff/foo/1.txt diff --git a/tests/borg_json_output/extract_archives_search_stderr.json b/tests/borg_json_output/extract_archives_search_stderr.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/borg_json_output/extract_archives_search_stdout.json b/tests/borg_json_output/extract_archives_search_stdout.json new file mode 100644 index 000000000..8700c2b3f --- /dev/null +++ b/tests/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, "mtime": "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, "mtime": "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, "mtime": "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, "mtime": "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, "mtime": "2023-08-17T00:00:00.000000", "size": 10000000} From b91ada96ea67ee9d0628b61925369863ac4cd941 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 19 Aug 2023 18:50:08 -0400 Subject: [PATCH 13/27] Added new flag 'exclude-parents' and added test to cover all flags and search syntax in extract view Signed-off-by: Chirag Aggarwal --- src/vorta/assets/icons/search.svg | 1 + src/vorta/views/partials/treemodel.py | 8 ++ .../extract_archives_search_stdout.json | 10 +-- tests/unit/conftest.py | 4 +- tests/unit/test_archives.py | 76 +++++++++++++++++++ 5 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/vorta/assets/icons/search.svg b/src/vorta/assets/icons/search.svg index 15d3b909f..79a4a3a43 100644 --- a/src/vorta/assets/icons/search.svg +++ b/src/vorta/assets/icons/search.svg @@ -1 +1,2 @@ + diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index e74812c4e..f87f3eb20 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -1103,6 +1103,9 @@ def valid_date_range(value): parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") parser.add_argument("--last-modified", type=valid_date_range, help="Match by last modified date.") + # no parents allowed + parser.add_argument("--exclude-parents", action="store_true", help="Match only items without parents.") + try: return parser.parse_args(pattern.split()) except SystemExit: @@ -1131,6 +1134,11 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: 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" diff --git a/tests/unit/borg_json_output/extract_archives_search_stdout.json b/tests/unit/borg_json_output/extract_archives_search_stdout.json index 8700c2b3f..338f6812b 100644 --- a/tests/unit/borg_json_output/extract_archives_search_stdout.json +++ b/tests/unit/borg_json_output/extract_archives_search_stdout.json @@ -1,5 +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, "mtime": "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, "mtime": "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, "mtime": "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, "mtime": "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, "mtime": "2023-08-17T00:00:00.000000", "size": 10000000} +{"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..7da18b0f6 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -64,8 +64,8 @@ def init_db(qapp, qtbot, tmpdir_factory): source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True) source_dir.save() - qapp.main_window.deleteLater() - del qapp.main_window + # qapp.main_window.deleteLater() + # del qapp.main_window qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI yield diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index c17bf6249..bae499357 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -196,3 +196,79 @@ def test_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): # Successful rename case qtbot.waitUntil(lambda: tab.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) + + +@pytest.mark.parametrize( + 'search_string,expected_search_results', + [ + # Normal "in" search + ('txt', ['hello.txt', 'file1.txt', 'file.txt']), + # Ignore Case + ('HELLO.txt -i', ['hello.txt']), + ('HELLO.txt', []), + # Health match + ('--unhealthy', ['abigfile.pdf']), + ('--healthy', ['hello.txt', 'file1.txt', 'file.txt', 'abigfile.pdf']), + # Size Match + ('--size >=15MB', []), + ('--size >9MB,<11MB', ['abigfile.pdf']), + ('--size >1KB,<4KB --exclude-parents', ['hello.txt']), + # Path Match Type + ('home/kali/vorta/source1/hello.txt --path', ['hello.txt']), + ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt', 'file.txt']), + # Regex Match Type + ("file[^/]*\\.txt|\\.pdf -m re", ['file1.txt', 'file.txt', 'abigfile.pdf']), + # Exact Match Type + ('hello', ['hello.txt']), + ('hello -m ex', []), + # Date Filter + ('--last-modified >2025-01-01', ['file.txt']), + ('--last-modified <2025-01-01 --exclude-parents', ['hello.txt', 'file1.txt', 'abigfile.pdf']), + ], +) +def test_archive_extract_filters(qtbot, mocker, borg_json_output, archive_env, search_string, expected_search_results): + 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 diff button + qtbot.mouseClick(tab.bExtract, QtCore.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(QtCore.QModelIndex()) > 0, **pytest._wait_defaults) + + tab._window.searchWidget.setText(search_string) + qtbot.mouseClick(tab._window.bSearch, QtCore.Qt.MouseButton.LeftButton) + + qtbot.waitUntil( + lambda: (tab._window.treeView.model().rowCount(QtCore.QModelIndex()) > 0) + or (len(expected_search_results) == 0), + **pytest._wait_defaults, + ) + + proxy_model = tab._window.treeView.model() + + 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, QtCore.Qt.ItemDataRole.DisplayRole) is not None: + if model.rowCount(index) == 0: + filtered_items.append(model.data(index, QtCore.Qt.ItemDataRole.DisplayRole)) + recursive_search_visible_items_in_tree(model, index) + + recursive_search_visible_items_in_tree(proxy_model, QtCore.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 + vorta.utils.borg_compat.version = '1.1.0' From 2db3d24ed30e0cd7d2372ed834cff75edd734bcf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 17:48:31 -0400 Subject: [PATCH 14/27] Modular approach for Diff and Extract Tree Views with base class Signed-off-by: Chirag Aggarwal --- src/vorta/views/diff_result.py | 4 +- src/vorta/views/extract_dialog.py | 4 +- src/vorta/views/partials/treemodel.py | 336 +++++++++++++++----------- src/vorta/views/utils.py | 13 + 4 files changed, 208 insertions(+), 149 deletions(-) diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 20aa625fd..cd7df5521 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -21,9 +21,9 @@ 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 ( + DiffTreeSortFilterProxyModel, FileSystemItem, FileTreeModel, - FileTreeSortProxyModel, path_to_str, relative_path, ) @@ -393,7 +393,7 @@ def size_to_byte(significand: str, unit: str) -> int: # ---- Sorting --------------------------------------------------------------- -class DiffSortProxyModel(FileTreeSortProxyModel): +class DiffSortProxyModel(DiffTreeSortFilterProxyModel): """ Sort a DiffTree model. """ diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index b0c792278..4fcee9df2 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -27,9 +27,9 @@ from vorta.views.utils import get_colored_icon from .partials.treemodel import ( + ExtractTreeSortFilterProxyModel, FileSystemItem, FileTreeModel, - FileTreeSortProxyModel, path_to_str, relative_path, ) @@ -171,7 +171,7 @@ def parse_json_lines(lines, model: "ExtractTree"): # ---- Sorting --------------------------------------------------------------- -class ExtractSortProxyModel(FileTreeSortProxyModel): +class ExtractSortProxyModel(ExtractTreeSortFilterProxyModel): """ Sort a ExtractTree model. """ diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index f87f3eb20..a051d6dc5 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -22,6 +22,8 @@ pyqtSignal, ) +from vorta.views.utils import compare_values_with_sign + #: A representation of a path Path = Tuple[str, ...] PathLike = Union[Path, Sequence[str]] @@ -899,9 +901,9 @@ 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) @@ -910,9 +912,49 @@ class FileTreeSortProxyModel(QSortFilterProxyModel): 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): + """ + 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.") + + @staticmethod + def valid_size(value): + size = value.split(',') + + if len(size) == 1: + return [FileTreeSortFilterProxyModel.parse_size(size[0])] + elif len(size) == 2: + return [FileTreeSortFilterProxyModel.parse_size(size[0]), FileTreeSortFilterProxyModel.parse_size(size[1])] + else: + raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.") + @overload def keepFoldersOnTop(self) -> bool: ... @@ -968,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: @@ -1004,122 +1046,46 @@ def setSearchString(self, pattern: str): """ Set the pattern to filter for. """ - self.searchStringError.emit(False) - if not pattern: - self.searchPattern = None + try: + self.searchPattern = self.parse_search_string(pattern) + except SystemExit: + self.searchStringError.emit(True) + return None - self.searchPattern = self.parse_search_string(pattern) + self.searchStringError.emit(False) self.invalidateRowsFilter() - def parse_search_string(self, pattern: str): + def get_parser(self): """ - Parse the search string into a list of tokens. + Creates and returns the parser for the search string. """ - - def parse_size(size): - """ - 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.") - - def parse_date(value): - """ - 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) - - def valid_size(value): - size = value.split(',') - - if len(size) == 1: - return [parse_size(size[0])] - elif len(size) == 2: - return [parse_size(size[0]), parse_size(size[1])] - else: - raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.") - - def valid_date_range(value): - date = value.split(',') - - if len(date) == 1: - return [parse_date(date[0])] - elif len(date) == 2: - return [parse_date(date[0]), parse_date(date[1])] - else: - raise argparse.ArgumentTypeError("Invalid date format. Can only accept two values.") - 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." - ) # TODO: Type "regex" + ) 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("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.") - parser.add_argument("-s", "--size", type=valid_size, help="Match by size.") + 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.") - # Diff view only - parser.add_argument("-b", "--balance", type=valid_size, help="Match by balance size.") - - # Extract view only - parser.add_argument("--healthy", action="store_true", help="Match only healthy items.") - parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") - parser.add_argument("--last-modified", type=valid_date_range, help="Match by last modified date.") + return parser - # no parents allowed - parser.add_argument("--exclude-parents", action="store_true", help="Match only items without parents.") + def parse_search_string(self, pattern: str): + """ + Parse the search string into a list of tokens. + """ - try: - return parser.parse_args(pattern.split()) - except SystemExit: - self.searchStringError.emit(True) - return None + 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. """ - self.setRecursiveFilteringEnabled(True) - self.setAutoAcceptChildRows(True) - if not self.searchPattern: return True @@ -1128,7 +1094,7 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: self.searchStringError.emit(True) return False - model: FileTreeModel = self.sourceModel() + model = self.sourceModel() item = model.index(sourceRow, 0, sourceParent).internalPointer() item_path = path_to_str(item.path) @@ -1167,18 +1133,6 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: elif self.searchPattern.match == "fm" and not fnmatch(search_item, search_string): return False - def compare_values_with_sign(item_value, filter_value, 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 - if self.searchPattern.size: # Diff view has size column corresponding to the changed_size while # Extract view has size column corresponding to the size @@ -1191,47 +1145,139 @@ def compare_values_with_sign(item_value, filter_value, comparison_sign): if not compare_values_with_sign(item_size, filter_size[1], filter_size[0]): return False - if self.searchPattern.balance: - # Only available in Diff view - if hasattr(item.data, 'changed_size'): - item_balance = item.data.size + if self.searchPattern.change: + item_change = item.data.change_type.short() - for filter_balance in self.searchPattern.balance: - if not compare_values_with_sign(item_balance, filter_balance[1], filter_balance[0]): - return False - else: - self.searchStringError.emit(True) + if item_change != self.searchPattern.change: + return False + + return True - if self.searchPattern.healthy: - if hasattr(item.data, 'health'): - if not item.data.health: - return False - else: - self.searchStringError.emit(True) - if self.searchPattern.unhealthy: - if hasattr(item.data, 'health'): - if item.data.health: +class DiffTreeSortFilterProxyModel(FileTreeSortFilterProxyModel): + """ + Tree model for diff view. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + def get_parser(self): + parser = super().get_parser() + parser.add_argument( + "-b", "--balance", type=FileTreeSortFilterProxyModel.valid_size, help="Match by balance size." + ) + + 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 - else: - self.searchStringError.emit(True) - if self.searchPattern.last_modified: - if hasattr(item.data, '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 - else: - self.searchStringError.emit(True) + return True - if self.searchPattern.change: - item_change = item.data.change_type.short() - if item_change != self.searchPattern.change: - return False +class ExtractTreeSortFilterProxyModel(FileTreeSortFilterProxyModel): + """ + Tree model for diff view. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + @staticmethod + def parse_date(value): + """ + 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) + + @staticmethod + def valid_date_range(value): + date = value.split(',') + + if len(date) == 1: + return [ExtractTreeSortFilterProxyModel.parse_date(date[0])] + elif len(date) == 2: + return [ + ExtractTreeSortFilterProxyModel.parse_date(date[0]), + ExtractTreeSortFilterProxyModel.parse_date(date[1]), + ] + else: + raise argparse.ArgumentTypeError("Invalid date format. Can only accept two values.") + + def get_parser(self): + parser = super().get_parser() + parser.add_argument("--healthy", action="store_true", help="Match only healthy items.") + parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") + parser.add_argument( + "--last-modified", + type=ExtractTreeSortFilterProxyModel.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 and not item.data.health: + return False + + if self.searchPattern.unhealthy and item.data.health: + return False + + 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 diff --git a/src/vorta/views/utils.py b/src/vorta/views/utils.py index 870a8ccb7..320f25e63 100644 --- a/src/vorta/views/utils.py +++ b/src/vorta/views/utils.py @@ -15,3 +15,16 @@ def get_colored_icon(icon_name): svg_img = QImage.fromData(svg_str).scaledToHeight(128) return QIcon(QPixmap(svg_img)) + + +def compare_values_with_sign(item_value, filter_value, 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 From 547d3b494d6cec536a42864afc32d31fef97cea0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 17:52:58 -0400 Subject: [PATCH 15/27] Fixed Tooltip border on error Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/file_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py index 38e9c690f..05a1dbf83 100644 --- a/src/vorta/views/partials/file_dialog.py +++ b/src/vorta/views/partials/file_dialog.py @@ -142,4 +142,4 @@ def submitSearchPattern(self): self.sortproxy.setSearchString(self.searchWidget.text()) def searchStringError(self, error: bool): - self.searchWidget.setStyleSheet("border: 2px solid red;" if error else "") + self.searchWidget.setStyleSheet("QLineEdit { border: 2px solid red; }" if error else "") From 57309cc64bf24cb5cf39640ea30cecc889b1af4e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 18:06:12 -0400 Subject: [PATCH 16/27] Added docstrings description to BaseFileDialog Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/file_dialog.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py index 05a1dbf83..05cef229c 100644 --- a/src/vorta/views/partials/file_dialog.py +++ b/src/vorta/views/partials/file_dialog.py @@ -15,9 +15,19 @@ 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) @@ -70,14 +80,12 @@ def __init__(self, model): @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): - pass - - @abstractmethod - def set_archive_names(self): + """Return the display mode for the diff result.""" pass def copy_item(self, index: QModelIndex = None): @@ -133,13 +141,16 @@ def slot_sorted(self, column, order): 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 "") From 1b0f0b6b5f7078c922b38a20f34194cccc268532 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 18:08:00 -0400 Subject: [PATCH 17/27] Added docstring for test Signed-off-by: Chirag Aggarwal --- tests/unit/test_archives.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index bae499357..0e7167ba0 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -227,6 +227,10 @@ def test_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): ], ) def test_archive_extract_filters(qtbot, mocker, borg_json_output, archive_env, search_string, expected_search_results): + """ + Tests the supported search filters for the extract window. + """ + vorta.utils.borg_compat.version = '1.2.4' _, tab = archive_env From a56af3ab8279beeb0abd70f076f31651c1ede6fd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 18:19:23 -0400 Subject: [PATCH 18/27] Fixed pytest exception with scheduler Signed-off-by: Chirag Aggarwal --- tests/unit/conftest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 7da18b0f6..0f1015465 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -64,15 +64,16 @@ def init_db(qapp, qtbot, tmpdir_factory): source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True) source_dir.save() - # qapp.main_window.deleteLater() - # del qapp.main_window + qapp.main_window.deleteLater() + 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() From 0706d878216e1246a9f4acd0bc98ae70fe368599 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 18:32:25 -0400 Subject: [PATCH 19/27] Fix diff test Signed-off-by: Chirag Aggarwal --- tests/unit/test_diff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 91c1e1cd4..8dbf98ab6 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -447,7 +447,7 @@ def check(feature_name): # 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" @@ -456,7 +456,7 @@ def check(feature_name): # test 'diff_item_copy()' by selecting a row to copy 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" From cd77419a17d63b02210876c432ffd0a8d6fdb44c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 21 Aug 2023 18:42:18 -0400 Subject: [PATCH 20/27] Added missing docstring in treemodel Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index a051d6dc5..10d42a410 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -946,6 +946,7 @@ def parse_size(size): @staticmethod def valid_size(value): + """Validate the size string.""" size = value.split(',') if len(size) == 1: @@ -1163,6 +1164,7 @@ 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." @@ -1229,6 +1231,7 @@ def parse_date(value): @staticmethod def valid_date_range(value): + """Parse the date range string.""" date = value.split(',') if len(date) == 1: @@ -1242,6 +1245,7 @@ def valid_date_range(value): 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() parser.add_argument("--healthy", action="store_true", help="Match only healthy items.") parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") From 7ebc36ee733934a38f7ab6405f3287d12f3908ee Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 Sep 2023 13:08:36 -0400 Subject: [PATCH 21/27] Common dest for healthy and unhealthy search syntax Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 41 ++++++++++++++++----------- src/vorta/views/utils.py | 5 +++- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 10d42a410..20f3a5926 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -920,7 +920,7 @@ def __init__(self, parent=None) -> None: self.searchPattern = None @staticmethod - def parse_size(size): + def parse_size(size: str) -> Tuple[str, int]: """ Parse the size string into a tuple of two values. """ @@ -944,15 +944,15 @@ def parse_size(size): except ValueError: raise argparse.ArgumentTypeError("Invalid size format. Must be a number.") - @staticmethod - def valid_size(value): + @classmethod + def valid_size(cls, value: str) -> List[Tuple[str, int]]: """Validate the size string.""" size = value.split(',') if len(size) == 1: - return [FileTreeSortFilterProxyModel.parse_size(size[0])] + return [cls.parse_size(size[0])] elif len(size) == 2: - return [FileTreeSortFilterProxyModel.parse_size(size[0]), FileTreeSortFilterProxyModel.parse_size(size[1])] + return [cls.parse_size(size[0]), cls.parse_size(size[1])] else: raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.") @@ -1077,6 +1077,8 @@ def get_parser(self): def parse_search_string(self, pattern: str): """ Parse the search string into a list of tokens. + + May raise `SystemExit`. """ parser = self.get_parser() @@ -1205,7 +1207,7 @@ def __init__(self, parent=None) -> None: super().__init__(parent) @staticmethod - def parse_date(value): + def parse_date(value: str) -> Tuple[str, datetime]: """ Parse the date string into a tuple of two values. """ @@ -1229,17 +1231,17 @@ def parse_date(value): return (sign, date) - @staticmethod - def valid_date_range(value): + @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 [ExtractTreeSortFilterProxyModel.parse_date(date[0])] + return [cls.parse_date(date[0])] elif len(date) == 2: return [ - ExtractTreeSortFilterProxyModel.parse_date(date[0]), - ExtractTreeSortFilterProxyModel.parse_date(date[1]), + cls.parse_date(date[0]), + cls.parse_date(date[1]), ] else: raise argparse.ArgumentTypeError("Invalid date format. Can only accept two values.") @@ -1247,11 +1249,18 @@ def valid_date_range(value): def get_parser(self): """Add Extract view specific arguments to the parser.""" parser = super().get_parser() - parser.add_argument("--healthy", action="store_true", help="Match only healthy items.") - parser.add_argument("--unhealthy", action="store_true", help="Match only unhealthy items.") + + 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=ExtractTreeSortFilterProxyModel.valid_date_range, + type=self.valid_date_range, help="Match by last modified date.", ) @@ -1271,10 +1280,10 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: model = self.sourceModel() item = model.index(sourceRow, 0, sourceParent).internalPointer() - if self.searchPattern.healthy and not item.data.health: + if self.searchPattern.healthy is True and not item.data.health: return False - if self.searchPattern.unhealthy and item.data.health: + if self.searchPattern.healthy is False and item.data.health: return False if self.searchPattern.last_modified: diff --git a/src/vorta/views/utils.py b/src/vorta/views/utils.py index 320f25e63..ff312479a 100644 --- a/src/vorta/views/utils.py +++ b/src/vorta/views/utils.py @@ -17,7 +17,10 @@ def get_colored_icon(icon_name): return QIcon(QPixmap(svg_img)) -def compare_values_with_sign(item_value, filter_value, comparison_sign): +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 == '>': From e2912f52ff17785cdc38b17231cff4728f799418 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 25 Sep 2023 17:03:33 -0400 Subject: [PATCH 22/27] Added tests for both extract and diff filtering Signed-off-by: Chirag Aggarwal --- ....json => diff_archives_search_stderr.json} | 0 .../diff_archives_search_stdout.json | 5 ++ .../diff_extract_archives_search_stdout.json | 8 -- tests/unit/conftest.py | 24 +++++ tests/unit/test_archives.py | 80 ----------------- tests/unit/test_diff.py | 87 ++++++++++++++++++- tests/unit/test_extract.py | 74 ++++++++++++++++ 7 files changed, 189 insertions(+), 89 deletions(-) rename tests/unit/borg_json_output/{diff_extract_archives_search_stderr.json => diff_archives_search_stderr.json} (100%) create mode 100644 tests/unit/borg_json_output/diff_archives_search_stdout.json delete mode 100644 tests/unit/borg_json_output/diff_extract_archives_search_stdout.json diff --git a/tests/unit/borg_json_output/diff_extract_archives_search_stderr.json b/tests/unit/borg_json_output/diff_archives_search_stderr.json similarity index 100% rename from tests/unit/borg_json_output/diff_extract_archives_search_stderr.json rename to tests/unit/borg_json_output/diff_archives_search_stderr.json 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/diff_extract_archives_search_stdout.json b/tests/unit/borg_json_output/diff_extract_archives_search_stdout.json deleted file mode 100644 index ac3fdad60..000000000 --- a/tests/unit/borg_json_output/diff_extract_archives_search_stdout.json +++ /dev/null @@ -1,8 +0,0 @@ -added directory Users/manu/Downloads -added 122 B Users/manu/Downloads/.transifexrc -added 9.22 kB Users/manu/Downloads/12042021 ORDER COD NUMBER.doc -added 1000 kB Users/manu/Documents/abigfile.pdf -removed directory Users/manu/Downloads/test-diff/bar -removed 0 B Users/manu/Downloads/test-diff/bar/2.txt -removed directory Users/manu/Downloads/test-diff/foo -removed 0 B Users/manu/Downloads/test-diff/foo/1.txt diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 0f1015465..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, @@ -119,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_archives.py b/tests/unit/test_archives.py index 0e7167ba0..c17bf6249 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -196,83 +196,3 @@ def test_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): # Successful rename case qtbot.waitUntil(lambda: tab.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) - - -@pytest.mark.parametrize( - 'search_string,expected_search_results', - [ - # Normal "in" search - ('txt', ['hello.txt', 'file1.txt', 'file.txt']), - # Ignore Case - ('HELLO.txt -i', ['hello.txt']), - ('HELLO.txt', []), - # Health match - ('--unhealthy', ['abigfile.pdf']), - ('--healthy', ['hello.txt', 'file1.txt', 'file.txt', 'abigfile.pdf']), - # Size Match - ('--size >=15MB', []), - ('--size >9MB,<11MB', ['abigfile.pdf']), - ('--size >1KB,<4KB --exclude-parents', ['hello.txt']), - # Path Match Type - ('home/kali/vorta/source1/hello.txt --path', ['hello.txt']), - ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt', 'file.txt']), - # Regex Match Type - ("file[^/]*\\.txt|\\.pdf -m re", ['file1.txt', 'file.txt', 'abigfile.pdf']), - # Exact Match Type - ('hello', ['hello.txt']), - ('hello -m ex', []), - # Date Filter - ('--last-modified >2025-01-01', ['file.txt']), - ('--last-modified <2025-01-01 --exclude-parents', ['hello.txt', 'file1.txt', 'abigfile.pdf']), - ], -) -def test_archive_extract_filters(qtbot, mocker, borg_json_output, archive_env, search_string, expected_search_results): - """ - 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 diff button - qtbot.mouseClick(tab.bExtract, QtCore.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(QtCore.QModelIndex()) > 0, **pytest._wait_defaults) - - tab._window.searchWidget.setText(search_string) - qtbot.mouseClick(tab._window.bSearch, QtCore.Qt.MouseButton.LeftButton) - - qtbot.waitUntil( - lambda: (tab._window.treeView.model().rowCount(QtCore.QModelIndex()) > 0) - or (len(expected_search_results) == 0), - **pytest._wait_defaults, - ) - - proxy_model = tab._window.treeView.model() - - 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, QtCore.Qt.ItemDataRole.DisplayRole) is not None: - if model.rowCount(index) == 0: - filtered_items.append(model.data(index, QtCore.Qt.ItemDataRole.DisplayRole)) - recursive_search_visible_items_in_tree(model, index) - - recursive_search_visible_items_in_tree(proxy_model, QtCore.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 - vorta.utils.borg_compat.version = '1.1.0' diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 8dbf98ab6..08932ba06 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 vorta.views.diff_result import ( ChangeType, DiffData, @@ -460,3 +460,88 @@ def check(feature_name): clipboard_data = clipboard_spy.call_args[0][0] assert clipboard_data.hasText() assert clipboard_data.text() == "/test" + + +@pytest.mark.parametrize( + 'search_string,expected_search_results', + [ + # Normal "in" search + ('txt', ['hello.txt', 'file1.txt']), + # Ignore Case + ('HELLO.txt -i', ['hello.txt']), + ('HELLO.txt', []), + # Size Match + ('--size >=15MB', []), + ('--size >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), + ('--size >1KB,<1MB --exclude-parents', ['hello.txt']), + # Path Match Type + ('home/kali/vorta/source1/hello.txt --path', ['hello.txt']), + ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt']), + ('home/kali/vorta/source1/*.bin --path -m fm', ['notemptyfile.bin', 'emptyfile.bin']), + # Regex Match Type + ("file[^/]*\\.txt|\\.bin -m re", ['file1.txt', 'notemptyfile.bin', 'emptyfile.bin']), + # Exact Match Type + ('hello', ['hello.txt']), + ('hello -m ex', []), + # Diff Specific Filters # + # Balance Match + ('--balance >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), + ('--balance >1KB,<1MB --exclude-parents', ['hello.txt']), + ('--balance >10GB', []), + ], +) +def test_archive_diff_filters( + qtbot, mocker, borg_json_output, search_visible_items_in_tree, archive_env, search_string, expected_search_results +): + """ + Tests the supported search filters for the extract 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) + + # qtbot.wait(100000) + 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) + + # qtbot.wait(1000000) + + # 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 + vorta.utils.borg_compat.version = '1.1.0' diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index f4fa2fdf2..61ba808e7 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -1,3 +1,4 @@ +import pytest import vorta.borg from PyQt6.QtCore import QModelIndex, Qt from vorta.views.extract_dialog import ExtractTree, FileData, FileType, parse_json_lines @@ -22,6 +23,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, } @@ -177,3 +179,75 @@ def test_selection(): select(model, iab) assert a.data.checkstate == Qt.CheckState(1) + + +@pytest.mark.parametrize( + 'search_string,expected_search_results', + [ + # Normal "in" search + ('txt', ['hello.txt', 'file1.txt', 'file.txt']), + # Ignore Case + ('HELLO.txt -i', ['hello.txt']), + ('HELLO.txt', []), + # Size Match + ('--size >=15MB', []), + ('--size >9MB,<11MB', ['abigfile.pdf']), + ('--size >1KB,<4KB --exclude-parents', ['hello.txt']), + # Path Match Type + ('home/kali/vorta/source1/hello.txt --path', ['hello.txt']), + ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt', 'file.txt']), + # Regex Match Type + ("file[^/]*\\.txt|\\.pdf -m re", ['file1.txt', 'file.txt', 'abigfile.pdf']), + # Exact Match Type + ('hello', ['hello.txt']), + ('hello -m ex', []), + # Extract Specific Filters # + # Date Filter + ('--last-modified >2025-01-01', ['file.txt']), + ('--last-modified <2025-01-01 --exclude-parents', ['hello.txt', 'file1.txt', 'abigfile.pdf']), + # Health match + ('--unhealthy', ['abigfile.pdf']), + ('--healthy', ['hello.txt', 'file1.txt', 'file.txt', 'abigfile.pdf']), + ], +) +def test_archive_extract_filters( + qtbot, mocker, borg_json_output, search_visible_items_in_tree, archive_env, search_string, expected_search_results +): + """ + 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 + vorta.utils.borg_compat.version = '1.1.0' From 9e43cca510937c609d7560119e624684283bee4f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 28 Oct 2023 17:19:13 -0400 Subject: [PATCH 23/27] Fixed test; Added help icon linking to doc Signed-off-by: Chirag Aggarwal --- src/vorta/assets/UI/diffresult.ui | 11 ++ src/vorta/assets/UI/extractdialog.ui | 11 ++ src/vorta/views/diff_result.py | 45 ++++++++- src/vorta/views/extract_dialog.py | 105 ++++++++++++++++++- src/vorta/views/partials/treemodel.py | 140 -------------------------- tests/unit/test_diff.py | 4 +- 6 files changed, 167 insertions(+), 149 deletions(-) diff --git a/src/vorta/assets/UI/diffresult.ui b/src/vorta/assets/UI/diffresult.ui index 91c933b4b..97fb25e39 100644 --- a/src/vorta/assets/UI/diffresult.ui +++ b/src/vorta/assets/UI/diffresult.ui @@ -148,6 +148,17 @@ + + + + Qt::NoFocus + + + Help + + + + diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui index 82344e916..7f7e8e452 100644 --- a/src/vorta/assets/UI/extractdialog.ui +++ b/src/vorta/assets/UI/extractdialog.ui @@ -139,6 +139,17 @@ + + + + Qt::NoFocus + + + Help + + + + diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index cd7df5521..9f5579e4a 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 @@ -21,13 +22,13 @@ 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 ( - DiffTreeSortFilterProxyModel, FileSystemItem, FileTreeModel, + 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) @@ -78,6 +79,8 @@ def __init__(self, archive_newer, archive_older, model): self.archiveNameLabel_1.setText(f'{archive_newer.name}') self.archiveNameLabel_2.setText(f'{archive_older.name}') + self.bHelp.clicked.connect(lambda: webbrowser.open('https://vorta.borgbase.com/usage/search/')) + def get_sort_proxy_model(self): """Return the sort proxy model for the tree view.""" return DiffSortProxyModel(self) @@ -90,6 +93,7 @@ def set_icons(self): 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")) @@ -393,11 +397,46 @@ def size_to_byte(significand: str, unit: str) -> int: # ---- Sorting --------------------------------------------------------------- -class DiffSortProxyModel(DiffTreeSortFilterProxyModel): +class DiffSortProxyModel(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." + ) + + 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 + + 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 4fcee9df2..ab94d47e2 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -1,10 +1,12 @@ +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 ( @@ -24,12 +26,12 @@ from vorta.store.models import SettingsModel from vorta.utils import borg_compat, get_asset, pretty_bytes, uses_dark_mode from vorta.views.partials.file_dialog import BaseFileDialog -from vorta.views.utils import get_colored_icon +from vorta.views.utils import compare_values_with_sign, get_colored_icon from .partials.treemodel import ( - ExtractTreeSortFilterProxyModel, FileSystemItem, FileTreeModel, + FileTreeSortFilterProxyModel, path_to_str, relative_path, ) @@ -89,6 +91,8 @@ def __init__(self, archive, model): self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.close) + 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 ExtractSortProxyModel() @@ -110,6 +114,7 @@ def set_icons(self): 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")) @@ -171,11 +176,103 @@ def parse_json_lines(lines, model: "ExtractTree"): # ---- Sorting --------------------------------------------------------------- -class ExtractSortProxyModel(ExtractTreeSortFilterProxyModel): +class ExtractSortProxyModel(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 True and not item.data.health: + return False + + if self.searchPattern.healthy is False and item.data.health: + return False + + 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/treemodel.py b/src/vorta/views/partials/treemodel.py index 20f3a5926..87fa92ea8 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -7,7 +7,6 @@ import enum import os.path as osp import re -from datetime import datetime from fnmatch import fnmatch from functools import reduce from pathlib import PurePath @@ -1155,142 +1154,3 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: return False return True - - -class DiffTreeSortFilterProxyModel(FileTreeSortFilterProxyModel): - """ - Tree model for diff view. - """ - - 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." - ) - - 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 - - return True - - -class ExtractTreeSortFilterProxyModel(FileTreeSortFilterProxyModel): - """ - Tree model for diff view. - """ - - 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 True and not item.data.health: - return False - - if self.searchPattern.healthy is False and item.data.health: - return False - - 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 diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index ace470ed6..eff538a42 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -72,7 +72,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" @@ -83,7 +83,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" From 4f46c4a334fda1be2bb913eec7f8597e4e2c4cf5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Feb 2024 09:31:16 -0500 Subject: [PATCH 24/27] Minor refactoring Signed-off-by: Chirag Aggarwal --- src/vorta/views/diff_result.py | 11 +++++++++-- src/vorta/views/extract_dialog.py | 12 ++++-------- src/vorta/views/partials/treemodel.py | 9 --------- tests/unit/test_diff.py | 9 +++++---- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 2d60e3a87..97ace6348 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -83,7 +83,7 @@ def __init__(self, archive_newer, archive_older, model): def get_sort_proxy_model(self): """Return the sort proxy model for the tree view.""" - return DiffSortProxyModel(self) + return DiffSortFilterProxyModel(self) def get_diff_result_display_mode(self): return SettingsModel.get(key='diff_files_display_mode').str_value @@ -396,7 +396,7 @@ def size_to_byte(significand: str, unit: str) -> int: # ---- Sorting --------------------------------------------------------------- -class DiffSortProxyModel(FileTreeSortFilterProxyModel): +class DiffSortFilterProxyModel(FileTreeSortFilterProxyModel): """ Sort a DiffTree model. """ @@ -410,6 +410,7 @@ def get_parser(self): 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 @@ -434,6 +435,12 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: 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): diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index ab94d47e2..d9e2113c1 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -70,7 +70,6 @@ class ExtractDialog(BaseFileDialog, ExtractDialogBase, ExtractDialogUI): def __init__(self, archive, model): """Init.""" super().__init__(model) - # TODO: disable self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # header header = self.treeView.header() @@ -95,7 +94,7 @@ def __init__(self, archive, model): def get_sort_proxy_model(self): """Get the sort proxy model for this dialog.""" - return ExtractSortProxyModel() + return ExtractSortFilterProxyModel() def get_diff_result_display_mode(self): """Get the display mode for this dialog.""" @@ -176,7 +175,7 @@ def parse_json_lines(lines, model: "ExtractTree"): # ---- Sorting --------------------------------------------------------------- -class ExtractSortProxyModel(FileTreeSortFilterProxyModel): +class ExtractSortFilterProxyModel(FileTreeSortFilterProxyModel): """ Sort a ExtractTree model. """ @@ -258,11 +257,8 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: model = self.sourceModel() item = model.index(sourceRow, 0, sourceParent).internalPointer() - if self.searchPattern.healthy is True and not item.data.health: - return False - - if self.searchPattern.healthy is False and item.data.health: - return False + 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 diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index b98f5331d..3e979d5ca 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -1067,7 +1067,6 @@ def get_parser(self): ) 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("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.") 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.") @@ -1117,8 +1116,6 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: search_item = item_name if self.searchPattern.search_string: - # TODO: Move operations on search string to setSearchString method - search_string = " ".join(self.searchPattern.search_string) # Ignore Case? @@ -1147,10 +1144,4 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: if not compare_values_with_sign(item_size, filter_size[1], filter_size[0]): return False - if self.searchPattern.change: - item_change = item.data.change_type.short() - - if item_change != self.searchPattern.change: - return False - return True diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index eff538a42..04764f696 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -485,13 +485,17 @@ def test_archive_diff_json_parser(line, expected): ('--balance >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), ('--balance >1KB,<1MB --exclude-parents', ['hello.txt']), ('--balance >10GB', []), + # Change Type + ('--change A', ['notemptyfile.bin', 'emptyfile.bin']), + ('--change D', ['file1.txt']), + ('--change M', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), ], ) def test_archive_diff_filters( qtbot, mocker, borg_json_output, search_visible_items_in_tree, archive_env, search_string, expected_search_results ): """ - Tests the supported search filters for the extract window. + Tests the supported search filters for the diff window. """ vorta.utils.borg_compat.version = '1.2.4' @@ -510,7 +514,6 @@ def test_archive_diff_filters( selection_model.select(model.index(0, 0), flags) selection_model.select(model.index(1, 0), flags) - # qtbot.wait(100000) 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) @@ -518,8 +521,6 @@ def test_archive_diff_filters( # click on diff button qtbot.mouseClick(tab.bDiff, Qt.MouseButton.LeftButton) - # qtbot.wait(1000000) - # 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) From a6a0707547869248cfbe7536bb4235a7f8adc24f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 8 Apr 2024 13:03:04 -0400 Subject: [PATCH 25/27] Remove condition to force --path with -m fm Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 3e979d5ca..98ddb944d 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -1090,11 +1090,6 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: if not self.searchPattern: return True - # Match type "fm" is only available with path - if self.searchPattern.match == "fm" and not self.searchPattern.path: - self.searchStringError.emit(True) - return False - model = self.sourceModel() item = model.index(sourceRow, 0, sourceParent).internalPointer() From ab394673c8277483417a0a36abdb5be1a51d756d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 8 Apr 2024 13:04:54 -0400 Subject: [PATCH 26/27] Check for invalid regex pattern Signed-off-by: Chirag Aggarwal --- src/vorta/views/partials/treemodel.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index 98ddb944d..7be5da84d 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -1122,8 +1122,13 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: return False elif self.searchPattern.match == "ex" and search_string != search_item: return False - elif self.searchPattern.match == "re" and not re.search(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 From e53546affa901320c5eeb83d53314827ae34e15c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 8 Apr 2024 13:14:32 -0400 Subject: [PATCH 27/27] Test now check whether test syntax emitted error or not Signed-off-by: Chirag Aggarwal --- tests/unit/test_diff.py | 55 ++++++++++++++++++++++++-------------- tests/unit/test_extract.py | 49 +++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index c94e0d9ad..bcd846537 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -479,39 +479,47 @@ def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAl @pytest.mark.parametrize( - 'search_string,expected_search_results', + 'search_string,expected_search_results,emit_error', [ # Normal "in" search - ('txt', ['hello.txt', 'file1.txt']), + ('txt', ['hello.txt', 'file1.txt'], False), # Ignore Case - ('HELLO.txt -i', ['hello.txt']), - ('HELLO.txt', []), + ('HELLO.txt -i', ['hello.txt'], False), + ('HELLO.txt', [], False), # Size Match - ('--size >=15MB', []), - ('--size >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), - ('--size >1KB,<1MB --exclude-parents', ['hello.txt']), + ('--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']), - ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt']), - ('home/kali/vorta/source1/*.bin --path -m fm', ['notemptyfile.bin', 'emptyfile.bin']), + ('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']), + ("file[^/]*\\.txt|\\.bin -m re", ['file1.txt', 'notemptyfile.bin', 'emptyfile.bin'], False), + ("[ -m re", [], True), # Exact Match Type - ('hello', ['hello.txt']), - ('hello -m ex', []), + ('hello', ['hello.txt'], False), + ('hello -m ex', [], False), # Diff Specific Filters # # Balance Match - ('--balance >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), - ('--balance >1KB,<1MB --exclude-parents', ['hello.txt']), - ('--balance >10GB', []), + ('--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']), - ('--change D', ['file1.txt']), - ('--change M', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin']), + ('--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 + 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. @@ -561,4 +569,11 @@ def test_archive_diff_filters( 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 cfe684d43..7f8442947 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -201,36 +201,44 @@ def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAl @pytest.mark.parametrize( - 'search_string,expected_search_results', + 'search_string,expected_search_results,emit_error', [ # Normal "in" search - ('txt', ['hello.txt', 'file1.txt', 'file.txt']), + ('txt', ['hello.txt', 'file1.txt', 'file.txt'], False), # Ignore Case - ('HELLO.txt -i', ['hello.txt']), - ('HELLO.txt', []), + ('HELLO.txt -i', ['hello.txt'], False), + ('HELLO.txt', [], False), # Size Match - ('--size >=15MB', []), - ('--size >9MB,<11MB', ['abigfile.pdf']), - ('--size >1KB,<4KB --exclude-parents', ['hello.txt']), + ('--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']), - ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt', 'file.txt']), + ('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']), + ("file[^/]*\\.txt|\\.pdf -m re", ['file1.txt', 'file.txt', 'abigfile.pdf'], False), + ("[ -m re", [], True), # Exact Match Type - ('hello', ['hello.txt']), - ('hello -m ex', []), + ('hello', ['hello.txt'], False), + ('hello -m ex', [], False), # Extract Specific Filters # # Date Filter - ('--last-modified >2025-01-01', ['file.txt']), - ('--last-modified <2025-01-01 --exclude-parents', ['hello.txt', 'file1.txt', 'abigfile.pdf']), + ('--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']), - ('--healthy', ['hello.txt', 'file1.txt', 'file.txt', 'abigfile.pdf']), + ('--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 + 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. @@ -269,4 +277,11 @@ def test_archive_extract_filters( 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'