diff --git a/src/vorta/assets/UI/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index 1f4b98be6..c83bac106 100644 --- a/src/vorta/assets/UI/archivetab.ui +++ b/src/vorta/assets/UI/archivetab.ui @@ -230,8 +230,38 @@ + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 10 + + + + + + + + true + + + Extract + + + + :/icons/cloud-download.svg:/icons/cloud-download.svg + + + @@ -260,20 +290,6 @@ - - - - true - - - Extract - - - - :/icons/cloud-download.svg:/icons/cloud-download.svg - - - diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui index 755e47248..4d636c780 100644 --- a/src/vorta/assets/UI/extractdialog.ui +++ b/src/vorta/assets/UI/extractdialog.ui @@ -14,29 +14,28 @@ Dialog - - - - <html><head/><body><p>Archive: <span style=" font-weight:600;">nyx2.local-2018-11-16T09:49:58 from November 16, 2018</span></p></body></html> - - - 10 - + - Select All + Archive: - - + + + + + 75 + true + + - Unselect All + nyx2.local-2018-11-16T09:49:58 from November 16, 2018 @@ -53,23 +52,10 @@ - - - - Exclude Patterns - - - - - - - 1 - - - + @@ -90,7 +76,7 @@ - + Cancel diff --git a/src/vorta/borg/borg_thread.py b/src/vorta/borg/borg_thread.py index d7204fe72..56af4f52d 100644 --- a/src/vorta/borg/borg_thread.py +++ b/src/vorta/borg/borg_thread.py @@ -3,6 +3,9 @@ import sys import shutil import signal +import select +import fcntl +import time import logging from PyQt5 import QtCore from PyQt5.QtWidgets import QApplication @@ -55,6 +58,7 @@ def __init__(self, cmd, params, parent=None): self.env = env self.cmd = cmd + self.cwd = params.get('cwd', None) self.params = params self.process = None @@ -135,40 +139,74 @@ def run(self): profile=self.params.get('profile_name', None) ) log_entry.save() + logger.info('Running command %s', ' '.join(self.cmd)) - self.process = Popen(self.cmd, stdout=PIPE, stderr=PIPE, bufsize=1, - universal_newlines=True, env=self.env, preexec_fn=os.setsid) + p = Popen(self.cmd, stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True, + env=self.env, cwd=self.cwd, preexec_fn=os.setsid) + self.process = p - for line in iter(self.process.stderr.readline, ''): + # Prevent blocking. via https://stackoverflow.com/a/7730201/3983708 + + # Helper function to add the O_NONBLOCK flag to a file descriptor + def make_async(fd): + fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK) + + # Helper function to read some data from a file descriptor, ignoring EAGAIN errors + def read_async(fd): try: - parsed = json.loads(line) - if parsed['type'] == 'log_message': - self.log_event(f'{parsed["levelname"]}: {parsed["message"]}') - level_int = getattr(logging, parsed["levelname"]) - logger.log(level_int, parsed["message"]) - elif parsed['type'] == 'file_status': - self.log_event(f'{parsed["path"]} ({parsed["status"]})') - except json.decoder.JSONDecodeError: - msg = line.strip() - self.log_event(msg) - logger.warning(msg) - - self.process.wait() - stdout = self.process.stdout.read() + return fd.read() + except (IOError, TypeError): + return '' + + make_async(p.stdout) + make_async(p.stderr) + + stdout = [] + while True: + # Wait for new output + select.select([p.stdout, p.stderr], [], []) + + stdout.append(read_async(p.stdout)) + stderr = read_async(p.stderr) + if stderr: + for line in stderr.split('\n'): + try: + parsed = json.loads(line) + if parsed['type'] == 'log_message': + self.log_event(f'{parsed["levelname"]}: {parsed["message"]}') + level_int = getattr(logging, parsed["levelname"]) + logger.log(level_int, parsed["message"]) + elif parsed['type'] == 'file_status': + self.log_event(f'{parsed["path"]} ({parsed["status"]})') + except json.decoder.JSONDecodeError: + msg = line.strip() + self.log_event(msg) + logger.warning(msg) + + if p.poll() is not None: + stdout.append(read_async(p.stdout)) + break + result = { 'params': self.params, 'returncode': self.process.returncode, - 'cmd': self.cmd + 'cmd': self.cmd, } + stdout = ''.join(stdout) + try: result['data'] = json.loads(stdout) - except: # noqa - result['data'] = {} + except ValueError: + result['data'] = stdout - log_entry.returncode = self.process.returncode + log_entry.returncode = p.returncode log_entry.repo_url = self.params.get('repo_url', None) log_entry.save() + # Ensure async reading of mock stdout/stderr is finished. + if hasattr(sys, '_called_from_test'): + time.sleep(1) + self.process_result(result) self.finished_event(result) mutex.unlock() @@ -190,44 +228,3 @@ def started_event(self): def finished_event(self, result): self.result.emit(result) - - -class BorgThreadChain(BorgThread): - """ - Metaclass of `BorgThread` that can run multiple other BorgThread actions while providing the same - interface as a single action. - """ - - def __init__(self, cmds, input_values, parent=None): - """ - Takes a list of tuples with `BorgThread` subclass and optional input parameters. Then all actions are executed - and a merged result object is returned to the caller. If there is any error, then current result is returned. - - :param actions: - :return: dict(results) - """ - self.parent = parent - self.threads = [] - self.combined_result = {} - - for cmd, input_value in zip(cmds, input_values): - if input_value is not None: - msg = cmd.prepare(input_value) - else: - msg = cmd.prepare() - if msg['ok']: - thread = cmd(msg['cmd'], msg, parent) - thread.updated.connect(self.updated.emit) # All log entries are immediately sent to the parent. - thread.result.connect(self.partial_result) - self.threads.append(thread) - self.threads[0].start() - - def partial_result(self, result): - if result['returncode'] == 0: - self.combined_result.update(result) - self.threads.pop(0) - - if len(self.threads) > 0: - self.threads[0].start() - else: - self.result.emit(self.combined_result) diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index eb8f72c80..e6a224024 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -67,9 +67,9 @@ def prepare(cls, profile): ( WifiSettingModel.ssid == current_wifi ) & ( - WifiSettingModel.allowed is False + WifiSettingModel.allowed == False # noqa ) & ( - WifiSettingModel.profile == profile.id + WifiSettingModel.profile == profile ) ) if wifi_is_disallowed.count() > 0 and profile.repo.is_remote_repo(): diff --git a/src/vorta/borg/extract.py b/src/vorta/borg/extract.py new file mode 100644 index 000000000..f97d774f6 --- /dev/null +++ b/src/vorta/borg/extract.py @@ -0,0 +1,38 @@ +from .borg_thread import BorgThread + + +class BorgExtractThread(BorgThread): + + def log_event(self, msg): + self.app.backup_log_event.emit(msg) + + def started_event(self): + self.app.backup_started_event.emit() + self.app.backup_log_event.emit('Downloading files from archive..') + + def finished_event(self, result): + self.app.backup_finished_event.emit(result) + self.result.emit(result) + self.app.backup_log_event.emit('Restored files from archive.') + + @classmethod + def prepare(cls, profile, archive_name, selected_files, destination_folder): + ret = super().prepare(profile) + if not ret['ok']: + return ret + else: + ret['ok'] = False # Set back to false, so we can do our own checks here. + + cmd = ['borg', 'extract', '--list', '--info', '--log-json'] + cmd.append(f'{profile.repo.url}::{archive_name}') + for s in selected_files: + cmd.append(s) + + ret['ok'] = True + ret['cmd'] = cmd + ret['cwd'] = destination_folder + + return ret + + def process_result(self, result): + pass diff --git a/src/vorta/borg/list_archive.py b/src/vorta/borg/list_archive.py new file mode 100644 index 000000000..1e3ff8c5c --- /dev/null +++ b/src/vorta/borg/list_archive.py @@ -0,0 +1,32 @@ +from .borg_thread import BorgThread + + +class BorgListArchiveThread(BorgThread): + + def log_event(self, msg): + self.app.backup_log_event.emit(msg) + + def started_event(self): + self.app.backup_started_event.emit() + self.app.backup_log_event.emit('Getting archive content..') + + def finished_event(self, result): + self.app.backup_finished_event.emit(result) + self.app.backup_log_event.emit('Done getting archive content.') + self.result.emit(result) + + @classmethod + def prepare(cls, profile): + ret = super().prepare(profile) + if not ret['ok']: + return ret + else: + ret['ok'] = False # Set back to false, so we can do our own checks here. + + cmd = ['borg', 'list', '--info', '--log-json', '--format', "{size:8d}{TAB}{mtime}{TAB}{path}{NL}"] + cmd.append(f'{profile.repo.url}') + + ret['ok'] = True + ret['cmd'] = cmd + + return ret diff --git a/src/vorta/borg/list.py b/src/vorta/borg/list_repo.py similarity index 98% rename from src/vorta/borg/list.py rename to src/vorta/borg/list_repo.py index 94dc5b711..52f801ffb 100644 --- a/src/vorta/borg/list.py +++ b/src/vorta/borg/list_repo.py @@ -3,7 +3,7 @@ from vorta.models import ArchiveModel, RepoModel -class BorgListThread(BorgThread): +class BorgListRepoThread(BorgThread): def log_event(self, msg): self.app.backup_log_event.emit(msg) diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 7bfdbfe5e..8d77026f6 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -6,7 +6,7 @@ from vorta.borg.create import BorgCreateThread from .models import BackupProfileModel, EventLogModel from vorta.borg.prune import BorgPruneThread -from vorta.borg.list import BorgListThread +from vorta.borg.list_repo import BorgListRepoThread from vorta.borg.check import BorgCheckThread from .notifications import VortaNotifications @@ -105,9 +105,9 @@ def post_backup_tasks(self, profile_id): prune_thread.wait() # Refresh snapshots - msg = BorgListThread.prepare(profile) + msg = BorgListRepoThread.prepare(profile) if msg['ok']: - list_thread = BorgListThread(msg['cmd'], msg) + list_thread = BorgListRepoThread(msg['cmd'], msg) list_thread.start() list_thread.wait() diff --git a/src/vorta/tray_menu.py b/src/vorta/tray_menu.py index c8eb24928..5dcba6dd0 100644 --- a/src/vorta/tray_menu.py +++ b/src/vorta/tray_menu.py @@ -13,7 +13,7 @@ def __init__(self, parent=None): self.app = parent menu = QMenu() - # https://stackoverflow.com/questions/43657890/pyqt5-qsystemtrayicon-activated-signal-not-working + # Workaround to get `activated` signal on Unity: https://stackoverflow.com/a/43683895/3983708 menu.aboutToShow.connect(self.on_user_click) self.setContextMenu(menu) @@ -21,10 +21,7 @@ def __init__(self, parent=None): self.show() def on_user_click(self): - """ - Build system tray menu based on current status. - - """ + """Build system tray menu based on current state.""" menu = self.contextMenu() menu.clear() @@ -32,26 +29,23 @@ def on_user_click(self): status = menu.addAction(self.app.scheduler.next_job) status.setEnabled(False) - profiles = BackupProfileModel.select() - if profiles.count() > 1: - profile_menu = menu.addMenu('Backup Now') - for profile in profiles: - new_item = profile_menu.addAction(profile.name) - new_item.setData(profile.id) - new_item.triggered.connect(lambda profile_id=profile.id: self.app.create_backup_action(profile_id)) - else: - profile = profiles.first() - profile_menu = menu.addAction('Backup Now') - profile_menu.triggered.connect((lambda profile_id=profile.id: self.app.create_backup_action(profile_id))) - if BorgThread.is_running(): status.setText('Backup in Progress') - profile_menu.setVisible(False) cancel_action = menu.addAction("Cancel Backup") cancel_action.triggered.connect(self.app.backup_cancelled_event.emit) else: status.setText(f'Next Task: {self.app.scheduler.next_job}') - profile_menu.setEnabled(True) + profiles = BackupProfileModel.select() + if profiles.count() > 1: + profile_menu = menu.addMenu('Backup Now') + for profile in profiles: + new_item = profile_menu.addAction(profile.name) + new_item.setData(profile.id) + new_item.triggered.connect(lambda profile_id=profile.id: self.app.create_backup_action(profile_id)) + else: + profile = profiles.first() + profile_menu = menu.addAction('Backup Now') + profile_menu.triggered.connect(lambda profile_id=profile.id: self.app.create_backup_action(profile_id)) settings_action = menu.addAction("Settings") settings_action.triggered.connect(self.app.open_main_window_action) diff --git a/src/vorta/utils.py b/src/vorta/utils.py index f9a2530aa..5c12c4dd9 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -1,6 +1,11 @@ import os import sys import plistlib + +from collections import defaultdict +from functools import reduce +import operator + from paramiko.rsakey import RSAKey from paramiko.ecdsakey import ECDSAKey from paramiko.ed25519key import Ed25519Key @@ -57,6 +62,20 @@ def delete_password(self, service, repo_url): keyring.set_keyring(VortaKeyring()) +def nested_dict(): + """ + Combination of two idioms to quickly build dicts from lists of keys: + + - https://stackoverflow.com/a/16724937/3983708 + - https://stackoverflow.com/a/14692747/3983708 + """ + return defaultdict(nested_dict) + + +def get_dict_from_list(dataDict, mapList): + return reduce(operator.getitem, mapList, dataDict) + + def choose_folder_dialog(parent, title, want_folder=True): options = QFileDialog.Options() if want_folder: diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index c97d98218..644b30b43 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -4,9 +4,11 @@ from PyQt5.QtWidgets import QTableWidgetItem, QTableView, QHeaderView from vorta.borg.prune import BorgPruneThread -from vorta.borg.list import BorgListThread +from vorta.borg.list_repo import BorgListRepoThread +from vorta.borg.list_archive import BorgListArchiveThread from vorta.borg.check import BorgCheckThread from vorta.borg.mount import BorgMountThread +from vorta.borg.extract import BorgExtractThread from vorta.borg.umount import BorgUmountThread from vorta.views.extract_dialog import ExtractDialog from vorta.utils import get_asset, pretty_bytes, choose_folder_dialog @@ -51,7 +53,7 @@ def __init__(self, parent=None): self.listButton.clicked.connect(self.list_action) self.pruneButton.clicked.connect(self.prune_action) self.checkButton.clicked.connect(self.check_action) - self.extractButton.clicked.connect(self.extract_action) + self.extractButton.clicked.connect(self.list_archive_action) self.populate_from_profile() @@ -60,12 +62,13 @@ def _set_status(self, text): self.mountErrors.repaint() def _toggle_all_buttons(self, enabled=True): - self.checkButton.setEnabled(enabled) - self.listButton.setEnabled(enabled) - self.pruneButton.setEnabled(enabled) - self.mountButton.setEnabled(enabled) + for button in [self.checkButton, self.listButton, self.pruneButton, self.mountButton, self.extractButton]: + button.setEnabled(enabled) + button.repaint() def populate_from_profile(self): + """Populate archive list and prune settings from profile.""" + profile = self.profile() if profile.repo is not None: self.currentRepoLabel.setText(profile.repo.url) @@ -84,6 +87,8 @@ def populate_from_profile(self): self.archiveTable.setItem(row, 2, QTableWidgetItem(formatted_duration)) self.archiveTable.setItem(row, 3, QTableWidgetItem(archive.name)) self.archiveTable.setRowCount(len(archives)) + item = self.archiveTable.item(0, 0) + self.archiveTable.scrollToItem(item) self._toggle_all_buttons(enabled=True) else: self.archiveTable.setRowCount(0) @@ -131,9 +136,9 @@ def prune_result(self, result): self._toggle_all_buttons(True) def list_action(self): - params = BorgListThread.prepare(self.profile()) + params = BorgListRepoThread.prepare(self.profile()) if params['ok']: - thread = BorgListThread(params['cmd'], params, parent=self) + thread = BorgListRepoThread(params['cmd'], params, parent=self) thread.updated.connect(self._set_status) thread.result.connect(self.list_result) self._toggle_all_buttons(False) @@ -219,7 +224,57 @@ def save_prune_setting(self, new_value=None): profile.prune_keep_within = self.prune_keep_within.text() profile.save() - def extract_action(self): - window = ExtractDialog() - window.setParent(self, QtCore.Qt.Sheet) - window.show() + def list_archive_action(self): + profile = self.profile() + + row_selected = self.archiveTable.selectionModel().selectedRows() + if row_selected: + archive_cell = self.archiveTable.item(row_selected[0].row(), 3) + if archive_cell: + archive_name = archive_cell.text() + params = BorgListArchiveThread.prepare(profile) + + if not params['ok']: + self._set_status(params['message']) + return + params['cmd'][-1] += f'::{archive_name}' + params['archive_name'] = archive_name + self._set_status('') + self._toggle_all_buttons(False) + + thread = BorgListArchiveThread(params['cmd'], params, parent=self) + thread.updated.connect(self.mountErrors.setText) + thread.result.connect(self.list_archive_result) + thread.start() + else: + self._set_status('Select an archive to restore first.') + + def list_archive_result(self, result): + self._set_status('') + if result['returncode'] == 0: + archive = ArchiveModel.get(name=result['params']['archive_name']) + window = ExtractDialog(result['data'], archive) + self._toggle_all_buttons(True) + window.setParent(self, QtCore.Qt.Sheet) + window.show() + + if window.exec_(): + def receive(): + extraction_folder = dialog.selectedFiles() + if extraction_folder: + params = BorgExtractThread.prepare( + self.profile(), archive.name, window.selected, extraction_folder[0]) + if params['ok']: + self._toggle_all_buttons(False) + thread = BorgExtractThread(params['cmd'], params, parent=self) + thread.updated.connect(self.mountErrors.setText) + thread.result.connect(self.extract_archive_result) + thread.start() + else: + self._set_status(params['message']) + + dialog = choose_folder_dialog(self, "Choose Extraction Point") + dialog.open(receive) + + def extract_archive_result(self, result): + self._toggle_all_buttons(True) diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index 0a3da461e..9307724d3 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -1,90 +1,261 @@ +import sys +import os from PyQt5 import uic -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QTreeWidgetItem, QHeaderView +from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt +from PyQt5.QtWidgets import QApplication, QHeaderView -from ..utils import get_asset +from vorta.utils import get_asset, pretty_bytes, get_dict_from_list, nested_dict uifile = get_asset('UI/extractdialog.ui') ExtractDialogUI, ExtractDialogBase = uic.loadUiType(uifile) -n = 0 +ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f' + +full_list = [] +folder_list = nested_dict() +selected = set() class ExtractDialog(ExtractDialogBase, ExtractDialogUI): - def __init__(self): + def __init__(self, fs_data, archive): super().__init__() self.setupUi(self) + global full_list, folder_list, selected + + def parse_line(line): + size, modified, full_path = line.split('\t') + size = int(size) + dir, name = os.path.split(full_path) + if size == 0: + d = get_dict_from_list(folder_list, dir.split('/')) + if name not in d: + d[name] = {} + + return size, modified, name, dir + + full_list = [parse_line(l) for l in fs_data.split('\n')[:-1]] - d = {'key1': 'value1', - 'key2': ['value2', 'value', 'value4'], - 'key5': {'another key1': 'another value1', - 'another key2': ['value2', 'value', 'value4']} - } - - # add some nested folders - for i in range(6, 200): - d[f'folder-{i}'] = {'another key1': 'another value1', - 'another key2': ['value2', 'value', 'value4']} - for j in range(50): - d[f'folder-{i}'][f'large folder {j}'] = {'another key1': 'another value1', - 'another key2': ['value2', 'value', 'value4']} - - # add top-level folders to test scroll performance - for f in range(1000000): - d[f'flat folder {f}'] = 'no subfolders. test' - - self.d = d - - t = self.fileTree - t.setColumnCount(2) - t.setHeaderLabels(['File/Foldername', 'Size', 'Modified']) - t.setAlternatingRowColors(True) - t.setUniformRowHeights(True) # Allows for scrolling optimizations. - header = t.header() + model = TreeModel() + + view = self.treeView + view.setAlternatingRowColors(True) + view.setUniformRowHeights(True) # Allows for scrolling optimizations. + view.setModel(model) + header = view.header() header.setStretchLastSection(False) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) header.setSectionResizeMode(0, QHeaderView.Stretch) - self.extractButton.clicked.connect(self.build_tree) - - def build_tree(self): - fill_item(self.fileTree.invisibleRootItem(), self.d) - print('Added test items', n) - - -def fill_item(item, value): - global n - # item.setExpanded(True) - if type(value) is dict: - for key, val in sorted(value.items()): - child = QTreeWidgetItem() - child.setText(0, str(key)) - child.setText(1, str(key)) - child.setText(2, str(key)) - child.setFlags(child.flags() | Qt.ItemIsUserCheckable) - child.setCheckState(0, Qt.Unchecked) - item.addChild(child) - n += 1 - fill_item(child, val) - elif type(value) is list: - for val in value: - child = QTreeWidgetItem() - child.setFlags(child.flags() | Qt.ItemIsUserCheckable) - child.setCheckState(0, Qt.Unchecked) - item.addChild(child) - n += 1 - if type(val) is dict: - child.setText(0, '[dict]') - fill_item(child, val) - elif type(val) is list: - child.setText(0, '[list]') - fill_item(child, val) - else: - child.setText(0, str(val)) - else: - child = QTreeWidgetItem() - child.setText(0, str(value)) - child.setFlags(child.flags() | Qt.ItemIsUserCheckable) - child.setCheckState(0, Qt.Unchecked) - item.addChild(child) - n += 1 + self.archiveNameLabel.setText(f'{archive.name}, {archive.time}') + self.cancelButton.clicked.connect(self.close) + self.extractButton.clicked.connect(self.accept) + self.selected = selected + + +class FileItem: + def __init__(self, name, modified, size, parent=None): + self.parentItem = parent + self.itemData = [name, modified, size] # dt.strptime(modified, ISO_FORMAT) + self.checkedState = False + + def childCount(self): + return 0 + + def columnCount(self): + return 3 + + def data(self, column): + if column == 1: + return self.itemData[column] # .strftime('%Y-%m-%dT%H:%M') + elif column == 2: + return pretty_bytes(self.itemData[column]) + elif column == 0: + return self.itemData[column] + + def parent(self): + return self.parentItem + + def row(self): + return self.parentItem.childItems.index(self) + + def setCheckedState(self, value): + if value == 2: + self.checkedState = True + selected.add( + os.path.join(self.parentItem.path, self.parentItem.data(0), self.itemData[0])) + else: + self.checkedState = False + selected.remove( + os.path.join(self.parentItem.path, self.parentItem.data(0), self.itemData[0])) + + def getCheckedState(self): + if self.checkedState: + return Qt.Checked + else: + return Qt.Unchecked + + +class FolderItem(FileItem): + def __init__(self, path, name, modified, parent=None): + self.parentItem = parent + self.path = path + self.itemData = [name, modified] + self.checkedState = False + self.childItems = [] + + # Pre-filter children + self._filtered_children = [] + search_path = os.path.join(self.path, name) + if parent is None: # Find path for root folder + for root_folder in folder_list.keys(): + self._filtered_children.append((0, '', root_folder, '', )) + else: + self._filtered_children = [f for f in full_list if search_path == f[3]] + if self.childCount() == 0: # If there are no immediate children, we try the next-deepest folder. + for immediate_child in get_dict_from_list(folder_list, search_path.split('/')).keys(): + self._filtered_children.append((0, '', immediate_child, search_path)) + + self.is_loaded = False + + def load_children(self): + for child_item in self._filtered_children: + if child_item[0] > 0: # This is a file + self.childItems.append(FileItem( + name=child_item[2], + modified=child_item[1], + size=child_item[0], + parent=self)) + else: # Folder + self.childItems.append( + FolderItem( + path=child_item[3], + name=child_item[2], + modified=child_item[1], + parent=self)) + + self.is_loaded = True + + def child(self, row): + return self.childItems[row] + + def childCount(self): + return len(self._filtered_children) + + def columnCount(self): + return 3 + + def data(self, column): + if column <= 1: + return self.itemData[column] + else: + return None + + def parent(self): + return self.parentItem + + def row(self): + if self.parentItem: + return self.parentItem.childItems.index(self) + + return 0 + + +class TreeModel(QAbstractItemModel): + column_names = ['Name', 'Modified', 'Size'] + + def __init__(self, parent=None): + super(TreeModel, self).__init__(parent) + + self.rootItem = FolderItem(path='', name='', modified=None) + self.rootItem.load_children() + + def columnCount(self, parent): + return 3 + + def data(self, index, role): + if not index.isValid(): + return None + + item = index.internalPointer() + + if role == Qt.DisplayRole: + return item.data(index.column()) + elif role == Qt.CheckStateRole and index.column() == 0: + return item.getCheckedState() + else: + return None + + def setData(self, index, value, role=Qt.EditRole): + if role == Qt.CheckStateRole: + item = index.internalPointer() + item.setCheckedState(value) + + return True + + def canFetchMore(self, index): + if not index.isValid(): + return False + item = index.internalPointer() + return not item.is_loaded + + def fetchMore(self, index): + item = index.internalPointer() + item.load_children() + + def flags(self, index): + if not index.isValid(): + return Qt.NoItemFlags + + return Qt.ItemIsEnabled | Qt.ItemIsUserCheckable + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return self.column_names[section] + + return None + + def index(self, row, column, parent): + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + childItem = parentItem.child(row) + if childItem: + return self.createIndex(row, column, childItem) + else: + return QModelIndex() + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + + childItem = index.internalPointer() + parentItem = childItem.parent() + + if parentItem == self.rootItem: + return QModelIndex() + + return self.createIndex(parentItem.row(), 0, parentItem) + + def rowCount(self, parent): + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + return parentItem.childCount() + + +if __name__ == '__main__': + app = QApplication(sys.argv) + test_list = open('/Users/manu/Downloads/archive_list.txt').read() + view = ExtractDialog(test_list.split('\n')) + view.show() + sys.exit(app.exec_()) diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index aca808522..71a56e3fa 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -6,7 +6,7 @@ from .source_tab import SourceTab from .archive_tab import ArchiveTab from .schedule_tab import ScheduleTab -from .profile_add_edit import AddProfileWindow, EditProfileWindow +from .profile_add_edit_dialog import AddProfileWindow, EditProfileWindow from ..utils import get_asset from ..models import BackupProfileModel from vorta.borg.borg_thread import BorgThread diff --git a/src/vorta/views/profile_add_edit.py b/src/vorta/views/profile_add_edit_dialog.py similarity index 100% rename from src/vorta/views/profile_add_edit.py rename to src/vorta/views/profile_add_edit_dialog.py diff --git a/src/vorta/views/repo_add.py b/src/vorta/views/repo_add_dialog.py similarity index 100% rename from src/vorta/views/repo_add.py rename to src/vorta/views/repo_add_dialog.py diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 71e4ec7eb..82ba41117 100644 --- a/src/vorta/views/repo_tab.py +++ b/src/vorta/views/repo_tab.py @@ -3,9 +3,9 @@ from PyQt5.QtWidgets import QApplication, QMessageBox from ..models import RepoModel, ArchiveModel, BackupProfileMixin -from .repo_add import AddRepoWindow, ExistingRepoWindow +from .repo_add_dialog import AddRepoWindow, ExistingRepoWindow from ..utils import pretty_bytes, get_private_keys, get_asset -from .ssh_add import SSHAddWindow +from .ssh_dialog import SSHAddWindow uifile = get_asset('UI/repotab.ui') RepoUI, RepoBase = uic.loadUiType(uifile, from_imports=True, import_from='vorta.views') diff --git a/src/vorta/views/ssh_add.py b/src/vorta/views/ssh_dialog.py similarity index 100% rename from src/vorta/views/ssh_add.py rename to src/vorta/views/ssh_dialog.py diff --git a/tests/conftest.py b/tests/conftest.py index 4f0056044..855fcc21d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import io import pytest import peewee @@ -7,6 +6,11 @@ from vorta.models import RepoModel, SourceDirModel +def pytest_configure(config): + import sys + sys._called_from_test = True + + @pytest.fixture() def app(tmpdir, qtbot): tmp_db = tmpdir.join('settings.sqlite') @@ -33,7 +37,7 @@ def app_with_repo(app): @pytest.fixture def borg_json_output(): def _read_json(subcommand): - stdout = open(f'tests/borg_json_output/{subcommand}_stdout.json').read() - stderr = open(f'tests/borg_json_output/{subcommand}_stderr.json').read() - return io.StringIO(stdout), io.StringIO(stderr) + stdout = open(f'tests/borg_json_output/{subcommand}_stdout.json') + stderr = open(f'tests/borg_json_output/{subcommand}_stderr.json') + return stdout, stderr return _read_json diff --git a/tests/test_archives.py b/tests/test_archives.py index 8da0d31e5..4d1077d08 100644 --- a/tests/test_archives.py +++ b/tests/test_archives.py @@ -26,7 +26,7 @@ def test_repo_list(app_with_repo, qtbot, mocker, borg_json_output): popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_thread, 'Popen', return_value=popen_result) - qtbot.waitUntil(lambda: main.createProgressText.text() == 'Refreshing snapshots done.') + qtbot.waitUntil(lambda: main.createProgressText.text() == 'Refreshing snapshots done.', timeout=3000) assert ArchiveModel.select().count() == 6 assert main.createProgressText.text() == 'Refreshing snapshots done.' assert tab.checkButton.isEnabled() diff --git a/tests/test_repo.py b/tests/test_repo.py index 4d58895a7..221d25dfd 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -2,7 +2,7 @@ import vorta.borg.borg_thread import vorta.models -from vorta.views.repo_add import AddRepoWindow +from vorta.views.repo_add_dialog import AddRepoWindow from vorta.models import EventLogModel, RepoModel, ArchiveModel @@ -32,7 +32,7 @@ def test_repo_add(app, qtbot, mocker, borg_json_output): qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.LeftButton) - with qtbot.waitSignal(add_repo_window.thread.result, timeout=1000) as blocker: + with qtbot.waitSignal(add_repo_window.thread.result, timeout=3000) as blocker: pass main.repoTab.process_new_repo(blocker.args[0]) @@ -48,8 +48,8 @@ def test_create(app_with_repo, borg_json_output, mocker, qtbot): mocker.patch.object(vorta.borg.borg_thread, 'Popen', return_value=popen_result) qtbot.mouseClick(main.createStartBtn, QtCore.Qt.LeftButton) - qtbot.waitUntil(lambda: main.createProgressText.text().startswith('Backup finished.')) - qtbot.waitUntil(lambda: main.createStartBtn.isEnabled()) + qtbot.waitUntil(lambda: main.createProgressText.text().startswith('Backup finished.'), timeout=3000) + qtbot.waitUntil(lambda: main.createStartBtn.isEnabled(), timeout=3000) assert EventLogModel.select().count() == 1 assert ArchiveModel.select().count() == 1 assert RepoModel.get(id=1).unique_size == 15520474