From 3c519e492814582fbed3c4321486f2c95ff54d15 Mon Sep 17 00:00:00 2001 From: Manuel Riel Date: Wed, 5 Dec 2018 17:05:47 +0800 Subject: [PATCH] v0.5.3 (#80) * Apply selection status to children in restore-dialog. Fixes #69 * Backup triggered from system tray wont use correct profile. Fixes #78 * Use the term FILE if both FILE or FOLDER is meant. Fixes #55 * Implement update check on/off setting for macOS * Implement autostart setting for macOS. Fixes #56 --- src/vorta/assets/UI/extractdialog.ui | 7 ++ src/vorta/assets/UI/sourcetab.ui | 2 +- src/vorta/borg/borg_thread.py | 1 + src/vorta/borg/create.py | 6 +- src/vorta/models.py | 9 ++- src/vorta/tray_menu.py | 5 +- src/vorta/updater.py | 10 ++- src/vorta/utils.py | 22 ++++++- src/vorta/views/archive_tab.py | 6 +- src/vorta/views/extract_dialog.py | 98 +++++++++++++++------------- src/vorta/views/misc_tab.py | 5 +- src/vorta/views/repo_add_dialog.py | 4 +- src/vorta/views/source_tab.py | 20 +++--- tests/conftest.py | 6 +- tests/test_archives.py | 6 +- tests/test_source.py | 8 +-- 16 files changed, 131 insertions(+), 84 deletions(-) diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui index 4d636c78..7d543f48 100644 --- a/src/vorta/assets/UI/extractdialog.ui +++ b/src/vorta/assets/UI/extractdialog.ui @@ -57,6 +57,13 @@ + + + + Note: If you select a top-level folder and deselect its children, they will still be restored. + + + diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index b587ea99..6d871c35 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -24,7 +24,7 @@ - + 0 diff --git a/src/vorta/borg/borg_thread.py b/src/vorta/borg/borg_thread.py index 8230b2c9..87d29027 100644 --- a/src/vorta/borg/borg_thread.py +++ b/src/vorta/borg/borg_thread.py @@ -44,6 +44,7 @@ class BorgThread(QtCore.QThread, BackupProfileMixin): env = os.environ.copy() env['BORG_HOSTNAME_IS_UNIQUE'] = '1' + env['BORG_RELOCATED_REPO_ACCESS_IS_OK'] = '1' password = params.get('password') if password is not None: env['BORG_PASSPHRASE'] = password diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 17f8f33e..ea08ab92 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -5,7 +5,7 @@ from dateutil import parser from datetime import datetime as dt from ..utils import get_current_wifi -from ..models import SourceDirModel, ArchiveModel, WifiSettingModel, RepoModel +from ..models import SourceFileModel, ArchiveModel, WifiSettingModel, RepoModel from .borg_thread import BorgThread @@ -56,7 +56,7 @@ class BorgCreateThread(BorgThread): else: ret['ok'] = False # Set back to false, so we can do our own checks here. - n_backup_folders = SourceDirModel.select().count() + n_backup_folders = SourceFileModel.select().count() if n_backup_folders == 0: ret['message'] = 'Add some folders to back up first.' return ret @@ -105,7 +105,7 @@ class BorgCreateThread(BorgThread): # Add repo url and source dirs. cmd.append(f"{profile.repo.url}::{platform.node()}-{profile.slug()}-{dt.now().isoformat(timespec='seconds')}") - for f in SourceDirModel.select().where(SourceDirModel.profile == profile.id): + for f in SourceFileModel.select().where(SourceFileModel.profile == profile.id): cmd.append(f.dir) ret['message'] = 'Starting backup..' diff --git a/src/vorta/models.py b/src/vorta/models.py index a261c608..af4e992e 100644 --- a/src/vorta/models.py +++ b/src/vorta/models.py @@ -91,7 +91,7 @@ class BackupProfileModel(pw.Model): database = db -class SourceDirModel(pw.Model): +class SourceFileModel(pw.Model): """A folder to be backed up, related to a Backup Configuration.""" dir = pw.CharField() profile = pw.ForeignKeyField(BackupProfileModel, default=1) @@ -99,6 +99,7 @@ class SourceDirModel(pw.Model): class Meta: database = db + table_name = 'sourcedirmodel' class ArchiveModel(pw.Model): @@ -181,7 +182,7 @@ def _apply_schema_update(current_schema, version_after, *operations): def init_db(con): db.initialize(con) db.connect() - db.create_tables([RepoModel, RepoPassword, BackupProfileModel, SourceDirModel, SettingsModel, + db.create_tables([RepoModel, RepoPassword, BackupProfileModel, SourceFileModel, SettingsModel, ArchiveModel, WifiSettingModel, EventLogModel, SchemaVersion]) if BackupProfileModel.select().count() == 0: @@ -201,6 +202,8 @@ def init_db(con): 'label': 'Display notifications when background tasks fail.'}, {'key': 'check_for_updates', 'value': True, 'type': 'checkbox', 'label': 'Check for updates on startup.'}, + {'key': 'updates_include_beta', 'value': False, 'type': 'checkbox', + 'label': 'Include pre-release versions when checking for updates.'}, ] for setting in settings: # Create missing settings and update labels. @@ -242,7 +245,7 @@ def init_db(con): if current_schema.version < 7: _apply_schema_update( current_schema, 7, - migrator.rename_column(SourceDirModel._meta.table_name, 'config_id', 'profile_id'), + migrator.rename_column(SourceFileModel._meta.table_name, 'config_id', 'profile_id'), migrator.drop_column(EventLogModel._meta.table_name, 'profile_id'), migrator.add_column(EventLogModel._meta.table_name, 'profile', pw.CharField(null=True)) ) diff --git a/src/vorta/tray_menu.py b/src/vorta/tray_menu.py index 7a41257d..889ba825 100644 --- a/src/vorta/tray_menu.py +++ b/src/vorta/tray_menu.py @@ -39,12 +39,11 @@ class TrayMenu(QSystemTrayIcon): profile_menu = menu.addMenu('Backup Now') for profile in profiles: new_item = profile_menu.addAction(profile.name) - new_item.setData(profile.id) - new_item.triggered.connect(lambda profile_id=profile.id: self.app.create_backup_action(profile_id)) + new_item.triggered.connect(lambda state, i=profile.id: self.app.create_backup_action(i)) else: profile = profiles.first() profile_menu = menu.addAction('Backup Now') - profile_menu.triggered.connect(lambda profile_id=profile.id: self.app.create_backup_action(profile_id)) + profile_menu.triggered.connect(lambda state, i=profile.id: self.app.create_backup_action(i)) settings_action = menu.addAction("Settings") settings_action.triggered.connect(self.app.open_main_window_action) diff --git a/src/vorta/updater.py b/src/vorta/updater.py index bc81b92f..475a2373 100644 --- a/src/vorta/updater.py +++ b/src/vorta/updater.py @@ -1,5 +1,6 @@ import sys import os +from vorta.models import SettingsModel def get_updater(): @@ -10,9 +11,14 @@ def get_updater(): bundle_path = os.path.join(os.path.dirname(sys.executable), os.pardir, 'Frameworks', 'Sparkle.framework') loadBundle('Sparkle', globals(), bundle_path) sparkle = SUUpdater.sharedUpdater() # noqa: F821 - sparkle.setAutomaticallyChecksForUpdates_(True) + if SettingsModel.get(key='updates_include_beta').value: + sparkle.SharedUpdater.FeedURL = 'https://borgbase.github.io/vorta/appcast-pre.xml' + + if SettingsModel.get(key='check_for_updates').value: + sparkle.setAutomaticallyChecksForUpdates_(True) + sparkle.checkForUpdatesInBackground() + sparkle.setAutomaticallyDownloadsUpdates_(False) - sparkle.checkForUpdatesInBackground() return sparkle else: # TODO: implement for Linux diff --git a/src/vorta/utils.py b/src/vorta/utils.py index 99c41e97..33dfd4fe 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -79,7 +79,7 @@ def get_dict_from_list(dataDict, mapList): return reduce(operator.getitem, mapList, dataDict) -def choose_folder_dialog(parent, title, want_folder=True): +def choose_file_dialog(parent, title, want_folder=True): options = QFileDialog.Options() if want_folder: options |= QFileDialog.ShowDirsOnly @@ -219,3 +219,23 @@ def set_tray_icon(tray, active=False): icon_name = f"icons/hdd-o{'-active' if active else ''}-{'light' if use_light_style else 'dark'}.png" icon = QIcon(get_asset(icon_name)) tray.setIcon(icon) + + +def open_app_at_startup(enabled=True): + if sys.platform == 'darwin': + # From https://stackoverflow.com/questions/26213884/cocoa-add-app-to-startup-in-sandbox-using-pyobjc + from Foundation import NSDictionary + from Cocoa import NSBundle, NSURL + from CoreFoundation import kCFAllocatorDefault + from LaunchServices import (LSSharedFileListCreate, kLSSharedFileListSessionLoginItems, + LSSharedFileListInsertItemURL, kLSSharedFileListItemHidden, + kLSSharedFileListItemLast, LSSharedFileListItemRemove) + app_path = NSBundle.mainBundle().bundlePath() + url = NSURL.alloc().initFileURLWithPath_(app_path) + login_items = LSSharedFileListCreate(kCFAllocatorDefault, kLSSharedFileListSessionLoginItems, None) + props = NSDictionary.dictionaryWithObject_forKey_(True, kLSSharedFileListItemHidden) + + new_item = LSSharedFileListInsertItemURL(login_items, kLSSharedFileListItemLast, + None, None, url, props, None) + if not enabled: + LSSharedFileListItemRemove(login_items, new_item) diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 843f231e..8dd65afc 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -11,7 +11,7 @@ from vorta.borg.mount import BorgMountThread from vorta.borg.extract import BorgExtractThread from vorta.borg.umount import BorgUmountThread from vorta.views.extract_dialog import ExtractDialog -from vorta.utils import get_asset, pretty_bytes, choose_folder_dialog +from vorta.utils import get_asset, pretty_bytes, choose_file_dialog from vorta.models import BackupProfileMixin, ArchiveModel uifile = get_asset('UI/archivetab.ui') @@ -177,7 +177,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): thread.result.connect(self.mount_result) thread.start() - dialog = choose_folder_dialog(self, "Choose Mount Point") + dialog = choose_file_dialog(self, "Choose Mount Point") dialog.open(receive) def mount_result(self, result): @@ -274,7 +274,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin): else: self._set_status(params['message']) - dialog = choose_folder_dialog(self, "Choose Extraction Point") + dialog = choose_file_dialog(self, "Choose Extraction Point") dialog.open(receive) def extract_archive_result(self, result): diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index a27425cb..ffd90b8e 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -56,56 +56,13 @@ class ExtractDialog(ExtractDialogBase, ExtractDialogUI): self.selected = selected_files_folders -class FileItem: - def __init__(self, name, modified, size, parent=None): - self.parentItem = parent - self.itemData = [name, modified, size] # dt.strptime(modified, ISO_FORMAT) - self.checkedState = False - - def childCount(self): - return 0 - - def columnCount(self): - return 3 - - def data(self, column): - if column == 1: - return self.itemData[column] # .strftime('%Y-%m-%dT%H:%M') - elif column == 2: - return pretty_bytes(self.itemData[column]) - elif column == 0: - return self.itemData[column] - - def parent(self): - return self.parentItem - - def row(self): - return self.parentItem.childItems.index(self) - - def setCheckedState(self, value): - if value == 2: - self.checkedState = True - selected_files_folders.add( - os.path.join(self.parentItem.path, self.parentItem.data(0), self.itemData[0])) - else: - self.checkedState = False - selected_files_folders.remove( - os.path.join(self.parentItem.path, self.parentItem.data(0), self.itemData[0])) - - def getCheckedState(self): - if self.checkedState: - return Qt.Checked - else: - return Qt.Unchecked - - -class FolderItem(FileItem): +class FolderItem: def __init__(self, path, name, modified, parent=None): self.parentItem = parent self.path = path self.itemData = [name, modified] - self.checkedState = False self.childItems = [] + self.checkedState = False # Pre-filter children self._filtered_children = [] @@ -114,6 +71,8 @@ class FolderItem(FileItem): for root_folder in nested_file_list.keys(): self._filtered_children.append((0, '', root_folder, '', )) else: + self.checkedState = parent.checkedState # If there is a parent, use its checked-status. + # This adds direct children. self._filtered_children = [f for f in files_with_attributes if search_path == f[3]] @@ -142,6 +101,27 @@ class FolderItem(FileItem): self.is_loaded = True + def setCheckedState(self, value): + if value == 2: + self.checkedState = True + selected_files_folders.add( + os.path.join(self.parentItem.path, self.parentItem.data(0), self.itemData[0])) + else: + self.checkedState = False + path_to_remove = os.path.join(self.parentItem.path, self.parentItem.data(0), self.itemData[0]) + if path_to_remove in selected_files_folders: + selected_files_folders.remove(path_to_remove) + + if hasattr(self, 'childItems'): + for child in self.childItems: + child.setCheckedState(value) + + def getCheckedState(self): + if self.checkedState: + return Qt.Checked + else: + return Qt.Unchecked + def child(self, row): return self.childItems[row] @@ -167,6 +147,33 @@ class FolderItem(FileItem): return 0 +class FileItem(FolderItem): + def __init__(self, name, modified, size, parent=None): + self.parentItem = parent + self.itemData = [name, modified, size] # dt.strptime(modified, ISO_FORMAT) + self.checkedState = parent.checkedState + + def childCount(self): + return 0 + + def columnCount(self): + return 3 + + def data(self, column): + if column == 1: + return self.itemData[column] # .strftime('%Y-%m-%dT%H:%M') + elif column == 2: + return pretty_bytes(self.itemData[column]) + elif column == 0: + return self.itemData[column] + + def parent(self): + return self.parentItem + + def row(self): + return self.parentItem.childItems.index(self) + + class TreeModel(QAbstractItemModel): column_names = ['Name', 'Modified', 'Size'] @@ -196,6 +203,7 @@ class TreeModel(QAbstractItemModel): if role == Qt.CheckStateRole: item = index.internalPointer() item.setCheckedState(value) + self.dataChanged.emit(QModelIndex(), QModelIndex(), []) return True diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py index 39b28792..c0cbc891 100644 --- a/src/vorta/views/misc_tab.py +++ b/src/vorta/views/misc_tab.py @@ -1,6 +1,6 @@ from PyQt5 import uic from PyQt5.QtWidgets import QCheckBox -from vorta.utils import get_asset +from vorta.utils import get_asset, open_app_at_startup from vorta.models import SettingsModel from vorta._version import __version__ @@ -26,3 +26,6 @@ class MiscTab(MiscTabBase, MiscTabUI): setting = SettingsModel.get(key=key) setting.value = bool(new_value) setting.save() + + if key == 'autostart': + open_app_at_startup(new_value) diff --git a/src/vorta/views/repo_add_dialog.py b/src/vorta/views/repo_add_dialog.py index c9276c8f..adf66236 100644 --- a/src/vorta/views/repo_add_dialog.py +++ b/src/vorta/views/repo_add_dialog.py @@ -1,5 +1,5 @@ from PyQt5 import uic -from ..utils import get_private_keys, get_asset, choose_folder_dialog +from ..utils import get_private_keys, get_asset, choose_file_dialog from vorta.borg.init import BorgInitThread from vorta.borg.info import BorgInfoThread @@ -41,7 +41,7 @@ class AddRepoWindow(AddRepoBase, AddRepoUI): self.sshComboBox.setEnabled(False) self.repoLabel.setText('Repository Path:') - dialog = choose_folder_dialog(self, "Choose Location of Borg Repository") + dialog = choose_file_dialog(self, "Choose Location of Borg Repository") dialog.open(receive) def use_remote_repo_action(self): diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 104aa38e..45b0bee6 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -1,6 +1,6 @@ from PyQt5 import uic -from ..models import SourceDirModel, BackupProfileMixin -from ..utils import get_asset, choose_folder_dialog +from ..models import SourceFileModel, BackupProfileMixin +from ..utils import get_asset, choose_file_dialog uifile = get_asset('UI/sourcetab.ui') SourceUI, SourceBase = uic.loadUiType(uifile) @@ -22,12 +22,12 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin): profile = self.profile() self.excludePatternsField.textChanged.disconnect() self.excludeIfPresentField.textChanged.disconnect() - self.sourceDirectoriesWidget.clear() + self.sourceFilesWidget.clear() self.excludePatternsField.clear() self.excludeIfPresentField.clear() - for source in SourceDirModel.select().where(SourceDirModel.profile == profile): - self.sourceDirectoriesWidget.addItem(source.dir) + for source in SourceFileModel.select().where(SourceFileModel.profile == profile): + self.sourceFilesWidget.addItem(source.dir) self.excludePatternsField.appendPlainText(profile.exclude_patterns) self.excludeIfPresentField.appendPlainText(profile.exclude_if_present) @@ -38,18 +38,18 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin): def receive(): dir = dialog.selectedFiles() if dir: - new_source, created = SourceDirModel.get_or_create(dir=dir[0], profile=self.profile()) + new_source, created = SourceFileModel.get_or_create(dir=dir[0], profile=self.profile()) if created: - self.sourceDirectoriesWidget.addItem(dir[0]) + self.sourceFilesWidget.addItem(dir[0]) new_source.save() item = "directory" if want_folder else "file" - dialog = choose_folder_dialog(self, "Choose %s to back up" % item, want_folder=want_folder) + dialog = choose_file_dialog(self, "Choose %s to back up" % item, want_folder=want_folder) dialog.open(receive) def source_remove(self): - item = self.sourceDirectoriesWidget.takeItem(self.sourceDirectoriesWidget.currentRow()) - db_item = SourceDirModel.get(dir=item.text()) + item = self.sourceFilesWidget.takeItem(self.sourceFilesWidget.currentRow()) + db_item = SourceFileModel.get(dir=item.text()) db_item.delete_instance() item = None diff --git a/tests/conftest.py b/tests/conftest.py index ac28e80a..58432b21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from datetime import datetime as dt import vorta from vorta.application import VortaApp -from vorta.models import RepoModel, SourceDirModel, ArchiveModel, BackupProfileModel +from vorta.models import RepoModel, SourceFileModel, ArchiveModel, BackupProfileModel def pytest_configure(config): @@ -28,7 +28,7 @@ def app(tmpdir, qtbot): test_archive = ArchiveModel(snapshot_id='99999', name='test-archive', time=dt(2000, 1, 1, 0, 0), repo=1) test_archive.save() - source_dir = SourceDirModel(dir='/tmp/another', repo=new_repo) + source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo) source_dir.save() app = VortaApp([]) @@ -38,7 +38,7 @@ def app(tmpdir, qtbot): @pytest.fixture -def choose_folder_dialog(*args): +def choose_file_dialog(*args): class MockFileDialog: def __init__(self, *args, **kwargs): pass diff --git a/tests/test_archives.py b/tests/test_archives.py index 31993e29..693270b4 100644 --- a/tests/test_archives.py +++ b/tests/test_archives.py @@ -71,10 +71,10 @@ def test_check(app, mocker, borg_json_output, qtbot): qtbot.mouseClick(tab.checkButton, QtCore.Qt.LeftButton) success_text = 'INFO: Archive consistency check complete' - qtbot.waitUntil(lambda: main.createProgressText.text().startswith(success_text)) + qtbot.waitUntil(lambda: main.createProgressText.text().startswith(success_text), timeout=3000) -def test_archive_mount(app, qtbot, mocker, borg_json_output, monkeypatch, choose_folder_dialog): +def test_archive_mount(app, qtbot, mocker, borg_json_output, monkeypatch, choose_file_dialog): def psutil_disk_partitions(**kwargs): DiskPartitions = namedtuple('DiskPartitions', ['device', 'mountpoint']) return [DiskPartitions('borgfs', '/tmp')] @@ -94,7 +94,7 @@ def test_archive_mount(app, qtbot, mocker, borg_json_output, monkeypatch, choose mocker.patch.object(vorta.borg.borg_thread, 'Popen', return_value=popen_result) monkeypatch.setattr( - vorta.views.archive_tab, "choose_folder_dialog", choose_folder_dialog + vorta.views.archive_tab, "choose_file_dialog", choose_file_dialog ) qtbot.mouseClick(tab.mountButton, QtCore.Qt.LeftButton) diff --git a/tests/test_source.py b/tests/test_source.py index 6820525d..8089d077 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -4,16 +4,16 @@ import vorta.models import vorta.views -def test_add_folder(app, qtbot, tmpdir, monkeypatch, choose_folder_dialog): +def test_add_folder(app, qtbot, tmpdir, monkeypatch, choose_file_dialog): monkeypatch.setattr( - vorta.views.source_tab, "choose_folder_dialog", choose_folder_dialog + vorta.views.source_tab, "choose_file_dialog", choose_file_dialog ) main = app.main_window main.tabWidget.setCurrentIndex(1) tab = main.sourceTab qtbot.mouseClick(tab.sourceAddFolder, QtCore.Qt.LeftButton) - qtbot.waitUntil(lambda: tab.sourceDirectoriesWidget.count() == 2) + qtbot.waitUntil(lambda: tab.sourceFilesWidget.count() == 2) - for src in vorta.models.SourceDirModel.select(): + for src in vorta.models.SourceFileModel.select(): logging.error(src.dir, src.profile)