From ab368b32a4729f107118a58ed78938ba4af83fcf Mon Sep 17 00:00:00 2001 From: Manuel Riel Date: Mon, 21 Jan 2019 18:44:01 +0800 Subject: [PATCH] Improve archive mount feature. Fixes #131. By @antlarr (#156) --- src/vorta/assets/UI/archivetab.ui | 5 ++ src/vorta/borg/umount.py | 3 +- src/vorta/utils.py | 22 ++++++ src/vorta/views/archive_tab.py | 120 ++++++++++++++++++++++-------- 4 files changed, 120 insertions(+), 30 deletions(-) diff --git a/src/vorta/assets/UI/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index d7603d08..ffa7c97b 100644 --- a/src/vorta/assets/UI/archivetab.ui +++ b/src/vorta/assets/UI/archivetab.ui @@ -92,6 +92,11 @@ font-weight: bold; Duration + + + Mounted + + Name diff --git a/src/vorta/borg/umount.py b/src/vorta/borg/umount.py index cd385daa..25b1120a 100644 --- a/src/vorta/borg/umount.py +++ b/src/vorta/borg/umount.py @@ -1,3 +1,4 @@ +import os.path import psutil from .borg_thread import BorgThread from ..i18n import trans_late @@ -20,7 +21,7 @@ class BorgUmountThread(BorgThread): partitions = psutil.disk_partitions(all=True) for p in partitions: if p.device == 'borgfs': - ret['active_mount_points'].append(p.mountpoint) + ret['active_mount_points'].append(os.path.normpath(p.mountpoint)) if len(ret['active_mount_points']) == 0: ret['message'] = trans_late('messages', 'No active Borg mounts found.') diff --git a/src/vorta/utils.py b/src/vorta/utils.py index 125bd7c2..e70f51d8 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -10,6 +10,7 @@ import getpass from collections import defaultdict from functools import reduce import operator +import psutil from paramiko.rsakey import RSAKey from paramiko.ecdsakey import ECDSAKey @@ -247,3 +248,24 @@ def format_archive_name(profile, archive_name_tpl): 'user': getpass.getuser() } return archive_name_tpl.format(**available_vars) + + +def get_mount_points(repo_url): + mount_points = {} + for proc in psutil.process_iter(): + if proc.name() == 'borg' or proc.name().startswith('python'): + if 'mount' not in proc.cmdline(): + continue + + for idx, parameter in enumerate(proc.cmdline()): + if parameter.startswith(repo_url + '::'): + archive_name = parameter[len(repo_url) + 2:] + + # The borg mount command specifies that the mount_point + # parameter comes after the archive name + if len(proc.cmdline()) > idx + 1: + mount_point = proc.cmdline()[idx + 1] + mount_points[archive_name] = mount_point + break + + return mount_points diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 5cb00ce9..2266799a 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -1,6 +1,8 @@ +import os.path import sys from datetime import timedelta from PyQt5 import uic, QtCore +from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtWidgets import QTableWidgetItem, QTableView, QHeaderView from vorta.borg.prune import BorgPruneThread @@ -12,7 +14,7 @@ from vorta.borg.extract import BorgExtractThread from vorta.borg.umount import BorgUmountThread from vorta.views.extract_dialog import ExtractDialog from vorta.i18n import translate -from vorta.utils import get_asset, pretty_bytes, choose_file_dialog, format_archive_name +from vorta.utils import get_asset, pretty_bytes, choose_file_dialog, format_archive_name, get_mount_points from vorta.models import BackupProfileMixin, ArchiveModel uifile = get_asset('UI/archivetab.ui') @@ -25,23 +27,29 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): def __init__(self, parent=None): super().__init__(parent) self.setupUi(parent) - self.mount_point = None + self.mount_points = {} + self.menu = None self.toolBox.setCurrentIndex(0) + self.folder_icon = QIcon(':/icons/folder-open.svg') header = self.archiveTable.horizontalHeader() header.setVisible(True) header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.Stretch) header.setStretchLastSection(True) if sys.platform != 'darwin': self._set_status('') # Set platform-specific hints. self.archiveTable.setSelectionBehavior(QTableView.SelectRows) + self.archiveTable.setSelectionMode(QTableView.SingleSelection) self.archiveTable.setEditTriggers(QTableView.NoEditTriggers) self.archiveTable.setAlternatingRowColors(True) + self.archiveTable.cellDoubleClicked.connect(self.cell_double_clicked) + self.archiveTable.itemSelectionChanged.connect(self.update_mount_button_text) # Populate pruning options from database profile = self.profile() @@ -75,9 +83,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): def populate_from_profile(self): """Populate archive list and prune settings from profile.""" - profile = self.profile() if profile.repo is not None: + self.mount_points = get_mount_points(profile.repo.url) self.toolBox.setItemText(0, self.tr('Archives for %s') % profile.repo.url) archives = [s for s in profile.repo.archives.select().order_by(ArchiveModel.time.desc())] @@ -91,13 +99,22 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): formatted_duration = str(timedelta(seconds=round(archive.duration))) else: formatted_duration = '' + self.archiveTable.setItem(row, 2, QTableWidgetItem(formatted_duration)) - self.archiveTable.setItem(row, 3, QTableWidgetItem(archive.name)) + + mount_point = self.mount_points.get(archive.name) + if mount_point is not None: + item = QTableWidgetItem(f'…{mount_point[-5:]}') + self.archiveTable.setItem(row, 3, item) + + self.archiveTable.setItem(row, 4, 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.mount_points = {} self.archiveTable.setRowCount(0) self.toolBox.setItemText(0, self.tr('Archives')) self._toggle_all_buttons(enabled=False) @@ -128,7 +145,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): # Conditions are met (borg binary available, etc) row_selected = self.archiveTable.selectionModel().selectedRows() if row_selected: - archive_cell = self.archiveTable.item(row_selected[0].row(), 3) + archive_cell = self.archiveTable.item(row_selected[0].row(), 4) if archive_cell: archive_name = archive_cell.text() params['cmd'][-1] += f'::{archive_name}' @@ -174,6 +191,20 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): self._set_status(self.tr('Refreshed archives.')) self.populate_from_profile() + def selected_archive_name(self): + row_selected = self.archiveTable.selectionModel().selectedRows() + if row_selected: + archive_cell = self.archiveTable.item(row_selected[0].row(), 4) + if archive_cell: + return archive_cell.text() + return None + + def set_mount_button_mode(self, mode): + self.mountButton.clicked.disconnect() + mount = (mode == 'Mount') + self.mountButton.setText('Mount' if mount else 'Unmount') + self.mountButton.clicked.connect(self.mount_action if mount else self.umount_action) + def mount_action(self): profile = self.profile() params = BorgMountThread.prepare(profile) @@ -182,18 +213,16 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): return # Conditions are met (borg binary available, etc) - 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['cmd'][-1] += f'::{archive_name}' + archive_name = self.selected_archive_name() + if archive_name: + params['cmd'][-1] += f'::{archive_name}' + params['current_archive'] = archive_name def receive(): mount_point = dialog.selectedFiles() if mount_point: params['cmd'].append(mount_point[0]) - self.mount_point = mount_point[0] + self.mount_points[params['current_archive']] = mount_point[0] if params['ok']: self._toggle_all_buttons(False) thread = BorgMountThread(params['cmd'], params, parent=self) @@ -201,45 +230,54 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): thread.result.connect(self.mount_result) thread.start() - dialog = choose_file_dialog(self, self.tr("Choose Mount Point")) + dialog = choose_file_dialog(self, self.tr("Choose Mount Point"), want_folder=True) dialog.open(receive) def mount_result(self, result): self._toggle_all_buttons(True) if result['returncode'] == 0: self._set_status(self.tr('Mounted successfully.')) - self.mountButton.setText(self.tr('Unmount')) - self.mountButton.clicked.disconnect() - self.mountButton.clicked.connect(self.umount_action) - else: - self.mount_point = None + self.update_mount_button_text() + archive_name = result['params']['current_archive'] + row = self.row_of_archive(archive_name) + item = QTableWidgetItem(f"…{result['cmd'][-1][-5:]}") + self.archiveTable.setItem(row, 3, item) def umount_action(self): - if self.mount_point is not None: + archive_name = self.selected_archive_name() + + mount_point = self.mount_points.get(archive_name) + + if mount_point is not None: profile = self.profile() params = BorgUmountThread.prepare(profile) if not params['ok']: self._set_status(translate(params['message'])) return - if self.mount_point in params['active_mount_points']: - params['cmd'].append(self.mount_point) + params['current_archive'] = archive_name + + if os.path.normpath(mount_point) in params['active_mount_points']: + params['cmd'].append(mount_point) thread = BorgUmountThread(params['cmd'], params, parent=self) thread.updated.connect(self.mountErrors.setText) thread.result.connect(self.umount_result) thread.start() else: - self._set_status(self.tr('Mount point not active. Try restarting Vorta.')) + self._set_status(self.tr('Mount point not active.')) return def umount_result(self, result): self._toggle_all_buttons(True) if result['returncode'] == 0: self._set_status(self.tr('Un-mounted successfully.')) - self.mountButton.setText(self.tr('Mount')) - self.mountButton.clicked.disconnect() - self.mountButton.clicked.connect(self.mount_action) - self.mount_point = None + self._set_status('Un-mounted successfully.') + archive_name = result['params']['current_archive'] + del self.mount_points[archive_name] + self.update_mount_button_text() + row = self.row_of_archive(archive_name) + item = QTableWidgetItem('') + self.archiveTable.setItem(row, 3, item) def save_prune_setting(self, new_value=None): profile = self.profile() @@ -253,7 +291,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): row_selected = self.archiveTable.selectionModel().selectedRows() if row_selected: - archive_cell = self.archiveTable.item(row_selected[0].row(), 3) + archive_cell = self.archiveTable.item(row_selected[0].row(), 4) if archive_cell: archive_name = archive_cell.text() params = BorgListArchiveThread.prepare(profile) @@ -298,8 +336,32 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): else: self._set_status(translate(params['message'])) - dialog = choose_file_dialog(self, self.tr("Choose Extraction Point")) + dialog = choose_file_dialog(self, self.tr("Choose Extraction Point"), want_folder=True) dialog.open(receive) def extract_archive_result(self, result): self._toggle_all_buttons(True) + + def update_mount_button_text(self): + archive_name = self.selected_archive_name() + if not archive_name: + return + + mode = 'Unmount' if archive_name in self.mount_points else 'Mount' + self.set_mount_button_mode(mode) + + def cell_double_clicked(self, row, column): + if column == 3: + archive_name = self.selected_archive_name() + if not archive_name: + return + + mount_point = self.mount_points.get(archive_name) + + if mount_point is not None: + QDesktopServices.openUrl(QtCore.QUrl(f'file:///{mount_point}')) + + def row_of_archive(self, archive_name): + items = self.archiveTable.findItems(archive_name, QtCore.Qt.MatchExactly) + rows = [item.row() for item in items if item.column() == 4] + return rows[0] if rows else None