vorta/src/vorta/views/repo_tab.py

322 lines
12 KiB
Python

import os
from pathlib import PurePath
from PyQt6 import QtCore, uic
from PyQt6.QtCore import QMimeData, QUrl
from PyQt6.QtWidgets import QApplication, QLayout, QMenu, QMessageBox
from vorta.store.models import ArchiveModel, BackupProfileMixin, RepoModel
from vorta.utils import borg_compat, get_asset, get_private_keys, pretty_bytes
from .repo_add_dialog import AddRepoWindow, ExistingRepoWindow
from .ssh_dialog import SSHAddWindow
from .utils import get_colored_icon
uifile = get_asset('UI/repotab.ui')
RepoUI, RepoBase = uic.loadUiType(uifile)
class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
repo_changed = QtCore.pyqtSignal()
repo_added = QtCore.pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(parent)
# Populate dropdowns
self.repoRemoveToolbutton.clicked.connect(self.repo_unlink_action)
self.copyURLbutton.clicked.connect(self.copy_URL_action)
# init repo add button
self.menuAddRepo = QMenu(self.bAddRepo)
self.menuAddRepo.addAction(self.tr("New Repository…"), self.new_repo)
self.menuAddRepo.addAction(self.tr("Existing Repository…"), self.add_existing_repo)
self.bAddRepo.setMenu(self.menuAddRepo)
# note: it is hard to describe these algorithms with attributes like low/medium/high
# compression or speed on a unified scale. this is not 1-dimensional and also depends
# on the input data. so we just tell what we know for sure.
# "auto" is used for some slower / older algorithms to avoid wasting a lot of time
# on uncompressible data.
self.repoCompression.addItem(self.tr('LZ4 (modern, default)'), 'lz4')
self.repoCompression.addItem(self.tr('Zstandard Level 3 (modern)'), 'zstd,3')
self.repoCompression.addItem(self.tr('Zstandard Level 8 (modern)'), 'zstd,8')
# zlib and lzma come from python stdlib and are there (and in borg) since long.
# but maybe not much reason to start with these nowadays, considering zstd supports
# a very wide range of compression levels and has great speed. if speed is more
# important than compression, lz4 is even a little better.
self.repoCompression.addItem(self.tr('ZLIB Level 6 (auto, legacy)'), 'auto,zlib,6')
self.repoCompression.addItem(self.tr('LZMA Level 6 (auto, legacy)'), 'auto,lzma,6')
self.repoCompression.addItem(self.tr('No Compression'), 'none')
self.repoCompression.currentIndexChanged.connect(self.compression_select_action)
self.toggle_available_compression()
self.repoCompression.currentIndexChanged.connect(self.compression_select_action)
self.init_ssh()
self.sshComboBox.currentIndexChanged.connect(self.ssh_select_action)
self.sshKeyToClipboardButton.clicked.connect(self.ssh_copy_to_clipboard_action)
self.bAddSSHKey.clicked.connect(self.create_ssh_key)
self.set_icons()
# Connect to palette change
QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
self.populate_from_profile() # needs init of ssh and compression items
def set_icons(self):
self.bAddSSHKey.setIcon(get_colored_icon("plus"))
self.bAddRepo.setIcon(get_colored_icon("plus"))
self.repoRemoveToolbutton.setIcon(get_colored_icon('unlink'))
self.sshKeyToClipboardButton.setIcon(get_colored_icon('copy'))
self.copyURLbutton.setIcon(get_colored_icon('copy'))
def set_repos(self):
self.repoSelector.clear()
self.repoSelector.addItem(self.tr('No repository selected'), None)
for repo in RepoModel.select():
self.repoSelector.addItem(repo.url, repo.id)
def populate_from_profile(self):
try:
self.repoSelector.currentIndexChanged.disconnect(self.repo_select_action)
except TypeError: # raised when signal is not connected
pass
# populate repositories
self.set_repos()
# load profile configuration
profile = self.profile()
if profile.repo:
self.repoSelector.setCurrentIndex(self.repoSelector.findData(profile.repo.id))
else:
self.repoSelector.setCurrentIndex(0)
self.repoCompression.setCurrentIndex(self.repoCompression.findData(profile.compression))
self.sshComboBox.setCurrentIndex(self.sshComboBox.findData(profile.ssh_key))
self.init_repo_stats()
self.repoSelector.currentIndexChanged.connect(self.repo_select_action)
def init_repo_stats(self):
"""Set the strings of the repo stats labels."""
# prepare translations
na = self.tr('N/A', "Not available.")
no_repo_selected = self.tr("Select a repository first.")
refresh = self.tr("Try refreshing the metadata of any archive.")
# set labels
repo: RepoModel = self.profile().repo
if repo is not None:
# remove *unset* item
self.repoSelector.removeItem(self.repoSelector.findData(None))
# Start with every element enabled, then disable SSH-related if relevant
for child in self.frameRepoSettings.children():
child.setEnabled(True)
# local repo doesn't use ssh
ssh_enabled = repo.is_remote_repo()
# self.bAddSSHKey.setEnabled(ssh_enabled)
# otherwise one cannot add a ssh key for adding a repo
self.sshComboBox.setEnabled(ssh_enabled)
self.sshKeyToClipboardButton.setEnabled(ssh_enabled)
# update stats
if repo.unique_csize is not None:
self.sizeCompressed.setText(pretty_bytes(repo.unique_csize))
self.sizeCompressed.setToolTip('')
else:
self.sizeCompressed.setText(na)
self.sizeCompressed.setToolTip(refresh)
if repo.unique_size is not None:
self.sizeDeduplicated.setText(pretty_bytes(repo.unique_size))
self.sizeDeduplicated.setToolTip('')
else:
self.sizeDeduplicated.setText(na)
self.sizeDeduplicated.setToolTip(refresh)
if repo.total_size is not None:
self.sizeOriginal.setText(pretty_bytes(repo.total_size))
self.sizeOriginal.setToolTip('')
else:
self.sizeOriginal.setText(na)
self.sizeOriginal.setToolTip(refresh)
self.repoEncryption.setText(str(repo.encryption))
else:
# Compression and SSH key are only valid entries for a repo
# Yet Add SSH key button must be enabled for bootstrapping
for child in self.frameRepoSettings.children():
if not isinstance(child, QLayout):
child.setEnabled(False)
self.bAddSSHKey.setEnabled(True)
# unset stats
self.sizeCompressed.setText(na)
self.sizeCompressed.setToolTip(no_repo_selected)
self.sizeDeduplicated.setText(na)
self.sizeDeduplicated.setToolTip(no_repo_selected)
self.sizeOriginal.setText(na)
self.sizeOriginal.setToolTip(no_repo_selected)
self.repoEncryption.setText(na)
self.repoEncryption.setToolTip(no_repo_selected)
self.repo_changed.emit()
def init_ssh(self):
keys = get_private_keys()
self.sshComboBox.clear()
self.sshComboBox.addItem(self.tr('Automatically choose SSH Key (default)'), None)
for key in keys:
self.sshComboBox.addItem(f'{key}', key)
def toggle_available_compression(self):
use_zstd = borg_compat.check('ZSTD')
for algo in ['zstd,3', 'zstd,8']:
ix = self.repoCompression.findData(algo)
self.repoCompression.model().item(ix).setEnabled(use_zstd)
def ssh_select_action(self, index):
profile = self.profile()
profile.ssh_key = self.sshComboBox.itemData(index)
profile.save()
def create_ssh_key(self):
"""Open a dialog to create an ssh key."""
ssh_add_window = SSHAddWindow()
self._window = ssh_add_window # For tests
ssh_add_window.setParent(self, QtCore.Qt.WindowType.Sheet)
ssh_add_window.accepted.connect(self.init_ssh)
# ssh_add_window.rejected.connect(lambda: self.sshComboBox.setCurrentIndex(0))
ssh_add_window.open()
def ssh_copy_to_clipboard_action(self):
msg = QMessageBox()
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
msg.setParent(self, QtCore.Qt.WindowType.Sheet)
index = self.sshComboBox.currentIndex()
if index > 0:
ssh_key_filename = self.sshComboBox.itemData(index)
ssh_key_path = os.path.expanduser(f'~/.ssh/{ssh_key_filename}.pub')
if os.path.isfile(ssh_key_path):
pub_key = open(ssh_key_path).read().strip()
clipboard = QApplication.clipboard()
clipboard.setText(pub_key)
msg.setWindowTitle(self.tr("Public Key Copied to Clipboard"))
msg.setText(
self.tr(
"The selected public SSH key was copied to the clipboard. "
"Use it to set up remote repo permissions."
)
)
else:
msg.setText(self.tr("Could not find public key."))
else:
msg.setText(self.tr("Select a public key from the dropdown first."))
msg.show()
def compression_select_action(self, index):
profile = self.profile()
profile.compression = self.repoCompression.currentData()
profile.save()
def new_repo(self):
"""Open a dialog to create a new repo and add it to vorta."""
window = AddRepoWindow()
self._window = window # For tests
window.setParent(self, QtCore.Qt.WindowType.Sheet)
window.added_repo.connect(self.process_new_repo)
# window.rejected.connect(lambda: self.repoSelector.setCurrentIndex(0))
window.open()
def add_existing_repo(self):
"""Open a dialog to add a existing repo to vorta."""
window = ExistingRepoWindow()
self._window = window # For tests
window.setParent(self, QtCore.Qt.WindowType.Sheet)
window.added_repo.connect(self.process_new_repo)
# window.rejected.connect(lambda: self.repoSelector.setCurrentIndex(0))
window.open()
def repo_select_action(self):
profile = self.profile()
profile.repo = self.repoSelector.currentData()
profile.save()
self.init_repo_stats()
def process_new_repo(self, result):
if result['returncode'] == 0:
new_repo = RepoModel.get(url=result['params']['repo_url'])
profile = self.profile()
profile.repo = new_repo.id
profile.save()
self.set_repos()
self.repoSelector.setCurrentIndex(self.repoSelector.count() - 1)
self.repo_added.emit()
self.init_repo_stats()
def repo_unlink_action(self):
profile = self.profile()
self.init_repo_stats()
msg = QMessageBox()
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
msg.setParent(self, QtCore.Qt.WindowType.Sheet)
selected_repo_id = self.repoSelector.currentData()
selected_repo_index = self.repoSelector.currentIndex()
if not selected_repo_id:
# QComboBox is empty / repo unset
return
repo = RepoModel.get(id=selected_repo_id)
ArchiveModel.delete().where(ArchiveModel.repo_id == repo.id).execute()
profile.repo = None
profile.save()
repo.delete_instance(recursive=True) # This also deletes archives.
self.repoSelector.setCurrentIndex(0)
self.repoSelector.removeItem(selected_repo_index)
msg.setWindowTitle(self.tr('Repository was Unlinked'))
msg.setText(self.tr('You can always connect it again later.'))
msg.show()
self.repo_changed.emit()
self.populate_from_profile()
def copy_URL_action(self):
selected_repo_id = self.repoSelector.currentData()
if not selected_repo_id:
# QComboBox is empty / repo unset
return
repo = RepoModel.get(id=selected_repo_id)
url = repo.url
data = QMimeData()
if not repo.is_remote_repo():
path = PurePath(url)
data.setUrls([QUrl(path.as_uri())])
data.setText(str(path))
else:
data.setText(url)
QApplication.clipboard().setMimeData(data)