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