mirror of https://github.com/borgbase/vorta
Use new treemodel for extract dialog.
* src/vorta/views/extract_dialog.py: Add `ParseThread`, `ExtractTree` and `parse_json_lines`. * src/vorta/views/extract_dialog.py (ExtractTree.__init__): Adjust signature. * src/vorta/borg/list_archive.py (BorgListArchiveJob.prepare): Adjust format to include additional attributes. * src/vorta/views/archive_tab.py (ArchiveTab): Parse extract data using `ParseThread` and open `ExtractDialog` afterwards. * tests/test_archives.py (test_archive_extract): Update tests. * src/vorta/borg/extract.py (BorgExtractJob.prepare): Adjust signature. Handling of the data isn't implemented yet. * tests/test_extract.py
This commit is contained in:
parent
71aed9ba0e
commit
71af54f59b
|
@ -55,7 +55,11 @@
|
|||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTreeView" name="treeView"/>
|
||||
<widget class="QTreeView" name="treeView">
|
||||
<attribute name="headerMinimumSectionSize">
|
||||
<number>10</number>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from vorta.views.extract_dialog import ExtractTree
|
||||
|
||||
from .borg_job import BorgJob
|
||||
|
||||
|
||||
|
@ -13,7 +15,7 @@ class BorgExtractJob(BorgJob):
|
|||
self.app.backup_progress_event.emit(self.tr('Restored files from archive.'))
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, profile, archive_name, selected_files, destination_folder):
|
||||
def prepare(cls, profile, archive_name, selected_files: ExtractTree, destination_folder):
|
||||
ret = super().prepare(profile)
|
||||
if not ret['ok']:
|
||||
return ret
|
||||
|
@ -22,7 +24,8 @@ class BorgExtractJob(BorgJob):
|
|||
|
||||
cmd = ['borg', 'extract', '--list', '--info', '--log-json']
|
||||
cmd.append(f'{profile.repo.url}::{archive_name}')
|
||||
for s in selected_files:
|
||||
|
||||
for s in selected_files: # TODO
|
||||
cmd.append(s)
|
||||
|
||||
ret['ok'] = True
|
||||
|
|
|
@ -21,7 +21,9 @@ class BorgListArchiveJob(BorgJob):
|
|||
ret['archive_name'] = archive_name
|
||||
ret['cmd'] = [
|
||||
'borg', 'list', '--info', '--log-json', '--json-lines',
|
||||
'--format', "{size:8d}{TAB}{mtime}{TAB}{path}{NL}",
|
||||
'--format',
|
||||
# fields to include in json output
|
||||
"{mode}{user}{group}{size}{mtime}{path}{source}{health}{NL}",
|
||||
f'{profile.repo.url}::{archive_name}']
|
||||
ret['ok'] = True
|
||||
|
||||
|
|
|
@ -26,13 +26,12 @@ from vorta.borg.umount import BorgUmountJob
|
|||
from vorta.store.models import ArchiveModel, BackupProfileMixin
|
||||
from vorta.utils import (choose_file_dialog, format_archive_name, get_asset,
|
||||
get_mount_points, pretty_bytes)
|
||||
from vorta.views.diff_result import DiffResultDialog
|
||||
from vorta.views.extract_dialog import ExtractDialog
|
||||
from vorta.views import diff_result, extract_dialog
|
||||
from vorta.views.diff_result import DiffResultDialog, DiffTree
|
||||
from vorta.views.extract_dialog import ExtractDialog, ExtractTree
|
||||
from vorta.views.source_tab import SizeItem
|
||||
from vorta.views.utils import get_colored_icon
|
||||
|
||||
from .diff_result import DiffTree, ParseThread
|
||||
|
||||
uifile = get_asset('UI/archivetab.ui')
|
||||
ArchiveTabUI, ArchiveTabBase = uic.loadUiType(uifile)
|
||||
|
||||
|
@ -687,6 +686,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
profile.save()
|
||||
|
||||
def extract_action(self):
|
||||
"""
|
||||
Open a dialog for choosing what to extract from the selected archive.
|
||||
"""
|
||||
profile = self.profile()
|
||||
|
||||
row_selected = self.archiveTable.selectionModel().selectedRows()
|
||||
|
@ -711,35 +713,54 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
self._set_status(self.tr('Select an archive to restore first.'))
|
||||
|
||||
def extract_list_result(self, result):
|
||||
"""Process the contents of the archive to extract."""
|
||||
self._set_status('')
|
||||
if result['returncode'] == 0:
|
||||
def process_result():
|
||||
def receive():
|
||||
extraction_folder = dialog.selectedFiles()
|
||||
if extraction_folder:
|
||||
params = BorgExtractJob.prepare(
|
||||
self.profile(), archive.name, window.selected, extraction_folder[0])
|
||||
if params['ok']:
|
||||
self._toggle_all_buttons(False)
|
||||
job = BorgExtractJob(params['cmd'], params, self.profile().repo.id)
|
||||
job.updated.connect(self.mountErrors.setText)
|
||||
job.result.connect(self.extract_archive_result)
|
||||
self.app.jobs_manager.add_job(job)
|
||||
else:
|
||||
self._set_status(params['message'])
|
||||
|
||||
dialog = choose_file_dialog(self, self.tr("Choose Extraction Point"), want_folder=True)
|
||||
dialog.open(receive)
|
||||
|
||||
archive = ArchiveModel.get(name=result['params']['archive_name'])
|
||||
window = ExtractDialog(result['data'], archive)
|
||||
self._toggle_all_buttons(True)
|
||||
window.setParent(self, QtCore.Qt.Sheet)
|
||||
self._window = window # for testing
|
||||
window.show()
|
||||
window.accepted.connect(process_result)
|
||||
model = ExtractTree()
|
||||
self._set_status(self.tr("Processing archive contents"))
|
||||
self._t = extract_dialog.ParseThread(result['data'],
|
||||
model)
|
||||
self._t.finished.connect(lambda: self.extract_show_dialog(archive,
|
||||
model))
|
||||
self._t.start()
|
||||
|
||||
def extract_show_dialog(self, archive, model):
|
||||
"""Show the dialog for choosing the archive contents to extract."""
|
||||
self._set_status('')
|
||||
|
||||
def process_result():
|
||||
def receive():
|
||||
extraction_folder = dialog.selectedFiles()
|
||||
if extraction_folder:
|
||||
params = BorgExtractJob.prepare(self.profile(),
|
||||
archive.name,
|
||||
model,
|
||||
extraction_folder[0])
|
||||
if params['ok']:
|
||||
self._toggle_all_buttons(False)
|
||||
job = BorgExtractJob(params['cmd'], params,
|
||||
self.profile().repo.id)
|
||||
job.updated.connect(self.mountErrors.setText)
|
||||
job.result.connect(self.extract_archive_result)
|
||||
self.app.jobs_manager.add_job(job)
|
||||
else:
|
||||
self._set_status(params['message'])
|
||||
|
||||
dialog = choose_file_dialog(self,
|
||||
self.tr("Choose Extraction Point"),
|
||||
want_folder=True)
|
||||
dialog.open(receive)
|
||||
|
||||
window = ExtractDialog(archive, model)
|
||||
self._toggle_all_buttons(True)
|
||||
window.setParent(self, QtCore.Qt.Sheet)
|
||||
self._window = window # for testing
|
||||
window.show()
|
||||
window.accepted.connect(process_result)
|
||||
|
||||
def extract_archive_result(self, result):
|
||||
"""Finished extraction."""
|
||||
self._toggle_all_buttons(True)
|
||||
|
||||
def cell_double_clicked(self, row, column):
|
||||
|
@ -871,8 +892,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
|
||||
model = DiffTree()
|
||||
|
||||
self._t = ParseThread(result['data'], result['params']['json_lines'],
|
||||
model)
|
||||
self._t = diff_result.ParseThread(result['data'],
|
||||
result['params']['json_lines'],
|
||||
model)
|
||||
self._t.finished.connect(lambda: self.show_diff_result(
|
||||
archive_newer, archive_older, model))
|
||||
self._t.start()
|
||||
|
|
|
@ -62,7 +62,7 @@ class ParseThread(QThread):
|
|||
class DiffResultDialog(DiffResultBase, DiffResultUI):
|
||||
"""Display the results of `borg diff`."""
|
||||
|
||||
def __init__(self, archive_newer: str, archive_older: str,
|
||||
def __init__(self, archive_newer, archive_older,
|
||||
model: 'DiffTree'):
|
||||
"""Init."""
|
||||
super().__init__()
|
||||
|
|
|
@ -1,58 +1,69 @@
|
|||
import enum
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import PurePath
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtCore import QDateTime, QLocale, QModelIndex, Qt, QThread
|
||||
from PyQt5.QtGui import QColor
|
||||
from PyQt5.QtWidgets import QDialogButtonBox, QHeaderView, QPushButton
|
||||
|
||||
from vorta.utils import get_asset, get_dict_from_list, nested_dict
|
||||
from vorta.views.partials.tree_view import TreeModel
|
||||
from vorta.utils import get_asset, pretty_bytes, uses_dark_mode
|
||||
|
||||
from .partials.treemodel import (FileSystemItem, FileTreeModel, path_to_str,
|
||||
relative_path)
|
||||
|
||||
uifile = get_asset("UI/extractdialog.ui")
|
||||
ExtractDialogUI, ExtractDialogBase = uic.loadUiType(uifile)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseThread(QThread):
|
||||
"""A thread parsing diff results."""
|
||||
|
||||
def __init__(self, fs_data: str, model, parent=None):
|
||||
"""Init."""
|
||||
super().__init__(parent)
|
||||
self.model = model
|
||||
self.fs_data = fs_data
|
||||
|
||||
def run(self) -> None:
|
||||
"""Do the work"""
|
||||
# handle case of a single line of result, which will already be a dict
|
||||
if isinstance(self.fs_data, dict):
|
||||
lines = [self.fs_data]
|
||||
else:
|
||||
lines = [json.loads(line) for line in self.fs_data.split("\n") if line]
|
||||
|
||||
parse_json_lines(lines, self.model)
|
||||
|
||||
|
||||
class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
|
||||
def __init__(self, fs_data, archive):
|
||||
"""
|
||||
Show the contents of an archive and allow choosing what to extract.
|
||||
"""
|
||||
|
||||
def __init__(self, archive, model):
|
||||
"""Init."""
|
||||
super().__init__()
|
||||
self.setupUi(self)
|
||||
|
||||
nested_file_list = nested_dict()
|
||||
self.selected = set()
|
||||
|
||||
def parse_json_line(data):
|
||||
size = data["size"]
|
||||
# python >= 3.7
|
||||
# modified = datetime.fromisoformat(data["mtime"]).ctime()
|
||||
# python < 3.7
|
||||
try:
|
||||
modified = datetime.strptime(data["mtime"], "%Y-%m-%dT%H:%M:%S.%f").ctime()
|
||||
except ValueError:
|
||||
modified = datetime.strptime(data["mtime"], "%Y-%m-%dT%H:%M:%S").ctime()
|
||||
dirpath, name = os.path.split(data["path"])
|
||||
# add to nested dict of folders to find nested dirs.
|
||||
d = get_dict_from_list(nested_file_list, dirpath.split("/"))
|
||||
if name not in d:
|
||||
d[name] = {}
|
||||
return size, modified, name, dirpath, data["type"]
|
||||
|
||||
# handle case of a single line of result, which will already be a dict
|
||||
lines = [fs_data] if isinstance(fs_data, dict) else \
|
||||
[json.loads(line) for line in fs_data.split('\n') if line]
|
||||
|
||||
files_with_attributes = [parse_json_line(line) for line in lines]
|
||||
|
||||
model = ExtractTree(files_with_attributes, nested_file_list, self.selected)
|
||||
self.model = model
|
||||
self.model.setParent(self)
|
||||
|
||||
view = self.treeView
|
||||
view.setAlternatingRowColors(True)
|
||||
view.setUniformRowHeights(True) # Allows for scrolling optimizations.
|
||||
view.setModel(model)
|
||||
view.setModel(self.model)
|
||||
header = view.header()
|
||||
header.setStretchLastSection(False)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
|
||||
# add extract button to button box
|
||||
|
@ -60,10 +71,13 @@ class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
|
|||
self.extractButton.setObjectName("extractButton")
|
||||
self.extractButton.setText(self.tr("Extract"))
|
||||
|
||||
self.buttonBox.addButton(self.extractButton,
|
||||
QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
self.buttonBox.addButton(
|
||||
self.extractButton, QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
|
||||
self.archiveNameLabel.setText(f"{archive.name}, {archive.time}")
|
||||
|
||||
# connect signals
|
||||
self.buttonBox.rejected.connect(self.close)
|
||||
self.buttonBox.accepted.connect(self.accept)
|
||||
|
||||
|
@ -72,37 +86,462 @@ class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
|
|||
super().retranslateUi(dialog)
|
||||
|
||||
# setupUi calls retranslateUi
|
||||
if hasattr(self, 'extractButton'):
|
||||
if hasattr(self, "extractButton"):
|
||||
self.extractButton.setText(self.tr("Extract"))
|
||||
|
||||
|
||||
class ExtractTree(TreeModel):
|
||||
def __init__(
|
||||
self,
|
||||
files_with_attributes,
|
||||
nested_file_list,
|
||||
selected_files_folders,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(
|
||||
files_with_attributes, nested_file_list, selected_files_folders, parent
|
||||
def parse_json_lines(lines, model: "ExtractTree"):
|
||||
"""Parse json output of `borg list`."""
|
||||
for item in lines:
|
||||
path = PurePath(item["path"])
|
||||
|
||||
size = item["size"]
|
||||
mode = item["mode"]
|
||||
file_type = FileType(mode[0])
|
||||
user = item["user"]
|
||||
group = item["group"]
|
||||
health = item["healthy"]
|
||||
source_path = item["source"] if "source" in item else None
|
||||
|
||||
# For python >= 3.7 this would work
|
||||
# modified = datetime.fromisoformat(item["mtime"]).ctime()
|
||||
# for python == 3.6 this must do the job
|
||||
# try:
|
||||
# modified = datetime.strptime(item["mtime"], "%Y-%m-%dT%H:%M:%S.%f")
|
||||
# except ValueError:
|
||||
# modified = datetime.strptime(item["mtime"], "%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
modified = QDateTime.fromString(item["mtime"], Qt.DateFormat.ISODateWithMs)
|
||||
|
||||
model.addItem(
|
||||
(
|
||||
path,
|
||||
FileData(
|
||||
file_type, size, mode, user, group, health, modified, source_path
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def data(self, index, role):
|
||||
|
||||
class FileType(enum.Enum):
|
||||
"""File type of an item inside a borg archive."""
|
||||
|
||||
FILE = "-"
|
||||
DIRECTORY = "d"
|
||||
SYMBOLIC_LINK = "l"
|
||||
LINK = SYMBOLIC_LINK
|
||||
HARD_LINK = "h"
|
||||
FIFO = "p"
|
||||
SOCKET = "s"
|
||||
CHRDEV = "c"
|
||||
BLKDEV = "b"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileData:
|
||||
"""The data linked to a item inside a borg archive."""
|
||||
|
||||
file_type: FileType
|
||||
size: int
|
||||
mode: str
|
||||
user: str
|
||||
group: str
|
||||
health: bool
|
||||
last_modified: QDateTime
|
||||
source_path: Optional[str] = None # only relevant for links
|
||||
|
||||
checkstate: int = 0 # whether to extract the file (0, 1 or 2)
|
||||
checked_children: int = 0 # number of children checked
|
||||
|
||||
|
||||
class ExtractTree(FileTreeModel[FileData]):
|
||||
"""The file tree model for diff results."""
|
||||
|
||||
def _make_filesystemitem(self, path, data):
|
||||
return super()._make_filesystemitem(path, data)
|
||||
|
||||
def _merge_data(self, item, data):
|
||||
if data:
|
||||
logger.debug("Overriding data for {}".format(path_to_str(item.path)))
|
||||
return super()._merge_data(item, data)
|
||||
|
||||
def _flat_filter(self, item):
|
||||
"""
|
||||
Return whether an item is part of the flat model representation.
|
||||
|
||||
The item's data might have not been set yet.
|
||||
"""
|
||||
return item.data and not item.children
|
||||
|
||||
def _simplify_filter(self, item: FileSystemItem[FileData]) -> bool:
|
||||
"""
|
||||
Return whether an item may be merged in simplified mode.
|
||||
|
||||
Allows simplification for every item.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _process_child(self, child):
|
||||
"""
|
||||
Process a new child.
|
||||
|
||||
This can make some changes to the child's data like
|
||||
setting a default value if the child's data is None.
|
||||
This can also update the data of the parent.
|
||||
This must emit `dataChanged` if data is changed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : FileSystemItem
|
||||
The child that was added.
|
||||
"""
|
||||
parent = child._parent
|
||||
|
||||
if not child.data:
|
||||
child.data = FileData(
|
||||
FileType.DIRECTORY, 0, "", "", "", True, datetime.now()
|
||||
)
|
||||
|
||||
if child.data.size != 0:
|
||||
# update size
|
||||
size = child.data.size
|
||||
|
||||
def add_size(parent):
|
||||
if parent is self.root:
|
||||
return
|
||||
|
||||
if parent.data is None:
|
||||
raise Exception(
|
||||
"Item {} without data".format(path_to_str(parent.path))
|
||||
)
|
||||
else:
|
||||
parent.data.size += size
|
||||
|
||||
# update parent
|
||||
parent = parent._parent
|
||||
if parent:
|
||||
add_size(parent)
|
||||
|
||||
add_size(parent)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
"""
|
||||
Returns the number of columns for the children of the given parent.
|
||||
|
||||
This corresponds to the number of data (column) entries shown
|
||||
for each item in the tree view.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QModelIndex, optional
|
||||
The index of the parent, by default QModelIndex()
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of rows.
|
||||
"""
|
||||
# name, last modified, size, health
|
||||
return 4
|
||||
|
||||
def headerData(
|
||||
self,
|
||||
section: int,
|
||||
orientation: Qt.Orientation,
|
||||
role: int = Qt.ItemDataRole.DisplayRole,
|
||||
):
|
||||
"""
|
||||
Get the data for the given role and section in the given header.
|
||||
|
||||
The header is identified by its orientation.
|
||||
For horizontal headers, the section number corresponds to
|
||||
the column number. Similarly, for vertical headers,
|
||||
the section number corresponds to the row number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
section : int
|
||||
The row or column number.
|
||||
orientation : Qt.Orientation
|
||||
The orientation of the header.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------Improve
|
||||
Any
|
||||
The data for the specified header section.
|
||||
"""
|
||||
if (
|
||||
orientation == Qt.Orientation.Horizontal
|
||||
and role == Qt.ItemDataRole.DisplayRole
|
||||
):
|
||||
if section == 0:
|
||||
return self.tr("Name")
|
||||
elif section == 1:
|
||||
return self.tr("Last Modified")
|
||||
elif section == 2:
|
||||
return self.tr("Size")
|
||||
elif section == 3:
|
||||
return self.tr("Health")
|
||||
|
||||
return None
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
||||
"""
|
||||
Get the data for the given role and index.
|
||||
|
||||
The indexes internal pointer references the corresponding
|
||||
`FileSystemItem`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index of the item.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The data, return None if no data is available for the role.
|
||||
"""
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
item: FileSystemItem[FileData] = index.internalPointer()
|
||||
column = index.column()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if column == 0:
|
||||
# name
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
return path_to_str(item.path)
|
||||
|
||||
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
|
||||
parent = index.parent()
|
||||
if parent == QModelIndex():
|
||||
return path_to_str(relative_path(self.root.path, item.path))
|
||||
|
||||
return path_to_str(
|
||||
relative_path(parent.internalPointer().path, item.path)
|
||||
)
|
||||
|
||||
# standard tree mode
|
||||
return item.subpath
|
||||
elif column == 1:
|
||||
# last modified
|
||||
return QLocale.system().toString(
|
||||
item.data.last_modified, QLocale.FormatType.ShortFormat
|
||||
)
|
||||
elif column == 2:
|
||||
# size
|
||||
return pretty_bytes(item.data.size)
|
||||
else:
|
||||
# health
|
||||
return
|
||||
|
||||
if role == Qt.ItemDataRole.BackgroundRole and column == 3:
|
||||
# health indicator
|
||||
if item.data.health:
|
||||
return QColor(Qt.green) if uses_dark_mode() else QColor(Qt.darkGreen)
|
||||
else:
|
||||
return QColor(Qt.green) if uses_dark_mode() else QColor(Qt.darkGreen)
|
||||
|
||||
if role == Qt.ItemDataRole.ToolTipRole:
|
||||
if column == 0:
|
||||
# name column -> display fullpath
|
||||
return path_to_str(item.path)
|
||||
|
||||
# info/data tooltip -> no real size limitation
|
||||
tooltip_template = (
|
||||
"{name}\n"
|
||||
+ "\n"
|
||||
+ "{filetype}\n"
|
||||
+ "{permissions}\n"
|
||||
+ "{user} {group}\n"
|
||||
+ "Modified: {last_modified}\n"
|
||||
+ "Health: {health}\n"
|
||||
)
|
||||
|
||||
# format
|
||||
if item.data.file_type == FileType.FILE:
|
||||
filetype = self.tr("File")
|
||||
elif item.data.file_type == FileType.DIRECTORY:
|
||||
filetype = self.tr("Directory")
|
||||
elif item.data.file_type == FileType.LINK:
|
||||
filetype = self.tr("Symbolic link")
|
||||
elif item.data.file_type == FileType.FIFO:
|
||||
filetype = self.tr("FIFO pipe")
|
||||
elif item.data.file_type == FileType.HARD_LINK:
|
||||
filetype = self.tr("Hard link")
|
||||
elif item.data.file_type == FileType.SOCKET:
|
||||
filetype = self.tr("Socket")
|
||||
elif item.data.file_type == FileType.BLKDEV:
|
||||
filetype = self.tr("Block special file")
|
||||
elif item.data.file_type == FileType.CHRDEV:
|
||||
filetype = self.tr("Character special file")
|
||||
else:
|
||||
raise Exception("Unknown filetype {}".format(item.data.file_type))
|
||||
|
||||
modified = QLocale.system().toString(item.data.last_modified)
|
||||
|
||||
if item.data.health:
|
||||
health = self.tr("healthy")
|
||||
else:
|
||||
health = self.tr("broken")
|
||||
|
||||
tooltip = tooltip_template.format(
|
||||
name=item.path[-1],
|
||||
filetype=filetype,
|
||||
permissions=item.data.mode,
|
||||
user=item.data.user,
|
||||
group=item.data.group,
|
||||
last_modified=modified,
|
||||
health=health,
|
||||
)
|
||||
|
||||
if item.data.source_path:
|
||||
tooltip += self.tr("Linked to: {}").format(item.data.source_path)
|
||||
|
||||
return tooltip
|
||||
|
||||
if role == Qt.ItemDataRole.CheckStateRole and column == 0:
|
||||
return item.data.checkstate
|
||||
|
||||
def setData(
|
||||
self, index: QModelIndex, value, role: int = Qt.ItemDataRole.DisplayRole
|
||||
) -> bool:
|
||||
"""
|
||||
Sets the role data for the item at index to value.
|
||||
|
||||
Returns true if successful; otherwise returns false.
|
||||
The dataChanged() signal should be emitted if the data was
|
||||
successfully set.
|
||||
"""
|
||||
if role != Qt.ItemDataRole.CheckStateRole:
|
||||
return False
|
||||
|
||||
item: FileSystemItem[FileData] = index.internalPointer()
|
||||
|
||||
if value == item.data.checkstate:
|
||||
return True
|
||||
|
||||
super_index = index.parent()
|
||||
if super_index == QModelIndex():
|
||||
super_item = self.root
|
||||
else:
|
||||
super_item: ExtractFileItem = super_index.internalPointer()
|
||||
|
||||
parent = item._parent
|
||||
while parent != super_item:
|
||||
if value == Qt.CheckState.Unchecked:
|
||||
# must have been one of the others previously
|
||||
parent.data.checked_children -= 1
|
||||
elif item.data.checkstate == Qt.CheckState.Unchecked: # old value
|
||||
# change from partially checked to checked
|
||||
# or the other way around does not change this count
|
||||
parent.data.checked_children += 1
|
||||
|
||||
if parent.data.checked_children:
|
||||
parent.data.checkstate = Qt.CheckState.PartiallyChecked
|
||||
else:
|
||||
parent.data.checkstate = Qt.CheckState.Unchecked
|
||||
|
||||
parent = parent._parent
|
||||
|
||||
if super_index != QModelIndex():
|
||||
if value == Qt.CheckState.Unchecked:
|
||||
# must have been one of the others previously
|
||||
super_item.data.checked_children -= 1
|
||||
elif item.data.checkstate == Qt.CheckState.Unchecked:
|
||||
# change from partially checked to checked
|
||||
# or the other way around does not change this count
|
||||
super_item.data.checked_children += 1
|
||||
|
||||
# update parent's state and possibly the parent's parent's state
|
||||
if parent.data.checked_children:
|
||||
self.setData(index.parent(), Qt.CheckState.PartiallyChecked, role)
|
||||
else:
|
||||
self.setData(index.parent(), Qt.CheckState.Unchecked, role)
|
||||
|
||||
# update state of the children without changing their parents' states
|
||||
if value != Qt.CheckState.PartiallyChecked:
|
||||
self.set_checkstate_recursively(index, value)
|
||||
|
||||
# update this item's state
|
||||
item.data.checkstate = value
|
||||
self.dataChanged.emit(index, index, (role,))
|
||||
|
||||
return True
|
||||
|
||||
def set_checkstate_recursively(self, index: QModelIndex, value: Qt.CheckState):
|
||||
"""
|
||||
Set the checkstate of the children of an index recursively.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The parent index to start with.
|
||||
value : Qt.CheckState
|
||||
The state to set.
|
||||
"""
|
||||
|
||||
number_children = self.rowCount(index)
|
||||
if not number_children:
|
||||
return
|
||||
|
||||
index.internalPointer().data.checked_children = (
|
||||
0 if value == Qt.CheckState.Unchecked else number_children
|
||||
)
|
||||
|
||||
item = index.internalPointer()
|
||||
for i in range(number_children):
|
||||
child = index.child(i, 0)
|
||||
child_item: ExtractFileItem = child.internalPointer()
|
||||
child_item.data.checkstate = value
|
||||
|
||||
if role == Qt.DisplayRole:
|
||||
return item.data(index.column())
|
||||
elif role == Qt.CheckStateRole and index.column() == 0:
|
||||
return item.getCheckedState()
|
||||
else:
|
||||
return None
|
||||
# set state of hidden items
|
||||
parent = child_item._parent
|
||||
while parent != item:
|
||||
# hidden parent must have 1 child
|
||||
parent.data.checked_children = (
|
||||
0 if value == Qt.CheckState.Unchecked
|
||||
else self.rowCount(child)
|
||||
)
|
||||
parent.data.checkstate = value
|
||||
|
||||
def flags(self, index):
|
||||
if not index.isValid():
|
||||
return Qt.NoItemFlags
|
||||
parent = parent._parent
|
||||
|
||||
return Qt.ItemIsEnabled | Qt.ItemIsUserCheckable
|
||||
# set state of this child's children
|
||||
self.set_checkstate_recursively(child, value)
|
||||
|
||||
self.dataChanged.emit(
|
||||
index.child(0, 0),
|
||||
index.child(0, number_children - 1),
|
||||
(Qt.ItemDataRole.CheckStateRole,),
|
||||
)
|
||||
|
||||
def flags(self, index: QModelIndex):
|
||||
"""
|
||||
Returns the item flags for the given index.
|
||||
|
||||
The base class implementation returns a combination of flags
|
||||
that enables the item (ItemIsEnabled) and
|
||||
allows it to be selected (ItemIsSelectable).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Qt.ItemFlags
|
||||
The flags.
|
||||
"""
|
||||
flags = super().flags(index)
|
||||
if index.column() == 0:
|
||||
flags |= Qt.ItemFlag.ItemIsUserCheckable
|
||||
|
||||
return flags
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -152,8 +152,8 @@ def test_archive_extract(qapp, qtbot, mocker, borg_json_output):
|
|||
qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults)
|
||||
# qtbot.waitUntil(lambda: tab._window == qapp.activeWindow(), **pytest._wait_defaults)
|
||||
|
||||
assert tab._window.treeView.model().rootItem.childItems[0].data(0) == 'Users'
|
||||
tab._window.treeView.model().rootItem.childItems[0].load_children()
|
||||
model = tab._window.model
|
||||
assert model.root.children[0].subpath == 'home'
|
||||
assert tab._window.archiveNameLabel.text().startswith('test-archive, 2000')
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
from PyQt5.QtCore import QModelIndex, Qt
|
||||
|
||||
import vorta.borg
|
||||
from vorta.views.extract_dialog import (ExtractTree, FileData, FileType,
|
||||
parse_json_lines)
|
||||
from vorta.views.partials.treemodel import FileSystemItem
|
||||
|
||||
|
||||
def prepare_borg(mocker, borg_json_output):
|
||||
stdout, stderr = borg_json_output("list_archive")
|
||||
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
|
||||
mocker.patch.object(vorta.borg.borg_job, "Popen", return_value=popen_result)
|
||||
|
||||
|
||||
default = {
|
||||
"mode": "drwxrwxr-x",
|
||||
"user": "theuser",
|
||||
"group": "theuser",
|
||||
"uid": 1000,
|
||||
"gid": 1000,
|
||||
"path": "",
|
||||
"healthy": True,
|
||||
"source": "",
|
||||
"linktarget": "",
|
||||
"flags": None,
|
||||
"mtime": "2022-05-13T14:33:57.305797",
|
||||
"size": 0,
|
||||
}
|
||||
|
||||
|
||||
def updated(path, values):
|
||||
d = default.copy()
|
||||
d.update(values)
|
||||
d["path"] = path
|
||||
return d
|
||||
|
||||
|
||||
def test_parser():
|
||||
"""Test creating a tree with correct data from json lines."""
|
||||
|
||||
lines = [
|
||||
updated("a", {}),
|
||||
updated("a/b", {"mode": "-rwxrwxr-x"}),
|
||||
updated("a/b/c", {}),
|
||||
updated("a/b/d", {}),
|
||||
updated("a/a", {}),
|
||||
updated("a/a/a", {}),
|
||||
updated("a/a/b", {}),
|
||||
updated("a/a/c", {"healthy": False}),
|
||||
updated("a/a/d", {}),
|
||||
updated("a/a/e", {}),
|
||||
]
|
||||
|
||||
model = ExtractTree()
|
||||
parse_json_lines(lines, model)
|
||||
|
||||
index = model.indexPath(("a", "b"))
|
||||
assert index != QModelIndex()
|
||||
assert index.internalPointer().data.file_type == FileType.FILE
|
||||
|
||||
item = model.getItem(("a", "a", "c"))
|
||||
assert item
|
||||
assert item.data.health is False
|
||||
|
||||
|
||||
def select(model, index):
|
||||
model.setData(index, Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
|
||||
|
||||
def deselect(model, index):
|
||||
model.setData(index, Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
|
||||
|
||||
|
||||
def test_selection():
|
||||
"""Test selecting items for extraction."""
|
||||
lines = [
|
||||
updated("a", {}),
|
||||
updated("a/b", {}),
|
||||
updated("a/b/c", {}),
|
||||
updated("a/b/d", {}),
|
||||
updated("a/a", {}),
|
||||
updated("a/a/a", {}),
|
||||
updated("a/a/b", {}),
|
||||
updated("a/a/c", {}),
|
||||
updated("a/a/d", {}),
|
||||
updated("a/a/e", {}),
|
||||
updated("c", {}),
|
||||
]
|
||||
model = ExtractTree()
|
||||
parse_json_lines(lines, model)
|
||||
|
||||
# Test select
|
||||
ic = model.index(1, 0, QModelIndex())
|
||||
c: FileSystemItem[FileData] = ic.internalPointer()
|
||||
|
||||
select(model, ic)
|
||||
assert c.data.checkstate == 2
|
||||
assert c.data.checked_children == 0
|
||||
|
||||
# Test deselect
|
||||
deselect(model, ic)
|
||||
assert c.data.checkstate == 0
|
||||
assert c.data.checked_children == 0
|
||||
|
||||
# Test select parent as well as children
|
||||
ia = model.index(0, 0, QModelIndex())
|
||||
a: FileSystemItem[FileData] = ia.internalPointer()
|
||||
aa = model.getItem(("a", "a"))
|
||||
aab = model.getItem(("a", "a", "b"))
|
||||
ab = model.getItem(("a", "b"))
|
||||
abc = model.getItem(("a", "b", "c"))
|
||||
|
||||
select(model, ia)
|
||||
assert a.data.checkstate
|
||||
assert a.data.checked_children == 2
|
||||
assert a.data.checked_children == 2
|
||||
assert aab.data.checkstate
|
||||
assert ab.data.checkstate
|
||||
assert aa.data.checkstate
|
||||
assert aa.data.checked_children == 5
|
||||
|
||||
# Test deselect item as well as children with selected parent
|
||||
iab = model.indexPath(("a", "b"))
|
||||
deselect(model, iab)
|
||||
|
||||
assert a.data.checkstate == 1
|
||||
assert aa.data.checkstate == 2
|
||||
assert ab.data.checkstate == 0
|
||||
assert abc.data.checkstate == 0
|
||||
assert a.data.checked_children == 1
|
||||
assert ab.data.checked_children == 0
|
||||
|
||||
# Test deselect item and children
|
||||
deselect(model, ia)
|
||||
|
||||
assert a.data.checkstate == 0
|
||||
assert aa.data.checkstate == 0
|
||||
assert ab.data.checkstate == 0
|
||||
assert a.data.checked_children == 0
|
||||
assert aa.data.checked_children == 0
|
||||
|
||||
# Test select child with partially selected parent
|
||||
iaac = model.indexPath(("a", "a", "c"))
|
||||
|
||||
select(model, ia)
|
||||
deselect(model, iab)
|
||||
deselect(model, iaac)
|
||||
select(model, iab)
|
||||
select(model, iaac)
|
||||
|
||||
assert a.data.checkstate == 1
|
||||
assert aa.data.checkstate == 1
|
||||
assert ab.data.checkstate == 2
|
||||
|
||||
assert a.data.checked_children == 2
|
||||
assert ab.data.checked_children == 2
|
||||
assert aa.data.checked_children == 5
|
||||
|
||||
# Test deselect all children with selected parent
|
||||
iaa = model.indexPath(("a", "a"))
|
||||
deselect(model, iaa)
|
||||
deselect(model, iab)
|
||||
|
||||
assert a.data.checkstate == 0
|
||||
assert a.data.checked_children == 0
|
||||
|
||||
# Test select child with deselected parent
|
||||
select(model, iaac)
|
||||
|
||||
assert a.data.checkstate == 1
|
||||
assert ab.data.checkstate == 0
|
||||
assert aa.data.checkstate == 1
|
||||
assert a.data.checked_children == 1
|
||||
assert ab.data.checked_children == 0
|
||||
assert aa.data.checked_children == 1
|
||||
|
||||
select(model, iaa)
|
||||
assert a.data.checkstate == 1
|
||||
|
||||
select(model, iab)
|
||||
assert a.data.checkstate == 1
|
Loading…
Reference in New Issue