vorta/src/vorta/views/source_tab.py

371 lines
15 KiB
Python

import logging
import os
from pathlib import PurePath
from PyQt6 import QtCore, QtGui, uic
from PyQt6.QtCore import QFileInfo, QMimeData, QPoint, Qt, QUrl, pyqtSlot
from PyQt6.QtGui import QShortcut
from PyQt6.QtWidgets import (
QApplication,
QHeaderView,
QMenu,
QMessageBox,
QTableWidgetItem,
)
from vorta.store.models import BackupProfileMixin, SettingsModel, SourceFileModel
from vorta.utils import (
FilePathInfoAsync,
choose_file_dialog,
get_asset,
pretty_bytes,
sort_sizes,
)
from vorta.views.exclude_dialog import ExcludeDialog
from vorta.views.utils import get_colored_icon
uifile = get_asset('UI/sourcetab.ui')
SourceUI, SourceBase = uic.loadUiType(uifile)
logger = logging.getLogger(__name__)
class SourceColumn:
Path = 0
Size = 1
FilesCount = 2
class SizeItem(QTableWidgetItem):
def __init__(self, s):
super().__init__(s)
self.setTextAlignment(Qt.AlignmentFlag.AlignVCenter + Qt.AlignmentFlag.AlignRight)
def __lt__(self, other):
if other.text() == '':
return False
elif self.text() == '':
return True
else:
return sort_sizes([self.text(), other.text()]) == [
self.text(),
other.text(),
]
class FilesCount(QTableWidgetItem):
def __lt__(self, other):
# Verify that conversion is only performed on valid integers
# If one of the 2 elements is no number, put these elements at the end
# This is important if the text is "Calculating..." or ""
if self.text().isdigit() and other.text().isdigit():
return int(self.text()) < int(other.text()) # Compare & return result
else:
if not self.text().isdigit():
return 1 # Move one down if current item has no valid count
if not other.text().isdigit():
return 0
class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
updateThreads = []
def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(parent)
# Prepare source files view
header = self.sourceFilesWidget.horizontalHeader()
header.setVisible(True)
header.setSortIndicatorShown(1)
header.setSectionResizeMode(SourceColumn.Path, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(SourceColumn.Size, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(SourceColumn.FilesCount, QHeaderView.ResizeMode.ResizeToContents)
self.sourceFilesWidget.setSortingEnabled(True)
self.sourceFilesWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.sourceFilesWidget.customContextMenuRequested.connect(self.sourceitem_contextmenu)
# Prepare add button
self.addMenu = QMenu(self.addButton)
self.addFilesAction = self.addMenu.addAction(self.tr("Files"), lambda: self.source_add(want_folder=False))
self.addFoldersAction = self.addMenu.addAction(self.tr("Folders"), lambda: self.source_add(want_folder=True))
self.pasteAction = self.addMenu.addAction(self.tr("Paste"), self.paste_text)
self.addButton.setMenu(self.addMenu)
# shortcuts
shortcut_copy = QShortcut(QtGui.QKeySequence.StandardKey.Copy, self.sourceFilesWidget)
shortcut_copy.activated.connect(self.source_copy)
# Connect signals
self.removeButton.clicked.connect(self.source_remove)
self.updateButton.clicked.connect(self.sources_update)
self.bExclude.clicked.connect(self.show_exclude_dialog)
header.sortIndicatorChanged.connect(self.update_sort_order)
# Connect to palette change
QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
# Populate
self.populate_from_profile()
self.set_icons()
def set_icons(self):
"Used when changing between light- and dark mode"
self.addButton.setIcon(get_colored_icon('plus'))
self.removeButton.setIcon(get_colored_icon('minus'))
self.updateButton.setIcon(get_colored_icon('refresh'))
self.addFilesAction.setIcon(get_colored_icon('file'))
self.addFoldersAction.setIcon(get_colored_icon('folder'))
self.pasteAction.setIcon(get_colored_icon('paste'))
for row in range(self.sourceFilesWidget.rowCount()):
path_item = self.sourceFilesWidget.item(row, SourceColumn.Path)
db_item = SourceFileModel.get(dir=path_item.text(), profile=self.profile())
if db_item.path_isdir:
path_item.setIcon(get_colored_icon('folder'))
else:
path_item.setIcon(get_colored_icon('file'))
@pyqtSlot(QPoint)
def sourceitem_contextmenu(self, pos: QPoint):
"""Show a context menu for the source item at `pos`."""
# index under cursor
index = self.sourceFilesWidget.indexAt(pos)
if not index.isValid():
return # popup only for items
menu = QMenu(self.sourceFilesWidget)
menu.addAction(
get_colored_icon('copy'),
self.tr("Copy"),
lambda: self.source_copy(index=index),
)
menu.addAction(get_colored_icon('minus'), self.tr("Remove"), self.source_remove)
menu.popup(self.sourceFilesWidget.viewport().mapToGlobal(pos))
def set_path_info(self, path, data_size, files_count):
# disable sorting temporarily
sorting = self.sourceFilesWidget.isSortingEnabled()
self.sourceFilesWidget.setSortingEnabled(False)
items = self.sourceFilesWidget.findItems(path, QtCore.Qt.MatchFlag.MatchExactly)
# Conversion int->str->int needed because QT limits int to 32-bit
data_size = int(data_size)
files_count = int(files_count)
for item in items:
db_item = SourceFileModel.get(dir=path, profile=self.profile())
if QFileInfo(path).isDir():
self.sourceFilesWidget.item(item.row(), SourceColumn.FilesCount).setText(format(files_count))
db_item.path_isdir = True
self.sourceFilesWidget.item(item.row(), SourceColumn.Path).setIcon(get_colored_icon('folder'))
else:
# No files count, if entry itself is a file
self.sourceFilesWidget.item(item.row(), SourceColumn.FilesCount).setText("")
db_item.path_isdir = False
self.sourceFilesWidget.item(item.row(), SourceColumn.Path).setIcon(get_colored_icon('file'))
self.sourceFilesWidget.item(item.row(), SourceColumn.Size).setText(pretty_bytes(data_size))
db_item.dir_size = data_size
db_item.dir_files_count = files_count
db_item.save()
# Remove thread from list when it's done
for thrd in self.updateThreads:
if thrd.objectName() == path:
self.updateThreads.remove(thrd)
# enable sorting again
self.sourceFilesWidget.setSortingEnabled(sorting)
def update_path_info(self, index_row: int):
"""
Update the information for the source in the given table row.
This displays `Calculating...` in the updated rows and creates a
`FilePathInfoAsync` instance to get the new information.
The method `set_path_info` will update the row with the information
provided by this instance.
Parameters
----------
index_row : int
The index of the row to update.
"""
logger.debug(f"Updating source in row {index_row}.") # Debug #1080
path = self.sourceFilesWidget.item(index_row, SourceColumn.Path).text()
self.sourceFilesWidget.item(index_row, SourceColumn.Size).setText(self.tr("Calculating…"))
self.sourceFilesWidget.item(index_row, SourceColumn.FilesCount).setText(self.tr("Calculating…"))
getDir = FilePathInfoAsync(path, self.profile().exclude_patterns)
getDir.signal.connect(self.set_path_info)
getDir.setObjectName(path)
self.updateThreads.append(getDir) # this is ugly, is there a better way to keep the thread object?
getDir.start()
def add_source_to_table(self, source, update_data=None):
# disable sorting temporarily
sorting = self.sourceFilesWidget.isSortingEnabled()
self.sourceFilesWidget.setSortingEnabled(False)
if update_data is None:
update_data = SettingsModel.get(key="get_srcpath_datasize").value
index_row = self.sourceFilesWidget.rowCount()
self.sourceFilesWidget.setRowCount(self.sourceFilesWidget.rowCount() + 1)
# Insert all items on current row, add tooltip containing the path name
new_item = QTableWidgetItem(source.dir)
new_item.setToolTip(source.dir)
self.sourceFilesWidget.setItem(index_row, SourceColumn.Path, new_item)
self.sourceFilesWidget.setItem(index_row, SourceColumn.Size, SizeItem(""))
self.sourceFilesWidget.setItem(index_row, SourceColumn.FilesCount, FilesCount(""))
logger.debug(f"Added item number {index_row}" + f" from {self.sourceFilesWidget.rowCount()}")
if update_data:
self.update_path_info(index_row)
# Debug #1080
logger.debug("Updated info for previously added item.")
else: # Use cached data from DB
if source.dir_size > -1:
self.sourceFilesWidget.item(index_row, SourceColumn.Size).setText(pretty_bytes(source.dir_size))
if source.path_isdir:
self.sourceFilesWidget.item(index_row, SourceColumn.FilesCount).setText(
format(source.dir_files_count)
)
self.sourceFilesWidget.item(index_row, SourceColumn.Path).setIcon(get_colored_icon('folder'))
else:
self.sourceFilesWidget.item(index_row, SourceColumn.Path).setIcon(get_colored_icon('file'))
# enable sorting again
self.sourceFilesWidget.setSortingEnabled(sorting)
def populate_from_profile(self):
profile = self.profile()
self.sourceFilesWidget.setRowCount(0) # Clear rows
for source in SourceFileModel.select().where(SourceFileModel.profile == profile):
self.add_source_to_table(source, False)
# Fetch the Sort by Column and order
sourcetab_sort_column = int(SettingsModel.get(key='sourcetab_sort_column').str_value)
sourcetab_sort_order = int(SettingsModel.get(key='sourcetab_sort_order').str_value)
# Sort items as per settings
self.sourceFilesWidget.sortItems(sourcetab_sort_column, Qt.SortOrder(sourcetab_sort_order))
def update_sort_order(self, column: int, order: int):
"""Save selected sort by column and order to settings"""
SettingsModel.update({SettingsModel.str_value: str(column)}).where(
SettingsModel.key == 'sourcetab_sort_column'
).execute()
SettingsModel.update({SettingsModel.str_value: str(order.value)}).where(
SettingsModel.key == 'sourcetab_sort_order'
).execute()
def sources_update(self):
"""
Update each row in the sources table.
Calls `update_path_info` for each row. to do the job.
"""
row_count = self.sourceFilesWidget.rowCount()
logger.debug(f"Updating sources ({row_count})") # Debug #1080
for row in range(0, row_count):
self.update_path_info(row) # Update data for each entry
def source_add(self, want_folder):
def receive():
dirs = dialog.selectedFiles()
for dir in dirs:
if not os.access(dir, os.R_OK):
msg = QMessageBox()
msg.setText(self.tr(f"You don't have read access to {dir}."))
msg.exec()
return
new_source, created = SourceFileModel.get_or_create(dir=dir, profile=self.profile())
if created:
self.add_source_to_table(new_source)
new_source.save()
msg = self.tr("Choose directory to back up") if want_folder else self.tr("Choose file(s) to back up")
dialog = choose_file_dialog(self, msg, want_folder=want_folder)
dialog.open(receive)
def source_copy(self, index=None):
"""
Copy a source path to the clipboard.
Copies the first selected source if no index is specified.
"""
if index is None:
indexes = self.sourceFilesWidget.selectionModel().selectedRows()
if not indexes:
return
index = indexes[0]
path = PurePath(self.sourceFilesWidget.item(index.row(), SourceColumn.Path).text())
data = QMimeData()
data.setUrls([QUrl(path.as_uri())])
data.setText(str(path))
QApplication.clipboard().setMimeData(data)
def source_remove(self):
indexes = self.sourceFilesWidget.selectionModel().selectedRows()
profile = self.profile()
# sort indexes, starting with lowest
indexes.sort()
# remove each selected row, starting with the highest index (otherwise, higher indexes become invalid)
for index in reversed(indexes):
db_item = SourceFileModel.get(
dir=self.sourceFilesWidget.item(index.row(), SourceColumn.Path).text(),
profile=profile,
)
db_item.delete_instance()
self.sourceFilesWidget.removeRow(index.row())
logger.debug(f"Removed source in row {index.row()}")
def show_exclude_dialog(self):
window = ExcludeDialog(self.profile(), self)
window.setParent(self, QtCore.Qt.WindowType.Sheet)
self._window = window # for testing
window.show()
def paste_text(self):
sources = QApplication.clipboard().text().splitlines()
invalidSources = ""
for source in sources:
if len(source) > 0: # Ignore empty newlines
if source.startswith('file://'): # Allow pasting multiple files/folders copied from file manager
source = source[7:]
if not os.path.exists(source):
invalidSources = invalidSources + "\n" + source
else:
new_source, created = SourceFileModel.get_or_create(dir=source, profile=self.profile())
if created:
self.add_source_to_table(new_source)
new_source.save()
if len(invalidSources) != 0: # Check if any invalid paths
msg = QMessageBox()
msg.setText(self.tr("Some of your sources are invalid:") + invalidSources)
self._msg = msg # for testing
msg.exec()