mirror of https://github.com/borgbase/vorta
1017 lines
40 KiB
Python
1017 lines
40 KiB
Python
import logging
|
|
import sys
|
|
from datetime import timedelta
|
|
from typing import Dict, Optional
|
|
|
|
from PyQt6 import QtCore, uic
|
|
from PyQt6.QtCore import QItemSelectionModel, QMimeData, QPoint, Qt, pyqtSlot
|
|
from PyQt6.QtGui import QDesktopServices, QKeySequence, QShortcut
|
|
from PyQt6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QApplication,
|
|
QHeaderView,
|
|
QLayout,
|
|
QMenu,
|
|
QMessageBox,
|
|
QStyledItemDelegate,
|
|
QTableView,
|
|
QTableWidgetItem,
|
|
QWidget,
|
|
)
|
|
|
|
from vorta.borg.check import BorgCheckJob
|
|
from vorta.borg.compact import BorgCompactJob
|
|
from vorta.borg.delete import BorgDeleteJob
|
|
from vorta.borg.diff import BorgDiffJob
|
|
from vorta.borg.extract import BorgExtractJob
|
|
from vorta.borg.info_archive import BorgInfoArchiveJob
|
|
from vorta.borg.list_archive import BorgListArchiveJob
|
|
from vorta.borg.list_repo import BorgListRepoJob
|
|
from vorta.borg.mount import BorgMountJob
|
|
from vorta.borg.prune import BorgPruneJob
|
|
from vorta.borg.rename import BorgRenameJob
|
|
from vorta.borg.umount import BorgUmountJob
|
|
from vorta.i18n import translate
|
|
from vorta.store.models import ArchiveModel, BackupProfileMixin, SettingsModel
|
|
from vorta.utils import (
|
|
borg_compat,
|
|
choose_file_dialog,
|
|
find_best_unit_for_sizes,
|
|
format_archive_name,
|
|
get_asset,
|
|
get_mount_points,
|
|
pretty_bytes,
|
|
)
|
|
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
|
|
|
|
uifile = get_asset('UI/archivetab.ui')
|
|
ArchiveTabUI, ArchiveTabBase = uic.loadUiType(uifile)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
#: The number of decimal digits to show in the size column
|
|
SIZE_DECIMAL_DIGITS = 1
|
|
|
|
|
|
# from https://stackoverflow.com/questions/63177587/pyqt-tableview-align-icons-to-center
|
|
class IconDelegate(QStyledItemDelegate):
|
|
def initStyleOption(self, option, index):
|
|
super().initStyleOption(option, index)
|
|
option.decorationSize = option.rect.size() - QtCore.QSize(0, 10)
|
|
|
|
|
|
class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|
prune_intervals = ['hour', 'day', 'week', 'month', 'year']
|
|
|
|
def __init__(self, parent=None, app=None):
|
|
"""Init."""
|
|
super().__init__(parent)
|
|
self.setupUi(parent)
|
|
self.mount_points = {} # mapping of archive name to mount point
|
|
self.repo_mount_point: Optional[str] = None # mount point of whole repo
|
|
self.menu = None
|
|
self.app = app
|
|
self.toolBox.setCurrentIndex(0)
|
|
self.repoactions_enabled = True
|
|
self.renamed_archive_original_name = None
|
|
self.remaining_refresh_archives = (
|
|
0 # number of archives that are left to refresh before action buttons are enabled again
|
|
)
|
|
|
|
#: Tooltip dict to save the tooltips set in the designer
|
|
self.tooltip_dict: Dict[QWidget, str] = {}
|
|
self.tooltip_dict[self.bDiff] = self.bDiff.toolTip()
|
|
self.tooltip_dict[self.bDelete] = self.bDelete.toolTip()
|
|
self.tooltip_dict[self.bRefreshArchive] = self.bRefreshArchive.toolTip()
|
|
self.tooltip_dict[self.compactButton] = self.compactButton.toolTip()
|
|
|
|
header = self.archiveTable.horizontalHeader()
|
|
header.setVisible(True)
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
|
|
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
|
|
|
|
delegate = IconDelegate(self.archiveTable)
|
|
self.archiveTable.setItemDelegateForColumn(5, delegate)
|
|
|
|
if sys.platform != 'darwin':
|
|
self._set_status('') # Set platform-specific hints.
|
|
|
|
self.archiveTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
self.archiveTable.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
self.archiveTable.setWordWrap(False)
|
|
self.archiveTable.setTextElideMode(QtCore.Qt.TextElideMode.ElideLeft)
|
|
self.archiveTable.setAlternatingRowColors(True)
|
|
self.archiveTable.cellDoubleClicked.connect(self.cell_double_clicked)
|
|
self.archiveTable.cellChanged.connect(self.cell_changed)
|
|
self.archiveTable.setSortingEnabled(True)
|
|
self.archiveTable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.archiveTable.customContextMenuRequested.connect(self.archiveitem_contextmenu)
|
|
|
|
# shortcuts
|
|
shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.archiveTable)
|
|
shortcut_copy.activated.connect(self.archive_copy)
|
|
|
|
# single and double selection feature
|
|
self.archiveTable.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
|
|
self.archiveTable.selectionModel().selectionChanged.connect(self.on_selection_change)
|
|
|
|
# connect archive actions
|
|
self.bMountArchive.clicked.connect(self.bmountarchive_clicked)
|
|
self.bRefreshArchive.clicked.connect(self.refresh_archive_info)
|
|
self.bRename.clicked.connect(self.cell_double_clicked)
|
|
self.bDelete.clicked.connect(self.delete_action)
|
|
self.bExtract.clicked.connect(self.extract_action)
|
|
self.compactButton.clicked.connect(self.compact_action)
|
|
|
|
# other signals
|
|
self.bList.clicked.connect(self.refresh_archive_list)
|
|
self.bPrune.clicked.connect(self.prune_action)
|
|
self.bCheck.clicked.connect(self.check_action)
|
|
self.bDiff.clicked.connect(self.diff_action)
|
|
self.bMountRepo.clicked.connect(self.bmountrepo_clicked)
|
|
|
|
self.archiveNameTemplate.textChanged.connect(
|
|
lambda tpl, key='new_archive_name': self.save_archive_template(tpl, key)
|
|
)
|
|
self.prunePrefixTemplate.textChanged.connect(
|
|
lambda tpl, key='prune_prefix': self.save_archive_template(tpl, key)
|
|
)
|
|
|
|
self.populate_from_profile()
|
|
self.selected_archives = None # TODO: remove unused variable
|
|
self.set_icons()
|
|
|
|
# Connect to palette change
|
|
self.app.paletteChanged.connect(lambda p: self.set_icons())
|
|
|
|
def set_icons(self):
|
|
"""Used when changing between light- and dark mode"""
|
|
self.bCheck.setIcon(get_colored_icon('check-circle'))
|
|
self.bDiff.setIcon(get_colored_icon('stream-solid'))
|
|
self.bPrune.setIcon(get_colored_icon('cut'))
|
|
self.bList.setIcon(get_colored_icon('refresh'))
|
|
self.compactButton.setIcon(get_colored_icon('broom-solid'))
|
|
self.toolBox.setItemIcon(0, get_colored_icon('tasks'))
|
|
self.toolBox.setItemIcon(1, get_colored_icon('cut'))
|
|
self.bRefreshArchive.setIcon(get_colored_icon('refresh'))
|
|
self.bRename.setIcon(get_colored_icon('edit'))
|
|
self.bDelete.setIcon(get_colored_icon('trash'))
|
|
self.bExtract.setIcon(get_colored_icon('cloud-download'))
|
|
|
|
self.bmountarchive_refresh(icon_only=True)
|
|
self.bmountrepo_refresh()
|
|
|
|
@pyqtSlot(QPoint)
|
|
def archiveitem_contextmenu(self, pos: QPoint):
|
|
# index under cursor
|
|
index = self.archiveTable.indexAt(pos)
|
|
if not index.isValid():
|
|
return # popup only for items
|
|
|
|
selected_rows = self.archiveTable.selectionModel().selectedRows(index.column())
|
|
|
|
if selected_rows and index not in selected_rows:
|
|
return # popup only for selected items
|
|
|
|
menu = QMenu(self.archiveTable)
|
|
menu.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.archive_copy(index=index))
|
|
menu.addSeparator()
|
|
|
|
# archive actions
|
|
button_connection_pairs = [
|
|
(self.bRefreshArchive, self.refresh_archive_info),
|
|
(self.bDiff, self.diff_action),
|
|
(self.bMountArchive, self.bmountarchive_clicked),
|
|
(self.bExtract, self.extract_action),
|
|
(self.bRename, self.cell_double_clicked),
|
|
(self.bDelete, self.delete_action),
|
|
]
|
|
|
|
for button, connection in button_connection_pairs:
|
|
action = menu.addAction(button.icon(), button.text(), connection)
|
|
action.setEnabled(button.isEnabled())
|
|
|
|
menu.popup(self.archiveTable.viewport().mapToGlobal(pos))
|
|
|
|
def cancel_action(self):
|
|
self._set_status(self.tr("Action cancelled."))
|
|
self._toggle_all_buttons(True)
|
|
|
|
def _set_status(self, text):
|
|
self.mountErrors.setText(text)
|
|
self.mountErrors.repaint()
|
|
|
|
def _toggle_all_buttons(self, enabled=True):
|
|
"""
|
|
Set all the buttons in the archive panel to the given state.
|
|
|
|
Parameters
|
|
----------
|
|
enabled : bool, optional
|
|
The enabled state, by default True
|
|
"""
|
|
self.repoactions_enabled = enabled
|
|
|
|
for button in [
|
|
self.bCheck,
|
|
self.bList,
|
|
self.bPrune,
|
|
self.bDiff,
|
|
self.bMountRepo,
|
|
self.bDelete,
|
|
self.compactButton,
|
|
self.fArchiveActions,
|
|
]:
|
|
button.setEnabled(enabled)
|
|
button.repaint()
|
|
|
|
# Restore states
|
|
self.on_selection_change()
|
|
|
|
def populate_from_profile(self):
|
|
"""Populate archive list and prune settings from profile."""
|
|
profile = self.profile()
|
|
if profile.repo is not None:
|
|
# get mount points
|
|
self.mount_points, repo_mount_points = get_mount_points(profile.repo.url)
|
|
if repo_mount_points:
|
|
self.repo_mount_point = repo_mount_points[0]
|
|
|
|
if profile.repo.name:
|
|
repo_name = f"{profile.repo.name} ({profile.repo.url})"
|
|
else:
|
|
repo_name = profile.repo.url
|
|
self.toolBox.setItemText(0, self.tr('Archives for {}').format(repo_name))
|
|
|
|
archives = [s for s in profile.repo.archives.select().order_by(ArchiveModel.time.desc())]
|
|
|
|
# if no archive's name can be found in self.mount_points, then hide the mount point column
|
|
if not any(a.name in self.mount_points for a in archives):
|
|
self.archiveTable.hideColumn(3)
|
|
else:
|
|
self.archiveTable.showColumn(3)
|
|
|
|
sorting = self.archiveTable.isSortingEnabled()
|
|
self.archiveTable.setSortingEnabled(False)
|
|
best_unit = find_best_unit_for_sizes((a.size for a in archives), precision=SIZE_DECIMAL_DIGITS)
|
|
for row, archive in enumerate(archives):
|
|
self.archiveTable.insertRow(row)
|
|
|
|
formatted_time = archive.time.strftime('%Y-%m-%d %H:%M')
|
|
self.archiveTable.setItem(row, 0, QTableWidgetItem(formatted_time))
|
|
|
|
# format units based on user settings for 'dynamic' or 'fixed' units
|
|
fixed_unit = best_unit if SettingsModel.get(key='enable_fixed_units').value else None
|
|
size = pretty_bytes(archive.size, fixed_unit=fixed_unit, precision=SIZE_DECIMAL_DIGITS)
|
|
self.archiveTable.setItem(row, 1, SizeItem(size))
|
|
|
|
if archive.duration is not None:
|
|
formatted_duration = str(timedelta(seconds=round(archive.duration)))
|
|
else:
|
|
formatted_duration = ''
|
|
|
|
self.archiveTable.setItem(row, 2, QTableWidgetItem(formatted_duration))
|
|
|
|
mount_point = self.mount_points.get(archive.name)
|
|
if mount_point is not None:
|
|
item = QTableWidgetItem(mount_point)
|
|
self.archiveTable.setItem(row, 3, item)
|
|
|
|
self.archiveTable.setItem(row, 4, QTableWidgetItem(archive.name))
|
|
|
|
if archive.trigger == 'scheduled':
|
|
item = QTableWidgetItem(get_colored_icon('clock-o'), '')
|
|
item.setToolTip(self.tr('Scheduled'))
|
|
self.archiveTable.setItem(row, 5, item)
|
|
elif archive.trigger == 'user':
|
|
item = QTableWidgetItem(get_colored_icon('user'), '')
|
|
item.setToolTip(self.tr('User initiated'))
|
|
item.setTextAlignment(Qt.AlignmentFlag.AlignRight)
|
|
self.archiveTable.setItem(row, 5, item)
|
|
|
|
self.archiveTable.setRowCount(len(archives))
|
|
self.archiveTable.setSortingEnabled(sorting)
|
|
item = self.archiveTable.item(0, 0)
|
|
self.archiveTable.scrollToItem(item)
|
|
|
|
self.archiveTable.selectionModel().clearSelection()
|
|
if self.remaining_refresh_archives == 0:
|
|
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)
|
|
|
|
self.archiveNameTemplate.setText(profile.new_archive_name)
|
|
self.prunePrefixTemplate.setText(profile.prune_prefix)
|
|
|
|
# Populate pruning options from database
|
|
profile = self.profile()
|
|
for i in self.prune_intervals:
|
|
getattr(self, f'prune_{i}').setValue(getattr(profile, f'prune_{i}'))
|
|
getattr(self, f'prune_{i}').valueChanged.connect(self.save_prune_setting)
|
|
self.prune_keep_within.setText(profile.prune_keep_within)
|
|
self.prune_keep_within.editingFinished.connect(self.save_prune_setting)
|
|
|
|
def on_selection_change(self, selected=None, deselected=None):
|
|
"""
|
|
React to a change of the selection of the archiveTableView.
|
|
|
|
Enables or disables archive actions and the diff button.
|
|
Makes sure at maximum 2 rows are selected.
|
|
|
|
Parameters
|
|
----------
|
|
selected : QItemSelection, optional
|
|
The new selection.
|
|
deselected : QItemSelection, optional
|
|
The previous selection.
|
|
"""
|
|
# handle selection of more than 2 rows
|
|
selectionModel: QItemSelectionModel = self.archiveTable.selectionModel()
|
|
indexes = selectionModel.selectedRows()
|
|
# actions that are enabled only when a single archive is selected
|
|
single_archive_action_buttons = [self.bMountArchive, self.bExtract, self.bRename]
|
|
# actions that are enabled when at least one archive is selected
|
|
multi_archive_action_buttons = [self.bDelete, self.bRefreshArchive]
|
|
|
|
# Toggle archive actions frame
|
|
layout: QLayout = self.fArchiveActions.layout()
|
|
|
|
# task in progress -> disable all
|
|
reason = ""
|
|
if not self.repoactions_enabled:
|
|
reason = self.tr("(borg already running)")
|
|
|
|
# Disable the delete and refresh buttons if no archive is selected
|
|
if self.repoactions_enabled and len(indexes) > 0:
|
|
for button in multi_archive_action_buttons:
|
|
button.setEnabled(True)
|
|
button.setToolTip(self.tooltip_dict.get(button, ""))
|
|
else:
|
|
for button in multi_archive_action_buttons:
|
|
button.setEnabled(False)
|
|
button.setToolTip(self.tooltip_dict.get(button, "") + " " + self.tr("(Select minimum one archive)"))
|
|
|
|
# Toggle diff button
|
|
if self.repoactions_enabled and len(indexes) == 2:
|
|
# Enable diff button
|
|
self.bDiff.setEnabled(True)
|
|
self.bDiff.setToolTip(self.tooltip_dict.get(self.bDiff, ""))
|
|
else:
|
|
# disable diff button
|
|
self.bDiff.setEnabled(False)
|
|
|
|
tooltip = self.tooltip_dict[self.bDiff]
|
|
self.bDiff.setToolTip(tooltip + " " + reason or self.tr("(Select two archives)"))
|
|
|
|
if self.repoactions_enabled and len(indexes) == 1:
|
|
# Enable archive actions
|
|
for widget in single_archive_action_buttons:
|
|
widget.setEnabled(True)
|
|
|
|
for index in range(layout.count()):
|
|
widget = layout.itemAt(index).widget()
|
|
if widget is not None:
|
|
widget.setToolTip(self.tooltip_dict.get(widget, ""))
|
|
|
|
# refresh bMountArchive for the selected archive
|
|
self.bmountarchive_refresh()
|
|
else:
|
|
reason = reason or self.tr("(Select exactly one archive)")
|
|
|
|
# too few or too many selected.
|
|
for widget in single_archive_action_buttons:
|
|
tooltip = widget.toolTip()
|
|
tooltip = self.tooltip_dict.setdefault(widget, tooltip)
|
|
widget.setToolTip(tooltip + " " + reason)
|
|
widget.setEnabled(False)
|
|
|
|
# special treatment for dynamic mount/unmount button.
|
|
self.bmountarchive_refresh()
|
|
tooltip = self.bMountArchive.toolTip()
|
|
self.bMountArchive.setToolTip(tooltip + " " + reason)
|
|
|
|
def archive_copy(self, index=None):
|
|
"""
|
|
Copy an archive name to the clipboard.
|
|
|
|
Copies the first selected archive if no index is specified.
|
|
"""
|
|
if index is None:
|
|
indexes = self.archiveTable.selectionModel().selectedRows()
|
|
|
|
if not indexes:
|
|
return
|
|
|
|
index = indexes[0]
|
|
|
|
archive_name = self.archiveTable.item(index.row(), 4).text()
|
|
|
|
data = QMimeData()
|
|
data.setText(archive_name)
|
|
|
|
QApplication.clipboard().setMimeData(data)
|
|
|
|
def save_archive_template(self, tpl, key):
|
|
profile = self.profile()
|
|
try:
|
|
preview = self.tr('Preview: %s') % format_archive_name(profile, tpl)
|
|
setattr(profile, key, tpl)
|
|
profile.save()
|
|
except Exception:
|
|
preview = self.tr('Error in archive name template.')
|
|
|
|
if key == 'new_archive_name':
|
|
self.archiveNamePreview.setText(preview)
|
|
else:
|
|
self.prunePrefixPreview.setText(preview)
|
|
|
|
def check_action(self):
|
|
params = BorgCheckJob.prepare(self.profile())
|
|
if not params['ok']:
|
|
self._set_status(params['message'])
|
|
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(), 4)
|
|
if archive_cell:
|
|
archive_name = archive_cell.text()
|
|
params['cmd'][-1] += f'::{archive_name}'
|
|
|
|
job = BorgCheckJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.check_result)
|
|
self._toggle_all_buttons(False)
|
|
self.app.jobs_manager.add_job(job)
|
|
|
|
def check_result(self, result):
|
|
if result['returncode'] == 0:
|
|
self._toggle_all_buttons(True)
|
|
|
|
def compact_action(self):
|
|
params = BorgCompactJob.prepare(self.profile())
|
|
if params['ok']:
|
|
job = BorgCompactJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.compact_result)
|
|
self._toggle_all_buttons(False)
|
|
self.app.jobs_manager.add_job(job)
|
|
else:
|
|
self._set_status(params['message'])
|
|
|
|
def compact_result(self, result):
|
|
self._toggle_all_buttons(True)
|
|
|
|
def prune_action(self):
|
|
params = BorgPruneJob.prepare(self.profile())
|
|
if params['ok']:
|
|
job = BorgPruneJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.prune_result)
|
|
self._toggle_all_buttons(False)
|
|
self.app.jobs_manager.add_job(job)
|
|
else:
|
|
self._set_status(params['message'])
|
|
|
|
def prune_result(self, result):
|
|
if result['returncode'] == 0:
|
|
self._set_status(self.tr('Pruning finished.'))
|
|
self.refresh_archive_list()
|
|
else:
|
|
self._toggle_all_buttons(True)
|
|
|
|
def refresh_archive_list(self):
|
|
params = BorgListRepoJob.prepare(self.profile())
|
|
if params['ok']:
|
|
job = BorgListRepoJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.list_result)
|
|
self._toggle_all_buttons(False)
|
|
self.app.jobs_manager.add_job(job)
|
|
else:
|
|
self._set_status(params['message'])
|
|
|
|
def list_result(self, result):
|
|
self._toggle_all_buttons(True)
|
|
if result['returncode'] == 0:
|
|
self._set_status(self.tr('Refreshed archives.'))
|
|
self.populate_from_profile()
|
|
|
|
def refresh_archive_info(self):
|
|
selected_archives = self.archiveTable.selectionModel().selectedRows()
|
|
|
|
archive_names = []
|
|
for index in selected_archives:
|
|
archive_names.append(self.archiveTable.item(index.row(), 4).text())
|
|
|
|
self.remaining_refresh_archives = len(archive_names) # number of archives to refresh
|
|
self._toggle_all_buttons(False)
|
|
for archive_name in archive_names:
|
|
if archive_name is not None:
|
|
params = BorgInfoArchiveJob.prepare(self.profile(), archive_name)
|
|
if params['ok']:
|
|
job = BorgInfoArchiveJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.info_result)
|
|
self.app.jobs_manager.add_job(job)
|
|
else:
|
|
self._set_status(params['message'])
|
|
return
|
|
|
|
def info_result(self, result):
|
|
self.remaining_refresh_archives -= 1
|
|
if result['returncode'] == 0 and self.remaining_refresh_archives == 0:
|
|
self._toggle_all_buttons(True)
|
|
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 bmountarchive_clicked(self):
|
|
"""
|
|
Handle `bMountArchive` being clicked.
|
|
|
|
Mount or umount the current archive depending on its current state.
|
|
"""
|
|
archive_name = self.selected_archive_name()
|
|
|
|
if not archive_name:
|
|
logger.warning("Archive name of selection is empty.")
|
|
return
|
|
|
|
if archive_name in self.mount_points:
|
|
self.unmount_action(archive_name=archive_name)
|
|
else:
|
|
self.mount_action(archive_name=archive_name)
|
|
|
|
def bmountrepo_clicked(self):
|
|
"""
|
|
Handle `bMountRepo` being clicked.
|
|
|
|
Mount or umount the repository depending on its current state.
|
|
"""
|
|
if self.repo_mount_point:
|
|
self.unmount_action()
|
|
else:
|
|
self.mount_action()
|
|
|
|
def bmountarchive_refresh(self, icon_only=False):
|
|
"""
|
|
Update label, tooltip and state of `bMountArchive`.
|
|
|
|
The new state depends on the mount status of the current archive.
|
|
This also updates the icon of the button.
|
|
"""
|
|
archive_name = self.selected_archive_name()
|
|
|
|
if archive_name in self.mount_points:
|
|
self.bMountArchive.setIcon(get_colored_icon('eject'))
|
|
if not icon_only:
|
|
self.bMountArchive.setText(self.tr("Unmount"))
|
|
self.bMountArchive.setToolTip(self.tr('Unmount the selected archive from the file system'))
|
|
else:
|
|
self.bMountArchive.setIcon(get_colored_icon('folder-open'))
|
|
if not icon_only:
|
|
self.bMountArchive.setText(self.tr("Mount…"))
|
|
self.bMountArchive.setToolTip(self.tr("Mount the selected archive " + "as a folder in the file system"))
|
|
|
|
def bmountrepo_refresh(self):
|
|
"""
|
|
Update label, tooltip and state of `bMountRepo`.
|
|
|
|
The new state depends on the mount status of the current archive.
|
|
This also updates the icon of the button.
|
|
"""
|
|
if self.repo_mount_point:
|
|
self.bMountRepo.setText(self.tr("Unmount"))
|
|
self.bMountRepo.setToolTip(self.tr('Unmount the repository from the file system'))
|
|
self.bMountRepo.setIcon(get_colored_icon('eject'))
|
|
else:
|
|
self.bMountRepo.setText(self.tr("Mount…"))
|
|
self.bMountRepo.setIcon(get_colored_icon('folder-open'))
|
|
self.bMountRepo.setToolTip(self.tr("Mount the repository as a folder in the file system"))
|
|
|
|
def mount_action(self, archive_name=None):
|
|
"""
|
|
Mount an archive or the whole repository.
|
|
|
|
Opens a file chooser to let the user choose a mount point and starts
|
|
the borg job for mounting afterwards.
|
|
|
|
Parameters
|
|
----------
|
|
archive_name : str, optional
|
|
The archive to mount or None, by default None
|
|
"""
|
|
profile = self.profile()
|
|
params = BorgMountJob.prepare(profile, archive=archive_name)
|
|
if not params['ok']:
|
|
self._set_status(params['message'])
|
|
return
|
|
|
|
def receive():
|
|
mount_point = dialog.selectedFiles()
|
|
if mount_point:
|
|
params['cmd'].append(mount_point[0])
|
|
params['mount_point'] = mount_point[0]
|
|
|
|
if params['ok']:
|
|
self._toggle_all_buttons(False)
|
|
job = BorgMountJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self.mountErrors.setText)
|
|
job.result.connect(self.mount_result)
|
|
self.app.jobs_manager.add_job(job)
|
|
|
|
dialog = choose_file_dialog(self, self.tr("Choose Mount Point"), want_folder=True)
|
|
dialog.open(receive)
|
|
|
|
def mount_result(self, result):
|
|
if result['returncode'] == 0:
|
|
self._set_status(self.tr('Mounted successfully.'))
|
|
|
|
mount_point = result['params']['mount_point']
|
|
|
|
if result['params'].get('mounted_archive'):
|
|
# archive was mounted
|
|
archive_name = result['params']['mounted_archive']
|
|
self.mount_points[archive_name] = mount_point
|
|
|
|
# update column in table
|
|
row = self.row_of_archive(archive_name)
|
|
item = QTableWidgetItem(result['cmd'][-1])
|
|
self.archiveTable.setItem(row, 3, item)
|
|
|
|
# update button
|
|
self.bmountarchive_refresh()
|
|
else:
|
|
# whole repo was mounted
|
|
self.repo_mount_point = mount_point
|
|
self.bmountrepo_refresh()
|
|
|
|
self._toggle_all_buttons(True)
|
|
|
|
def unmount_action(self, archive_name=None):
|
|
"""
|
|
Unmount a (mounted) repository or archive.
|
|
|
|
If the target isn't mounted nothing happens.
|
|
|
|
Parameters
|
|
----------
|
|
archive_name : str, optional
|
|
The archive to unmount, by default None
|
|
"""
|
|
if archive_name:
|
|
# unmount a single archive
|
|
mount_point = self.mount_points.get(archive_name)
|
|
else:
|
|
# unmount the whole repository
|
|
mount_point = self.repo_mount_point
|
|
|
|
if mount_point is not None:
|
|
profile = self.profile()
|
|
params = BorgUmountJob.prepare(profile, mount_point, archive_name=archive_name)
|
|
if not params['ok']:
|
|
self._set_status(translate('message', params['message']))
|
|
return
|
|
|
|
job = BorgUmountJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self.mountErrors.setText)
|
|
job.result.connect(self.umount_result)
|
|
self.app.jobs_manager.add_job(job)
|
|
|
|
def umount_result(self, result):
|
|
self._toggle_all_buttons(True)
|
|
archive_name = result['params'].get('current_archive')
|
|
mount_point = result['params']['mount_point']
|
|
|
|
if result['returncode'] == 0:
|
|
self._set_status(self.tr('Un-mounted successfully.'))
|
|
|
|
if archive_name:
|
|
# unmount single archive
|
|
del self.mount_points[archive_name]
|
|
row = self.row_of_archive(archive_name)
|
|
item = QTableWidgetItem('')
|
|
self.archiveTable.setItem(row, 3, item)
|
|
|
|
# update button
|
|
self.bmountarchive_refresh()
|
|
else:
|
|
# unmount repo
|
|
self.repo_mount_point = None
|
|
|
|
self.bmountrepo_refresh()
|
|
else:
|
|
self._set_status(self.tr('Unmounting failed. Make sure no programs are using {}').format(mount_point))
|
|
|
|
def save_prune_setting(self, new_value=None):
|
|
profile = self.profile()
|
|
for i in self.prune_intervals:
|
|
setattr(profile, f'prune_{i}', getattr(self, f'prune_{i}').value())
|
|
profile.prune_keep_within = self.prune_keep_within.text()
|
|
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()
|
|
if row_selected:
|
|
archive_cell = self.archiveTable.item(row_selected[0].row(), 4)
|
|
if archive_cell:
|
|
archive_name = archive_cell.text()
|
|
params = BorgListArchiveJob.prepare(profile, archive_name)
|
|
|
|
if not params['ok']:
|
|
self._set_status(params['message'])
|
|
return
|
|
self._set_status('')
|
|
self._toggle_all_buttons(False)
|
|
|
|
job = BorgListArchiveJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self.mountErrors.setText)
|
|
job.result.connect(self.extract_list_result)
|
|
self.app.jobs_manager.add_job(job)
|
|
return job
|
|
else:
|
|
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:
|
|
archive = ArchiveModel.get(name=result['params']['archive_name'])
|
|
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.WindowType.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=None, column=None):
|
|
if not self.bRename.isEnabled():
|
|
return
|
|
|
|
if not row or not column:
|
|
row = self.archiveTable.currentRow()
|
|
column = self.archiveTable.currentColumn()
|
|
|
|
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}'))
|
|
|
|
if column == 4:
|
|
item = self.archiveTable.item(row, column)
|
|
self.renamed_archive_original_name = item.text()
|
|
item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable)
|
|
self.archiveTable.editItem(item)
|
|
|
|
def cell_changed(self, row, column):
|
|
# return if this is not a name change
|
|
if column != 4:
|
|
return
|
|
|
|
item = self.archiveTable.item(row, column)
|
|
new_name = item.text()
|
|
profile = self.profile()
|
|
|
|
# if the name hasn't changed or if this slot is called when first repopulating the table, do nothing.
|
|
if new_name == self.renamed_archive_original_name or not self.renamed_archive_original_name:
|
|
return
|
|
|
|
if not new_name:
|
|
item.setText(self.renamed_archive_original_name)
|
|
self._set_status(self.tr('Archive name cannot be blank.'))
|
|
return
|
|
|
|
new_name_exists = ArchiveModel.get_or_none(name=new_name, repo=profile.repo)
|
|
if new_name_exists is not None:
|
|
self._set_status(self.tr('An archive with this name already exists.'))
|
|
item.setText(self.renamed_archive_original_name)
|
|
return
|
|
|
|
params = BorgRenameJob.prepare(profile, self.renamed_archive_original_name, new_name)
|
|
if not params['ok']:
|
|
self._set_status(params['message'])
|
|
|
|
job = BorgRenameJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.rename_result)
|
|
self._toggle_all_buttons(False)
|
|
self.app.jobs_manager.add_job(job)
|
|
|
|
def row_of_archive(self, archive_name):
|
|
items = self.archiveTable.findItems(archive_name, QtCore.Qt.MatchFlag.MatchExactly)
|
|
rows = [item.row() for item in items if item.column() == 4]
|
|
return rows[0] if rows else None
|
|
|
|
def confirm_dialog(self, title, text):
|
|
msg = QMessageBox()
|
|
msg.setIcon(QMessageBox.Icon.Information)
|
|
msg.setText(text)
|
|
msg.setWindowTitle(title)
|
|
msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
|
|
msg.button(QMessageBox.StandardButton.Yes).setText(self.tr("Yes"))
|
|
msg.button(QMessageBox.StandardButton.Cancel).setText(self.tr("Cancel"))
|
|
return msg.exec() == QMessageBox.StandardButton.Yes
|
|
|
|
def delete_action(self):
|
|
# Since this function modify the UI, we can't put the whole function in a JobQueue.
|
|
|
|
# determine selected archives
|
|
archives = []
|
|
for index in self.archiveTable.selectionModel().selectedRows():
|
|
archive_cell = self.archiveTable.item(index.row(), 4)
|
|
if archive_cell:
|
|
archives.append(archive_cell.text())
|
|
|
|
if not archives:
|
|
self._set_status(self.tr("No archive selected"))
|
|
return
|
|
|
|
params = BorgDeleteJob.prepare(self.profile(), archives)
|
|
if not params['ok']:
|
|
self._set_status(params['message'])
|
|
return
|
|
|
|
if len(archives) > 1:
|
|
body = self.tr("Are you sure you want to delete all the selected archives?")
|
|
else:
|
|
body = self.tr("Are you sure you want to delete the selected archive?")
|
|
if not self.confirm_dialog(self.tr("Confirm deletion"), body):
|
|
return
|
|
|
|
job = BorgDeleteJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self._set_status)
|
|
job.result.connect(self.delete_result)
|
|
self._toggle_all_buttons(False)
|
|
self.app.jobs_manager.add_job(job)
|
|
|
|
def delete_result(self, result):
|
|
archives = result['params']['archives']
|
|
if result['returncode'] == 0:
|
|
if len(archives) > 1:
|
|
status = self.tr('Archives deleted.')
|
|
else:
|
|
status = self.tr('Archive deleted.')
|
|
self._set_status(status)
|
|
|
|
# remove rows from list and database
|
|
for archive in archives:
|
|
for entry in self.archiveTable.findItems(archive, QtCore.Qt.MatchFlag.MatchExactly):
|
|
self.archiveTable.removeRow(entry.row())
|
|
ArchiveModel.get(name=archive).delete_instance()
|
|
|
|
self._toggle_all_buttons(True)
|
|
|
|
def diff_action(self):
|
|
"""
|
|
Handle the diff button being clicked.
|
|
|
|
Exactly two archives must be selected in `archiveTable`. This is
|
|
usually enforced by `on_selection_change`.
|
|
"""
|
|
selected_archives = self.archiveTable.selectionModel().selectedRows()
|
|
profile = self.profile()
|
|
|
|
name1 = self.archiveTable.item(selected_archives[0].row(), 4).text()
|
|
name2 = self.archiveTable.item(selected_archives[1].row(), 4).text()
|
|
|
|
archive1, archive2 = (
|
|
profile.repo.archives.select()
|
|
.where((ArchiveModel.name == name1) | (ArchiveModel.name == name2))
|
|
.order_by(ArchiveModel.time.desc())
|
|
)
|
|
|
|
archive_name_newer = archive1.name
|
|
archive_name_older = archive2.name
|
|
|
|
# Start diff job
|
|
params = BorgDiffJob.prepare(profile, archive_name_older, archive_name_newer)
|
|
|
|
if params['ok']:
|
|
self._toggle_all_buttons(False)
|
|
job = BorgDiffJob(params['cmd'], params, self.profile().repo.id)
|
|
job.updated.connect(self.mountErrors.setText)
|
|
job.result.connect(self.list_diff_result)
|
|
self.app.jobs_manager.add_job(job)
|
|
else:
|
|
self._set_status(params['message'])
|
|
|
|
def list_diff_result(self, result):
|
|
"""
|
|
Process the result of the `BorgDiffJob`.
|
|
|
|
The `BorgDiffJob` was initiated by `diff_action`.
|
|
|
|
Parameters
|
|
----------
|
|
result : dict
|
|
The BorgJob result.
|
|
"""
|
|
self._set_status('')
|
|
if result['returncode'] == 0:
|
|
archive_newer = ArchiveModel.get(name=result['params']['archive_name_newer'])
|
|
archive_older = ArchiveModel.get(name=result['params']['archive_name_older'])
|
|
self._set_status(self.tr("Processing diff results."))
|
|
|
|
model = DiffTree()
|
|
|
|
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()
|
|
|
|
def show_diff_result(self, archive_newer, archive_older, model):
|
|
self._t = None
|
|
|
|
# show dialog
|
|
self._toggle_all_buttons(True)
|
|
self._set_status('')
|
|
window = DiffResultDialog(archive_newer, archive_older, model)
|
|
window.setParent(self)
|
|
window.setWindowFlags(Qt.WindowType.Window)
|
|
window.setWindowModality(Qt.WindowModality.NonModal)
|
|
self._resultwindow = window # for testing
|
|
window.show()
|
|
|
|
def rename_result(self, result):
|
|
if result['returncode'] == 0:
|
|
self.refresh_archive_info()
|
|
self._set_status(self.tr('Archive renamed.'))
|
|
self.renamed_archive_original_name = None
|
|
self.populate_from_profile()
|
|
else:
|
|
self._toggle_all_buttons(True)
|
|
|
|
def toggle_compact_button_visibility(self):
|
|
"""
|
|
Enable or disable the compact button depending on the Borg version.
|
|
This function runs once on startup, and everytime the profile is changed.
|
|
"""
|
|
if borg_compat.check("COMPACT_SUBCOMMAND"):
|
|
self.compactButton.setEnabled(True)
|
|
self.compactButton.setToolTip(self.tooltip_dict[self.compactButton])
|
|
else:
|
|
self.compactButton.setEnabled(False)
|
|
tooltip = self.tooltip_dict[self.compactButton]
|
|
self.compactButton.setToolTip(tooltip + " " + self.tr("(This feature needs Borg 1.2.0 or higher)"))
|