diff --git a/src/vorta/assets/UI/archive_page.ui b/src/vorta/assets/UI/archive_page.ui
new file mode 100644
index 000000000..318521526
--- /dev/null
+++ b/src/vorta/assets/UI/archive_page.ui
@@ -0,0 +1,322 @@
+
+
+ ArchivePage
+
+
+ -
+
+
+ 4
+
+
-
+
+
-
+
+
+ Refresh archive list
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Check the consistency of the repository
+
+
+ Check
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+ Prune the archives in this repository
+
+
+ Prune
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+ Optimize disk space by defragmenting the repository
+
+
+ Compact
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ see source code
+
+
+ bMountRepo
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+
+
+ -
+
+
+
+ 1
+ 1
+
+
+
+ false
+
+
+ false
+
+
+ false
+
+
+
+ Date
+
+
+
+
+ Size
+
+
+
+
+ Duration
+
+
+
+
+ Mount Point
+
+
+
+
+ Name
+
+
+
+
+ Trigger
+
+
+
+
+ -
+
+
-
+
+
+ QFrame::NoFrame
+
+
+
+ 4
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Recalculate selected archive's size(s)
+
+
+ Recalculate
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Compare two archives
+
+
+ Diff
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ see source code
+
+
+ bMountArchive
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Extract selected archive
+
+
+ Extract…
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Rename selected archive
+
+
+ Rename…
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Delete selected archive(s)
+
+
+ Delete
+
+
+ Qt::ToolButtonTextBesideIcon
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ true
+
+
+
+ 0
+ 20
+
+
+
+ <html><head/><body><p>To mount archives, first install "FUSE for macOS" from <a href="https://osxfuse.github.io/"><span style=" text-decoration: underline; color:#0984e3;">here</span></a>.</p></body></html>
+
+
+ true
+
+
+
+
+
+
+
+
diff --git a/src/vorta/assets/UI/archive_tab.ui b/src/vorta/assets/UI/archive_tab.ui
index 9c81d49f2..611f7f059 100644
--- a/src/vorta/assets/UI/archive_tab.ui
+++ b/src/vorta/assets/UI/archive_tab.ui
@@ -28,7 +28,7 @@
-
-
+
0
@@ -38,324 +38,12 @@
- Archives
+ Archives222
-
-
-
-
-
- 4
-
-
-
-
-
-
-
-
- Refresh archive list
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Check the consistency of the repository
-
-
- Check
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
- Prune the archives in this repository
-
-
- Prune
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
- Optimize disk space by defragmenting the repository
-
-
- Compact
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- see source code
-
-
- bMountRepo
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
-
-
- -
-
-
-
- 1
- 1
-
-
-
- false
-
-
- false
-
-
- false
-
-
-
- Date
-
-
-
-
- Size
-
-
-
-
- Duration
-
-
-
-
- Mount Point
-
-
-
-
- Name
-
-
-
-
- Trigger
-
-
-
-
- -
-
-
-
-
-
- QFrame::NoFrame
-
-
-
- 4
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
- Recalculate selected archive's size(s)
-
-
- Recalculate
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Compare two archives
-
-
- Diff
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- see source code
-
-
- bMountArchive
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Extract selected archive
-
-
- Extract…
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Rename selected archive
-
-
- Rename…
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Delete selected archive(s)
-
-
- Delete
-
-
- Qt::ToolButtonTextBesideIcon
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- true
-
-
-
- 0
- 20
-
-
-
- <html><head/><body><p>To mount archives, first install "FUSE for macOS" from <a href="https://osxfuse.github.io/"><span style=" text-decoration: underline; color:#0984e3;">here</span></a>.</p></body></html>
-
-
- true
-
-
-
+
-
-
+
+
0
@@ -367,374 +55,9 @@
Prune Options and Archive Naming
-
-
- 12
-
- -
-
-
-
- 12
-
-
-
-
-
- <html><head/><body><p>Pruning removes older archives. You can choose the number of hourly, daily, etc. archives to preserve. Usually you will keep more newer and fewer old archives. Read <a href="https://borgbackup.readthedocs.io/en/stable/usage/prune.html"><span style=" text-decoration: underline; color:#FF4500;">more</span></a>.</p></body></html>
-
-
- true
-
-
- true
-
-
-
- -
-
-
- 0
-
-
- 0
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Hourly:
-
-
-
- -
-
-
- Use -1 for unlimited
-
-
- -1
-
-
- 9999
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Daily:
-
-
-
- -
-
-
- Use -1 for unlimited
-
-
- -1
-
-
- 999
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Weekly:
-
-
-
- -
-
-
- Use -1 for unlimited
-
-
- -1
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Monthly:
-
-
-
- -
-
-
- Use -1 for unlimited
-
-
- -1
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Annual:
-
-
- true
-
-
-
- -
-
-
- Use -1 for unlimited
-
-
- -1
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- No matter what, keep all archives of the last:
-
-
-
- -
-
-
- “<int><char>”, where char is “H”, “d”, “w”, “m”, “y”
-
-
-
-
-
- 24H, 1d, 52w, 12m, 1y
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- 0
-
-
- 4
-
-
-
-
-
- Available variables: hostname, fqdn, profile_id, profile_slug, now, utc_now, user
-
-
- {hostname}-{profile_slug}-
-
-
-
- -
-
-
-
- 100
- 0
-
-
-
- Prune Prefix:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
-
- true
-
-
-
- color: grey;
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
-
-
-
-
-
-
-
-
- -
-
-
-
- 12
-
-
-
-
-
- 4
-
-
-
-
-
- Available variables: hostname, fqdn, profile_id, profile_slug, now, utc_now, user
-
-
- {hostname}-{profile_slug}-{now:%Y-%m-%d-%H%M%S}
-
-
-
- -
-
-
-
- 100
- 0
-
-
-
- Archive Name:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
-
- true
-
-
-
- color: grey
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
-
-
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
+
-
+
diff --git a/src/vorta/assets/UI/prune_options_page.ui b/src/vorta/assets/UI/prune_options_page.ui
new file mode 100644
index 000000000..89215bbb7
--- /dev/null
+++ b/src/vorta/assets/UI/prune_options_page.ui
@@ -0,0 +1,386 @@
+
+
+ PruneOptionsPage
+
+
+
+ 0
+ 0
+ 781
+ 371
+
+
+
+ Prune Options and Archive Naming
+
+
+
+ 12
+
+ -
+
+
+
+ 12
+
+
-
+
+
+ <html><head/><body><p>Pruning removes older archives. You can choose the number of hourly, daily, etc. archives to preserve. Usually you will keep more newer and fewer old archives. Read <a href="https://borgbackup.readthedocs.io/en/stable/usage/prune.html"><span style=" text-decoration: underline; color:#FF4500;">more</span></a>.</p></body></html>
+
+
+ true
+
+
+ true
+
+
+
+ -
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Hourly:
+
+
+
+ -
+
+
+ Use -1 for unlimited
+
+
+ -1
+
+
+ 9999
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Daily:
+
+
+
+ -
+
+
+ Use -1 for unlimited
+
+
+ -1
+
+
+ 999
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Weekly:
+
+
+
+ -
+
+
+ Use -1 for unlimited
+
+
+ -1
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Monthly:
+
+
+
+ -
+
+
+ Use -1 for unlimited
+
+
+ -1
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Annual:
+
+
+ true
+
+
+
+ -
+
+
+ Use -1 for unlimited
+
+
+ -1
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ No matter what, keep all archives of the last:
+
+
+
+ -
+
+
+ “<int><char>”, where char is “H”, “d”, “w”, “m”, “y”
+
+
+
+
+
+ 24H, 1d, 52w, 12m, 1y
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ 0
+
+
+ 4
+
+
-
+
+
+ Available variables: hostname, fqdn, profile_id, profile_slug, now, utc_now, user
+
+
+ {hostname}-{profile_slug}-
+
+
+
+ -
+
+
+
+ 100
+ 0
+
+
+
+ Prune Prefix:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+
+ true
+
+
+
+ color: grey;
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+
+
+
+
+
+
+ -
+
+
+
+ 12
+
+
-
+
+
+ 4
+
+
-
+
+
+ Available variables: hostname, fqdn, profile_id, profile_slug, now, utc_now, user
+
+
+ {hostname}-{profile_slug}-{now:%Y-%m-%d-%H%M%S}
+
+
+
+ -
+
+
+
+ 100
+ 0
+
+
+
+ Archive Name:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+
+ true
+
+
+
+ color: grey
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/vorta/assets/UI/schedule_tab.ui b/src/vorta/assets/UI/schedule_tab.ui
index 3f4975723..743ec031f 100644
--- a/src/vorta/assets/UI/schedule_tab.ui
+++ b/src/vorta/assets/UI/schedule_tab.ui
@@ -1,102 +1,102 @@
- Form
-
-
-
- 0
- 0
- 687
- 544
-
-
-
- Form
-
-
-
- 0
-
-
- 0
-
- -
-
-
-
- 0
- 0
-
-
-
-
- false
-
-
-
-
-
+ Form
+
+
+
0
0
687
- 384
-
+ 544
+
+
+
+ Form
+
+
+
+ 0
-
- Schedule
-
-
-
-
-
-
-
- 0
- 0
- 687
- 384
-
-
-
- Networks
-
-
-
-
-
-
-
- 0
- 0
- 687
- 384
-
-
-
- Log
-
-
-
-
-
-
-
- 0
- 0
- 687
- 384
-
+
+ 0
-
- Shell Commands
-
-
-
-
-
-
-
-
-
-
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ false
+
+
+
+
+
+ 0
+ 0
+ 687
+ 384
+
+
+
+ Schedule
+
+
+
+
+
+
+
+ 0
+ 0
+ 687
+ 384
+
+
+
+ Networks
+
+
+
+
+
+
+
+ 0
+ 0
+ 687
+ 384
+
+
+
+ Log
+
+
+
+
+
+
+
+ 0
+ 0
+ 687
+ 384
+
+
+
+ Shell Commands
+
+
+
+
+
+
+
+
+
+
diff --git a/src/vorta/views/archive_page.py b/src/vorta/views/archive_page.py
new file mode 100644
index 000000000..2b1e38889
--- /dev/null
+++ b/src/vorta/views/archive_page.py
@@ -0,0 +1,975 @@
+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,
+ 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/archive_page.ui')
+ArchiveUI, ArchiveBase = 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 ArchivePage(ArchiveBase, ArchiveUI, BackupProfileMixin):
+ def __init__(self, parent=None, app=None):
+ super().__init__(parent)
+ self.setupUi(self)
+ 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
+ print(app)
+
+ 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 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]
+
+ 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)
+
+ 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 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 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)"))
diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py
index cab452856..e12a0dc95 100644
--- a/src/vorta/views/archive_tab.py
+++ b/src/vorta/views/archive_tab.py
@@ -1,1016 +1,47 @@
-import logging
-import sys
-from datetime import timedelta
-from typing import Dict, Optional
+from PyQt6 import uic
-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.store.models import BackupProfileMixin
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.archive_page import ArchivePage
+from vorta.views.prune_options_page import PruneOptionsPage
from vorta.views.utils import get_colored_icon
uifile = get_asset('UI/archive_tab.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.init_archive_page(self.app)
+ self.init_prune_options_page()
self.app.paletteChanged.connect(lambda p: self.set_icons())
+ 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."""
+ def init_archive_page(self, app=None):
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))
+ self.archivePage = ArchivePage(self, app)
+ self.archiveLayout.addWidget(self.archivePage)
+ self.archivePage.show()
- 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 init_prune_options_page(self):
+ self.pruneOptionsPage = PruneOptionsPage(self)
+ self.pruneLayout.addWidget(self.pruneOptionsPage)
+ self.pruneOptionsPage.show()
- 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)"))
+ def set_icons(self):
+ """Used when changing between light- and dark mode"""
+ self.toolBox.setItemIcon(0, get_colored_icon('tasks'))
+ self.toolBox.setItemIcon(1, get_colored_icon('cut'))
\ No newline at end of file
diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py
index cc7a1b890..b04a3de15 100644
--- a/src/vorta/views/main_window.py
+++ b/src/vorta/views/main_window.py
@@ -71,7 +71,7 @@ def __init__(self, parent=None):
# Load tab models
self.repoTab = RepoTab(self.repoTabSlot)
self.sourceTab = SourceTab(self.sourceTabSlot)
- self.archiveTab = ArchiveTab(self.archiveTabSlot, app=self.app)
+ self.archiveTab = ArchiveTab(self.archiveTabSlot, app=self.app).archivePage
self.scheduleTab = ScheduleTab(self.scheduleTabSlot)
self.miscTab = MiscTab(self.SettingsTabSlot)
self.aboutTab = AboutTab(self.AboutTabSlot)
diff --git a/src/vorta/views/prune_options_page.py b/src/vorta/views/prune_options_page.py
new file mode 100644
index 000000000..9032d5835
--- /dev/null
+++ b/src/vorta/views/prune_options_page.py
@@ -0,0 +1,52 @@
+from PyQt6 import uic
+
+from vorta.store.models import BackupProfileMixin
+from vorta.utils import format_archive_name, get_asset
+
+uifile = get_asset('UI/prune_options_page.ui')
+PruneOptionsUI, PruneOptionsBase = uic.loadUiType(uifile)
+
+
+class PruneOptionsPage(PruneOptionsBase, PruneOptionsUI, BackupProfileMixin):
+ prune_intervals = ['hour', 'day', 'week', 'month', 'year']
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setupUi(self)
+ self.populate_from_profile()
+ 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)
+ )
+
+ def populate_from_profile(self):
+ # 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 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 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)