From 70ad554e03e577cd0452021f57e7e70789c654a2 Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Mon, 19 Jun 2023 08:16:37 +0100 Subject: [PATCH 01/52] Replace Font Awesome icons with Fork Awesome and others (#1729) --- README.md | 4 ++-- src/vorta/assets/icons/angle-down-solid.svg | 8 ++------ src/vorta/assets/icons/angle-up-solid.svg | 7 ++----- src/vorta/assets/icons/broom-solid.svg | 3 ++- src/vorta/assets/icons/copy.svg | 4 ++-- src/vorta/assets/icons/eye-slash.svg | 20 +++++++++++++------- src/vorta/assets/icons/eye.svg | 11 ++++++++++- src/vorta/assets/icons/icon.svg | 7 +------ 8 files changed, 34 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d436d6670..9aa15bb20 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,6 @@ See our website for [download links and and install instructions](https://vorta. ## License and Credits - See [CONTRIBUTORS.md](CONTRIBUTORS.md) to see who programmed and translated Vorta. -- Licensed under [GPLv3](LICENSE.txt). © 2018-2020 Manuel Riel and Vorta contributors +- Licensed under [GPLv3](LICENSE.txt). © 2018-2023 Manuel Riel and Vorta contributors - Based on [PyQt](https://riverbankcomputing.com/software/pyqt/intro) and [Qt](https://www.qt.io). -- Icons by [FontAwesome](https://fontawesome.com) +- Icons by [Fork Awesome](https://forkaweso.me/) (licensed under [SIL Open Font License](https://scripts.sil.org/OFL), Version 1.1) unless specified otherwise. diff --git a/src/vorta/assets/icons/angle-down-solid.svg b/src/vorta/assets/icons/angle-down-solid.svg index e3a02036a..05ce3ecbf 100644 --- a/src/vorta/assets/icons/angle-down-solid.svg +++ b/src/vorta/assets/icons/angle-down-solid.svg @@ -1,6 +1,2 @@ - - - - - - + + diff --git a/src/vorta/assets/icons/angle-up-solid.svg b/src/vorta/assets/icons/angle-up-solid.svg index 7ba776f28..b93c1b201 100644 --- a/src/vorta/assets/icons/angle-up-solid.svg +++ b/src/vorta/assets/icons/angle-up-solid.svg @@ -1,5 +1,2 @@ - - - - - + + diff --git a/src/vorta/assets/icons/broom-solid.svg b/src/vorta/assets/icons/broom-solid.svg index 929a9fa56..cc3556bc3 100644 --- a/src/vorta/assets/icons/broom-solid.svg +++ b/src/vorta/assets/icons/broom-solid.svg @@ -1 +1,2 @@ - + + diff --git a/src/vorta/assets/icons/copy.svg b/src/vorta/assets/icons/copy.svg index 13e0c87df..35cc161dd 100644 --- a/src/vorta/assets/icons/copy.svg +++ b/src/vorta/assets/icons/copy.svg @@ -1,2 +1,2 @@ - - + + diff --git a/src/vorta/assets/icons/eye-slash.svg b/src/vorta/assets/icons/eye-slash.svg index 4f37a51a9..f4fb73f81 100644 --- a/src/vorta/assets/icons/eye-slash.svg +++ b/src/vorta/assets/icons/eye-slash.svg @@ -1,8 +1,14 @@ - - - - Layer 1 - - - + + + diff --git a/src/vorta/assets/icons/eye.svg b/src/vorta/assets/icons/eye.svg index c5763fcde..f911af0ff 100644 --- a/src/vorta/assets/icons/eye.svg +++ b/src/vorta/assets/icons/eye.svg @@ -1 +1,10 @@ - + + + + diff --git a/src/vorta/assets/icons/icon.svg b/src/vorta/assets/icons/icon.svg index f08ab3ffb..0b64c93b9 100644 --- a/src/vorta/assets/icons/icon.svg +++ b/src/vorta/assets/icons/icon.svg @@ -1,6 +1 @@ - - - - - - + From 210a968f913df63132cb7988f4150e6674fcddb6 Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Wed, 21 Jun 2023 09:09:38 +0100 Subject: [PATCH 02/52] Replace CC-BY Vaadin icon (#1735) --- src/vorta/assets/icons/copy.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vorta/assets/icons/copy.svg b/src/vorta/assets/icons/copy.svg index 35cc161dd..3dd95a141 100644 --- a/src/vorta/assets/icons/copy.svg +++ b/src/vorta/assets/icons/copy.svg @@ -1,2 +1,2 @@ - - + + From c56c6700ca48005bc77922ac9723a043780a0bae Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Fri, 23 Jun 2023 18:51:54 +0530 Subject: [PATCH 03/52] Show trigger (user/scheduled) in Archive tab. By @diivi (#1732) --- src/vorta/assets/UI/archivetab.ui | 8 +++++--- src/vorta/assets/icons/user.svg | 1 + src/vorta/borg/create.py | 1 + src/vorta/store/connection.py | 2 +- src/vorta/store/migrations.py | 11 +++++++++++ src/vorta/store/models.py | 1 + src/vorta/views/archive_tab.py | 29 ++++++++++++++++++++++++++++- 7 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 src/vorta/assets/icons/user.svg diff --git a/src/vorta/assets/UI/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index 3e3b0b4c5..6fc6d586b 100644 --- a/src/vorta/assets/UI/archivetab.ui +++ b/src/vorta/assets/UI/archivetab.ui @@ -142,9 +142,6 @@ false - - true - false @@ -173,6 +170,11 @@ Name + + + Trigger + + diff --git a/src/vorta/assets/icons/user.svg b/src/vorta/assets/icons/user.svg new file mode 100644 index 000000000..0a751e335 --- /dev/null +++ b/src/vorta/assets/icons/user.svg @@ -0,0 +1 @@ + diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 2e7b91965..69dc8b3f1 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -28,6 +28,7 @@ def process_result(self, result): 'repo': result['params']['repo_id'], 'duration': result['data']['archive']['duration'], 'size': result['data']['archive']['stats']['deduplicated_size'], + 'trigger': result['params'].get('category', 'user'), }, ) new_archive.save() diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index 21e6ec258..dcddcd88d 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -21,7 +21,7 @@ ) from .settings import get_misc_settings -SCHEMA_VERSION = 20 +SCHEMA_VERSION = 21 @signals.post_save(sender=SettingsModel) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index 160addc87..cf4ddd5a7 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -228,6 +228,17 @@ def run_migrations(current_schema, db_connection): migrator.add_column(SettingsModel._meta.table_name, 'tooltip', pw.CharField(default='')), ) + if current_schema.version < 21: + _apply_schema_update( + current_schema, + 21, + migrator.add_column( + ArchiveModel._meta.table_name, + 'trigger', + pw.CharField(null=True), + ), + ) + def _apply_schema_update(current_schema, version_after, *operations): with DB.atomic(): diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index f0c32938a..bda3cfd95 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -132,6 +132,7 @@ class ArchiveModel(BaseModel): time = pw.DateTimeField() duration = pw.FloatField(null=True) size = pw.IntegerField(null=True) + trigger = pw.CharField(null=True) def formatted_time(self): return diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index f5b92c8ee..71d74bb43 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -14,6 +14,7 @@ QLayout, QMenu, QMessageBox, + QStyledItemDelegate, QTableView, QTableWidgetItem, QWidget, @@ -57,6 +58,13 @@ 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'] @@ -83,7 +91,10 @@ def __init__(self, parent=None, app=None): header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) - header.setStretchLastSection(True) + 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. @@ -255,6 +266,12 @@ def populate_from_profile(self): self.toolBox.setItemText(0, self.tr('Archives for %s') % profile.repo.url) 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) @@ -280,6 +297,16 @@ def populate_from_profile(self): 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) From f76195e47dc267785e6f6ff2e6ce1106e07cc86a Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sat, 24 Jun 2023 00:50:23 +0530 Subject: [PATCH 04/52] Disable compact button for older borg versions. By @diivi (#1727) --- src/vorta/application.py | 1 + src/vorta/borg/compact.py | 6 ++---- src/vorta/views/archive_tab.py | 15 +++++++++++++++ src/vorta/views/main_window.py | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/vorta/application.py b/src/vorta/application.py index fac38440b..921fe02f2 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -175,6 +175,7 @@ def set_borg_details_result(self, result): borg_compat.set_version(result['data']['version'], result['data']['path']) self.main_window.miscTab.set_borg_details(borg_compat.version, borg_compat.path) self.main_window.repoTab.toggle_available_compression() + self.main_window.archiveTab.toggle_compact_button_visibility() self.scheduler.reload_all_timers() # Start timer after Borg version is set. else: self._alert_missing_borg() diff --git a/src/vorta/borg/compact.py b/src/vorta/borg/compact.py index 4f110a921..12bc160c4 100644 --- a/src/vorta/borg/compact.py +++ b/src/vorta/borg/compact.py @@ -1,7 +1,7 @@ from typing import Any, Dict from vorta import config -from vorta.i18n import trans_late, translate +from vorta.i18n import translate from vorta.utils import borg_compat from .borg_job import BorgJob @@ -44,9 +44,7 @@ def prepare(cls, profile): ret['ok'] = False # Set back to false, so we can do our own checks here. if not borg_compat.check('COMPACT_SUBCOMMAND'): - ret['ok'] = False - ret['message'] = trans_late('messages', 'This feature needs Borg 1.2.0 or higher.') - return ret + raise Exception('The compact action needs Borg >= 1.2.0') cmd = ['borg', '--info', '--log-json', 'compact', '--progress'] if borg_compat.check('V2'): diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 71d74bb43..14a53cfc1 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -35,6 +35,7 @@ from vorta.i18n import translate from vorta.store.models import ArchiveModel, BackupProfileMixin from vorta.utils import ( + borg_compat, choose_file_dialog, find_best_unit_for_sizes, format_archive_name, @@ -83,6 +84,7 @@ def __init__(self, parent=None, app=None): 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.compactButton] = self.compactButton.toolTip() header = self.archiveTable.horizontalHeader() header.setVisible(True) @@ -981,3 +983,16 @@ def rename_result(self, result): 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/main_window.py b/src/vorta/views/main_window.py index ca0c2426b..fab20dfe4 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -167,6 +167,7 @@ def profile_select_action(self, index): SettingsModel.update({SettingsModel.str_value: self.current_profile.id}).where( SettingsModel.key == 'previous_profile_id' ).execute() + self.archiveTab.toggle_compact_button_visibility() def profile_rename_action(self): window = EditProfileWindow(rename_existing_id=self.profileSelector.currentData()) From 92608f9eaaa6ac9d4bf28f9fa59edaae6001fe07 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Sun, 25 Jun 2023 01:27:46 +0530 Subject: [PATCH 05/52] Assign names to repos. By @diivi (#1665) --- src/vorta/assets/UI/repoadd.ui | 557 ++++++++++++++------------- src/vorta/assets/icons/eye-slash.svg | 2 +- src/vorta/assets/icons/eye.svg | 2 +- src/vorta/borg/borg_job.py | 3 +- src/vorta/borg/info_repo.py | 8 +- src/vorta/borg/init.py | 2 + src/vorta/store/connection.py | 2 +- src/vorta/store/migrations.py | 11 + src/vorta/store/models.py | 1 + src/vorta/views/archive_tab.py | 7 +- src/vorta/views/repo_add_dialog.py | 7 + src/vorta/views/repo_tab.py | 4 +- tests/test_repo.py | 4 +- 13 files changed, 332 insertions(+), 278 deletions(-) diff --git a/src/vorta/assets/UI/repoadd.ui b/src/vorta/assets/UI/repoadd.ui index 643e2a774..b0f861415 100644 --- a/src/vorta/assets/UI/repoadd.ui +++ b/src/vorta/assets/UI/repoadd.ui @@ -1,278 +1,299 @@ - AddRepository - - - - 0 - 0 - 466 - 274 - - - + AddRepository + + + + 0 + 0 + 583 + 338 + + + + true + + + + 0 + + + + + + 0 + 0 + + + + + 0 + 20 + + + + + 11 + + + + + + + Qt::PlainText + + + false + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + true - - - - 0 - - - - - - 0 - 0 - - - - - 0 - 20 - - - - - 11 - - - - - - - Qt::PlainText - - - false - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - + + + + + + + + 0 + 0 + + + + 0 + + + + General + + + + QFormLayout::ExpandingFieldsGrow + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + true + + + + Initialize New Backup Repository + - - - - - - 0 - 0 - - - - 0 - - - - General - - - - QFormLayout::ExpandingFieldsGrow - - - 5 - - - 5 - - - 5 - - - 5 - - - - - - true - - - - Initialize New Backup Repository - - - - - - - Repository URL: - - - - - - - 0 - - - 0 - - - - - ssh://abc123@abc123.repo.borgbase.com/./repo - - - - - - - Choose a local folder - - - ... - - - - :/icons/folder-open.svg:/icons/folder-open.svg - - - - - - - Choose a remote repository - - - ... - - - - :/icons/globe.svg:/icons/globe.svg - - - - - - - - - Borg passphrase: - - - - - - - true - - - QLineEdit::Password - - - - - - - true - - - QLineEdit::Password - - - - - - - Confirm passphrase: - - - - - - - TextLabel - - - - + + + + + Repository URL: + + + + + + + 0 + + + 0 + + + + + ssh://abc123@abc123.repo.borgbase.com/./repo + + + + + + + Choose a local folder + + + ... + + + + :/icons/folder-open.svg:/icons/folder-open.svg + - - - Advanced - - - - QFormLayout::ExpandingFieldsGrow - - - 5 - - - 5 - - - 5 - - - 5 - - - - - SSH Key: - - - - - - - - 0 - 0 - - - - - Automatically choose SSH Key (default) - - - - - - - - Encryption: - - - - - - - - 0 - 0 - - - - - - - - Extra Borg Arguments: - - - - - - - + + + + + Choose a remote repository + + + ... + + + + :/icons/globe.svg:/icons/globe.svg + + + + + + + + Repository Name: + + + + + + + Macbook Pro Office (optional) + - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + Borg passphrase: + + + + + + + true + + + QLineEdit::Password + + + + + + + Confirm passphrase: + + + + + + + true + + + QLineEdit::Password + + + + + + + TextLabel + + + + + + + + Advanced + + + + QFormLayout::ExpandingFieldsGrow + + + 5 + + + 5 + + + 5 + + + 5 + + + + + SSH Key: + + + + + + + + 0 + 0 + + + + + Automatically choose SSH Key (default) + + + + + + + + Encryption: + + + + + + + + 0 + 0 + + + + + + + + Extra Borg Arguments: + - - - - - + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + diff --git a/src/vorta/assets/icons/eye-slash.svg b/src/vorta/assets/icons/eye-slash.svg index f4fb73f81..e4d04343c 100644 --- a/src/vorta/assets/icons/eye-slash.svg +++ b/src/vorta/assets/icons/eye-slash.svg @@ -1,5 +1,5 @@ - - 64: + self._set_status(self.tr('Repository name must be less than 64 characters.')) + return False + if RepoModel.get_or_none(RepoModel.url == self.values['repo_url']) is not None: self._set_status(self.tr('This repo has already been added.')) return False diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 2f6b52fb4..96b614b02 100644 --- a/src/vorta/views/repo_tab.py +++ b/src/vorta/views/repo_tab.py @@ -79,8 +79,10 @@ def set_icons(self): def set_repos(self): self.repoSelector.clear() self.repoSelector.addItem(self.tr('No repository selected'), None) + # set tooltip = url for each item in the repoSelector for repo in RepoModel.select(): - self.repoSelector.addItem(repo.url, repo.id) + self.repoSelector.addItem(f"{repo.name + ' - ' if repo.name else ''}{repo.url}", repo.id) + self.repoSelector.setItemData(self.repoSelector.count() - 1, repo.url, QtCore.Qt.ItemDataRole.ToolTipRole) def populate_from_profile(self): try: diff --git a/tests/test_repo.py b/tests/test_repo.py index bfbb75c75..b5b070968 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -85,8 +85,10 @@ def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): main.repoTab.new_repo() # couldn't click menu add_repo_window = main.repoTab._window test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain + test_repo_name = 'Test Repo' qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) + qtbot.keyClicks(add_repo_window.repoName, test_repo_name) qtbot.keyClicks(add_repo_window.passwordLineEdit, LONG_PASSWORD) qtbot.keyClicks(add_repo_window.confirmLineEdit, LONG_PASSWORD) @@ -103,7 +105,7 @@ def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): keyring = VortaKeyring.get_keyring() assert keyring.get_password("vorta-repo", RepoModel.get(id=2).url) == LONG_PASSWORD - assert main.repoTab.repoSelector.currentText() == test_repo_url + assert main.repoTab.repoSelector.currentText() == f"{test_repo_name} - {test_repo_url}" def test_ssh_dialog(qapp, qtbot, tmpdir): From 50be34cabe558acd4526ffbf778b98a8f0cae040 Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Sun, 25 Jun 2023 21:53:29 +0100 Subject: [PATCH 06/52] Use maintained stale action (#1737) --- .github/ISSUE_TEMPLATE/feature_request.md | 4 +- .github/stale.yml | 62 ++++++++++++----------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 86687a59e..ac806fcff 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,8 +1,8 @@ --- name: Feature Request about: Suggest an idea for this project. -title: 'FR: ' -labels: 'type:enhancement' +title: '' +labels: '' assignees: '' --- diff --git a/.github/stale.yml b/.github/stale.yml index 60a650d61..150275de7 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,33 +1,37 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 +name: Close stale issues +on: + schedule: + - cron: '50 1 * * *' -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + days-before-issue-stale: 60 + days-before-pr-stale: -1 + days-before-issue-close: 7 + # days-before-pr-close: 10 -# Issues with these labels will never be considered stale -exemptLabels: - - "status:idea" - - "status:planning" - - "status:on hold" - - "status:ready" - - "type:bug" - - "type:docs" - - "type:enhancement" - - "type:feature" - - "type:refactor" - - "type:task" + stale-issue-label: "status:stale" + stale-pr-label: "status:stale" -# Label to use when marking an issue as stale -staleLabel: "status:stale" + exempt-issue-labels: > + status:idea, + status:planning, + status:on hold, + status:ready, + type:bug, + type:docs, + type:enhancement, + type:feature, + type:refactor, + type:task, -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. - -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false - -# Limit to only `issues` or `pulls` -only: issues + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + close-issue-message: > + This issue was closed because it has been stalled for 7 days with no activity. From d087654eb39d165e146c3a94414a33b88b70dca8 Mon Sep 17 00:00:00 2001 From: Punyam Singh <89277920+punyamsingh@users.noreply.github.com> Date: Tue, 27 Jun 2023 15:00:41 +0530 Subject: [PATCH 07/52] Update GSoC notice in README (#1739) * README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9aa15bb20..846d6ec3b 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@

-🤝 **This project is part of the [Google Summer of Code](https://summerofcode.withgoogle.com/) 2023 program. Apply or learn more [here](https://github.com/borgbase/vorta/wiki/Google-Summer-of-Code-2023-Ideas)!** - Vorta is a backup client for macOS and Linux desktops. It integrates the mighty [BorgBackup](https://borgbackup.readthedocs.io) with your desktop environment to protect your data from disk failure, ransomware and theft. ![](https://files.qmax.us/vorta/screencast-8-small.gif) @@ -36,6 +34,7 @@ See our website for [download links and and install instructions](https://vorta. - To discuss everything around using, improving, packaging and translating Vorta, join the [discussion on Github](https://github.com/borgbase/vorta/discussions). - Report bugs by opening a new [Github issue](https://github.com/borgbase/vorta/issues/new/choose). - Want to contribute to Vorta? Great! See our [contributor guide](https://vorta.borgbase.com/contributing/) on how to help out with coding, translation and packaging. +- We currently have students from the Google Summer Of Code 2023 Program contributing to this project. ## License and Credits - See [CONTRIBUTORS.md](CONTRIBUTORS.md) to see who programmed and translated Vorta. From ec1dfcd8031b6732d627011e6baf110b38e4d8a4 Mon Sep 17 00:00:00 2001 From: yfprojects <62463991+real-yfprojects@users.noreply.github.com> Date: Wed, 28 Jun 2023 19:25:08 +0000 Subject: [PATCH 08/52] Add profile name to error notification. (#1728) In a similar fashion like #1637 the commit adds the profile name to the error notification. * src/vorta/scheduler.py Co-authored-by: herrwusel --- src/vorta/scheduler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 222ccbc56..7a3fcee5d 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -34,7 +34,6 @@ class ScheduleStatus(NamedTuple): class VortaScheduler(QtCore.QObject): - #: The schedule for the profile with the given id changed. schedule_changed = QtCore.pyqtSignal(int) @@ -198,7 +197,6 @@ def set_timer_for_profile(self, profile_id: int): return with self.lock: # Acquire lock - self.remove_job(profile_id) # reset schedule pause = self.pauses.get(profile_id) @@ -292,7 +290,6 @@ def set_timer_for_profile(self, profile_id: int): # handle missing of a scheduled time if next_time <= dt.now(): - if profile.schedule_make_up_missed: self.lock.release() try: @@ -446,7 +443,7 @@ def notify(self, result): else: notifier.deliver( self.tr('Vorta Backup'), - self.tr('Error during backup creation.'), + self.tr('Error during backup creation for %s.') % profile_name, level='error', ) logger.error('Error during backup creation.') From 157ac373a9bdb0396479d01eb1c983ccf046844d Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Wed, 5 Jul 2023 15:58:09 +0530 Subject: [PATCH 09/52] Run actions on multiple archives. By @diivi (#1723) --- src/vorta/assets/UI/archivetab.ui | 4 +- src/vorta/views/archive_tab.py | 69 ++++++++++++++++++++----------- src/vorta/views/main_window.py | 4 +- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/vorta/assets/UI/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index 6fc6d586b..dfaa5d512 100644 --- a/src/vorta/assets/UI/archivetab.ui +++ b/src/vorta/assets/UI/archivetab.ui @@ -209,10 +209,10 @@ - Refresh selected archive + Recalculate selected archive's size(s) - Refresh + Recalculate Qt::ToolButtonTextBesideIcon diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 95427511a..54ae7aa13 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -79,11 +79,15 @@ def __init__(self, parent=None, app=None): self.app = app self.toolBox.setCurrentIndex(0) self.repoactions_enabled = True + 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() @@ -320,7 +324,8 @@ def populate_from_profile(self): self.archiveTable.scrollToItem(item) self.archiveTable.selectionModel().clearSelection() - self._toggle_all_buttons(enabled=True) + if self.remaining_refresh_archives == 0: + self._toggle_all_buttons(enabled=True) else: self.mount_points = {} self.archiveTable.setRowCount(0) @@ -355,6 +360,10 @@ def on_selection_change(self, selected=None, deselected=None): # 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() @@ -364,14 +373,15 @@ def on_selection_change(self, selected=None, deselected=None): if not self.repoactions_enabled: reason = self.tr("(borg already running)") - # toggle delete button + # Disable the delete and refresh buttons if no archive is selected if self.repoactions_enabled and len(indexes) > 0: - self.bDelete.setEnabled(True) - self.bDelete.setToolTip(self.tooltip_dict.get(self.bDelete, "")) + for button in multi_archive_action_buttons: + button.setEnabled(True) + button.setToolTip(self.tooltip_dict.get(button, "")) else: - self.bDelete.setEnabled(False) - tooltip = self.tooltip_dict[self.bDelete] - self.bDelete.setToolTip(tooltip + " " + reason or self.tr("(Select minimum one archive)")) + 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: @@ -387,7 +397,8 @@ def on_selection_change(self, selected=None, deselected=None): if self.repoactions_enabled and len(indexes) == 1: # Enable archive actions - self.fArchiveActions.setEnabled(True) + for widget in single_archive_action_buttons: + widget.setEnabled(True) for index in range(layout.count()): widget = layout.itemAt(index).widget() @@ -399,14 +410,11 @@ def on_selection_change(self, selected=None, deselected=None): reason = reason or self.tr("(Select exactly one archive)") # too few or too many selected. - self.fArchiveActions.setEnabled(False) - - for index in range(layout.count()): - widget = layout.itemAt(index).widget() + 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() @@ -522,20 +530,31 @@ def list_result(self, result): self.populate_from_profile() def refresh_archive_info(self): - archive_name = self.selected_archive_name() - 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._toggle_all_buttons(False) - self.app.jobs_manager.add_job(job) + 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._toggle_all_buttons(True) - if result['returncode'] == 0: - self._set_status(self.tr('Refreshed archive.')) + 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): diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index fab20dfe4..3da134f46 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -275,7 +275,9 @@ def backup_finished_event(self): self.repoTab.init_repo_stats() self.scheduleTab.populate_logs() - if not self.app.jobs_manager.is_worker_running(): + if not self.app.jobs_manager.is_worker_running() and ( + self.archiveTab.remaining_refresh_archives == 0 or self.archiveTab.remaining_refresh_archives == 1 + ): # Either the refresh is done or this is the last archive to refresh. self._toggle_buttons(create_enabled=True) self.archiveTab._toggle_all_buttons(enabled=True) From 25b4cc0b8bc2221bd7f7e474522d3ebccd6c56b9 Mon Sep 17 00:00:00 2001 From: jetchirag Date: Fri, 28 Jul 2023 13:09:10 +0530 Subject: [PATCH 10/52] Introduced password input widget (#1662) Move existing code for password input widgets into common classes to increase maintainability and reusability alongside reducing redundancy. This implements a `PasswordLineEdit` that can show a red border when an invalid password is entered. It also features a button for showing/hiding the password entered. When combining two of these entries for setting a new password `PasswordInput` can be used from now on. It combines a form for entering and confirming a password with a label to show a message when there is an issue with the password. It also checks the entered password against some rules regarding its length. This PR replaces existing widgets for entering passwords with these two new widgets. * src/vorta/views/partials/password_input.py : Implement common input widgets/classes * src/vorta/views/repo_add_dialog.py : Use new widgets. * src/vorta/assets/UI/repoadd.ui : ^^^ --- src/vorta/assets/UI/repoadd.ui | 68 +----- src/vorta/utils.py | 16 -- src/vorta/views/partials/password_input.py | 172 +++++++++++++++ src/vorta/views/repo_add_dialog.py | 242 ++++++++++----------- tests/test_password_input.py | 165 ++++++++++++++ tests/test_repo.py | 43 ++-- 6 files changed, 476 insertions(+), 230 deletions(-) create mode 100644 src/vorta/views/partials/password_input.py create mode 100644 tests/test_password_input.py diff --git a/src/vorta/assets/UI/repoadd.ui b/src/vorta/assets/UI/repoadd.ui index b0f861415..730f1df08 100644 --- a/src/vorta/assets/UI/repoadd.ui +++ b/src/vorta/assets/UI/repoadd.ui @@ -2,14 +2,6 @@ AddRepository - - - 0 - 0 - 583 - 338 - - true @@ -82,7 +74,7 @@ 5 - 5 + 20 @@ -169,47 +161,6 @@ - - - - Borg passphrase: - - - - - - - true - - - QLineEdit::Password - - - - - - - Confirm passphrase: - - - - - - - true - - - QLineEdit::Password - - - - - - - TextLabel - - - @@ -254,23 +205,6 @@ - - - - Encryption: - - - - - - - - 0 - 0 - - - - diff --git a/src/vorta/utils.py b/src/vorta/utils.py index 89510e803..1793a5e88 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -18,7 +18,6 @@ from PyQt6.QtWidgets import QApplication, QFileDialog, QSystemTrayIcon from vorta.borg._compatibility import BorgCompatibility -from vorta.i18n import trans_late from vorta.log import logger from vorta.network_status.abc import NetworkStatusMonitor @@ -507,21 +506,6 @@ def is_system_tray_available(): return is_available -def validate_passwords(first_pass, second_pass): - '''Validates the password for borg, do not use on single fields''' - pass_equal = first_pass == second_pass - pass_long = len(first_pass) > 8 - - if not pass_long and not pass_equal: - return trans_late('utils', "Passwords must be identical and greater than 8 characters long.") - if not pass_equal: - return trans_late('utils', "Passwords must be identical.") - if not pass_long: - return trans_late('utils', "Passwords must be greater than 8 characters long.") - - return "" - - def search(key, iterable: Iterable, func: Callable = None) -> Tuple[int, Any]: """ Search for a key in an iterable. diff --git a/src/vorta/views/partials/password_input.py b/src/vorta/views/partials/password_input.py new file mode 100644 index 000000000..bc8b0dd4c --- /dev/null +++ b/src/vorta/views/partials/password_input.py @@ -0,0 +1,172 @@ +from typing import Optional + +from PyQt6.QtCore import QObject +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QFormLayout, QLabel, QLineEdit, QWidget + +from vorta.i18n import translate +from vorta.views.utils import get_colored_icon + + +class PasswordLineEdit(QLineEdit): + def __init__(self, *, parent: Optional[QWidget] = None, show_visibility_button: bool = True) -> None: + super().__init__(parent) + + self._show_visibility_button = show_visibility_button + self._error_state = False + self._visible = False + + self.setEchoMode(QLineEdit.EchoMode.Password) + + if self._show_visibility_button: + self.showHideAction = QAction(self.tr("Show password"), self) + self.showHideAction.setCheckable(True) + self.showHideAction.toggled.connect(self.toggle_visibility) + self.showHideAction.setIcon(get_colored_icon("eye")) + self.addAction(self.showHideAction, QLineEdit.ActionPosition.TrailingPosition) + + def get_password(self) -> str: + """Return password text""" + return self.text() + + @property + def visible(self) -> bool: + """Return password visibility""" + return self._visible + + @visible.setter + def visible(self, value: bool) -> None: + """Set password visibility""" + if not isinstance(value, bool): + raise TypeError("visible must be a boolean value") + self._visible = value + self.setEchoMode(QLineEdit.EchoMode.Normal if self._visible else QLineEdit.EchoMode.Password) + + if self._show_visibility_button: + if self._visible: + self.showHideAction.setIcon(get_colored_icon("eye-slash")) + self.showHideAction.setText(self.tr("Hide password")) + + else: + self.showHideAction.setIcon(get_colored_icon("eye")) + self.showHideAction.setText(self.tr("Show password")) + + def toggle_visibility(self) -> None: + """Toggle password visibility""" + self.visible = not self._visible + + @property + def error_state(self) -> bool: + """Return error state""" + return self._error_state + + @error_state.setter + def error_state(self, error: bool) -> None: + """Set error state and update style""" + self._error_state = error + if error: + self.setStyleSheet("QLineEdit { border: 2px solid red; }") + else: + self.setStyleSheet('') + + +class PasswordInput(QObject): + def __init__(self, *, parent=None, minimum_length: int = 9, show_error: bool = True, label: list = None) -> None: + super().__init__(parent) + self._minimum_length = minimum_length + self._show_error = show_error + + if label: + self._label_password = QLabel(label[0]) + self._label_confirm = QLabel(label[1]) + else: + self._label_password = QLabel(self.tr("Enter passphrase:")) + self._label_confirm = QLabel(self.tr("Confirm passphrase:")) + + # Create password line edits + self.passwordLineEdit = PasswordLineEdit() + self.confirmLineEdit = PasswordLineEdit() + self.validation_label = QLabel("") + + self.passwordLineEdit.editingFinished.connect(self.on_editing_finished) + self.confirmLineEdit.textChanged.connect(self.validate) + + def on_editing_finished(self) -> None: + self.passwordLineEdit.editingFinished.disconnect(self.on_editing_finished) + self.passwordLineEdit.textChanged.connect(self.validate) + self.validate() + + def set_labels(self, label_1: str, label_2: str) -> None: + self._label_password.setText(label_1) + self._label_confirm.setText(label_2) + + def set_error_label(self, text: str) -> None: + self.validation_label.setText(text) + + def set_validation_enabled(self, enable: bool) -> None: + self._show_error = enable + self.passwordLineEdit.error_state = False + self.confirmLineEdit.error_state = False + if not enable: + self.set_error_label("") + + def clear(self) -> None: + self.passwordLineEdit.clear() + self.confirmLineEdit.clear() + self.passwordLineEdit.error_state = False + self.confirmLineEdit.error_state = False + self.set_error_label("") + + def get_password(self) -> str: + return self.passwordLineEdit.text() + + def validate(self) -> bool: + if not self._show_error: + return True + + first_pass = self.passwordLineEdit.text() + second_pass = self.confirmLineEdit.text() + + pass_equal = first_pass == second_pass + pass_long = len(first_pass) >= self._minimum_length + + self.passwordLineEdit.error_state = False + self.confirmLineEdit.error_state = False + self.set_error_label("") + + if not pass_long and not pass_equal: + self.passwordLineEdit.error_state = True + self.confirmLineEdit.error_state = True + self.set_error_label( + translate('PasswordInput', "Passwords must be identical and atleast {0} characters long.").format( + self._minimum_length + ) + ) + elif not pass_equal: + self.confirmLineEdit.error_state = True + self.set_error_label(translate('PasswordInput', "Passwords must be identical.")) + elif not pass_long: + self.passwordLineEdit.error_state = True + self.set_error_label( + translate('PasswordInput', "Passwords must be atleast {0} characters long.").format( + self._minimum_length + ) + ) + + return not bool(self.validation_label.text()) + + def add_form_to_layout(self, form_layout: QFormLayout) -> None: + """Adds form to layout""" + form_layout.addRow(self._label_password, self.passwordLineEdit) + form_layout.addRow(self._label_confirm, self.confirmLineEdit) + form_layout.addRow(self.validation_label) + + def create_form_widget(self, parent: Optional[QWidget] = None) -> QWidget: + """ "Creates and Returns a new QWidget with form layout""" + widget = QWidget(parent=parent) + form_layout = QFormLayout(widget) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + self.add_form_to_layout(form_layout) + widget.setLayout(form_layout) + return widget diff --git a/src/vorta/views/repo_add_dialog.py b/src/vorta/views/repo_add_dialog.py index cdb4c3aa2..38cc65e25 100644 --- a/src/vorta/views/repo_add_dialog.py +++ b/src/vorta/views/repo_add_dialog.py @@ -1,28 +1,28 @@ import re from PyQt6 import QtCore, uic -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QApplication, QDialogButtonBox, QLineEdit +from PyQt6.QtWidgets import ( + QApplication, + QComboBox, + QDialogButtonBox, + QFormLayout, + QLabel, + QSizePolicy, +) from vorta.borg.info_repo import BorgInfoRepoJob from vorta.borg.init import BorgInitJob -from vorta.i18n import translate from vorta.keyring.abc import VortaKeyring from vorta.store.models import RepoModel -from vorta.utils import ( - borg_compat, - choose_file_dialog, - get_asset, - get_private_keys, - validate_passwords, -) +from vorta.utils import borg_compat, choose_file_dialog, get_asset, get_private_keys +from vorta.views.partials.password_input import PasswordInput, PasswordLineEdit from vorta.views.utils import get_colored_icon uifile = get_asset('UI/repoadd.ui') AddRepoUI, AddRepoBase = uic.loadUiType(uifile) -class AddRepoWindow(AddRepoBase, AddRepoUI): +class RepoWindow(AddRepoBase, AddRepoUI): added_repo = QtCore.pyqtSignal(dict) def __init__(self, parent=None): @@ -32,7 +32,8 @@ def __init__(self, parent=None): self.result = None self.is_remote_repo = True - # dialogButtonBox + self.setMinimumWidth(583) + self.saveButton = self.buttonBox.button(QDialogButtonBox.StandardButton.Ok) self.saveButton.setText(self.tr("Add")) @@ -41,23 +42,11 @@ def __init__(self, parent=None): self.chooseLocalFolderButton.clicked.connect(self.choose_local_backup_folder) self.useRemoteRepoButton.clicked.connect(self.use_remote_repo_action) self.repoURL.textChanged.connect(self.set_password) - self.passwordLineEdit.textChanged.connect(self.password_listener) - self.confirmLineEdit.textChanged.connect(self.password_listener) - self.encryptionComboBox.activated.connect(self.display_backend_warning) - - # Add clickable icon to toggle password visibility to end of box - self.showHideAction = QAction(self.tr("Show my passwords"), self) - self.showHideAction.setCheckable(True) - self.showHideAction.toggled.connect(self.set_visibility) - - self.passwordLineEdit.addAction(self.showHideAction, QLineEdit.ActionPosition.TrailingPosition) self.tabWidget.setCurrentIndex(0) - self.init_encryption() self.init_ssh_key() self.set_icons() - self.display_backend_warning() def retranslateUi(self, dialog): """Retranslate strings in ui.""" @@ -70,25 +59,6 @@ def retranslateUi(self, dialog): def set_icons(self): self.chooseLocalFolderButton.setIcon(get_colored_icon('folder-open')) self.useRemoteRepoButton.setIcon(get_colored_icon('globe')) - self.showHideAction.setIcon(get_colored_icon("eye")) - - @property - def values(self): - out = dict( - ssh_key=self.sshComboBox.currentData(), - repo_url=self.repoURL.text(), - repo_name=self.repoName.text(), - password=self.passwordLineEdit.text(), - extra_borg_arguments=self.extraBorgArgumentsLineEdit.text(), - ) - if self.__class__ == AddRepoWindow: - out['encryption'] = self.encryptionComboBox.currentData() - return out - - def display_backend_warning(self): - '''Display password backend message based off current keyring''' - if self.encryptionComboBox.currentData() != 'none': - self.passwordLabel.setText(VortaKeyring.get_keyring().get_backend_warning()) def choose_local_backup_folder(self): def receive(): @@ -104,27 +74,6 @@ def receive(): dialog = choose_file_dialog(self, self.tr("Choose Location of Borg Repository")) dialog.open(receive) - def set_password(self, URL): - '''Autofill password from keyring only if current entry is empty''' - password = VortaKeyring.get_keyring().get_password('vorta-repo', URL) - if password and self.passwordLineEdit.text() == "": - self.passwordLabel.setText(self.tr("Autofilled password from password manager.")) - self.passwordLineEdit.setText(password) - if self.__class__ == AddRepoWindow: - self.confirmLineEdit.setText(password) - - def set_visibility(self, visible): - visibility = QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password - self.passwordLineEdit.setEchoMode(visibility) - self.confirmLineEdit.setEchoMode(visibility) - - if visible: - self.showHideAction.setIcon(get_colored_icon("eye-slash")) - self.showHideAction.setText(self.tr("Hide my passwords")) - else: - self.showHideAction.setIcon(get_colored_icon("eye")) - self.showHideAction.setText(self.tr("Show my passwords")) - def use_remote_repo_action(self): self.repoURL.setText('') self.repoURL.setEnabled(True) @@ -134,19 +83,6 @@ def use_remote_repo_action(self): self.repoLabel.setText(self.tr('Repository URL:')) self.is_remote_repo = True - # No need to add this function to JobsManager because repo is set for the first time - def run(self): - if self.validate() and self.password_listener(): - params = BorgInitJob.prepare(self.values) - if params['ok']: - self.saveButton.setEnabled(False) - job = BorgInitJob(params['cmd'], params) - job.updated.connect(self._set_status) - job.result.connect(self.run_result) - QApplication.instance().jobs_manager.add_job(job) - else: - self._set_status(params['message']) - def _set_status(self, text): self.errorText.setText(text) self.errorText.repaint() @@ -159,6 +95,73 @@ def run_result(self, result): else: self._set_status(self.tr('Unable to add your repository.')) + def init_ssh_key(self): + keys = get_private_keys() + for key in keys: + self.sshComboBox.addItem(f'{key}', key) + + def validate(self): + """Pre-flight check for valid input and borg binary.""" + if self.is_remote_repo and not re.match(r'.+:.+', self.values['repo_url']): + self._set_status(self.tr('Please enter a valid repo URL or select a local path.')) + return False + + if len(self.values['repo_name']) > 64: + self._set_status(self.tr('Repository name must be less than 64 characters.')) + return False + + if RepoModel.get_or_none(RepoModel.url == self.values['repo_url']) is not None: + self._set_status(self.tr('This repo has already been added.')) + return False + + return True + + @property + def values(self): + out = dict( + ssh_key=self.sshComboBox.currentData(), + repo_url=self.repoURL.text(), + repo_name=self.repoName.text(), + password=self.passwordInput.get_password(), + extra_borg_arguments=self.extraBorgArgumentsLineEdit.text(), + ) + return out + + +class AddRepoWindow(RepoWindow): + def __init__(self, parent=None): + super().__init__(parent) + + self.passwordInput = PasswordInput() + self.passwordInput.add_form_to_layout(self.repoDataFormLayout) + + self.encryptionLabel = QLabel(self.tr('Encryption:')) + self.encryptionComboBox = QComboBox() + self.encryptionComboBox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + self.advancedFormLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.encryptionLabel) + self.advancedFormLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.encryptionComboBox) + + self.encryptionComboBox.activated.connect(self.display_backend_warning) + self.encryptionComboBox.currentIndexChanged.connect(self.encryption_listener) + + self.display_backend_warning() + self.init_encryption() + + def set_password(self, URL): + '''Autofill password from keyring only if current entry is empty''' + password = VortaKeyring.get_keyring().get_password('vorta-repo', URL) + if password and self.passwordInput.get_password() == "": + self.passwordInput.set_error_label(self.tr("Autofilled password from password manager.")) + self.passwordInput.passwordLineEdit.setText(password) + self.passwordInput.confirmLineEdit.setText(password) + + @property + def values(self): + out = super().values + out['encryption'] = self.encryptionComboBox.currentData() + return out + def init_encryption(self): if borg_compat.check('V2'): encryption_algos = [ @@ -191,64 +194,49 @@ def init_encryption(self): self.encryptionComboBox.model().item(2).setEnabled(False) self.encryptionComboBox.setCurrentIndex(1) - def init_ssh_key(self): - keys = get_private_keys() - for key in keys: - self.sshComboBox.addItem(f'{key}', key) - - def validate(self): - """Pre-flight check for valid input and borg binary.""" - if self.is_remote_repo and not re.match(r'.+:.+', self.values['repo_url']): - self._set_status(self.tr('Please enter a valid repo URL or select a local path.')) - return False - - if len(self.values['repo_name']) > 64: - self._set_status(self.tr('Repository name must be less than 64 characters.')) - return False - - if RepoModel.get_or_none(RepoModel.url == self.values['repo_url']) is not None: - self._set_status(self.tr('This repo has already been added.')) - return False - - return True - - def password_listener(self): + def encryption_listener(self): '''Validates passwords only if its going to be used''' if self.values['encryption'] == 'none': - self.passwordLabel.setText("") - return True + self.passwordInput.set_validation_enabled(False) else: - firstPass = self.passwordLineEdit.text() - secondPass = self.confirmLineEdit.text() - msg = validate_passwords(firstPass, secondPass) - self.passwordLabel.setText(translate('utils', msg)) - return not bool(msg) + self.passwordInput.set_validation_enabled(True) + def display_backend_warning(self): + '''Display password backend message based off current keyring''' + if self.encryptionComboBox.currentData() != 'none': + self.passwordInput.set_error_label(VortaKeyring.get_keyring().get_backend_warning()) + + def validate(self): + return super().validate() and self.passwordInput.validate() -class ExistingRepoWindow(AddRepoWindow): + def run(self): + if self.validate(): + params = BorgInitJob.prepare(self.values) + if params['ok']: + self.saveButton.setEnabled(False) + job = BorgInitJob(params['cmd'], params) + job.updated.connect(self._set_status) + job.result.connect(self.run_result) + QApplication.instance().jobs_manager.add_job(job) + else: + self._set_status(params['message']) + + +class ExistingRepoWindow(RepoWindow): def __init__(self): super().__init__() - self.encryptionComboBox.hide() - self.encryptionLabel.hide() self.title.setText(self.tr('Connect to existing Repository')) - self.showHideAction.setText(self.tr("Show my password")) - self.passwordLineEdit.textChanged.disconnect() - self.confirmLineEdit.textChanged.disconnect() - self.confirmLineEdit.hide() - self.confirmLabel.hide() - del self.confirmLineEdit - del self.confirmLabel - - def set_visibility(self, visible): - visibility = QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password - self.passwordLineEdit.setEchoMode(visibility) - - if visible: - self.showHideAction.setIcon(get_colored_icon("eye-slash")) - self.showHideAction.setText(self.tr("Hide my password")) - else: - self.showHideAction.setIcon(get_colored_icon("eye")) - self.showHideAction.setText(self.tr("Show my password")) + + self.passwordLabel = QLabel(self.tr('Password:')) + self.passwordInput = PasswordLineEdit() + self.repoDataFormLayout.addRow(self.passwordLabel, self.passwordInput) + + def set_password(self, URL): + '''Autofill password from keyring only if current entry is empty''' + password = VortaKeyring.get_keyring().get_password('vorta-repo', URL) + if password and self.passwordInput.get_password() == "": + self._set_status(self.tr("Autofilled password from password manager.")) + self.passwordInput.setText(password) def run(self): if self.validate(): diff --git a/tests/test_password_input.py b/tests/test_password_input.py new file mode 100644 index 000000000..429b99e41 --- /dev/null +++ b/tests/test_password_input.py @@ -0,0 +1,165 @@ +import pytest +from PyQt6.QtWidgets import QFormLayout, QWidget +from vorta.views.partials.password_input import PasswordInput, PasswordLineEdit + + +def test_create_password_line_edit(qtbot): + password_line_edit = PasswordLineEdit() + qtbot.addWidget(password_line_edit) + assert password_line_edit is not None + + +def test_password_line_get_password(qtbot): + password_line_edit = PasswordLineEdit() + qtbot.addWidget(password_line_edit) + + assert password_line_edit.get_password() == "" + + qtbot.keyClicks(password_line_edit, "test") + assert password_line_edit.get_password() == "test" + + +def test_password_line_visible(qtbot): + password_line_edit = PasswordLineEdit() + qtbot.addWidget(password_line_edit) + assert not password_line_edit.visible + + password_line_edit.toggle_visibility() + assert password_line_edit.visible + + with pytest.raises(TypeError): + password_line_edit.visible = "OK" + + +def test_password_line_error_state(qtbot): + password_line_edit = PasswordLineEdit() + qtbot.addWidget(password_line_edit) + assert not password_line_edit.error_state + assert password_line_edit.styleSheet() == "" + + password_line_edit.error_state = True + assert password_line_edit.error_state + assert password_line_edit.styleSheet() == "QLineEdit { border: 2px solid red; }" + + +def test_password_line_visibility_button(qtbot): + password_line_edit = PasswordLineEdit(show_visibility_button=False) + qtbot.addWidget(password_line_edit) + assert not password_line_edit._show_visibility_button + + password_line_edit = PasswordLineEdit() + qtbot.addWidget(password_line_edit) + assert password_line_edit._show_visibility_button + + # test visibility button + password_line_edit.showHideAction.trigger() + assert password_line_edit.visible + password_line_edit.showHideAction.trigger() + assert not password_line_edit.visible + + +# PasswordInput +def test_create_password_input(qapp, qtbot): + password_input = PasswordInput() + qtbot.addWidget(password_input.create_form_widget(parent=qapp.main_window)) + assert password_input is not None + + assert not password_input.passwordLineEdit.error_state + assert not password_input.confirmLineEdit.error_state + + +def test_password_input_get_password(qapp, qtbot): + password_input = PasswordInput() + qtbot.addWidget(password_input.create_form_widget(parent=qapp.main_window)) + + assert password_input.get_password() == "" + + password_input.passwordLineEdit.setText("test") + assert password_input.get_password() == "test" + + +def test_password_input_validation(qapp, qtbot): + password_input = PasswordInput(minimum_length=10) + qtbot.addWidget(password_input.create_form_widget(parent=qapp.main_window)) + + qtbot.keyClicks(password_input.passwordLineEdit, "123456789") + qtbot.keyClicks(password_input.confirmLineEdit, "123456789") + + assert password_input.passwordLineEdit.error_state + assert password_input.validation_label.text() == "Passwords must be atleast 10 characters long." + + password_input.clear() + qtbot.keyClicks(password_input.passwordLineEdit, "123456789") + qtbot.keyClicks(password_input.confirmLineEdit, "test") + + assert password_input.passwordLineEdit.error_state + assert password_input.confirmLineEdit.error_state + assert password_input.validation_label.text() == "Passwords must be identical and atleast 10 characters long." + + password_input.clear() + qtbot.keyClicks(password_input.passwordLineEdit, "1234567890") + qtbot.keyClicks(password_input.confirmLineEdit, "test") + + assert not password_input.passwordLineEdit.error_state + assert password_input.confirmLineEdit.error_state + assert password_input.validation_label.text() == "Passwords must be identical." + + password_input.clear() + qtbot.keyClicks(password_input.passwordLineEdit, "1234567890") + qtbot.keyClicks(password_input.confirmLineEdit, "1234567890") + + assert not password_input.passwordLineEdit.error_state + assert not password_input.confirmLineEdit.error_state + assert password_input.validation_label.text() == "" + + +def test_password_input_validation_disabled(qapp, qtbot): + password_input = PasswordInput(show_error=False) + qtbot.addWidget(password_input.create_form_widget(parent=qapp.main_window)) + + qtbot.keyClicks(password_input.passwordLineEdit, "test") + qtbot.keyClicks(password_input.confirmLineEdit, "test") + + assert not password_input.passwordLineEdit.error_state + assert not password_input.confirmLineEdit.error_state + assert password_input.validation_label.text() == "" + + password_input.set_validation_enabled(True) + qtbot.keyClicks(password_input.passwordLineEdit, "s") + qtbot.keyClicks(password_input.confirmLineEdit, "a") + + assert password_input.passwordLineEdit.error_state + assert password_input.confirmLineEdit.error_state + assert password_input.validation_label.text() == "Passwords must be identical and atleast 9 characters long." + + password_input.set_validation_enabled(False) + assert not password_input.passwordLineEdit.error_state + assert not password_input.confirmLineEdit.error_state + assert password_input.validation_label.text() == "" + + +def test_password_input_set_label(qapp, qtbot): + password_input = PasswordInput(label=["test", "test2"]) + qtbot.addWidget(password_input.create_form_widget(parent=qapp.main_window)) + + assert password_input._label_password.text() == "test" + assert password_input._label_confirm.text() == "test2" + + password_input.set_labels("test3", "test4") + assert password_input._label_password.text() == "test3" + assert password_input._label_confirm.text() == "test4" + + +def test_password_input_add_form_to_layout(qapp, qtbot): + password_input = PasswordInput() + + widget = QWidget() + form_layout = QFormLayout(widget) + + qtbot.addWidget(widget) + password_input.add_form_to_layout(form_layout) + + assert form_layout.itemAt(0, QFormLayout.ItemRole.LabelRole).widget() == password_input._label_password + assert form_layout.itemAt(0, QFormLayout.ItemRole.FieldRole).widget() == password_input.passwordLineEdit + assert form_layout.itemAt(1, QFormLayout.ItemRole.LabelRole).widget() == password_input._label_confirm + assert form_layout.itemAt(1, QFormLayout.ItemRole.FieldRole).widget() == password_input.confirmLineEdit diff --git a/tests/test_repo.py b/tests/test_repo.py index b5b070968..3e13084d7 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -19,33 +19,36 @@ def test_repo_add_failures(qapp, qtbot, mocker, borg_json_output): add_repo_window = main.repoTab._window qtbot.addWidget(add_repo_window) - qtbot.keyClicks(add_repo_window.passwordLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.confirmLineEdit, LONG_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD) qtbot.keyClicks(add_repo_window.repoURL, 'aaa') qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) assert add_repo_window.errorText.text().startswith('Please enter a valid') - add_repo_window.passwordLineEdit.clear() - add_repo_window.confirmLineEdit.clear() - qtbot.keyClicks(add_repo_window.passwordLineEdit, SHORT_PASSWORD) - qtbot.keyClicks(add_repo_window.confirmLineEdit, SHORT_PASSWORD) + add_repo_window.passwordInput.passwordLineEdit.clear() + add_repo_window.passwordInput.confirmLineEdit.clear() + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, SHORT_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) qtbot.keyClicks(add_repo_window.repoURL, 'bbb.com:repo') qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.passwordLabel.text() == 'Passwords must be greater than 8 characters long.' + assert add_repo_window.passwordInput.validation_label.text() == 'Passwords must be atleast 9 characters long.' - add_repo_window.passwordLineEdit.clear() - add_repo_window.confirmLineEdit.clear() - qtbot.keyClicks(add_repo_window.passwordLineEdit, SHORT_PASSWORD + "1") - qtbot.keyClicks(add_repo_window.confirmLineEdit, SHORT_PASSWORD) + add_repo_window.passwordInput.passwordLineEdit.clear() + add_repo_window.passwordInput.confirmLineEdit.clear() + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, SHORT_PASSWORD + "1") + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.passwordLabel.text() == 'Passwords must be identical and greater than 8 characters long.' + assert ( + add_repo_window.passwordInput.validation_label.text() + == 'Passwords must be identical and atleast 9 characters long.' + ) - add_repo_window.passwordLineEdit.clear() - add_repo_window.confirmLineEdit.clear() - qtbot.keyClicks(add_repo_window.passwordLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.confirmLineEdit, SHORT_PASSWORD) + add_repo_window.passwordInput.passwordLineEdit.clear() + add_repo_window.passwordInput.confirmLineEdit.clear() + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.passwordLabel.text() == 'Passwords must be identical.' + assert add_repo_window.passwordInput.validation_label.text() == 'Passwords must be identical.' def test_repo_unlink(qapp, qtbot, monkeypatch): @@ -76,7 +79,7 @@ def test_password_autofill(qapp, qtbot): qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) - assert add_repo_window.passwordLineEdit.text() == password + assert add_repo_window.passwordInput.passwordLineEdit.text() == password def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): @@ -89,8 +92,8 @@ def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) qtbot.keyClicks(add_repo_window.repoName, test_repo_name) - qtbot.keyClicks(add_repo_window.passwordLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.confirmLineEdit, LONG_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD) stdout, stderr = borg_json_output('info') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) From 0e37e1cf90fb83841e7a589a3c2d71447daa9f58 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 31 Jul 2023 06:22:28 +1000 Subject: [PATCH 11/52] Correct homebrew path on arm macOS. By @sammcj (#1760) --- Makefile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7b6307b9a..cb3e82793 100644 --- a/Makefile +++ b/Makefile @@ -5,12 +5,19 @@ VERSION := $(shell python -c "from src.vorta._version import __version__; print( .PHONY : help clean lint test .DEFAULT_GOAL := help +# Set Homebrew location to /opt/homebrew on Apple Silicon, /usr/local on Intel +ifeq ($(shell uname -m),arm64) + export HOMEBREW = /opt/homebrew +else + export HOMEBREW = /usr/local +endif + clean: rm -rf dist/* dist/Vorta.app: ## Build macOS app locally (without Borg) pyinstaller --clean --noconfirm package/vorta.spec - cp -R /usr/local/Caskroom/sparkle/*/Sparkle.framework dist/Vorta.app/Contents/Frameworks/ + cp -R ${HOMEBREW}/Caskroom/sparkle/*/Sparkle.framework dist/Vorta.app/Contents/Frameworks/ rm -rf build/vorta dist/vorta dist/Vorta.dmg: dist/Vorta.app ## Create notarized macOS DMG for distribution. From b015368fee93bd64e11c31774efd74cf25187e9a Mon Sep 17 00:00:00 2001 From: jetchirag Date: Sat, 5 Aug 2023 19:19:45 +0530 Subject: [PATCH 12/52] Integration Tests for Borg (#1716) Move existing tests into subfolder `tests/unit`. Write integration tests that actually run the installed borg executable. Those tests can be found in `tests/integration`. Those pytest fixtures that are the same for both kinds of tests remain in `tests/conftest.py`. The others can be found in `tests/integration/conftest.py` or `tests/unit/conftest.py`. This adds nox to the project and configures it to run the tests with different borg versions. This also updates the ci workflow to run the integration tests using nox. * noxfile.py : Run pytest with a matrix of borg versions OR a specific borg version * Makefile : Run using nox. Add phonies `test-unit` and `test-integration`. * tests/conftest.py : Move some fixtures/functions to `tests/unit/conftest.py`. * tests/test_*.py --> tests/unit/ : Move unittests and assets into subfolder * tests/integration/ : Write integration tests. * requirements.d/dev.txt: Add `nox` and `pkgconfig`. The latter is needed for installing new borg versions. * .github/actions/setup/action.yml : Update to install pre-commit and nox when needed. The action now no longer installs Vorta. * .github/actions/install-dependencies/action.yml : Install system deps of borg with this new composite action. * .github/workflows/test.yml : Rename `test` ci to `test-unit` and update it for the new test setup. Implement `test-integration` ci. Signed-off-by: Chirag Aggarwal --- .../actions/install-dependencies/action.yml | 22 + .github/actions/setup/action.yml | 17 +- .github/workflows/test.yml | 80 +++- Makefile | 8 +- noxfile.py | 56 +++ requirements.d/dev.txt | 2 + tests/conftest.py | 104 ----- .../__init__.py} | 0 tests/integration/conftest.py | 226 ++++++++++ tests/integration/test_archives.py | 185 +++++++++ tests/integration/test_borg.py | 63 +++ tests/integration/test_diff.py | 385 ++++++++++++++++++ tests/integration/test_init.py | 98 +++++ tests/integration/test_repo.py | 24 ++ .../__init__.py} | 0 .../borg_json_output/check_stderr.json | 0 .../borg_json_output/check_stdout.json | 0 .../borg_json_output/compact_stderr.json | 0 .../borg_json_output/compact_stdout.json} | 0 .../create_break_stderr.json} | 0 .../create_break_stdout.json} | 0 .../borg_json_output/create_lock_stderr.json | 0 .../borg_json_output/create_lock_stdout.json} | 0 .../borg_json_output/create_perm_stderr.json | 0 .../borg_json_output/create_perm_stdout.json} | 0 .../borg_json_output/create_stderr.json | 0 .../borg_json_output/create_stdout.json | 0 .../borg_json_output/delete_stderr.json | 0 .../borg_json_output/delete_stdout.json} | 0 .../diff_archives_dict_issue_stderr.json} | 0 .../diff_archives_dict_issue_stdout.json | 0 .../diff_archives_stderr.json} | 0 .../diff_archives_stdout.json | 0 .../borg_json_output/info_stderr.json | 0 .../borg_json_output/info_stdout.json | 0 .../borg_json_output/list_archive_stderr.json | 0 .../borg_json_output/list_archive_stdout.json | 0 .../borg_json_output/list_stderr.json | 0 .../borg_json_output/list_stdout.json | 0 .../borg_json_output/prune_stderr.json | 0 .../borg_json_output/prune_stdout.json | 0 .../unit/borg_json_output/rename_stderr.json | 0 .../unit/borg_json_output/rename_stdout.json | 0 tests/unit/conftest.py | 107 +++++ .../profile_exports/invalid_no_json.json | 0 tests/{ => unit}/profile_exports/valid.json | 0 tests/{ => unit}/test_archives.py | 0 tests/{ => unit}/test_borg.py | 0 tests/{ => unit}/test_create.py | 0 tests/{ => unit}/test_diff.py | 0 tests/{ => unit}/test_extract.py | 0 tests/{ => unit}/test_import_export.py | 0 tests/{ => unit}/test_lock.py | 0 tests/{ => unit}/test_misc.py | 0 tests/{ => unit}/test_notifications.py | 0 tests/{ => unit}/test_password_input.py | 0 tests/{ => unit}/test_profile.py | 0 tests/{ => unit}/test_repo.py | 0 tests/{ => unit}/test_schedule.py | 0 tests/{ => unit}/test_scheduler.py | 0 tests/{ => unit}/test_source.py | 0 tests/{ => unit}/test_treemodel.py | 0 tests/{ => unit}/test_utils.py | 0 63 files changed, 1253 insertions(+), 124 deletions(-) create mode 100644 .github/actions/install-dependencies/action.yml create mode 100644 noxfile.py rename tests/{borg_json_output/compact_stdout.json => integration/__init__.py} (100%) create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_archives.py create mode 100644 tests/integration/test_borg.py create mode 100644 tests/integration/test_diff.py create mode 100644 tests/integration/test_init.py create mode 100644 tests/integration/test_repo.py rename tests/{borg_json_output/create_break_stderr.json => unit/__init__.py} (100%) rename tests/{ => unit}/borg_json_output/check_stderr.json (100%) rename tests/{ => unit}/borg_json_output/check_stdout.json (100%) rename tests/{ => unit}/borg_json_output/compact_stderr.json (100%) rename tests/{borg_json_output/create_break_stdout.json => unit/borg_json_output/compact_stdout.json} (100%) rename tests/{borg_json_output/create_lock_stdout.json => unit/borg_json_output/create_break_stderr.json} (100%) rename tests/{borg_json_output/create_perm_stdout.json => unit/borg_json_output/create_break_stdout.json} (100%) rename tests/{ => unit}/borg_json_output/create_lock_stderr.json (100%) rename tests/{borg_json_output/delete_stdout.json => unit/borg_json_output/create_lock_stdout.json} (100%) rename tests/{ => unit}/borg_json_output/create_perm_stderr.json (100%) rename tests/{borg_json_output/diff_archives_dict_issue_stderr.json => unit/borg_json_output/create_perm_stdout.json} (100%) rename tests/{ => unit}/borg_json_output/create_stderr.json (100%) rename tests/{ => unit}/borg_json_output/create_stdout.json (100%) rename tests/{ => unit}/borg_json_output/delete_stderr.json (100%) rename tests/{borg_json_output/diff_archives_stderr.json => unit/borg_json_output/delete_stdout.json} (100%) rename tests/{borg_json_output/rename_stderr.json => unit/borg_json_output/diff_archives_dict_issue_stderr.json} (100%) rename tests/{ => unit}/borg_json_output/diff_archives_dict_issue_stdout.json (100%) rename tests/{borg_json_output/rename_stdout.json => unit/borg_json_output/diff_archives_stderr.json} (100%) rename tests/{ => unit}/borg_json_output/diff_archives_stdout.json (100%) rename tests/{ => unit}/borg_json_output/info_stderr.json (100%) rename tests/{ => unit}/borg_json_output/info_stdout.json (100%) rename tests/{ => unit}/borg_json_output/list_archive_stderr.json (100%) rename tests/{ => unit}/borg_json_output/list_archive_stdout.json (100%) rename tests/{ => unit}/borg_json_output/list_stderr.json (100%) rename tests/{ => unit}/borg_json_output/list_stdout.json (100%) rename tests/{ => unit}/borg_json_output/prune_stderr.json (100%) rename tests/{ => unit}/borg_json_output/prune_stdout.json (100%) create mode 100644 tests/unit/borg_json_output/rename_stderr.json create mode 100644 tests/unit/borg_json_output/rename_stdout.json create mode 100644 tests/unit/conftest.py rename tests/{ => unit}/profile_exports/invalid_no_json.json (100%) rename tests/{ => unit}/profile_exports/valid.json (100%) rename tests/{ => unit}/test_archives.py (100%) rename tests/{ => unit}/test_borg.py (100%) rename tests/{ => unit}/test_create.py (100%) rename tests/{ => unit}/test_diff.py (100%) rename tests/{ => unit}/test_extract.py (100%) rename tests/{ => unit}/test_import_export.py (100%) rename tests/{ => unit}/test_lock.py (100%) rename tests/{ => unit}/test_misc.py (100%) rename tests/{ => unit}/test_notifications.py (100%) rename tests/{ => unit}/test_password_input.py (100%) rename tests/{ => unit}/test_profile.py (100%) rename tests/{ => unit}/test_repo.py (100%) rename tests/{ => unit}/test_schedule.py (100%) rename tests/{ => unit}/test_scheduler.py (100%) rename tests/{ => unit}/test_source.py (100%) rename tests/{ => unit}/test_treemodel.py (100%) rename tests/{ => unit}/test_utils.py (100%) diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml new file mode 100644 index 000000000..e888476ae --- /dev/null +++ b/.github/actions/install-dependencies/action.yml @@ -0,0 +1,22 @@ +name: Install Dependencies +description: Installs system dependencies + +runs: + using: "composite" + steps: + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt update && sudo apt install -y \ + xvfb libssl-dev openssl libacl1-dev libacl1 fuse3 build-essential \ + libxkbcommon-x11-0 dbus-x11 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ + libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 \ + libegl1 libxcb-cursor0 libfuse-dev libsqlite3-dev libfuse3-dev pkg-config \ + python3-pkgconfig libxxhash-dev borgbackup + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + brew install openssl readline xz xxhash pkg-config borgbackup diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 633ee06cf..1057ddb9e 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -15,7 +15,10 @@ inputs: description: The python version to install required: true default: "3.10" - + install-nox: + description: Whether nox shall be installed + required: false + default: "" # == false runs: using: "composite" steps: @@ -37,16 +40,20 @@ runs: restore-keys: | ${{ runner.os }}-pip- - - name: Install Vorta + - name: Install pre-commit shell: bash - run: | - pip install -e . - pip install -r requirements.d/dev.txt + run: pip install pre-commit + + - name: Install nox + if: ${{ inputs.install-nox }} + shell: bash + run: pip install nox - name: Hash python version if: ${{ inputs.setup-pre-commit }} shell: bash run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV + - name: Caching for Pre-Commit if: ${{ inputs.setup-pre-commit }} uses: actions/cache@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59ee4914e..19175959a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: shell: bash run: make lint - test: + test-unit: timeout-minutes: 20 runs-on: ${{ matrix.os }} strategy: @@ -35,40 +35,92 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, macos-latest] + borg-version: ["1.2.4"] steps: - uses: actions/checkout@v3 - - name: Install system dependencies (Linux) + - name: Install system dependencies + uses: ./.github/actions/install-dependencies + + - name: Setup python, vorta and dev deps + uses: ./.github/actions/setup + with: + python-version: ${{ matrix.python-version }} + install-nox: true + + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled }} + + - name: Run Unit Tests with pytest (Linux) if: runner.os == 'Linux' + env: + BORG_VERSION: ${{ matrix.borg-version }} run: | - sudo apt update && sudo apt install -y \ - xvfb libssl-dev openssl libacl1-dev libacl1 build-essential borgbackup \ - libxkbcommon-x11-0 dbus-x11 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ - libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 \ - libegl1 libxcb-cursor0 - - name: Install system dependencies (macOS) + xvfb-run --server-args="-screen 0 1024x768x24+32" \ + -a dbus-run-session -- make test-unit + + - name: Run Unit Tests with pytest (macOS) if: runner.os == 'macOS' - run: | - brew install openssl readline xz borgbackup + env: + BORG_VERSION: ${{ matrix.borg-version }} + PKG_CONFIG_PATH: /usr/local/opt/openssl@3/lib/pkgconfig + run: echo $PKG_CONFIG_PATH && make test-unit + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + env: + OS: ${{ runner.os }} + python: ${{ matrix.python-version }} + with: + token: ${{ secrets.CODECOV_TOKEN }} + env_vars: OS, python + + test-integration: + timeout-minutes: 20 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + os: [ubuntu-latest, macos-latest] + borg-version: ["1.1.18", "1.2.2", "1.2.4", "2.0.0b5"] + exclude: + - borg-version: "2.0.0b5" + python-version: "3.8" + + steps: + - uses: actions/checkout@v3 + + - name: Install system dependencies + uses: ./.github/actions/install-dependencies - name: Setup python, vorta and dev deps uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} + install-nox: true - name: Setup tmate session uses: mxschmitt/action-tmate@v3 if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled }} - - name: Test with pytest (Linux) + - name: Run Integration Tests with pytest (Linux) if: runner.os == 'Linux' + env: + BORG_VERSION: ${{ matrix.borg-version }} run: | xvfb-run --server-args="-screen 0 1024x768x24+32" \ - -a dbus-run-session -- make test - - name: Test with pytest (macOS) + -a dbus-run-session -- make test-integration + + - name: Run Integration Tests with pytest (macOS) if: runner.os == 'macOS' - run: make test + env: + BORG_VERSION: ${{ matrix.borg-version }} + PKG_CONFIG_PATH: /usr/local/opt/openssl@3/lib/pkgconfig + run: echo $PKG_CONFIG_PATH && make test-integration - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/Makefile b/Makefile index cb3e82793..fbbb8b718 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,13 @@ lint: pre-commit run --all-files --show-diff-on-failure test: - pytest --cov=vorta + nox -- --cov=vorta + +test-unit: + nox -- --cov=vorta tests/unit + +test-integration: + nox -- --cov=vorta tests/integration help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 000000000..804b8d60c --- /dev/null +++ b/noxfile.py @@ -0,0 +1,56 @@ +import os +import re +import sys + +import nox + +borg_version = os.getenv("BORG_VERSION") + +if borg_version: + # Use specified borg version + supported_borgbackup_versions = [borg_version] +else: + # Generate a list of borg versions compatible with system installed python version + system_python_version = tuple(sys.version_info[:3]) + + supported_borgbackup_versions = [ + borgbackup + for borgbackup in ("1.1.18", "1.2.2", "1.2.4", "2.0.0b6") + # Python version requirements for borgbackup versions + if (borgbackup == "1.1.18" and system_python_version >= (3, 5, 0)) + or (borgbackup == "1.2.2" and system_python_version >= (3, 8, 0)) + or (borgbackup == "1.2.4" and system_python_version >= (3, 8, 0)) + or (borgbackup == "2.0.0b6" and system_python_version >= (3, 9, 0)) + ] + + +@nox.session +@nox.parametrize("borgbackup", supported_borgbackup_versions) +def run_tests(session, borgbackup): + # install borgbackup + if (sys.platform == 'darwin'): + # in macOS there's currently no fuse package which works with borgbackup directly + session.install(f"borgbackup=={borgbackup}") + elif (borgbackup == "1.1.18"): + # borgbackup 1.1.18 doesn't support pyfuse3 + session.install("llfuse") + session.install(f"borgbackup[llfuse]=={borgbackup}") + else: + session.install(f"borgbackup[pyfuse3]=={borgbackup}") + + # install dependencies + session.install("-r", "requirements.d/dev.txt") + session.install("-e", ".") + + # check versions + cli_version = session.run("borg", "--version", silent=True).strip() + cli_version = re.search(r"borg (\S+)", cli_version).group(1) + python_version = session.run("python", "-c", "import borg; print(borg.__version__)", silent=True).strip() + + session.log(f"Borg CLI version: {cli_version}") + session.log(f"Borg Python version: {python_version}") + + assert cli_version == borgbackup + assert python_version == borgbackup + + session.run("pytest", *session.posargs, env={"BORG_VERSION": borgbackup}) diff --git a/requirements.d/dev.txt b/requirements.d/dev.txt index 239dfbff6..5391c54a0 100644 --- a/requirements.d/dev.txt +++ b/requirements.d/dev.txt @@ -2,6 +2,8 @@ black==22.* coverage flake8 macholib +nox +pkgconfig pre-commit pyinstaller pylint diff --git a/tests/conftest.py b/tests/conftest.py index 03a8d22b3..0f7810265 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,37 +1,11 @@ import os import sys -from datetime import datetime as dt -from unittest.mock import MagicMock import pytest import vorta import vorta.application import vorta.borg.jobs_manager from peewee import SqliteDatabase -from vorta.store.models import ( - ArchiveModel, - BackupProfileModel, - EventLogModel, - RepoModel, - RepoPassword, - SchemaVersion, - SettingsModel, - SourceFileModel, - WifiSettingModel, -) -from vorta.views.main_window import MainWindow - -models = [ - RepoModel, - RepoPassword, - BackupProfileModel, - SourceFileModel, - SettingsModel, - ArchiveModel, - WifiSettingModel, - EventLogModel, - SchemaVersion, -] def pytest_configure(config): @@ -55,86 +29,8 @@ def qapp(tmpdir_factory): from vorta.application import VortaApp - VortaApp.set_borg_details_action = MagicMock() # Can't use pytest-mock in session scope - VortaApp.scheduler = MagicMock() - qapp = VortaApp([]) # Only init QApplication once to avoid segfaults while testing. yield qapp mock_db.close() qapp.quit() - - -@pytest.fixture(scope='function', autouse=True) -def init_db(qapp, qtbot, tmpdir_factory): - tmp_db = tmpdir_factory.mktemp('Vorta').join('settings.sqlite') - mock_db = SqliteDatabase( - str(tmp_db), - pragmas={ - 'journal_mode': 'wal', - }, - ) - vorta.store.connection.init_db(mock_db) - - default_profile = BackupProfileModel(name='Default') - default_profile.save() - - new_repo = RepoModel(url='i0fi93@i593.repo.borgbase.com:repo') - new_repo.encryption = 'none' - new_repo.save() - - default_profile.repo = new_repo.id - default_profile.dont_run_on_metered_networks = False - default_profile.validation_on = False - default_profile.save() - - test_archive = ArchiveModel(snapshot_id='99999', name='test-archive', time=dt(2000, 1, 1, 0, 0), repo=1) - test_archive.save() - - test_archive1 = ArchiveModel(snapshot_id='99998', name='test-archive1', time=dt(2000, 1, 1, 0, 0), repo=1) - test_archive1.save() - - source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True) - source_dir.save() - - qapp.main_window.deleteLater() - del qapp.main_window - qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI - - yield - - qapp.jobs_manager.cancel_all_jobs() - qapp.backup_finished_event.disconnect() - qapp.scheduler.schedule_changed.disconnect() - qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults) - mock_db.close() - - -@pytest.fixture -def choose_file_dialog(*args): - class MockFileDialog: - def __init__(self, *args, **kwargs): - pass - - def open(self, func): - func() - - def selectedFiles(self): - return ['/tmp'] - - return MockFileDialog - - -@pytest.fixture -def borg_json_output(): - def _read_json(subcommand): - stdout = open(f'tests/borg_json_output/{subcommand}_stdout.json') - stderr = open(f'tests/borg_json_output/{subcommand}_stderr.json') - return stdout, stderr - - return _read_json - - -@pytest.fixture -def rootdir(): - return os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/borg_json_output/compact_stdout.json b/tests/integration/__init__.py similarity index 100% rename from tests/borg_json_output/compact_stdout.json rename to tests/integration/__init__.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 000000000..3d67fc648 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,226 @@ +import os +import subprocess + +import pytest +import vorta +import vorta.application +import vorta.borg.jobs_manager +from peewee import SqliteDatabase +from pkg_resources import parse_version +from vorta.store.models import ( + ArchiveModel, + BackupProfileModel, + EventLogModel, + RepoModel, + RepoPassword, + SchemaVersion, + SettingsModel, + SourceFileModel, + WifiSettingModel, +) +from vorta.utils import borg_compat +from vorta.views.main_window import MainWindow + +models = [ + RepoModel, + RepoPassword, + BackupProfileModel, + SourceFileModel, + SettingsModel, + ArchiveModel, + WifiSettingModel, + EventLogModel, + SchemaVersion, +] + + +@pytest.fixture(scope='function', autouse=True) +def borg_version(): + borg_version = os.getenv('BORG_VERSION') + if not borg_version: + borg_version = subprocess.run(['borg', '--version'], stdout=subprocess.PIPE).stdout.decode('utf-8') + borg_version = borg_version.split(' ')[1] + + # test window does not automatically set borg version + borg_compat.set_version(borg_version, borg_compat.path) + + parsed_borg_version = parse_version(borg_version) + return borg_version, parsed_borg_version + + +@pytest.fixture(scope='function', autouse=True) +def create_test_repo(tmpdir_factory, borg_version): + repo_path = tmpdir_factory.mktemp('repo') + source_files_dir = tmpdir_factory.mktemp('borg_src') + + is_borg_v2 = borg_version[1] >= parse_version('2.0.0b1') + + if is_borg_v2: + subprocess.run(['borg', '-r', str(repo_path), 'rcreate', '--encryption=none'], check=True) + else: + subprocess.run(['borg', 'init', '--encryption=none', str(repo_path)], check=True) + + def create_archive(timestamp, name): + if is_borg_v2: + subprocess.run( + ['borg', '-r', str(repo_path), 'create', '--timestamp', timestamp, name, str(source_files_dir)], + cwd=str(repo_path), + check=True, + ) + else: + subprocess.run( + ['borg', 'create', '--timestamp', timestamp, f'{repo_path}::{name}', str(source_files_dir)], + cwd=str(repo_path), + check=True, + ) + + # /src/file + file_path = os.path.join(source_files_dir, 'file') + with open(file_path, 'w') as f: + f.write('test') + + # /src/dir/ + dir_path = os.path.join(source_files_dir, 'dir') + os.mkdir(dir_path) + + # /src/dir/file + file_path = os.path.join(dir_path, 'file') + with open(file_path, 'w') as f: + f.write('test') + + # Create first archive + create_archive('2023-06-14T01:00:00', 'test-archive1') + + # /src/dir/symlink + symlink_path = os.path.join(dir_path, 'symlink') + os.symlink(file_path, symlink_path) + + # /src/dir/hardlink + hardlink_path = os.path.join(dir_path, 'hardlink') + os.link(file_path, hardlink_path) + + # /src/dir/fifo + fifo_path = os.path.join(dir_path, 'fifo') + os.mkfifo(fifo_path) + + # /src/dir/chrdev + supports_chrdev = True + try: + chrdev_path = os.path.join(dir_path, 'chrdev') + os.mknod(chrdev_path, mode=0o600 | 0o020000) + except PermissionError: + supports_chrdev = False + + create_archive('2023-06-14T02:00:00', 'test-archive2') + + # Rename dir to dir1 + os.rename(dir_path, os.path.join(source_files_dir, 'dir1')) + + create_archive('2023-06-14T03:00:00', 'test-archive3') + + # Rename all files under dir1 + for file in os.listdir(os.path.join(source_files_dir, 'dir1')): + os.rename(os.path.join(source_files_dir, 'dir1', file), os.path.join(source_files_dir, 'dir1', file + '1')) + + create_archive('2023-06-14T04:00:00', 'test-archive4') + + # Delete all file under dir1 + for file in os.listdir(os.path.join(source_files_dir, 'dir1')): + os.remove(os.path.join(source_files_dir, 'dir1', file)) + + create_archive('2023-06-14T05:00:00', 'test-archive5') + + # change permission of dir1 + os.chmod(os.path.join(source_files_dir, 'dir1'), 0o700) + + create_archive('2023-06-14T06:00:00', 'test-archive6') + + return repo_path, source_files_dir, supports_chrdev + + +@pytest.fixture(scope='function', autouse=True) +def init_db(qapp, qtbot, tmpdir_factory, create_test_repo): + tmp_db = tmpdir_factory.mktemp('Vorta').join('settings.sqlite') + mock_db = SqliteDatabase( + str(tmp_db), + pragmas={ + 'journal_mode': 'wal', + }, + ) + vorta.store.connection.init_db(mock_db) + + default_profile = BackupProfileModel(name='Default') + default_profile.save() + + repo_path, source_dir, _ = create_test_repo + + new_repo = RepoModel(url=repo_path) + new_repo.encryption = 'none' + new_repo.save() + + default_profile.repo = new_repo.id + default_profile.dont_run_on_metered_networks = False + default_profile.validation_on = False + default_profile.save() + + source_dir = SourceFileModel(dir=source_dir, repo=new_repo, dir_size=12, dir_files_count=3, path_isdir=True) + source_dir.save() + + qapp.main_window.deleteLater() + del qapp.main_window + qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI + + qapp.scheduler.schedule_changed.disconnect() + + yield + + qapp.jobs_manager.cancel_all_jobs() + qapp.backup_finished_event.disconnect() + qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults) + mock_db.close() + + +@pytest.fixture +def choose_file_dialog(tmpdir): + class MockFileDialog: + def __init__(self, *args, **kwargs): + self.directory = kwargs.get('directory', None) + self.subdirectory = kwargs.get('subdirectory', None) + + def open(self, func): + func() + + def selectedFiles(self): + if self.subdirectory: + return [str(tmpdir.join(self.subdirectory))] + elif self.directory: + return [str(self.directory)] + else: + return [str(tmpdir)] + + return MockFileDialog + + +@pytest.fixture +def rootdir(): + return os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(autouse=True) +def min_borg_version(borg_version, request): + if request.node.get_closest_marker('min_borg_version'): + parsed_borg_version = borg_version[1] + + if parsed_borg_version < parse_version(request.node.get_closest_marker('min_borg_version').args[0]): + pytest.skip( + 'skipped due to borg version requirement for test: {}'.format( + request.node.get_closest_marker('min_borg_version').args[0] + ) + ) + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "min_borg_version(): set minimum required borg version for a test", + ) diff --git a/tests/integration/test_archives.py b/tests/integration/test_archives.py new file mode 100644 index 000000000..98d653fbc --- /dev/null +++ b/tests/integration/test_archives.py @@ -0,0 +1,185 @@ +""" +This file contains tests for the Archive tab to test the various archive related borg commands. +""" + +import sys +from collections import namedtuple + +import psutil +import pytest +import vorta.borg +import vorta.utils +import vorta.views.archive_tab +from PyQt6 import QtCore +from vorta.store.models import ArchiveModel + + +def test_repo_list(qapp, qtbot): + """Test that the archives are created and repo list is populated correctly""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: not tab.bCheck.isEnabled(), **pytest._wait_defaults) + + assert not tab.bCheck.isEnabled() + + qtbot.waitUntil(lambda: 'Refreshing archives done.' in main.progressText.text(), **pytest._wait_defaults) + assert ArchiveModel.select().count() == 6 + assert 'Refreshing archives done.' in main.progressText.text() + assert tab.bCheck.isEnabled() + + +def test_repo_prune(qapp, qtbot): + """Test for archive pruning""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + qtbot.mouseClick(tab.bPrune, QtCore.Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: 'Pruning old archives' in main.progressText.text(), **pytest._wait_defaults) + qtbot.waitUntil(lambda: 'Refreshing archives done.' in main.progressText.text(), **pytest._wait_defaults) + + +@pytest.mark.min_borg_version('1.2.0a1') +def test_repo_compact(qapp, qtbot): + """Test for archive compaction""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + qtbot.waitUntil(lambda: tab.compactButton.isEnabled(), **pytest._wait_defaults) + assert tab.compactButton.isEnabled() + + qtbot.mouseClick(tab.compactButton, QtCore.Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: 'compaction freed about' in main.logText.text().lower(), **pytest._wait_defaults) + + +def test_check(qapp, qtbot): + """Test for archive consistency check""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + qapp.check_failed_event.disconnect() + + qtbot.waitUntil(lambda: tab.bCheck.isEnabled(), **pytest._wait_defaults) + qtbot.mouseClick(tab.bCheck, QtCore.Qt.MouseButton.LeftButton) + success_text = 'INFO: Archive consistency check complete' + + qtbot.waitUntil(lambda: success_text in main.logText.text(), **pytest._wait_defaults) + + +@pytest.mark.skipif(sys.platform == 'darwin', reason="Macos fuse support is uncertain") +def test_mount(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): + """Test for archive mounting and unmounting""" + + def psutil_disk_partitions(**kwargs): + DiskPartitions = namedtuple('DiskPartitions', ['device', 'mountpoint']) + return [DiskPartitions('borgfs', str(tmpdir))] + + monkeypatch.setattr(psutil, "disk_partitions", psutil_disk_partitions) + monkeypatch.setattr(vorta.views.archive_tab, "choose_file_dialog", choose_file_dialog) + + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + tab.archiveTable.selectRow(0) + + qtbot.waitUntil(lambda: tab.bMountRepo.isEnabled(), **pytest._wait_defaults) + + qtbot.mouseClick(tab.bMountArchive, QtCore.Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: tab.mountErrors.text().startswith('Mounted'), **pytest._wait_defaults) + + tab.bmountarchive_clicked() + qtbot.waitUntil(lambda: tab.mountErrors.text().startswith('Un-mounted successfully.'), **pytest._wait_defaults) + + tab.bmountrepo_clicked() + qtbot.waitUntil(lambda: tab.mountErrors.text().startswith('Mounted'), **pytest._wait_defaults) + + tab.bmountrepo_clicked() + qtbot.waitUntil(lambda: tab.mountErrors.text().startswith('Un-mounted successfully.'), **pytest._wait_defaults) + + +def test_archive_extract(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): + """Test for archive extraction""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + tab.archiveTable.selectRow(2) + tab.extract_action() + + qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults) + + # Select all files + tree_view = tab._window.treeView.model() + tree_view.setData(tree_view.index(0, 0), QtCore.Qt.CheckState.Checked, QtCore.Qt.ItemDataRole.CheckStateRole) + monkeypatch.setattr(vorta.views.archive_tab, "choose_file_dialog", choose_file_dialog) + qtbot.mouseClick(tab._window.extractButton, QtCore.Qt.MouseButton.LeftButton) + + qtbot.waitUntil(lambda: 'Restored files from archive.' in main.progressText.text(), **pytest._wait_defaults) + + assert [item.basename for item in tmpdir.listdir()] == ['private' if sys.platform == 'darwin' else 'tmp'] + + +def test_archive_delete(qapp, qtbot, mocker): + """Test for archive deletion""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + archivesCount = tab.archiveTable.rowCount() + + mocker.patch.object(vorta.views.archive_tab.ArchiveTab, 'confirm_dialog', lambda x, y, z: True) + + tab.archiveTable.selectRow(0) + tab.delete_action() + qtbot.waitUntil(lambda: 'Archive deleted.' in main.progressText.text(), **pytest._wait_defaults) + + assert ArchiveModel.select().count() == archivesCount - 1 + assert tab.archiveTable.rowCount() == archivesCount - 1 + + +def test_archive_rename(qapp, qtbot, mocker): + """Test for archive renaming""" + main = qapp.main_window + tab = main.archiveTab + + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + tab.archiveTable.selectRow(0) + new_archive_name = 'idf89d8f9d8fd98' + mocker.patch.object(vorta.views.archive_tab.QInputDialog, 'getText', return_value=(new_archive_name, True)) + tab.rename_action() + + # Successful rename case + qtbot.waitUntil(lambda: tab.mountErrors.text() == 'Archive renamed.', **pytest._wait_defaults) + assert ArchiveModel.select().filter(name=new_archive_name).count() == 1 + + # Duplicate name case + tab.archiveTable.selectRow(0) + exp_text = 'An archive with this name already exists.' + tab.rename_action() + qtbot.waitUntil(lambda: tab.mountErrors.text() == exp_text, **pytest._wait_defaults) diff --git a/tests/integration/test_borg.py b/tests/integration/test_borg.py new file mode 100644 index 000000000..2a4817109 --- /dev/null +++ b/tests/integration/test_borg.py @@ -0,0 +1,63 @@ +""" +This file contains tests that directly call borg commands and verify the exit code. +""" + +from pathlib import Path + +import pytest +import vorta.borg +import vorta.store.models +from vorta.borg.info_archive import BorgInfoArchiveJob +from vorta.borg.info_repo import BorgInfoRepoJob +from vorta.borg.prune import BorgPruneJob + + +def test_borg_prune(qapp, qtbot): + """This test runs borg prune on a test repo directly without UI""" + params = BorgPruneJob.prepare(vorta.store.models.BackupProfileModel.select().first()) + thread = BorgPruneJob(params['cmd'], params, qapp) + + with qtbot.waitSignal(thread.result, **pytest._wait_defaults) as blocker: + blocker.connect(thread.updated) + thread.run() + + assert blocker.args[0]['returncode'] == 0 + + +# test borg info +def test_borg_repo_info(qapp, qtbot, tmpdir): + """This test runs borg info on a test repo directly without UI""" + repo_info = { + 'repo_url': str(Path(tmpdir).parent / 'repo0'), + 'repo_name': 'repo0', + 'extra_borg_arguments': '', + 'ssh_key': '', + 'password': '', + } + + params = BorgInfoRepoJob.prepare(repo_info) + thread = BorgInfoRepoJob(params['cmd'], params, qapp) + + with qtbot.waitSignal(thread.result, **pytest._wait_defaults) as blocker: + blocker.connect(thread.result) + thread.run() + + assert blocker.args[0]['returncode'] == 0 + + +def test_borg_archive_info(qapp, qtbot, tmpdir): + """Check that archive info command works""" + main = qapp.main_window + tab = main.archiveTab + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + params = BorgInfoArchiveJob.prepare(vorta.store.models.BackupProfileModel.select().first(), "test-archive1") + thread = BorgInfoArchiveJob(params['cmd'], params, qapp) + + with qtbot.waitSignal(thread.result, **pytest._wait_defaults) as blocker: + blocker.connect(thread.result) + thread.run() + + assert blocker.args[0]['returncode'] == 0 diff --git a/tests/integration/test_diff.py b/tests/integration/test_diff.py new file mode 100644 index 000000000..28e8037b5 --- /dev/null +++ b/tests/integration/test_diff.py @@ -0,0 +1,385 @@ +""" +These tests compare the output of the diff command with the expected output. +""" + +import pytest +import vorta.borg +import vorta.utils +import vorta.views.archive_tab +from pkg_resources import parse_version +from vorta.borg.diff import BorgDiffJob +from vorta.views.diff_result import ( + ChangeType, + DiffTree, + FileType, + ParseThread, +) + + +@pytest.mark.parametrize( + 'archive_name_1, archive_name_2, expected', + [ + ( + 'test-archive1', + 'test-archive2', + [ + { + 'subpath': 'dir', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.MODIFIED, + 'modified': None, + }, + 'min_version': '1.2.4', + 'max_version': '1.2.4', + }, + { + 'subpath': 'file', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.MODIFIED, + 'modified': (0, 0), + }, + 'min_version': '1.2.4', + 'max_version': '1.2.4', + }, + { + 'subpath': 'chrdev', + 'data': { + 'file_type': FileType.CHRDEV, + 'change_type': ChangeType.ADDED, + 'modified': None, + }, + }, + { + 'subpath': 'fifo', + 'data': { + 'file_type': FileType.FIFO, + 'change_type': ChangeType.ADDED, + 'modified': None, + }, + }, + { + 'subpath': 'hardlink', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.ADDED, + 'modified': None, + }, + }, + { + 'subpath': 'symlink', + 'data': { + 'file_type': FileType.LINK, + 'change_type': ChangeType.ADDED, + 'modified': None, + }, + }, + ], + ), + ( + 'test-archive2', + 'test-archive3', + [ + { + 'subpath': 'borg_src', + 'match_startsWith': True, + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.MODIFIED, + 'modified': None, + }, + 'min_version': '1.2.4', + 'max_version': '1.2.4', + }, + { + 'subpath': 'dir', + 'data': { + 'file_type': FileType.DIRECTORY, + 'change_type': ChangeType.REMOVED, + 'modified': None, + }, + }, + { + 'subpath': 'chrdev', + 'data': { + 'file_type': FileType.CHRDEV, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'fifo', + 'data': { + 'file_type': FileType.FIFO, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'file', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'hardlink', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'symlink', + 'data': { + 'file_type': FileType.LINK, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'dir1', + 'data': { + 'file_type': FileType.DIRECTORY, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'chrdev', + 'data': { + 'file_type': FileType.CHRDEV, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'fifo', + 'data': { + 'file_type': FileType.FIFO, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'file', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'hardlink', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'symlink', + 'data': { + 'file_type': FileType.LINK, + 'change_type': ChangeType.ADDED, + }, + }, + ], + ), + ( + 'test-archive3', + 'test-archive4', + [ + { + 'subpath': 'dir1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.MODIFIED, + }, + 'min_version': '1.2.4', + 'max_version': '1.2.4', + }, + { + 'subpath': 'chrdev', + 'data': { + 'file_type': FileType.CHRDEV, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'chrdev1', + 'data': { + 'file_type': FileType.CHRDEV, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'fifo', + 'data': { + 'file_type': FileType.FIFO, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'fifo1', + 'data': { + 'file_type': FileType.FIFO, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'file', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'file1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'hardlink', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'hardlink1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.ADDED, + }, + }, + { + 'subpath': 'symlink', + 'data': { + 'file_type': FileType.LINK, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'symlink1', + 'data': { + 'file_type': FileType.LINK, + 'change_type': ChangeType.ADDED, + }, + }, + ], + ), + ( + 'test-archive4', + 'test-archive5', + [ + { + 'subpath': 'dir1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.MODIFIED, + }, + 'min_version': '1.2.4', + 'max_version': '1.2.4', + }, + { + 'subpath': 'chrdev1', + 'data': { + 'file_type': FileType.CHRDEV, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'fifo1', + 'data': { + 'file_type': FileType.FIFO, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'file1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'hardlink1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.REMOVED, + }, + }, + { + 'subpath': 'symlink1', + 'data': { + 'file_type': FileType.LINK, + 'change_type': ChangeType.REMOVED, + }, + }, + ], + ), + ( + 'test-archive5', + 'test-archive6', + [ + { + 'subpath': 'dir1', + 'data': { + 'file_type': FileType.FILE, + 'change_type': ChangeType.MODIFIED, + }, + 'min_version': '1.2.4', + 'max_version': '1.2.4', + }, + ], + ), + ], +) +def test_archive_diff_lines(qapp, qtbot, borg_version, create_test_repo, archive_name_1, archive_name_2, expected): + """Test that the diff lines are parsed correctly for supported borg versions""" + parsed_borg_version = borg_version[1] + supports_fifo = parsed_borg_version > parse_version('1.1.18') + supports_chrdev = create_test_repo[2] + + params = BorgDiffJob.prepare(vorta.store.models.BackupProfileModel.select().first(), archive_name_1, archive_name_2) + thread = BorgDiffJob(params['cmd'], params, qapp) + + with qtbot.waitSignal(thread.result, **pytest._wait_defaults) as blocker: + blocker.connect(thread.updated) + thread.run() + + diff_lines = blocker.args[0]['data'] + json_lines = blocker.args[0]['params']['json_lines'] + + model = DiffTree() + model.setMode(model.DisplayMode.FLAT) + + # Use ParseThread to parse the diff lines + parse_thread = ParseThread(diff_lines, json_lines, model) + parse_thread.start() + qtbot.waitUntil(lambda: parse_thread.isFinished(), **pytest._wait_defaults) + + expected = [ + item + for item in expected + if ( + ('min_version' not in item or parse_version(item['min_version']) <= parsed_borg_version) + and ('max_version' not in item or parse_version(item['max_version']) >= parsed_borg_version) + and (item['data']['file_type'] != FileType.FIFO or supports_fifo) + and (item['data']['file_type'] != FileType.CHRDEV or supports_chrdev) + ) + ] + + # diff versions of borg produce inconsistent ordering of diff lines so we sort the expected and model + expected = sorted(expected, key=lambda item: item['subpath']) + sorted_model = sorted( + [model.index(index, 0).internalPointer() for index in range(model.rowCount())], + key=lambda item: item.subpath, + ) + + assert len(sorted_model) == len(expected) + + for index, item in enumerate(expected): + if 'match_startsWith' in item and item['match_startsWith']: + assert sorted_model[index].subpath.startswith(item['subpath']) + else: + assert sorted_model[index].subpath == item['subpath'] + + for key, value in item['data'].items(): + assert getattr(sorted_model[index].data, key) == value diff --git a/tests/integration/test_init.py b/tests/integration/test_init.py new file mode 100644 index 000000000..7312a4202 --- /dev/null +++ b/tests/integration/test_init.py @@ -0,0 +1,98 @@ +""" +Test initialization of new repositories and adding existing ones. +""" + +import os +from pathlib import PurePath + +import pytest +import vorta.borg +import vorta.utils +import vorta.views.repo_add_dialog +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QMessageBox + +LONG_PASSWORD = 'long-password-long' +TEST_REPO_NAME = 'TEST - REPONAME' + + +def test_create_repo(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): + """Test initializing a new repository""" + main = qapp.main_window + main.repoTab.new_repo() + add_repo_window = main.repoTab._window + main.show() + + # create new folder in tmpdir + new_repo_path = tmpdir.join('new_repo') + new_repo_path.mkdir() + + monkeypatch.setattr( + vorta.views.repo_add_dialog, + "choose_file_dialog", + lambda *args, **kwargs: choose_file_dialog(*args, **kwargs, subdirectory=new_repo_path.basename), + ) + qtbot.mouseClick(add_repo_window.chooseLocalFolderButton, Qt.MouseButton.LeftButton) + + # clear auto input of repo name from url + add_repo_window.repoName.selectAll() + add_repo_window.repoName.del_() + qtbot.keyClicks(add_repo_window.repoName, TEST_REPO_NAME) + + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD) + + add_repo_window.run() + + qtbot.waitUntil(lambda: main.repoTab.repoSelector.count() == 2, **pytest._wait_defaults) + + # Check if repo was created in tmpdir + repo_url = ( + vorta.store.models.RepoModel.select().where(vorta.store.models.RepoModel.name == TEST_REPO_NAME).get().url + ) + assert PurePath(repo_url).parent == tmpdir + assert PurePath(repo_url).name == 'new_repo' + + # check that new_repo_path contains folder data + assert os.path.exists(new_repo_path.join('data')) + assert os.path.exists(new_repo_path.join('config')) + assert os.path.exists(new_repo_path.join('README')) + + +def test_add_existing_repo(qapp, qtbot, monkeypatch, choose_file_dialog): + """Test adding an existing repository""" + main = qapp.main_window + tab = main.repoTab + + main.tabWidget.setCurrentIndex(0) + current_repo_path = vorta.store.models.RepoModel.select().first().url + + monkeypatch.setattr(QMessageBox, "show", lambda *args: True) + qtbot.mouseClick(main.repoTab.repoRemoveToolbutton, Qt.MouseButton.LeftButton) + qtbot.waitUntil( + lambda: tab.repoSelector.count() == 1 and tab.repoSelector.currentText() == "No repository selected", + **pytest._wait_defaults, + ) + + # add existing repo again + main.repoTab.add_existing_repo() + add_repo_window = main.repoTab._window + + monkeypatch.setattr( + vorta.views.repo_add_dialog, + "choose_file_dialog", + lambda *args, **kwargs: choose_file_dialog(*args, **kwargs, directory=current_repo_path), + ) + qtbot.mouseClick(add_repo_window.chooseLocalFolderButton, Qt.MouseButton.LeftButton) + + # clear auto input of repo name from url + add_repo_window.repoName.selectAll() + add_repo_window.repoName.del_() + qtbot.keyClicks(add_repo_window.repoName, TEST_REPO_NAME) + + add_repo_window.run() + + # check that repo was added + qtbot.waitUntil(lambda: tab.repoSelector.count() == 1, **pytest._wait_defaults) + assert vorta.store.models.RepoModel.select().first().url == str(current_repo_path) + assert vorta.store.models.RepoModel.select().first().name == TEST_REPO_NAME diff --git a/tests/integration/test_repo.py b/tests/integration/test_repo.py new file mode 100644 index 000000000..5fb4e972a --- /dev/null +++ b/tests/integration/test_repo.py @@ -0,0 +1,24 @@ +""" +Test backup creation +""" + +import pytest +from PyQt6 import QtCore +from vorta.store.models import ArchiveModel, EventLogModel + + +def test_create(qapp, qtbot): + """Test for manual archive creation""" + main = qapp.main_window + main.archiveTab.refresh_archive_list() + qtbot.waitUntil(lambda: main.archiveTab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + + qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: 'Backup finished.' in main.progressText.text(), **pytest._wait_defaults) + qtbot.waitUntil(lambda: main.createStartBtn.isEnabled(), **pytest._wait_defaults) + + assert EventLogModel.select().count() == 2 + assert ArchiveModel.select().count() == 7 + assert main.createStartBtn.isEnabled() + assert main.archiveTab.archiveTable.rowCount() == 7 + assert main.scheduleTab.logTableWidget.rowCount() == 2 diff --git a/tests/borg_json_output/create_break_stderr.json b/tests/unit/__init__.py similarity index 100% rename from tests/borg_json_output/create_break_stderr.json rename to tests/unit/__init__.py diff --git a/tests/borg_json_output/check_stderr.json b/tests/unit/borg_json_output/check_stderr.json similarity index 100% rename from tests/borg_json_output/check_stderr.json rename to tests/unit/borg_json_output/check_stderr.json diff --git a/tests/borg_json_output/check_stdout.json b/tests/unit/borg_json_output/check_stdout.json similarity index 100% rename from tests/borg_json_output/check_stdout.json rename to tests/unit/borg_json_output/check_stdout.json diff --git a/tests/borg_json_output/compact_stderr.json b/tests/unit/borg_json_output/compact_stderr.json similarity index 100% rename from tests/borg_json_output/compact_stderr.json rename to tests/unit/borg_json_output/compact_stderr.json diff --git a/tests/borg_json_output/create_break_stdout.json b/tests/unit/borg_json_output/compact_stdout.json similarity index 100% rename from tests/borg_json_output/create_break_stdout.json rename to tests/unit/borg_json_output/compact_stdout.json diff --git a/tests/borg_json_output/create_lock_stdout.json b/tests/unit/borg_json_output/create_break_stderr.json similarity index 100% rename from tests/borg_json_output/create_lock_stdout.json rename to tests/unit/borg_json_output/create_break_stderr.json diff --git a/tests/borg_json_output/create_perm_stdout.json b/tests/unit/borg_json_output/create_break_stdout.json similarity index 100% rename from tests/borg_json_output/create_perm_stdout.json rename to tests/unit/borg_json_output/create_break_stdout.json diff --git a/tests/borg_json_output/create_lock_stderr.json b/tests/unit/borg_json_output/create_lock_stderr.json similarity index 100% rename from tests/borg_json_output/create_lock_stderr.json rename to tests/unit/borg_json_output/create_lock_stderr.json diff --git a/tests/borg_json_output/delete_stdout.json b/tests/unit/borg_json_output/create_lock_stdout.json similarity index 100% rename from tests/borg_json_output/delete_stdout.json rename to tests/unit/borg_json_output/create_lock_stdout.json diff --git a/tests/borg_json_output/create_perm_stderr.json b/tests/unit/borg_json_output/create_perm_stderr.json similarity index 100% rename from tests/borg_json_output/create_perm_stderr.json rename to tests/unit/borg_json_output/create_perm_stderr.json diff --git a/tests/borg_json_output/diff_archives_dict_issue_stderr.json b/tests/unit/borg_json_output/create_perm_stdout.json similarity index 100% rename from tests/borg_json_output/diff_archives_dict_issue_stderr.json rename to tests/unit/borg_json_output/create_perm_stdout.json diff --git a/tests/borg_json_output/create_stderr.json b/tests/unit/borg_json_output/create_stderr.json similarity index 100% rename from tests/borg_json_output/create_stderr.json rename to tests/unit/borg_json_output/create_stderr.json diff --git a/tests/borg_json_output/create_stdout.json b/tests/unit/borg_json_output/create_stdout.json similarity index 100% rename from tests/borg_json_output/create_stdout.json rename to tests/unit/borg_json_output/create_stdout.json diff --git a/tests/borg_json_output/delete_stderr.json b/tests/unit/borg_json_output/delete_stderr.json similarity index 100% rename from tests/borg_json_output/delete_stderr.json rename to tests/unit/borg_json_output/delete_stderr.json diff --git a/tests/borg_json_output/diff_archives_stderr.json b/tests/unit/borg_json_output/delete_stdout.json similarity index 100% rename from tests/borg_json_output/diff_archives_stderr.json rename to tests/unit/borg_json_output/delete_stdout.json diff --git a/tests/borg_json_output/rename_stderr.json b/tests/unit/borg_json_output/diff_archives_dict_issue_stderr.json similarity index 100% rename from tests/borg_json_output/rename_stderr.json rename to tests/unit/borg_json_output/diff_archives_dict_issue_stderr.json diff --git a/tests/borg_json_output/diff_archives_dict_issue_stdout.json b/tests/unit/borg_json_output/diff_archives_dict_issue_stdout.json similarity index 100% rename from tests/borg_json_output/diff_archives_dict_issue_stdout.json rename to tests/unit/borg_json_output/diff_archives_dict_issue_stdout.json diff --git a/tests/borg_json_output/rename_stdout.json b/tests/unit/borg_json_output/diff_archives_stderr.json similarity index 100% rename from tests/borg_json_output/rename_stdout.json rename to tests/unit/borg_json_output/diff_archives_stderr.json diff --git a/tests/borg_json_output/diff_archives_stdout.json b/tests/unit/borg_json_output/diff_archives_stdout.json similarity index 100% rename from tests/borg_json_output/diff_archives_stdout.json rename to tests/unit/borg_json_output/diff_archives_stdout.json diff --git a/tests/borg_json_output/info_stderr.json b/tests/unit/borg_json_output/info_stderr.json similarity index 100% rename from tests/borg_json_output/info_stderr.json rename to tests/unit/borg_json_output/info_stderr.json diff --git a/tests/borg_json_output/info_stdout.json b/tests/unit/borg_json_output/info_stdout.json similarity index 100% rename from tests/borg_json_output/info_stdout.json rename to tests/unit/borg_json_output/info_stdout.json diff --git a/tests/borg_json_output/list_archive_stderr.json b/tests/unit/borg_json_output/list_archive_stderr.json similarity index 100% rename from tests/borg_json_output/list_archive_stderr.json rename to tests/unit/borg_json_output/list_archive_stderr.json diff --git a/tests/borg_json_output/list_archive_stdout.json b/tests/unit/borg_json_output/list_archive_stdout.json similarity index 100% rename from tests/borg_json_output/list_archive_stdout.json rename to tests/unit/borg_json_output/list_archive_stdout.json diff --git a/tests/borg_json_output/list_stderr.json b/tests/unit/borg_json_output/list_stderr.json similarity index 100% rename from tests/borg_json_output/list_stderr.json rename to tests/unit/borg_json_output/list_stderr.json diff --git a/tests/borg_json_output/list_stdout.json b/tests/unit/borg_json_output/list_stdout.json similarity index 100% rename from tests/borg_json_output/list_stdout.json rename to tests/unit/borg_json_output/list_stdout.json diff --git a/tests/borg_json_output/prune_stderr.json b/tests/unit/borg_json_output/prune_stderr.json similarity index 100% rename from tests/borg_json_output/prune_stderr.json rename to tests/unit/borg_json_output/prune_stderr.json diff --git a/tests/borg_json_output/prune_stdout.json b/tests/unit/borg_json_output/prune_stdout.json similarity index 100% rename from tests/borg_json_output/prune_stdout.json rename to tests/unit/borg_json_output/prune_stdout.json diff --git a/tests/unit/borg_json_output/rename_stderr.json b/tests/unit/borg_json_output/rename_stderr.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/borg_json_output/rename_stdout.json b/tests/unit/borg_json_output/rename_stdout.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 000000000..e2ac7d4f0 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,107 @@ +import os +from datetime import datetime as dt + +import pytest +import vorta +import vorta.application +import vorta.borg.jobs_manager +from peewee import SqliteDatabase +from vorta.store.models import ( + ArchiveModel, + BackupProfileModel, + EventLogModel, + RepoModel, + RepoPassword, + SchemaVersion, + SettingsModel, + SourceFileModel, + WifiSettingModel, +) +from vorta.views.main_window import MainWindow + +models = [ + RepoModel, + RepoPassword, + BackupProfileModel, + SourceFileModel, + SettingsModel, + ArchiveModel, + WifiSettingModel, + EventLogModel, + SchemaVersion, +] + + +@pytest.fixture(scope='function', autouse=True) +def init_db(qapp, qtbot, tmpdir_factory): + tmp_db = tmpdir_factory.mktemp('Vorta').join('settings.sqlite') + mock_db = SqliteDatabase( + str(tmp_db), + pragmas={ + 'journal_mode': 'wal', + }, + ) + vorta.store.connection.init_db(mock_db) + + default_profile = BackupProfileModel(name='Default') + default_profile.save() + + new_repo = RepoModel(url='i0fi93@i593.repo.borgbase.com:repo') + new_repo.encryption = 'none' + new_repo.save() + + default_profile.repo = new_repo.id + default_profile.dont_run_on_metered_networks = False + default_profile.validation_on = False + default_profile.save() + + test_archive = ArchiveModel(snapshot_id='99999', name='test-archive', time=dt(2000, 1, 1, 0, 0), repo=1) + test_archive.save() + + test_archive1 = ArchiveModel(snapshot_id='99998', name='test-archive1', time=dt(2000, 1, 1, 0, 0), repo=1) + test_archive1.save() + + source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True) + source_dir.save() + + qapp.main_window.deleteLater() + del qapp.main_window + qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI + + yield + + qapp.jobs_manager.cancel_all_jobs() + qapp.backup_finished_event.disconnect() + qapp.scheduler.schedule_changed.disconnect() + qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults) + mock_db.close() + + +@pytest.fixture +def choose_file_dialog(*args): + class MockFileDialog: + def __init__(self, *args, **kwargs): + pass + + def open(self, func): + func() + + def selectedFiles(self): + return ['/tmp'] + + return MockFileDialog + + +@pytest.fixture +def borg_json_output(): + def _read_json(subcommand): + stdout = open(f'tests/unit/borg_json_output/{subcommand}_stdout.json') + stderr = open(f'tests/unit/borg_json_output/{subcommand}_stderr.json') + return stdout, stderr + + return _read_json + + +@pytest.fixture +def rootdir(): + return os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/profile_exports/invalid_no_json.json b/tests/unit/profile_exports/invalid_no_json.json similarity index 100% rename from tests/profile_exports/invalid_no_json.json rename to tests/unit/profile_exports/invalid_no_json.json diff --git a/tests/profile_exports/valid.json b/tests/unit/profile_exports/valid.json similarity index 100% rename from tests/profile_exports/valid.json rename to tests/unit/profile_exports/valid.json diff --git a/tests/test_archives.py b/tests/unit/test_archives.py similarity index 100% rename from tests/test_archives.py rename to tests/unit/test_archives.py diff --git a/tests/test_borg.py b/tests/unit/test_borg.py similarity index 100% rename from tests/test_borg.py rename to tests/unit/test_borg.py diff --git a/tests/test_create.py b/tests/unit/test_create.py similarity index 100% rename from tests/test_create.py rename to tests/unit/test_create.py diff --git a/tests/test_diff.py b/tests/unit/test_diff.py similarity index 100% rename from tests/test_diff.py rename to tests/unit/test_diff.py diff --git a/tests/test_extract.py b/tests/unit/test_extract.py similarity index 100% rename from tests/test_extract.py rename to tests/unit/test_extract.py diff --git a/tests/test_import_export.py b/tests/unit/test_import_export.py similarity index 100% rename from tests/test_import_export.py rename to tests/unit/test_import_export.py diff --git a/tests/test_lock.py b/tests/unit/test_lock.py similarity index 100% rename from tests/test_lock.py rename to tests/unit/test_lock.py diff --git a/tests/test_misc.py b/tests/unit/test_misc.py similarity index 100% rename from tests/test_misc.py rename to tests/unit/test_misc.py diff --git a/tests/test_notifications.py b/tests/unit/test_notifications.py similarity index 100% rename from tests/test_notifications.py rename to tests/unit/test_notifications.py diff --git a/tests/test_password_input.py b/tests/unit/test_password_input.py similarity index 100% rename from tests/test_password_input.py rename to tests/unit/test_password_input.py diff --git a/tests/test_profile.py b/tests/unit/test_profile.py similarity index 100% rename from tests/test_profile.py rename to tests/unit/test_profile.py diff --git a/tests/test_repo.py b/tests/unit/test_repo.py similarity index 100% rename from tests/test_repo.py rename to tests/unit/test_repo.py diff --git a/tests/test_schedule.py b/tests/unit/test_schedule.py similarity index 100% rename from tests/test_schedule.py rename to tests/unit/test_schedule.py diff --git a/tests/test_scheduler.py b/tests/unit/test_scheduler.py similarity index 100% rename from tests/test_scheduler.py rename to tests/unit/test_scheduler.py diff --git a/tests/test_source.py b/tests/unit/test_source.py similarity index 100% rename from tests/test_source.py rename to tests/unit/test_source.py diff --git a/tests/test_treemodel.py b/tests/unit/test_treemodel.py similarity index 100% rename from tests/test_treemodel.py rename to tests/unit/test_treemodel.py diff --git a/tests/test_utils.py b/tests/unit/test_utils.py similarity index 100% rename from tests/test_utils.py rename to tests/unit/test_utils.py From b58ffb6aed0254c565788e912a7cbc13db826275 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Fri, 11 Aug 2023 04:22:10 -0700 Subject: [PATCH 13/52] Setting for number format in archive tab. By @bigtedde (#1719) --- src/vorta/store/settings.py | 12 ++++ src/vorta/utils.py | 12 +--- src/vorta/views/archive_tab.py | 11 +-- src/vorta/views/main_window.py | 1 + src/vorta/views/misc_tab.py | 6 +- tests/unit/test_misc.py | 67 ++++++++++++------ tests/unit/test_utils.py | 126 ++++++++++++++++----------------- 7 files changed, 133 insertions(+), 102 deletions(-) diff --git a/src/vorta/store/settings.py b/src/vorta/store/settings.py index f0559582b..40324e9b7 100644 --- a/src/vorta/store/settings.py +++ b/src/vorta/store/settings.py @@ -59,6 +59,18 @@ def get_misc_settings() -> List[Dict[str, str]]: 'label': trans_late('settings', 'Get statistics of file/folder when added'), 'tooltip': trans_late('settings', 'When adding a new source, calculate its size and the number of files.'), }, + { + 'key': 'enable_fixed_units', + 'value': False, + 'type': 'checkbox', + 'group': information, + 'label': trans_late('settings', 'Use the same unit of measurement for archive sizes'), + 'tooltip': trans_late( + 'settings', + 'When enabled, all archive sizes will use the same unit of measurement, ' + 'such as KB or MB. This can make archive sizes easier to compare.', + ), + }, { 'key': 'use_system_keyring', 'value': True, diff --git a/src/vorta/utils.py b/src/vorta/utils.py index 1793a5e88..033b486a5 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -24,6 +24,8 @@ # Used to store whether a user wanted to override the # default directory for the --development flag DEFAULT_DIR_FLAG = object() +METRIC_UNITS = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] +NONMETRIC_UNITS = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'] borg_compat = BorgCompatibility() _network_status_monitor = None @@ -140,14 +142,10 @@ def get_network_status_monitor(): def get_path_datasize(path, exclude_patterns): file_info = QFileInfo(path) - data_size = 0 if file_info.isDir(): data_size, files_count = get_directory_size(file_info.absoluteFilePath(), exclude_patterns) - # logger.info("path (folder) %s %u elements size now=%u (%s)", - # file_info.absoluteFilePath(), files_count, data_size, pretty_bytes(data_size)) else: - # logger.info("path (file) %s size=%u", file_info.path(), file_info.size()) data_size = file_info.size() files_count = 1 @@ -279,11 +277,7 @@ def pretty_bytes( if not isinstance(size, int): return '' prefix = '+' if sign and size > 0 else '' - power, units = ( - (10**3, ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']) - if metric - else (2**10, ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi']) - ) + power, units = (10**3, METRIC_UNITS) if metric else (2**10, NONMETRIC_UNITS) if fixed_unit is None: n = find_best_unit_for_size(size, metric=metric, precision=precision) else: diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 54ae7aa13..c29218c97 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -33,7 +33,7 @@ from vorta.borg.rename import BorgRenameJob from vorta.borg.umount import BorgUmountJob from vorta.i18n import translate -from vorta.store.models import ArchiveModel, BackupProfileMixin +from vorta.store.models import ArchiveModel, BackupProfileMixin, SettingsModel from vorta.utils import ( borg_compat, choose_file_dialog, @@ -291,9 +291,12 @@ def populate_from_profile(self): formatted_time = archive.time.strftime('%Y-%m-%d %H:%M') self.archiveTable.setItem(row, 0, QTableWidgetItem(formatted_time)) - self.archiveTable.setItem( - row, 1, SizeItem(pretty_bytes(archive.size, fixed_unit=best_unit, precision=SIZE_DECIMAL_DIGITS)) - ) + + # 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: diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index 3da134f46..1f0c42e73 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -78,6 +78,7 @@ def __init__(self, parent=None): self.repoTab.repo_changed.connect(self.archiveTab.populate_from_profile) self.repoTab.repo_changed.connect(self.scheduleTab.populate_from_profile) self.repoTab.repo_added.connect(self.archiveTab.refresh_archive_list) + self.miscTab.refresh_archive.connect(self.archiveTab.populate_from_profile) self.createStartBtn.clicked.connect(self.app.create_backup_action) self.cancelButton.clicked.connect(self.app.backup_cancelled_event.emit) diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py index 2d52880f0..83c47be64 100644 --- a/src/vorta/views/misc_tab.py +++ b/src/vorta/views/misc_tab.py @@ -1,6 +1,6 @@ import logging -from PyQt6 import uic +from PyQt6 import QtCore, uic from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QApplication, @@ -28,6 +28,8 @@ class MiscTab(MiscTabBase, MiscTabUI, BackupProfileMixin): + refresh_archive = QtCore.pyqtSignal() + def __init__(self, parent=None): """Init.""" super().__init__(parent) @@ -101,6 +103,8 @@ def populate(self): cb.setCheckState(Qt.CheckState(setting.value)) cb.setTristate(False) cb.stateChanged.connect(lambda v, key=setting.key: self.save_setting(key, v)) + if setting.key == 'enable_fixed_units': + cb.stateChanged.connect(self.refresh_archive.emit) tb = ToolTipButton() tb.setToolTip(setting.tooltip) diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index 680ac9d88..eb1a5ac1f 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -11,7 +11,6 @@ def test_autostart(qapp, qtbot): """Check if file exists only on Linux, otherwise just check it doesn't crash""" - setting = "Automatically start Vorta at login" _click_toggle_setting(setting, qapp, qtbot) @@ -34,10 +33,41 @@ def test_autostart(qapp, qtbot): assert not os.path.exists(autostart_path) +def test_enable_fixed_units(qapp, qtbot, mocker): + """Tests the 'enable fixed units' setting to ensure the archive tab sizes are displayed correctly.""" + tab = qapp.main_window.archiveTab + setting = "Use the same unit of measurement for archive sizes" + + # set mocks + mock_setting = mocker.patch.object(vorta.views.archive_tab.SettingsModel, "get", return_value=Mock(value=True)) + mock_pretty_bytes = mocker.patch.object(vorta.views.archive_tab, "pretty_bytes") + + # with setting enabled, fixed units should be determined and passed to pretty_bytes as an 'int' + tab.populate_from_profile() + mock_pretty_bytes.assert_called() + kwargs_list = mock_pretty_bytes.call_args_list[0].kwargs + assert 'fixed_unit' in kwargs_list + assert isinstance(kwargs_list['fixed_unit'], int) + + # disable setting and reset mock + mock_setting.return_value = Mock(value=False) + mock_pretty_bytes.reset_mock() + + # with setting disabled, pretty_bytes should be called with fixed units set to 'None' + tab.populate_from_profile() + mock_pretty_bytes.assert_called() + kwargs_list = mock_pretty_bytes.call_args_list[0].kwargs + assert 'fixed_unit' in kwargs_list + assert kwargs_list['fixed_unit'] is None + + # use the qt bot to click the setting and see that the refresh_archive emit works as intended. + with qtbot.waitSignal(qapp.main_window.miscTab.refresh_archive, timeout=5000): + _click_toggle_setting(setting, qapp, qtbot) + + @pytest.mark.skipif(sys.platform != 'darwin', reason="Full Disk Access check only on Darwin") def test_check_full_disk_access(qapp, qtbot, mocker): """Enables/disables 'Check for Full Disk Access on startup' setting and ensures functionality""" - setting = "Check for Full Disk Access on startup" # Set mocks for setting enabled @@ -64,23 +94,16 @@ def test_check_full_disk_access(qapp, qtbot, mocker): def _click_toggle_setting(setting, qapp, qtbot): - """Click toggle setting in the misc tab""" - - main = qapp.main_window - main.tabWidget.setCurrentIndex(4) - tab = main.miscTab - - for x in range(0, tab.checkboxLayout.count()): - item = tab.checkboxLayout.itemAt(x, QFormLayout.ItemRole.FieldRole) - if not item: - continue - checkbox = item.itemAt(0).widget() - checkbox.__class__ = QCheckBox - - if checkbox.text() == setting: - # Have to use pos to click checkbox correctly - # https://stackoverflow.com/questions/19418125/pysides-qtest-not-checking-box/24070484#24070484 - qtbot.mouseClick( - checkbox, QtCore.Qt.MouseButton.LeftButton, pos=QtCore.QPoint(2, int(checkbox.height() / 2)) - ) - break + """Toggle setting checkbox in the misc tab""" + miscTab = qapp.main_window.miscTab + + for x in range(miscTab.checkboxLayout.count()): + item = miscTab.checkboxLayout.itemAt(x, QFormLayout.ItemRole.FieldRole) + if item is not None: + checkbox = item.itemAt(0).widget() + if checkbox.text() == setting and isinstance(checkbox, QCheckBox): + # Have to use pos to click checkbox correctly + # https://stackoverflow.com/questions/19418125/pysides-qtest-not-checking-box/24070484#24070484 + pos = QtCore.QPoint(2, int(checkbox.height() / 2)) + qtbot.mouseClick(checkbox, QtCore.Qt.MouseButton.LeftButton, pos=pos) + break diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 4d9c3d6f3..db575216a 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,7 +1,11 @@ import uuid +import pytest from vorta.keyring.abc import VortaKeyring -from vorta.utils import find_best_unit_for_sizes, pretty_bytes +from vorta.utils import ( + find_best_unit_for_sizes, + pretty_bytes, +) def test_keyring(): @@ -13,70 +17,60 @@ def test_keyring(): assert keyring.get_password("vorta-repo", REPO) == UNICODE_PW -def test_best_size_unit_precision0(): +@pytest.mark.parametrize( + "precision, expected_unit", + [ + (0, 1), # return units as "1" (represents KB), min=100KB + (1, 2), # return units as "2" (represents MB), min=0.1MB + (2, 2), # still returns KB, since 0.1MB < min=0.001 GB to allow for GB to be best_unit + ], +) +def test_best_unit_for_sizes_precision(precision, expected_unit): MB = 1000000 sizes = [int(0.1 * MB), 100 * MB, 2000 * MB] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=0) - assert unit == 1 # KB, min=100KB - - -def test_best_size_unit_precision1(): - MB = 1000000 - sizes = [int(0.1 * MB), 100 * MB, 2000 * MB] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=1) - assert unit == 2 # MB, min=0.1MB - - -def test_best_size_unit_empty(): - sizes = [] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=1) - assert unit == 0 # bytes - - -def test_best_size_unit_precision3(): - MB = 1000000 - sizes = [1 * MB, 100 * MB, 2000 * MB] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=3) - assert unit == 3 # GB, min=0.001 GB - - -def test_best_size_unit_nonmetric1(): - sizes = [102] - unit = find_best_unit_for_sizes(sizes, metric=False, precision=1) - assert unit == 0 # 102 < 0.1KB - - -def test_best_size_unit_nonmetric2(): - sizes = [103] - unit = find_best_unit_for_sizes(sizes, metric=False, precision=1) - assert unit == 1 # 103bytes == 0.1KB - - -def test_pretty_bytes_metric_fixed1(): - s = pretty_bytes(1000000, metric=True, precision=0, fixed_unit=2) - assert s == "1 MB" - - -def test_pretty_bytes_metric_fixed2(): - s = pretty_bytes(1000000, metric=True, precision=1, fixed_unit=2) - assert s == "1.0 MB" - - -def test_pretty_bytes_metric_fixed3(): - s = pretty_bytes(100000, metric=True, precision=1, fixed_unit=2) - assert s == "0.1 MB" - - -def test_pretty_bytes_nonmetric_fixed1(): - s = pretty_bytes(1024 * 1024, metric=False, precision=1, fixed_unit=2) - assert s == "1.0 MiB" - - -def test_pretty_bytes_metric_nonfixed2(): - s = pretty_bytes(1000000, metric=True, precision=1) - assert s == "1.0 MB" - - -def test_pretty_bytes_metric_large(): - s = pretty_bytes(10**30, metric=True, precision=1) - assert s == "1000000.0 YB" + best_unit = find_best_unit_for_sizes(sizes, metric=True, precision=precision) + assert best_unit == expected_unit + + +@pytest.mark.parametrize( + "sizes, expected_unit", + [ + ([], 0), # no sizes given but should still return "0" (represents bytes) as best representation + ([102], 0), # non-metric size 102 < 0.1KB (102 < 0.1 * 1024), so it will return 0 instead of 1 + ([103], 1), # non-metric size 103 > 0.1KB (103 < 0.1 * 1024), so it will return 1 + ], +) +def test_best_unit_for_sizes_nonmetric(sizes, expected_unit): + best_unit = find_best_unit_for_sizes(sizes, metric=False, precision=1) + assert best_unit == expected_unit + + +@pytest.mark.parametrize( + "size, metric, precision, fixed_unit, expected_output", + [ + (10**5, True, 1, 2, "0.1 MB"), # 100KB, metric, precision 1, fixed unit "2" (MB) + (10**6, True, 0, 2, "1 MB"), # 1MB, metric, precision 0, fixed unit "2" (MB) + (10**6, True, 1, 2, "1.0 MB"), # 1MB, metric, precision 1, fixed unit "2" (MB) + (1024 * 1024, False, 1, 2, "1.0 MiB"), # 1MiB, nonmetric, precision 1, fixed unit "2" (MiB) + ], +) +def test_pretty_bytes_fixed_units(size, metric, precision, fixed_unit, expected_output): + # test pretty bytes when specifying a fixed unit of measurement + output = pretty_bytes(size, metric=metric, precision=precision, fixed_unit=fixed_unit) + assert output == expected_output + + +@pytest.mark.parametrize( + "size, metric, expected_output", + [ + (10**6, True, "1.0 MB"), # 1MB, metric + (10**24, True, "1.0 YB"), # 1YB, metric + (10**30, True, "1000000.0 YB"), # test huge number, metric + (1024 * 1024, False, "1.0 MiB"), # 1MiB, nonmetric + (2**40 * 2**40, False, "1.0 YiB"), # 1YiB, nonmetric + ], +) +def test_pretty_bytes_nonfixed_units(size, metric, expected_output): + # test pretty bytes when NOT specifying a fixed unit of measurement + output = pretty_bytes(size, metric=metric, precision=1) + assert output == expected_output From 92f285f6231ee878b483158a3ef005e66c971a2a Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Sun, 13 Aug 2023 19:53:12 +0100 Subject: [PATCH 14/52] Add full font licenses, add Google icons to README. (#1740) --- .gitignore | 1 + README.md | 2 +- src/vorta/assets/icons/APACHE.txt | 204 ++++++++++++++++++++++++++++++ src/vorta/assets/icons/OFL.txt | 99 +++++++++++++++ 4 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/vorta/assets/icons/APACHE.txt create mode 100644 src/vorta/assets/icons/OFL.txt diff --git a/.gitignore b/.gitignore index 7d83381b0..2fdd4099d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ src/vorta/i18n/ts/vorta.en.ts src/vorta/i18n/ts/vorta.en_US.ts flatpak/app/ flatpak/.flatpak-builder/ +.vscode diff --git a/README.md b/README.md index 846d6ec3b..8111899a8 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,4 @@ See our website for [download links and and install instructions](https://vorta. - See [CONTRIBUTORS.md](CONTRIBUTORS.md) to see who programmed and translated Vorta. - Licensed under [GPLv3](LICENSE.txt). © 2018-2023 Manuel Riel and Vorta contributors - Based on [PyQt](https://riverbankcomputing.com/software/pyqt/intro) and [Qt](https://www.qt.io). -- Icons by [Fork Awesome](https://forkaweso.me/) (licensed under [SIL Open Font License](https://scripts.sil.org/OFL), Version 1.1) unless specified otherwise. +- Icons by [Fork Awesome](https://forkaweso.me/) (licensed under [SIL Open Font License](https://scripts.sil.org/OFL), Version 1.1) and Material Design icons by Google (licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt)). See the `src/vorta/assets/icons` folder for a copy of applicable licenses. diff --git a/src/vorta/assets/icons/APACHE.txt b/src/vorta/assets/icons/APACHE.txt new file mode 100644 index 000000000..4a2c5e0fa --- /dev/null +++ b/src/vorta/assets/icons/APACHE.txt @@ -0,0 +1,204 @@ +/!\ The Apache version 2 license applies to all SVG icon files in this directory that +have a copyright header referring to "Material Design icons by Google". + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/vorta/assets/icons/OFL.txt b/src/vorta/assets/icons/OFL.txt new file mode 100644 index 000000000..ba54e1d7c --- /dev/null +++ b/src/vorta/assets/icons/OFL.txt @@ -0,0 +1,99 @@ +/!\ The SIL OPEN FONT LICENSE applies to all SVG icon files in this directory that +don't have any other copyright information. + + +Copyright (c) 2018, Fork Awesome (https://forkawesome.github.io), +with Reserved Font Name Fork Awesome. + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. From 30c572250fae44fabc91880a21bab1a82bfe6b47 Mon Sep 17 00:00:00 2001 From: Divyansh Singh Date: Tue, 15 Aug 2023 17:08:51 +0530 Subject: [PATCH 15/52] Inline archive renaming. By @diivi (#1734) --- src/vorta/assets/UI/archivetab.ui | 2 +- src/vorta/borg/info_archive.py | 5 ++ src/vorta/views/archive_tab.py | 91 +++++++++++++++++------------- tests/integration/test_archives.py | 16 ++---- tests/unit/test_archives.py | 18 +++--- 5 files changed, 70 insertions(+), 62 deletions(-) diff --git a/src/vorta/assets/UI/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index dfaa5d512..6c206033f 100644 --- a/src/vorta/assets/UI/archivetab.ui +++ b/src/vorta/assets/UI/archivetab.ui @@ -257,7 +257,7 @@ - + diff --git a/src/vorta/borg/info_archive.py b/src/vorta/borg/info_archive.py index 72caf06c3..afb94b2f3 100644 --- a/src/vorta/borg/info_archive.py +++ b/src/vorta/borg/info_archive.py @@ -41,6 +41,11 @@ def process_result(self, result): # Update remote archives. for remote_archive in remote_archives: archive = ArchiveModel.get_or_none(snapshot_id=remote_archive['id'], repo=repo_id) + if archive is None: + # archive id was changed during rename, so we need to find it by name + archive = ArchiveModel.get_or_none(name=remote_archive['name'], repo=repo_id) + archive.snapshot_id = remote_archive['id'] + archive.name = remote_archive['name'] # incase name changed # archive.time = parser.parse(remote_archive['time']) archive.duration = remote_archive['duration'] diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index c29218c97..b18401f7a 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -10,7 +10,6 @@ QAbstractItemView, QApplication, QHeaderView, - QInputDialog, QLayout, QMenu, QMessageBox, @@ -79,6 +78,7 @@ def __init__(self, parent=None, app=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 ) @@ -111,6 +111,7 @@ def __init__(self, parent=None, app=None): 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) @@ -126,7 +127,7 @@ def __init__(self, parent=None, app=None): # connect archive actions self.bMountArchive.clicked.connect(self.bmountarchive_clicked) self.bRefreshArchive.clicked.connect(self.refresh_archive_info) - self.bRename.clicked.connect(self.rename_action) + 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) @@ -206,7 +207,7 @@ def archiveitem_contextmenu(self, pos: QPoint): ) ) archive_actions.append(menu.addAction(self.bExtract.icon(), self.bExtract.text(), self.extract_action)) - archive_actions.append(menu.addAction(self.bRename.icon(), self.bRename.text(), self.rename_action)) + archive_actions.append(menu.addAction(self.bRename.icon(), self.bRename.text(), self.cell_double_clicked)) # deletion possible with one but also multiple archives menu.addAction(self.bDelete.icon(), self.bDelete.text(), self.delete_action) @@ -823,7 +824,11 @@ def extract_archive_result(self, result): """Finished extraction.""" self._toggle_all_buttons(True) - def cell_double_clicked(self, row, column): + def cell_double_clicked(self, row=None, column=None): + 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: @@ -834,6 +839,46 @@ def cell_double_clicked(self, row, column): 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] @@ -968,45 +1013,11 @@ def show_diff_result(self, archive_newer, archive_older, model): self._resultwindow = window # for testing window.show() - def rename_action(self): - profile = self.profile() - - archive_name = self.selected_archive_name() - if archive_name is not None: - new_name, finished = QInputDialog.getText( - self, - self.tr("Change name"), - self.tr("New archive name:"), - text=archive_name, - ) - - if not finished: - return - - if not new_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.')) - return - - params = BorgRenameJob.prepare(profile, archive_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) - else: - self._set_status(self.tr("No archive selected")) - 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) diff --git a/tests/integration/test_archives.py b/tests/integration/test_archives.py index 98d653fbc..e74f9bb33 100644 --- a/tests/integration/test_archives.py +++ b/tests/integration/test_archives.py @@ -171,15 +171,11 @@ def test_archive_rename(qapp, qtbot, mocker): tab.archiveTable.selectRow(0) new_archive_name = 'idf89d8f9d8fd98' - mocker.patch.object(vorta.views.archive_tab.QInputDialog, 'getText', return_value=(new_archive_name, True)) - tab.rename_action() + pos = tab.archiveTable.visualRect(tab.archiveTable.model().index(0, 4)).center() + qtbot.mouseClick(tab.archiveTable.viewport(), QtCore.Qt.MouseButton.LeftButton, pos=pos) + qtbot.mouseDClick(tab.archiveTable.viewport(), QtCore.Qt.MouseButton.LeftButton, pos=pos) + qtbot.keyClicks(tab.archiveTable.viewport().focusWidget(), new_archive_name) + qtbot.keyClick(tab.archiveTable.viewport().focusWidget(), QtCore.Qt.Key.Key_Return) # Successful rename case - qtbot.waitUntil(lambda: tab.mountErrors.text() == 'Archive renamed.', **pytest._wait_defaults) - assert ArchiveModel.select().filter(name=new_archive_name).count() == 1 - - # Duplicate name case - tab.archiveTable.selectRow(0) - exp_text = 'An archive with this name already exists.' - tab.rename_action() - qtbot.waitUntil(lambda: tab.mountErrors.text() == exp_text, **pytest._wait_defaults) + qtbot.waitUntil(lambda: tab.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index a7627af16..a37dcfd8c 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -183,16 +183,12 @@ def test_archive_rename(qapp, qtbot, mocker, borg_json_output): stdout, stderr = borg_json_output('rename') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) - mocker.patch.object(vorta.views.archive_tab.QInputDialog, 'getText', return_value=(new_archive_name, True)) - tab.rename_action() - # Successful rename case - qtbot.waitUntil(lambda: tab.mountErrors.text() == 'Archive renamed.', **pytest._wait_defaults) - assert ArchiveModel.select().filter(name=new_archive_name).count() == 1 + pos = tab.archiveTable.visualRect(tab.archiveTable.model().index(0, 4)).center() + qtbot.mouseClick(tab.archiveTable.viewport(), QtCore.Qt.MouseButton.LeftButton, pos=pos) + qtbot.mouseDClick(tab.archiveTable.viewport(), QtCore.Qt.MouseButton.LeftButton, pos=pos) + qtbot.keyClicks(tab.archiveTable.viewport().focusWidget(), new_archive_name) + qtbot.keyClick(tab.archiveTable.viewport().focusWidget(), QtCore.Qt.Key.Key_Return) - # Duplicate name case - tab.archiveTable.selectRow(0) - exp_text = 'An archive with this name already exists.' - mocker.patch.object(vorta.views.archive_tab.QInputDialog, 'getText', return_value=(new_archive_name, True)) - tab.rename_action() - qtbot.waitUntil(lambda: tab.mountErrors.text() == exp_text, **pytest._wait_defaults) + # Successful rename case + qtbot.waitUntil(lambda: tab.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) From fb42614524d070abd3255fa499fed21cb3258fe8 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Thu, 17 Aug 2023 03:04:33 -0700 Subject: [PATCH 16/52] Add test utility functions (#1768) --- tests/unit/test_utils.py | 65 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index db575216a..cbb971b85 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,9 +1,13 @@ +import sys import uuid import pytest from vorta.keyring.abc import VortaKeyring from vorta.utils import ( find_best_unit_for_sizes, + get_path_datasize, + is_system_tray_available, + normalize_path, pretty_bytes, ) @@ -55,7 +59,9 @@ def test_best_unit_for_sizes_nonmetric(sizes, expected_unit): ], ) def test_pretty_bytes_fixed_units(size, metric, precision, fixed_unit, expected_output): - # test pretty bytes when specifying a fixed unit of measurement + """ + test pretty bytes when specifying a fixed unit of measurement + """ output = pretty_bytes(size, metric=metric, precision=precision, fixed_unit=fixed_unit) assert output == expected_output @@ -74,3 +80,60 @@ def test_pretty_bytes_nonfixed_units(size, metric, expected_output): # test pretty bytes when NOT specifying a fixed unit of measurement output = pretty_bytes(size, metric=metric, precision=1) assert output == expected_output + + +def test_normalize_path(): + """ + Test that path is normalized for macOS, but does nothing for other platforms. + """ + input_path = '/Users/username/caf\u00e9/file.txt' + expected_output = '/Users/username/café/file.txt' + + actual_output = normalize_path(input_path) + + if sys.platform == 'darwin': + assert actual_output == expected_output + else: + assert actual_output == input_path + + +def test_get_path_datasize(tmpdir): + """ + Test that get_path_datasize() works correctly when passed excluded patterns. + """ + # Create a temporary directory for testing + test_dir = tmpdir.mkdir("test_dir") + test_file = test_dir.join("test_file.txt") + test_file.write("Hello, World!") + + # Create a subdirectory with a file to exclude + excluded_dir = test_dir.mkdir("excluded_dir") + excluded_file = excluded_dir.join("excluded_file.txt") + excluded_file.write("Excluded file, should not be checked.") + + exclude_patterns = [f"{excluded_dir}"] + + # Test when the path is a directory + data_size, files_count = get_path_datasize(str(test_dir), exclude_patterns) + assert data_size == len("Hello, World!") + assert files_count == 1 + + # Test when the path is a file + data_size, files_count = get_path_datasize(str(test_file), exclude_patterns) + assert data_size == len("Hello, World!") + assert files_count == 1 + + # Test when the path is a directory with an excluded file + data_size, files_count = get_path_datasize(str(excluded_dir), exclude_patterns) + assert data_size == 0 + assert files_count == 0 + + +def test_is_system_tray_available(mocker): + """ + sanity check to ensure proper behavior + """ + mocker.patch('PyQt6.QtWidgets.QSystemTrayIcon.isSystemTrayAvailable', return_value=False) + assert is_system_tray_available() is False + mocker.patch('PyQt6.QtWidgets.QSystemTrayIcon.isSystemTrayAvailable', return_value=True) + assert is_system_tray_available() is True From ee71bcae9a424edc69a3bc77a264d024106e31f4 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Thu, 17 Aug 2023 03:05:52 -0700 Subject: [PATCH 17/52] DRY tests, increase coverage. By @bigtedde (#1769) --- tests/integration/conftest.py | 15 ++++- tests/integration/test_archives.py | 67 ++++--------------- tests/integration/test_borg.py | 9 +-- tests/integration/test_repo.py | 6 +- tests/unit/conftest.py | 15 ++++- tests/unit/test_archives.py | 100 +++++++++++++++-------------- tests/unit/test_diff.py | 9 +-- 7 files changed, 99 insertions(+), 122 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3d67fc648..683271f54 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,7 +19,7 @@ WifiSettingModel, ) from vorta.utils import borg_compat -from vorta.views.main_window import MainWindow +from vorta.views.main_window import ArchiveTab, MainWindow models = [ RepoModel, @@ -206,6 +206,19 @@ def rootdir(): return os.path.dirname(os.path.abspath(__file__)) +@pytest.fixture() +def archive_env(qapp, qtbot): + """ + Common setup for integration tests involving the archive tab. + """ + main: MainWindow = qapp.main_window + tab: ArchiveTab = main.archiveTab + main.tabWidget.setCurrentIndex(3) + tab.refresh_archive_list() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + return main, tab + + @pytest.fixture(autouse=True) def min_borg_version(borg_version, request): if request.node.get_closest_marker('min_borg_version'): diff --git a/tests/integration/test_archives.py b/tests/integration/test_archives.py index e74f9bb33..a32df06d0 100644 --- a/tests/integration/test_archives.py +++ b/tests/integration/test_archives.py @@ -18,11 +18,9 @@ def test_repo_list(qapp, qtbot): """Test that the archives are created and repo list is populated correctly""" main = qapp.main_window tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) tab.refresh_archive_list() qtbot.waitUntil(lambda: not tab.bCheck.isEnabled(), **pytest._wait_defaults) - assert not tab.bCheck.isEnabled() qtbot.waitUntil(lambda: 'Refreshing archives done.' in main.progressText.text(), **pytest._wait_defaults) @@ -31,30 +29,18 @@ def test_repo_list(qapp, qtbot): assert tab.bCheck.isEnabled() -def test_repo_prune(qapp, qtbot): +def test_repo_prune(qapp, qtbot, archive_env): """Test for archive pruning""" - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) - + main, tab = archive_env qtbot.mouseClick(tab.bPrune, QtCore.Qt.MouseButton.LeftButton) qtbot.waitUntil(lambda: 'Pruning old archives' in main.progressText.text(), **pytest._wait_defaults) qtbot.waitUntil(lambda: 'Refreshing archives done.' in main.progressText.text(), **pytest._wait_defaults) @pytest.mark.min_borg_version('1.2.0a1') -def test_repo_compact(qapp, qtbot): +def test_repo_compact(qapp, qtbot, archive_env): """Test for archive compaction""" - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) - + main, tab = archive_env qtbot.waitUntil(lambda: tab.compactButton.isEnabled(), **pytest._wait_defaults) assert tab.compactButton.isEnabled() @@ -62,14 +48,9 @@ def test_repo_compact(qapp, qtbot): qtbot.waitUntil(lambda: 'compaction freed about' in main.logText.text().lower(), **pytest._wait_defaults) -def test_check(qapp, qtbot): +def test_check(qapp, qtbot, archive_env): """Test for archive consistency check""" - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + main, tab = archive_env qapp.check_failed_event.disconnect() @@ -81,7 +62,7 @@ def test_check(qapp, qtbot): @pytest.mark.skipif(sys.platform == 'darwin', reason="Macos fuse support is uncertain") -def test_mount(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): +def test_mount(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir, archive_env): """Test for archive mounting and unmounting""" def psutil_disk_partitions(**kwargs): @@ -91,12 +72,7 @@ def psutil_disk_partitions(**kwargs): monkeypatch.setattr(psutil, "disk_partitions", psutil_disk_partitions) monkeypatch.setattr(vorta.views.archive_tab, "choose_file_dialog", choose_file_dialog) - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + main, tab = archive_env tab.archiveTable.selectRow(0) qtbot.waitUntil(lambda: tab.bMountRepo.isEnabled(), **pytest._wait_defaults) @@ -114,14 +90,9 @@ def psutil_disk_partitions(**kwargs): qtbot.waitUntil(lambda: tab.mountErrors.text().startswith('Un-mounted successfully.'), **pytest._wait_defaults) -def test_archive_extract(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): +def test_archive_extract(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir, archive_env): """Test for archive extraction""" - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + main, tab = archive_env tab.archiveTable.selectRow(2) tab.extract_action() @@ -139,14 +110,9 @@ def test_archive_extract(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): assert [item.basename for item in tmpdir.listdir()] == ['private' if sys.platform == 'darwin' else 'tmp'] -def test_archive_delete(qapp, qtbot, mocker): +def test_archive_delete(qapp, qtbot, mocker, archive_env): """Test for archive deletion""" - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + main, tab = archive_env archivesCount = tab.archiveTable.rowCount() @@ -160,14 +126,9 @@ def test_archive_delete(qapp, qtbot, mocker): assert tab.archiveTable.rowCount() == archivesCount - 1 -def test_archive_rename(qapp, qtbot, mocker): +def test_archive_rename(qapp, qtbot, mocker, archive_env): """Test for archive renaming""" - main = qapp.main_window - tab = main.archiveTab - - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + main, tab = archive_env tab.archiveTable.selectRow(0) new_archive_name = 'idf89d8f9d8fd98' diff --git a/tests/integration/test_borg.py b/tests/integration/test_borg.py index 2a4817109..0af84a96b 100644 --- a/tests/integration/test_borg.py +++ b/tests/integration/test_borg.py @@ -24,7 +24,6 @@ def test_borg_prune(qapp, qtbot): assert blocker.args[0]['returncode'] == 0 -# test borg info def test_borg_repo_info(qapp, qtbot, tmpdir): """This test runs borg info on a test repo directly without UI""" repo_info = { @@ -45,14 +44,8 @@ def test_borg_repo_info(qapp, qtbot, tmpdir): assert blocker.args[0]['returncode'] == 0 -def test_borg_archive_info(qapp, qtbot, tmpdir): +def test_borg_archive_info(qapp, qtbot, archive_env): """Check that archive info command works""" - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - tab.refresh_archive_list() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() > 0, **pytest._wait_defaults) - params = BorgInfoArchiveJob.prepare(vorta.store.models.BackupProfileModel.select().first(), "test-archive1") thread = BorgInfoArchiveJob(params['cmd'], params, qapp) diff --git a/tests/integration/test_repo.py b/tests/integration/test_repo.py index 5fb4e972a..e4751b917 100644 --- a/tests/integration/test_repo.py +++ b/tests/integration/test_repo.py @@ -7,11 +7,9 @@ from vorta.store.models import ArchiveModel, EventLogModel -def test_create(qapp, qtbot): +def test_create(qapp, qtbot, archive_env): """Test for manual archive creation""" - main = qapp.main_window - main.archiveTab.refresh_archive_list() - qtbot.waitUntil(lambda: main.archiveTab.archiveTable.rowCount() > 0, **pytest._wait_defaults) + main, tab = archive_env qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton) qtbot.waitUntil(lambda: 'Backup finished.' in main.progressText.text(), **pytest._wait_defaults) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e2ac7d4f0..e622a2118 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -17,7 +17,7 @@ SourceFileModel, WifiSettingModel, ) -from vorta.views.main_window import MainWindow +from vorta.views.main_window import ArchiveTab, MainWindow models = [ RepoModel, @@ -105,3 +105,16 @@ def _read_json(subcommand): @pytest.fixture def rootdir(): return os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture() +def archive_env(qapp, qtbot): + """ + Common setup for unit tests involving the archive tab. + """ + main: MainWindow = qapp.main_window + tab: ArchiveTab = main.archiveTab + main.tabWidget.setCurrentIndex(3) + tab.populate_from_profile() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2, **pytest._wait_defaults) + return main, tab diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index a37dcfd8c..c17bf6249 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -30,18 +30,15 @@ def test_prune_intervals(qapp, qtbot): assert getattr(profile, f'prune_{i}') == 9 -def test_repo_list(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab +def test_repo_list(qapp, qtbot, mocker, borg_json_output, archive_env): + main, tab = archive_env stdout, stderr = borg_json_output('list') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) - main.tabWidget.setCurrentIndex(3) tab.refresh_archive_list() qtbot.waitUntil(lambda: not tab.bCheck.isEnabled(), **pytest._wait_defaults) - assert not tab.bCheck.isEnabled() qtbot.waitUntil(lambda: 'Refreshing archives done.' in main.progressText.text(), **pytest._wait_defaults) @@ -50,11 +47,9 @@ def test_repo_list(qapp, qtbot, mocker, borg_json_output): assert tab.bCheck.isEnabled() -def test_repo_prune(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - tab.populate_from_profile() +def test_repo_prune(qapp, qtbot, mocker, borg_json_output, archive_env): + main, tab = archive_env + stdout, stderr = borg_json_output('prune') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) @@ -64,12 +59,10 @@ def test_repo_prune(qapp, qtbot, mocker, borg_json_output): qtbot.waitUntil(lambda: 'Refreshing archives done.' in main.progressText.text(), **pytest._wait_defaults) -def test_repo_compact(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab +def test_repo_compact(qapp, qtbot, mocker, borg_json_output, archive_env): vorta.utils.borg_compat.version = '1.2.0' - main.tabWidget.setCurrentIndex(3) - tab.populate_from_profile() + main, tab = archive_env + stdout, stderr = borg_json_output('compact') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) @@ -82,11 +75,8 @@ def test_repo_compact(qapp, qtbot, mocker, borg_json_output): vorta.utils.borg_compat.version = '1.1.0' -def test_check(qapp, mocker, borg_json_output, qtbot): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - tab.populate_from_profile() +def test_check(qapp, mocker, borg_json_output, qtbot, archive_env): + main, tab = archive_env stdout, stderr = borg_json_output('check') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) @@ -97,17 +87,13 @@ def test_check(qapp, mocker, borg_json_output, qtbot): qtbot.waitUntil(lambda: success_text in main.logText.text(), **pytest._wait_defaults) -def test_mount(qapp, qtbot, mocker, borg_json_output, monkeypatch, choose_file_dialog): +def test_mount(qapp, qtbot, mocker, borg_json_output, monkeypatch, choose_file_dialog, archive_env): def psutil_disk_partitions(**kwargs): DiskPartitions = namedtuple('DiskPartitions', ['device', 'mountpoint']) return [DiskPartitions('borgfs', '/tmp')] monkeypatch.setattr(psutil, "disk_partitions", psutil_disk_partitions) - - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - tab.populate_from_profile() + main, tab = archive_env tab.archiveTable.selectRow(0) stdout, stderr = borg_json_output('prune') # TODO: fully mock mount command? @@ -129,14 +115,8 @@ def psutil_disk_partitions(**kwargs): qtbot.waitUntil(lambda: tab.mountErrors.text().startswith('Un-mounted successfully.'), **pytest._wait_defaults) -def test_archive_extract(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - - tab.populate_from_profile() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) - +def test_archive_extract(qapp, qtbot, mocker, borg_json_output, archive_env): + main, tab = archive_env tab.archiveTable.selectRow(0) stdout, stderr = borg_json_output('list_archive') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) @@ -144,20 +124,14 @@ def test_archive_extract(qapp, qtbot, mocker, borg_json_output): tab.extract_action() qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults) - # qtbot.waitUntil(lambda: tab._window == qapp.activeWindow(), **pytest._wait_defaults) model = tab._window.model assert model.root.children[0].subpath == 'home' assert 'test-archive, 2000' in tab._window.archiveNameLabel.text() -def test_archive_delete(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - - tab.populate_from_profile() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) +def test_archive_delete(qapp, qtbot, mocker, borg_json_output, archive_env): + main, tab = archive_env tab.archiveTable.selectRow(0) stdout, stderr = borg_json_output('delete') @@ -170,13 +144,43 @@ def test_archive_delete(qapp, qtbot, mocker, borg_json_output): assert tab.archiveTable.rowCount() == 1 -def test_archive_rename(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) +def test_archive_copy(qapp, qtbot, monkeypatch, mocker, archive_env): + main, tab = archive_env + + # mock the clipboard to ensure no changes are made to it during testing + mocker.patch.object(qapp.clipboard(), "setMimeData") + clipboard_spy = mocker.spy(qapp.clipboard(), "setMimeData") + + # test 'archive_copy()' by passing it an index to copy + index = tab.archiveTable.model().index(0, 0) + tab.archive_copy(index) + assert clipboard_spy.call_count == 1 + actual_data = clipboard_spy.call_args[0][0] # retrieves the QMimeData() object used in method call + assert actual_data.text() == "test-archive" + + # test 'archive_copy()' by selecting a row to copy + tab.archiveTable.selectRow(1) + tab.archive_copy() + assert clipboard_spy.call_count == 2 + actual_data = clipboard_spy.call_args[0][0] # retrieves the QMimeData() object used in method call + assert actual_data.text() == "test-archive1" + + +def test_refresh_archive_info(qapp, qtbot, mocker, borg_json_output, archive_env): + main, tab = archive_env + tab.archiveTable.selectRow(0) + stdout, stderr = borg_json_output('info') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + with qtbot.waitSignal(tab.bRefreshArchive.clicked, timeout=5000): + qtbot.mouseClick(tab.bRefreshArchive, QtCore.Qt.MouseButton.LeftButton) + + qtbot.waitUntil(lambda: tab.mountErrors.text() == 'Refreshed archives.', **pytest._wait_defaults) + - tab.populate_from_profile() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) +def test_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): + main, tab = archive_env tab.archiveTable.selectRow(0) new_archive_name = 'idf89d8f9d8fd98' diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 7db44b2e7..191c575ab 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -18,13 +18,8 @@ @pytest.mark.parametrize( 'json_mock_file,folder_root', [('diff_archives', 'test'), ('diff_archives_dict_issue', 'Users')] ) -def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, folder_root): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - - tab.populate_from_profile() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) +def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, folder_root, archive_env): + main, tab = archive_env stdout, stderr = borg_json_output(json_mock_file) popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) From e85ec38c65f838a1c42dc3d5afdcb3dccb9802f4 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Thu, 17 Aug 2023 03:06:36 -0700 Subject: [PATCH 18/52] Add diff tests. By @bigtedde (#1770) --- tests/unit/test_diff.py | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 191c575ab..91c1e1cd4 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -404,3 +404,59 @@ def test_archive_diff_json_parser(line, expected): assert item.path == PurePath(expected[0]).parts assert item.data == DiffData(*expected[1:]) + + +def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output): + main = qapp.main_window + tab = main.archiveTab + main.tabWidget.setCurrentIndex(3) + + tab.populate_from_profile() + qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) + + stdout, stderr = borg_json_output("diff_archives") + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + compat = vorta.utils.borg_compat + + def check(feature_name): + if feature_name == 'DIFF_JSON_LINES': + return False + return vorta.utils.BorgCompatibility.check(compat, feature_name) + + mocker.patch.object(vorta.utils.borg_compat, 'check', check) + + selection_model: QItemSelectionModel = tab.archiveTable.selectionModel() + model = tab.archiveTable.model() + + flags = QItemSelectionModel.SelectionFlag.Rows + flags |= QItemSelectionModel.SelectionFlag.Select + + selection_model.select(model.index(0, 0), flags) + selection_model.select(model.index(1, 0), flags) + + tab.diff_action() + + qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults) + + # mock the clipboard to ensure no changes are made to it during testing + mocker.patch.object(qapp.clipboard(), "setMimeData") + clipboard_spy = mocker.spy(qapp.clipboard(), "setMimeData") + + # test 'diff_item_copy()' by passing it an item to copy + index = tab._resultwindow.treeView.model().index(0, 0) + assert index is not None + tab._resultwindow.diff_item_copy(index) + clipboard_data = clipboard_spy.call_args[0][0] + assert clipboard_data.hasText() + assert clipboard_data.text() == "/test" + + clipboard_spy.reset_mock() + + # test 'diff_item_copy()' by selecting a row to copy + tab._resultwindow.treeView.selectionModel().select(tab._resultwindow.treeView.model().index(0, 0), flags) + tab._resultwindow.diff_item_copy() + clipboard_data = clipboard_spy.call_args[0][0] + assert clipboard_data.hasText() + assert clipboard_data.text() == "/test" From 81920ea3f03f450fa82bb5780dc720528b128212 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Thu, 17 Aug 2023 03:08:03 -0700 Subject: [PATCH 19/52] Repo test improvements. By @bigtedde (#1771) --- src/vorta/views/partials/password_input.py | 4 +- src/vorta/views/repo_add_dialog.py | 2 +- tests/unit/test_password_input.py | 6 +- tests/unit/test_repo.py | 100 +++++++++++++-------- 4 files changed, 67 insertions(+), 45 deletions(-) diff --git a/src/vorta/views/partials/password_input.py b/src/vorta/views/partials/password_input.py index bc8b0dd4c..ce10f30f1 100644 --- a/src/vorta/views/partials/password_input.py +++ b/src/vorta/views/partials/password_input.py @@ -138,7 +138,7 @@ def validate(self) -> bool: self.passwordLineEdit.error_state = True self.confirmLineEdit.error_state = True self.set_error_label( - translate('PasswordInput', "Passwords must be identical and atleast {0} characters long.").format( + translate('PasswordInput', "Passwords must be identical and at least {0} characters long.").format( self._minimum_length ) ) @@ -148,7 +148,7 @@ def validate(self) -> bool: elif not pass_long: self.passwordLineEdit.error_state = True self.set_error_label( - translate('PasswordInput', "Passwords must be atleast {0} characters long.").format( + translate('PasswordInput', "Passwords must be at least {0} characters long.").format( self._minimum_length ) ) diff --git a/src/vorta/views/repo_add_dialog.py b/src/vorta/views/repo_add_dialog.py index 38cc65e25..4a839dfaf 100644 --- a/src/vorta/views/repo_add_dialog.py +++ b/src/vorta/views/repo_add_dialog.py @@ -107,7 +107,7 @@ def validate(self): return False if len(self.values['repo_name']) > 64: - self._set_status(self.tr('Repository name must be less than 64 characters.')) + self._set_status(self.tr('Repository name must be less than 65 characters.')) return False if RepoModel.get_or_none(RepoModel.url == self.values['repo_url']) is not None: diff --git a/tests/unit/test_password_input.py b/tests/unit/test_password_input.py index 429b99e41..218a1dac4 100644 --- a/tests/unit/test_password_input.py +++ b/tests/unit/test_password_input.py @@ -86,7 +86,7 @@ def test_password_input_validation(qapp, qtbot): qtbot.keyClicks(password_input.confirmLineEdit, "123456789") assert password_input.passwordLineEdit.error_state - assert password_input.validation_label.text() == "Passwords must be atleast 10 characters long." + assert password_input.validation_label.text() == "Passwords must be at least 10 characters long." password_input.clear() qtbot.keyClicks(password_input.passwordLineEdit, "123456789") @@ -94,7 +94,7 @@ def test_password_input_validation(qapp, qtbot): assert password_input.passwordLineEdit.error_state assert password_input.confirmLineEdit.error_state - assert password_input.validation_label.text() == "Passwords must be identical and atleast 10 characters long." + assert password_input.validation_label.text() == "Passwords must be identical and at least 10 characters long." password_input.clear() qtbot.keyClicks(password_input.passwordLineEdit, "1234567890") @@ -130,7 +130,7 @@ def test_password_input_validation_disabled(qapp, qtbot): assert password_input.passwordLineEdit.error_state assert password_input.confirmLineEdit.error_state - assert password_input.validation_label.text() == "Passwords must be identical and atleast 9 characters long." + assert password_input.validation_label.text() == "Passwords must be identical and at least 9 characters long." password_input.set_validation_enabled(False) assert not password_input.passwordLineEdit.error_state diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index 3e13084d7..c20f4be84 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -12,43 +12,49 @@ SHORT_PASSWORD = 'hunter2' -def test_repo_add_failures(qapp, qtbot, mocker, borg_json_output): +@pytest.mark.parametrize( + "first_password, second_password, validation_error", + [ + (SHORT_PASSWORD, SHORT_PASSWORD, 'Passwords must be at least 9 characters long.'), + (LONG_PASSWORD, SHORT_PASSWORD, 'Passwords must be identical.'), + (SHORT_PASSWORD + "1", SHORT_PASSWORD, 'Passwords must be identical and at least 9 characters long.'), + (LONG_PASSWORD, LONG_PASSWORD, ''), # no error, password meets requirements. + ], +) +def test_new_repo_password_validation(qapp, qtbot, borg_json_output, first_password, second_password, validation_error): # Add new repo window main = qapp.main_window - main.repoTab.new_repo() - add_repo_window = main.repoTab._window + tab = main.repoTab + tab.new_repo() + add_repo_window = tab._window qtbot.addWidget(add_repo_window) - qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.repoURL, 'aaa') - qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.errorText.text().startswith('Please enter a valid') - - add_repo_window.passwordInput.passwordLineEdit.clear() - add_repo_window.passwordInput.confirmLineEdit.clear() - qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, SHORT_PASSWORD) - qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) - qtbot.keyClicks(add_repo_window.repoURL, 'bbb.com:repo') - qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.passwordInput.validation_label.text() == 'Passwords must be atleast 9 characters long.' - - add_repo_window.passwordInput.passwordLineEdit.clear() - add_repo_window.passwordInput.confirmLineEdit.clear() - qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, SHORT_PASSWORD + "1") - qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, first_password) + qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, second_password) qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert ( - add_repo_window.passwordInput.validation_label.text() - == 'Passwords must be identical and atleast 9 characters long.' - ) + assert add_repo_window.passwordInput.validation_label.text() == validation_error + + +@pytest.mark.parametrize( + "repo_name, error_text", + [ + ('test_repo_name', ''), # valid repo name + ('a' * 64, ''), # also valid (<=64 characters) + ('a' * 65, 'Repository name must be less than 65 characters.'), # not valid (>64 characters) + ], +) +def test_repo_add_name_validation(qapp, qtbot, borg_json_output, repo_name, error_text): + main = qapp.main_window + tab = main.repoTab + tab.new_repo() + add_repo_window = tab._window + test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain + qtbot.addWidget(add_repo_window) - add_repo_window.passwordInput.passwordLineEdit.clear() - add_repo_window.passwordInput.confirmLineEdit.clear() - qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) + qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) + qtbot.keyClicks(add_repo_window.repoName, repo_name) qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.passwordInput.validation_label.text() == 'Passwords must be identical.' + assert add_repo_window.errorText.text() == error_text def test_repo_unlink(qapp, qtbot, monkeypatch): @@ -56,7 +62,6 @@ def test_repo_unlink(qapp, qtbot, monkeypatch): tab = main.repoTab monkeypatch.setattr(QMessageBox, "show", lambda *args: True) - main.tabWidget.setCurrentIndex(0) qtbot.mouseClick(tab.repoRemoveToolbutton, QtCore.Qt.MouseButton.LeftButton) qtbot.waitUntil(lambda: tab.repoSelector.count() == 1, **pytest._wait_defaults) assert RepoModel.select().count() == 0 @@ -69,8 +74,9 @@ def test_repo_unlink(qapp, qtbot, monkeypatch): def test_password_autofill(qapp, qtbot): main = qapp.main_window - main.repoTab.new_repo() # couldn't click menu - add_repo_window = main.repoTab._window + tab = main.repoTab + tab.new_repo() + add_repo_window = tab._window test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain keyring = VortaKeyring.get_keyring() @@ -82,14 +88,28 @@ def test_password_autofill(qapp, qtbot): assert add_repo_window.passwordInput.passwordLineEdit.text() == password +def test_repo_add_failure(qapp, qtbot, borg_json_output): + main = qapp.main_window + tab = main.repoTab + tab.new_repo() + add_repo_window = tab._window + qtbot.addWidget(add_repo_window) + + # Add repo with invalid URL + qtbot.keyClicks(add_repo_window.repoURL, 'aaa') + qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) + assert add_repo_window.errorText.text().startswith('Please enter a valid repo URL') + + def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): - # Add new repo window main = qapp.main_window - main.repoTab.new_repo() # couldn't click menu - add_repo_window = main.repoTab._window + tab = main.repoTab + tab.new_repo() + add_repo_window = tab._window test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain test_repo_name = 'Test Repo' + # Enter valid repo URL, name, and password qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) qtbot.keyClicks(add_repo_window.repoName, test_repo_name) qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) @@ -108,13 +128,15 @@ def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): keyring = VortaKeyring.get_keyring() assert keyring.get_password("vorta-repo", RepoModel.get(id=2).url) == LONG_PASSWORD - assert main.repoTab.repoSelector.currentText() == f"{test_repo_name} - {test_repo_url}" + assert tab.repoSelector.currentText() == f"{test_repo_name} - {test_repo_url}" def test_ssh_dialog(qapp, qtbot, tmpdir): main = qapp.main_window - qtbot.mouseClick(main.repoTab.bAddSSHKey, QtCore.Qt.MouseButton.LeftButton) - ssh_dialog = main.repoTab._window + tab = main.repoTab + + qtbot.mouseClick(tab.bAddSSHKey, QtCore.Qt.MouseButton.LeftButton) + ssh_dialog = tab._window ssh_dir = tmpdir key_tmpfile = ssh_dir.join("id_rsa-test") From 2caa09354173b7d30598c3109aad6e0de6b98bc3 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Thu, 17 Aug 2023 03:09:00 -0700 Subject: [PATCH 20/52] Source tab test improvements. By @bigtedde (#1772) --- src/vorta/views/source_tab.py | 1 + tests/unit/test_source.py | 92 ++++++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 13a48f5e4..68d9c3368 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -379,4 +379,5 @@ def paste_text(self): 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() diff --git a/tests/unit/test_source.py b/tests/unit/test_source.py index a4209576a..10fa0ed43 100644 --- a/tests/unit/test_source.py +++ b/tests/unit/test_source.py @@ -1,22 +1,102 @@ import pytest import vorta.views +from PyQt6 import QtCore +from PyQt6.QtWidgets import QMessageBox -def test_add_folder(qapp, qtbot, mocker, monkeypatch, choose_file_dialog): +@pytest.fixture() +def source_env(qapp, qtbot, monkeypatch, choose_file_dialog): + """ + Handles common setup and teardown for unit tests involving the source tab. + """ monkeypatch.setattr(vorta.views.source_tab, "choose_file_dialog", choose_file_dialog) main = qapp.main_window main.tabWidget.setCurrentIndex(1) tab = main.sourceTab + qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 1, timeout=2000) + yield main, tab + + # Wait for directory sizing to finish + qtbot.waitUntil(lambda: len(qapp.main_window.sourceTab.updateThreads) == 0, timeout=2000) + + +def test_source_add_remove(qapp, qtbot, monkeypatch, mocker, source_env): + """ + Tests adding and removing source to ensure expected behavior. + """ + main, tab = source_env + mocker.patch.object(QMessageBox, "exec") # prevent QMessageBox from stopping test + + # test adding a folder with os access + mocker.patch('os.access', return_value=True) tab.source_add(want_folder=True) qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 2, **pytest._wait_defaults) + assert tab.sourceFilesWidget.rowCount() == 2 + + # test adding a folder without os access + mocker.patch('os.access', return_value=False) + tab.source_add(want_folder=True) + assert tab.sourceFilesWidget.rowCount() == 2 + + # test removing a folder + tab.sourceFilesWidget.selectRow(1) + qtbot.mouseClick(tab.removeButton, QtCore.Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 1, **pytest._wait_defaults) + assert tab.sourceFilesWidget.rowCount() == 1 + - # Test paste button with mocked clipboard +@pytest.mark.parametrize( + "path, valid", + [ + (__file__, True), # valid path + ("test", False), # invalid path + (f"file://{__file__}", True), # valid - normal path with prefix that will be stripped + (f"file://{__file__}\n{__file__}", True), # valid - two files separated by new line + (f"file://{__file__}{__file__}", False), # invalid - no new line separating file names + ], +) +def test_valid_and_invalid_source_paths(qapp, qtbot, mocker, source_env, path, valid): + """ + Valid paths will be added as a source. + Invalid paths will trigger an alert and not be added as a source. + """ + main, tab = source_env mock_clipboard = mocker.Mock() - mock_clipboard.text.return_value = __file__ + mock_clipboard.text.return_value = path + mocker.patch.object(vorta.views.source_tab.QApplication, 'clipboard', return_value=mock_clipboard) + mocker.patch.object(QMessageBox, "exec") # prevent QMessageBox from stopping test tab.paste_text() - qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 3, **pytest._wait_defaults) - # Wait for directory sizing to finish - qtbot.waitUntil(lambda: len(qapp.main_window.sourceTab.updateThreads) == 0, **pytest._wait_defaults) + if valid: + assert not hasattr(tab, '_msg') + qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 2, **pytest._wait_defaults) + assert tab.sourceFilesWidget.rowCount() == 2 + else: + qtbot.waitUntil(lambda: hasattr(tab, "_msg"), **pytest._wait_defaults) + assert tab._msg.text().startswith("Some of your sources are invalid") + assert tab.sourceFilesWidget.rowCount() == 1 + + +def test_sources_update(qapp, qtbot, mocker, source_env): + """ + Tests the source update button in the source tab + """ + main, tab = source_env + update_path_info_spy = mocker.spy(tab, "update_path_info") + + # test that `update_path_info()` has been called for each source path + qtbot.mouseClick(tab.updateButton, QtCore.Qt.MouseButton.LeftButton) + assert tab.sourceFilesWidget.rowCount() == 1 + assert update_path_info_spy.call_count == 1 + + # add a new source and reset mock + tab.source_add(want_folder=True) + qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 2, **pytest._wait_defaults) + update_path_info_spy.reset_mock() + + # retest that `update_path_info()` has been called for each source path + qtbot.mouseClick(tab.updateButton, QtCore.Qt.MouseButton.LeftButton) + assert tab.sourceFilesWidget.rowCount() == 2 + assert update_path_info_spy.call_count == 2 From 3bfa78bf040295b4293406d4597dc321efe20fd9 Mon Sep 17 00:00:00 2001 From: jetchirag Date: Thu, 17 Aug 2023 22:19:17 +0530 Subject: [PATCH 21/52] Fix health indicator always being green in extract view. Fixes #1776. Now the indicator is red for unhealthy files. * src/vorta/views/extract_dialog.py (ExtractTree.data): Set red instead of green colour for unhealthy files. --- src/vorta/views/extract_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py index 0822d0670..d7781370b 100644 --- a/src/vorta/views/extract_dialog.py +++ b/src/vorta/views/extract_dialog.py @@ -493,7 +493,7 @@ def data(self, index: QModelIndex, role: Union[int, Qt.ItemDataRole] = Qt.ItemDa if item.data.health: return QColor(Qt.GlobalColor.green) if uses_dark_mode() else QColor(Qt.GlobalColor.darkGreen) else: - return QColor(Qt.GlobalColor.green) if uses_dark_mode() else QColor(Qt.GlobalColor.darkGreen) + return QColor(Qt.GlobalColor.red) if uses_dark_mode() else QColor(Qt.GlobalColor.darkRed) if role == Qt.ItemDataRole.ToolTipRole: if column == 0: From e5c9b2245a1d84321f684f10a2e756c3bd693b00 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Thu, 17 Aug 2023 10:05:42 -0700 Subject: [PATCH 22/52] Improve import/export feature test coverage. By @bigtedde (#1774) --- tests/unit/test_import_export.py | 38 +++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_import_export.py b/tests/unit/test_import_export.py index 47faef97a..d7987ab75 100644 --- a/tests/unit/test_import_export.py +++ b/tests/unit/test_import_export.py @@ -4,6 +4,7 @@ import pytest from PyQt6 import QtCore from PyQt6.QtWidgets import QDialogButtonBox, QFileDialog, QMessageBox +from vorta.profile_export import VersionException from vorta.store.models import BackupProfileModel, SourceFileModel from vorta.views.import_window import ImportWindow @@ -32,6 +33,41 @@ def test_import_success(qapp, qtbot, rootdir, monkeypatch): assert len(SourceFileModel.select().where(SourceFileModel.profile == restored_profile)) == 3 +@pytest.mark.parametrize( + "exception, error_message", + [ + (AttributeError, "Schema upgrade failure"), + (VersionException, "Newer profile_export export files cannot be used on older versions"), + (PermissionError, "Cannot read profile_export export file due to permission error"), + (FileNotFoundError, "Profile export file not found"), + ], +) +def test_import_exceptions(qapp, qtbot, rootdir, monkeypatch, mocker, exception, error_message): + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args: [VALID_IMPORT_FILE]) + monkeypatch.setattr(QMessageBox, 'information', lambda *args: None) + + main = qapp.main_window + main.profile_import_action() + import_dialog: ImportWindow = main.window + import_dialog.overwriteExistingSettings.setChecked(True) + + def raise_exception(*args, **kwargs): + raise exception + + # force an exception and mock the error QMessageBox + monkeypatch.setattr(import_dialog.profile_export, 'to_db', raise_exception) + mock_messagebox = mocker.patch.object(QMessageBox, "critical") + + qtbot.mouseClick( + import_dialog.buttonBox.button(QDialogButtonBox.StandardButton.Ok), QtCore.Qt.MouseButton.LeftButton + ) + + # assert the correct error appears, and the profile does not get added + mock_messagebox.assert_called_once() + assert error_message in mock_messagebox.call_args[0][2] + assert BackupProfileModel.get_or_none(name="Test Profile Restoration") is None + + def test_import_bootstrap_success(qapp, mocker): mocked_unlink = mocker.MagicMock() mocker.patch.object(Path, 'unlink', mocked_unlink) @@ -92,7 +128,7 @@ def getSaveFileName(*args, **kwargs): assert os.path.isfile(FILE_PATH) -def test_export_fail_unwritable(qapp, qtbot, tmpdir, monkeypatch): +def test_export_fail_unwritable(qapp, qtbot, monkeypatch): FILE_PATH = os.path.join(os.path.abspath(os.sep), "testresult.vortabackup") def getSaveFileName(*args, **kwargs): From 567a3546ae09878cf64f9f10c5c6f7bb7254720e Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Mon, 21 Aug 2023 12:31:51 -0700 Subject: [PATCH 23/52] Reduce number of tests. By @bigtedde (#1780) --- .github/scripts/generate-matrix.sh | 30 +++++++++++++++++++ .github/workflows/test.yml | 48 +++++++++++++++++++----------- 2 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/generate-matrix.sh diff --git a/.github/scripts/generate-matrix.sh b/.github/scripts/generate-matrix.sh new file mode 100644 index 000000000..3f6695611 --- /dev/null +++ b/.github/scripts/generate-matrix.sh @@ -0,0 +1,30 @@ +event_name="$1" +branch_name="$2" + +if [[ "$event_name" == "workflow_dispatch" ]] || [[ "$branch_name" == "master" ]]; then + echo '{ + "python-version": ["3.8", "3.9", "3.10", "3.11"], + "os": ["ubuntu-latest", "macos-latest"], + "borg-version": ["1.2.4"] + }' | jq -c . > matrix-unit.json + + echo '{ + "python-version": ["3.8", "3.9", "3.10", "3.11"], + "os": ["ubuntu-latest", "macos-latest"], + "borg-version": ["1.1.18", "1.2.2", "1.2.4", "2.0.0b5"], + "exclude": [{"borg-version": "2.0.0b5", "python-version": "3.8"}] + }' | jq -c . > matrix-integration.json + +elif [[ "$event_name" == "push" ]] || [[ "$event_name" == "pull_request" ]]; then + echo '{ + "python-version": ["3.8", "3.9", "3.10", "3.11"], + "os": ["ubuntu-latest", "macos-latest"], + "borg-version": ["1.2.4"] + }' | jq -c . > matrix-unit.json + + echo '{ + "python-version": ["3.10"], + "os": ["ubuntu-latest"], + "borg-version": ["1.2.4"] + }' | jq -c . > matrix-integration.json +fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19175959a..a533eaf86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,16 +26,36 @@ jobs: shell: bash run: make lint + prepare-matrix: + runs-on: ubuntu-latest + outputs: + matrix-unit: ${{ steps.set-matrix-unit.outputs.matrix }} + matrix-integration: ${{ steps.set-matrix-integration.outputs.matrix }} + steps: + - uses: actions/checkout@v3 + + - name: Give execute permission to script + run: chmod +x ./.github/scripts/generate-matrix.sh + + - name: Generate matrices + run: | + ./.github/scripts/generate-matrix.sh "${{ github.event_name }}" "${GITHUB_REF##refs/heads/}" + + - name: Set matrix for unit tests + id: set-matrix-unit + run: echo "matrix=$(cat matrix-unit.json)" >> $GITHUB_OUTPUT + + - name: Set matrix for integration tests + id: set-matrix-integration + run: echo "matrix=$(cat matrix-integration.json)" >> $GITHUB_OUTPUT + test-unit: + needs: prepare-matrix timeout-minutes: 20 runs-on: ${{ matrix.os }} strategy: fail-fast: false - - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - os: [ubuntu-latest, macos-latest] - borg-version: ["1.2.4"] + matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix-unit)}} steps: - uses: actions/checkout@v3 @@ -51,7 +71,7 @@ jobs: - name: Setup tmate session uses: mxschmitt/action-tmate@v3 - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} - name: Run Unit Tests with pytest (Linux) if: runner.os == 'Linux' @@ -59,7 +79,7 @@ jobs: BORG_VERSION: ${{ matrix.borg-version }} run: | xvfb-run --server-args="-screen 0 1024x768x24+32" \ - -a dbus-run-session -- make test-unit + -a dbus-run-session -- make test-unit - name: Run Unit Tests with pytest (macOS) if: runner.os == 'macOS' @@ -78,18 +98,12 @@ jobs: env_vars: OS, python test-integration: + needs: prepare-matrix timeout-minutes: 20 runs-on: ${{ matrix.os }} strategy: fail-fast: false - - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - os: [ubuntu-latest, macos-latest] - borg-version: ["1.1.18", "1.2.2", "1.2.4", "2.0.0b5"] - exclude: - - borg-version: "2.0.0b5" - python-version: "3.8" + matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix-integration)}} steps: - uses: actions/checkout@v3 @@ -105,7 +119,7 @@ jobs: - name: Setup tmate session uses: mxschmitt/action-tmate@v3 - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true'}} - name: Run Integration Tests with pytest (Linux) if: runner.os == 'Linux' @@ -113,7 +127,7 @@ jobs: BORG_VERSION: ${{ matrix.borg-version }} run: | xvfb-run --server-args="-screen 0 1024x768x24+32" \ - -a dbus-run-session -- make test-integration + -a dbus-run-session -- make test-integration - name: Run Integration Tests with pytest (macOS) if: runner.os == 'macOS' From 8c82c4069dea95d95376d23e0e3a43431e957052 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 6 Sep 2023 13:18:55 +0530 Subject: [PATCH 24/52] Changed title of adding `new repo` and `existing repo`. By @SAMAD101 (#1810) --- src/vorta/assets/UI/repoadd.ui | 3 +++ src/vorta/views/repo_add_dialog.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/vorta/assets/UI/repoadd.ui b/src/vorta/assets/UI/repoadd.ui index 730f1df08..b11c1482e 100644 --- a/src/vorta/assets/UI/repoadd.ui +++ b/src/vorta/assets/UI/repoadd.ui @@ -5,6 +5,9 @@ true + + Add Repository + 0 diff --git a/src/vorta/views/repo_add_dialog.py b/src/vorta/views/repo_add_dialog.py index 4a839dfaf..cd265a333 100644 --- a/src/vorta/views/repo_add_dialog.py +++ b/src/vorta/views/repo_add_dialog.py @@ -131,6 +131,7 @@ def values(self): class AddRepoWindow(RepoWindow): def __init__(self, parent=None): super().__init__(parent) + self.setWindowTitle("Add New Repository") self.passwordInput = PasswordInput() self.passwordInput.add_form_to_layout(self.repoDataFormLayout) @@ -226,6 +227,7 @@ class ExistingRepoWindow(RepoWindow): def __init__(self): super().__init__() self.title.setText(self.tr('Connect to existing Repository')) + self.setWindowTitle("Add Existing Repository") self.passwordLabel = QLabel(self.tr('Password:')) self.passwordInput = PasswordLineEdit() From 7d2b3634f10fe0ba89e2fac46065d2c6a13cfa52 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 9 Sep 2023 12:48:09 +0530 Subject: [PATCH 25/52] Changed label and tooltip for startup setting of Vorta (#1804) This makes the setting easier to comprehend and removes ambiguity between the autostart setting. * modifed foreground label and tooltip --- src/vorta/store/settings.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vorta/store/settings.py b/src/vorta/store/settings.py index 40324e9b7..6bac5f6a9 100644 --- a/src/vorta/store/settings.py +++ b/src/vorta/store/settings.py @@ -48,8 +48,11 @@ def get_misc_settings() -> List[Dict[str, str]]: 'value': True, 'type': 'checkbox', 'group': startup, - 'label': trans_late('settings', 'Open main window on startup'), - 'tooltip': trans_late('settings', 'Open main window when the application is launched'), + 'label': trans_late('settings', 'Show main window of Vorta on launch'), + 'tooltip': trans_late( + 'settings', + 'Make Vorta appear on screen instead of minimizing to system tray', + ), }, { 'key': 'get_srcpath_datasize', From 4350f78de53f4bec26f9398cf0eab3d3e30ee427 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Mon, 11 Sep 2023 12:29:07 -0700 Subject: [PATCH 26/52] Prevent borg operation while renaming. By @bigtedde (#1791) --- src/vorta/views/archive_tab.py | 3 +++ tests/unit/test_archives.py | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index b18401f7a..a6690be28 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -825,6 +825,9 @@ def extract_archive_result(self, result): 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() diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index c17bf6249..d965cd02e 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -179,7 +179,10 @@ def test_refresh_archive_info(qapp, qtbot, mocker, borg_json_output, archive_env qtbot.waitUntil(lambda: tab.mountErrors.text() == 'Refreshed archives.', **pytest._wait_defaults) -def test_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): +def test_inline_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): + """ + Tests the functionality of in-line renaming an archive by double-clicking its name. + """ main, tab = archive_env tab.archiveTable.selectRow(0) @@ -190,9 +193,12 @@ def test_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_env): pos = tab.archiveTable.visualRect(tab.archiveTable.model().index(0, 4)).center() qtbot.mouseClick(tab.archiveTable.viewport(), QtCore.Qt.MouseButton.LeftButton, pos=pos) + assert tab.bRename.isEnabled() qtbot.mouseDClick(tab.archiveTable.viewport(), QtCore.Qt.MouseButton.LeftButton, pos=pos) + tab.archiveTable.viewport().focusWidget().setText("") qtbot.keyClicks(tab.archiveTable.viewport().focusWidget(), new_archive_name) qtbot.keyClick(tab.archiveTable.viewport().focusWidget(), QtCore.Qt.Key.Key_Return) # Successful rename case qtbot.waitUntil(lambda: tab.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) + assert tab.archiveTable.model().index(0, 4).data() == new_archive_name From 3573bdbc78b5e582507794b7352ae71d3eefc703 Mon Sep 17 00:00:00 2001 From: Pradyot Ranjan <99216956+prady0t@users.noreply.github.com> Date: Tue, 19 Sep 2023 14:32:47 +0530 Subject: [PATCH 27/52] Minor: README type. By @prady0t (#1822) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8111899a8..59ddbf9fc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Learn more on [Vorta's website](https://vorta.borgbase.com). ## Installation Vorta should work on all platforms that support Qt and Borg. This includes macOS, Ubuntu, Debian, Fedora, Arch Linux and many others. Windows is currently not supported by Borg, but this may change in the future. -See our website for [download links and and install instructions](https://vorta.borgbase.com/install). +See our website for [download links and install instructions](https://vorta.borgbase.com/install). ## Connect and Contribute - To discuss everything around using, improving, packaging and translating Vorta, join the [discussion on Github](https://github.com/borgbase/vorta/discussions). From 43140beda12315f7527fa7e52e55b4882b263c41 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Tue, 19 Sep 2023 03:09:55 -0700 Subject: [PATCH 28/52] Refactor archive context menu. By @bigtedde (#1793) --- src/vorta/assets/UI/archivetab.ui | 102 +++++++++++++++--------------- src/vorta/views/archive_tab.py | 57 +++++------------ 2 files changed, 68 insertions(+), 91 deletions(-) diff --git a/src/vorta/assets/UI/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index 6c206033f..9c81d49f2 100644 --- a/src/vorta/assets/UI/archivetab.ui +++ b/src/vorta/assets/UI/archivetab.ui @@ -219,6 +219,25 @@ + + + + + 0 + 0 + + + + Compare two archives + + + Diff + + + Qt::ToolButtonTextBesideIcon + + + @@ -276,60 +295,41 @@ + + + + + 0 + 0 + + + + Delete selected archive(s) + + + Delete + + + Qt::ToolButtonTextBesideIcon + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 0 - 0 - - - - Compare two archives - - - Diff - - - Qt::ToolButtonTextBesideIcon - - - - - - - - 0 - 0 - - - - Delete selected archive(s) - - - Delete - - - Qt::ToolButtonTextBesideIcon - - - diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index a6690be28..cb4d41843 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -5,7 +5,7 @@ from PyQt6 import QtCore, uic from PyQt6.QtCore import QItemSelectionModel, QMimeData, QPoint, Qt, pyqtSlot -from PyQt6.QtGui import QAction, QDesktopServices, QKeySequence, QShortcut +from PyQt6.QtGui import QDesktopServices, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, @@ -154,7 +154,7 @@ def __init__(self, parent=None, app=None): self.app.paletteChanged.connect(lambda p: self.set_icons()) def set_icons(self): - "Used when changing between light- and dark mode" + """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')) @@ -183,46 +183,22 @@ def archiveitem_contextmenu(self, pos: QPoint): 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.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.archive_copy(index=index)) menu.addSeparator() # archive actions - archive_actions = [] - archive_actions.append( - menu.addAction( - self.bRefreshArchive.icon(), - self.bRefreshArchive.text(), - self.refresh_archive_info, - ) - ) - archive_actions.append( - menu.addAction( - self.bMountArchive.icon(), - self.bMountArchive.text(), - self.bmountarchive_clicked, - ) - ) - archive_actions.append(menu.addAction(self.bExtract.icon(), self.bExtract.text(), self.extract_action)) - archive_actions.append(menu.addAction(self.bRename.icon(), self.bRename.text(), self.cell_double_clicked)) - # deletion possible with one but also multiple archives - menu.addAction(self.bDelete.icon(), self.bDelete.text(), self.delete_action) - - if not (self.repoactions_enabled and len(selected_rows) <= 1): - for action in archive_actions: - action.setEnabled(False) - - # diff action - menu.addSeparator() - diff_action = QAction(self.bDiff.icon(), self.bDiff.text(), menu) - diff_action.triggered.connect(self.diff_action) - menu.addAction(diff_action) - - selected_rows = self.archiveTable.selectionModel().selectedRows(index.column()) - diff_action.setEnabled(self.repoactions_enabled and len(selected_rows) == 2) + 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)) @@ -406,7 +382,8 @@ def on_selection_change(self, selected=None, deselected=None): for index in range(layout.count()): widget = layout.itemAt(index).widget() - widget.setToolTip(self.tooltip_dict.get(widget, "")) + if widget is not None: + widget.setToolTip(self.tooltip_dict.get(widget, "")) # refresh bMountArchive for the selected archive self.bmountarchive_refresh() From cff00ad8e1346f950fdf2c50dc7bd2ee4d69757c Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Mon, 25 Sep 2023 14:51:17 -0700 Subject: [PATCH 29/52] Add '.log' suffix to log files. By @bigtedde (#1710) --- src/vorta/log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vorta/log.py b/src/vorta/log.py index 7251c335e..0c87e72db 100644 --- a/src/vorta/log.py +++ b/src/vorta/log.py @@ -24,6 +24,8 @@ def init_logger(background=False): # create handlers fh = TimedRotatingFileHandler(config.LOG_DIR / 'vorta.log', when='d', interval=1, backupCount=5) + # ensure ".log" suffix + fh.namer = lambda log_name: log_name.replace(".log", "") + ".log" fh.setLevel(logging.DEBUG) fh.setFormatter(formatter) logger.addHandler(fh) From 15fa46ff856204c6c1c829a2fd6e608c3c0a0656 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Tue, 26 Sep 2023 06:22:54 -0700 Subject: [PATCH 30/52] Improve SSH key process. By @bigtedde (#1802) --- src/vorta/views/repo_tab.py | 12 ++++++++--- src/vorta/views/ssh_dialog.py | 16 +++++++++------ tests/unit/test_repo.py | 38 ++++++++++++++++++++++++++++++----- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 96b614b02..3b04ffa23 100644 --- a/src/vorta/views/repo_tab.py +++ b/src/vorta/views/repo_tab.py @@ -198,10 +198,17 @@ def create_ssh_key(self): ssh_add_window = SSHAddWindow() self._window = ssh_add_window # For tests ssh_add_window.setParent(self, QtCore.Qt.WindowType.Sheet) - ssh_add_window.accepted.connect(self.init_ssh) - # ssh_add_window.rejected.connect(lambda: self.sshComboBox.setCurrentIndex(0)) + ssh_add_window.rejected.connect(self.init_ssh) + ssh_add_window.failure.connect(self.create_ssh_key_failure) ssh_add_window.open() + def create_ssh_key_failure(self, exit_code): + msg = QMessageBox() + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + msg.setParent(self, QtCore.Qt.WindowType.Sheet) + msg.setText(self.tr(f'Error during key generation. Exited with code {exit_code}.')) + msg.show() + def ssh_copy_to_clipboard_action(self): msg = QMessageBox() msg.setStandardButtons(QMessageBox.StandardButton.Ok) @@ -223,7 +230,6 @@ def ssh_copy_to_clipboard_action(self): "Use it to set up remote repo permissions." ) ) - else: msg.setText(self.tr("Could not find public key.")) else: diff --git a/src/vorta/views/ssh_dialog.py b/src/vorta/views/ssh_dialog.py index ab3102840..baf1c4dba 100644 --- a/src/vorta/views/ssh_dialog.py +++ b/src/vorta/views/ssh_dialog.py @@ -1,7 +1,7 @@ import os -from PyQt6 import uic -from PyQt6.QtCore import QProcess, Qt +from PyQt6 import QtCore, uic +from PyQt6.QtCore import QProcess, Qt, pyqtSlot from PyQt6.QtWidgets import QApplication, QDialogButtonBox from ..utils import get_asset @@ -11,6 +11,8 @@ class SSHAddWindow(SSHAddBase, SSHAddUI): + failure = QtCore.pyqtSignal(int) + def __init__(self): super().__init__() self.setupUi(self) @@ -68,15 +70,17 @@ def generate_key(self): self.sshproc.finished.connect(self.generate_key_result) self.sshproc.start('ssh-keygen', ['-t', format, '-b', length, '-f', output_path, '-N', '']) - def generate_key_result(self, exitCode, exitStatus): - if exitCode == 0: + @pyqtSlot(int) + def generate_key_result(self, exit_code): + if exit_code == 0: output_path = os.path.expanduser(self.outputFileTextBox.text()) pub_key = open(output_path + '.pub').read().strip() clipboard = QApplication.clipboard() clipboard.setText(pub_key) - self.errors.setText(self.tr('New key was copied to clipboard and written to %s.') % output_path) + self.reject() else: - self.errors.setText(self.tr('Error during key generation.')) + self.reject() + self.failure.emit(exit_code) def get_values(self): return { diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index c20f4be84..8cbb29a3e 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -131,13 +131,13 @@ def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): assert tab.repoSelector.currentText() == f"{test_repo_name} - {test_repo_url}" -def test_ssh_dialog(qapp, qtbot, tmpdir): +def test_ssh_dialog_success(qapp, qtbot, mocker, tmpdir): main = qapp.main_window tab = main.repoTab qtbot.mouseClick(tab.bAddSSHKey, QtCore.Qt.MouseButton.LeftButton) ssh_dialog = tab._window - + ssh_dialog_closed = mocker.spy(ssh_dialog, 'reject') ssh_dir = tmpdir key_tmpfile = ssh_dir.join("id_rsa-test") pub_tmpfile = ssh_dir.join("id_rsa-test.pub") @@ -145,18 +145,46 @@ def test_ssh_dialog(qapp, qtbot, tmpdir): ssh_dialog.outputFileTextBox.setText(key_tmpfile_full) ssh_dialog.generate_key() - # Ensure new key files exist - qtbot.waitUntil(lambda: ssh_dialog.errors.text().startswith('New key was copied'), **pytest._wait_defaults) + # Ensure new key file was created + qtbot.waitUntil(lambda: ssh_dialog_closed.called, **pytest._wait_defaults) assert len(ssh_dir.listdir()) == 2 + # Ensure new key is populated in SSH combobox + mocker.patch('os.path.expanduser', return_value=str(tmpdir)) + tab.init_ssh() + assert tab.sshComboBox.count() == 2 + # Ensure valid keys were created key_tmpfile_content = key_tmpfile.read() assert key_tmpfile_content.startswith('-----BEGIN OPENSSH PRIVATE KEY-----') pub_tmpfile_content = pub_tmpfile.read() assert pub_tmpfile_content.startswith('ssh-ed25519') + +def test_ssh_dialog_failure(qapp, qtbot, mocker, monkeypatch, tmpdir): + main = qapp.main_window + tab = main.repoTab + monkeypatch.setattr(QMessageBox, "show", lambda *args: True) + failure_message = mocker.spy(tab, "create_ssh_key_failure") + + qtbot.mouseClick(tab.bAddSSHKey, QtCore.Qt.MouseButton.LeftButton) + ssh_dialog = tab._window + ssh_dir = tmpdir + key_tmpfile = ssh_dir.join("invalid///===for_testing") + key_tmpfile_full = os.path.join(key_tmpfile.dirname, key_tmpfile.basename) + ssh_dialog.outputFileTextBox.setText(key_tmpfile_full) ssh_dialog.generate_key() - qtbot.waitUntil(lambda: ssh_dialog.errors.text().startswith('Key file already'), **pytest._wait_defaults) + + qtbot.waitUntil(lambda: failure_message.called, **pytest._wait_defaults) + failure_message.assert_called_once() + + # Ensure no new ney file was created + assert len(ssh_dir.listdir()) == 0 + + # Ensure no new key file in combo box + mocker.patch('os.path.expanduser', return_value=str(tmpdir)) + tab.init_ssh() + assert tab.sshComboBox.count() == 1 def test_create(qapp, borg_json_output, mocker, qtbot): From c807f93faf15c52e95d4127d8b957f7c02c04bdc Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 27 Sep 2023 11:20:55 +0100 Subject: [PATCH 31/52] Bump version to v0.9.1-beta1 --- src/vorta/_version.py | 2 +- .../metadata/com.borgbase.Vorta.appdata.xml | 2 +- src/vorta/i18n/qm/vorta.cs.qm | Bin 57815 -> 57811 bytes src/vorta/i18n/qm/vorta.de.qm | Bin 59187 -> 59148 bytes src/vorta/i18n/qm/vorta.es.qm | Bin 53007 -> 59051 bytes src/vorta/i18n/qm/vorta.fi.qm | Bin 50964 -> 53593 bytes src/vorta/i18n/qm/vorta.nl.qm | Bin 57457 -> 57455 bytes src/vorta/i18n/qm/vorta.sk.qm | Bin 50877 -> 50875 bytes src/vorta/i18n/ts/vorta.de.ts | 501 ++++------ src/vorta/i18n/ts/vorta.es.ts | 907 ++++++++---------- src/vorta/i18n/ts/vorta.fi.ts | 583 +++++------ 11 files changed, 882 insertions(+), 1113 deletions(-) diff --git a/src/vorta/_version.py b/src/vorta/_version.py index e4e49b3bb..1500ff3ea 100644 --- a/src/vorta/_version.py +++ b/src/vorta/_version.py @@ -1 +1 @@ -__version__ = '0.9.0' +__version__ = '0.9.1-beta1' diff --git a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index 3823c5f21..8b6ac2789 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -40,7 +40,7 @@ - + https://github.com/borgbase/vorta/issues diff --git a/src/vorta/i18n/qm/vorta.cs.qm b/src/vorta/i18n/qm/vorta.cs.qm index 7168f26cda0ec42ffad784cc8fa4f3f50b06a784..a4153f88490390ecd17bba8c779ad232105d4d19 100644 GIT binary patch delta 1833 zcmX9<2~<=^7QNNK`)~cbk)SB*umrVHP{JfbM^Fel%Akn0q8vAfBMu1yilQciEGnX5 z7zGPZ)|hBxgTy5$E+Iw*1W`jq&=}1abzF|oi8(@;#4YpA>3>fDdw$icSNGj_tNPm( zp}R#W3iW6P=EMQ(egYC&0MDghRT)52N^z{kVcezkPcNb#RRSxYVTO(?s?5(GOkrQ!zb`0=d0NSjZfMe5eyYU$io{C3N+koSLvC2-JsIHPtiF`Ee4N9M45vZ6+JS~Sx=RoK3`8*9T9HTu8YuRTnn5ZBivY>MBK`Rd*0iCPnHNjcGKR*P~rIv z(xd9OURX?Z{4-E56wIUgT=a%c>YGEy*XtA5X#Tx^1!$xI8yw+D3ssc~XN**GdZSDgO%Yhc`2ac1rnV3$Fh9o$Ohnj|jh zF91Gh78kNXz~NMJQ5%_V$aQhu28wqn7qbS@=Cymo@+GH$SrOvy?Svh>MSI9YV1wqw zU8_WgYXu4SO!Q$se7Iz>{Va{FryXKCq<|u_Q(c1ePRZJ!jK76Tl3LyBppleNxM zN=QfpPV`HugK4}oSla5+N6hq6N!$ovRj*X)P4gc=;w6&RCtm7;85r?Ux|~cR2X{-C zPkRBy<>_unBCtZbJLPP|8kp2G`eYfi0vKf4@I+?`mFFFy*s z1zg-HzqmxK%maK=U5clyd_PQOiH$dCI7qT`!f#fAa+PZcu(( zK>M9ZSA|ScGNVrwuBt$Ozba*$$!>R5w+j>}eX0&SOX+v6c4CuB^|RE_I#<;{u#q(O zP-lN}6YzdropYd`91)=|)u_%=tD2Z}7f3&;CRLLU^4isuk2u^=eonP_(m>6-s-v=q z?!gm$f@)!_d7^4jwiq*>pQk%;7%x#R^V5yB%i@98{f!N!qbV}mcxdY>AhyWZE_hH0 zzQ&Fjlu5pB{P(M0fTud2~pF{qm7mbhjwddFUj7aJzGpN3@g%}Z@CLZ7iur$9^g`si3K%L zo9rfLCrfQ=HR-lfn=g$t2_AG4HB|5;#&Dk|(?2#6qqPO5;x&}}Y>27uE*(qV4b%Qi z%C`3@AE;SaI-j6fqAr`^_8J*MHILmA0}Mzr2PRNej!ow9DZ8lg@0bf`4g@~mZ*F`# zh5m}Jn~#T(5odj9Zms+d5Z0L6E!2SKuXuwN$;x@3W@Q6;fXTuR@mN!&yO+!Vj^xwP zx4As`^8|(#ybtsYZai!X+|KUs+g0DWsqjW7f{-PH9+7^_c~|ukRrSt%Ba2=^S^S5c L?v1l+Hc$RP?5#UA delta 1855 zcmX9<3s4kS7Ck*Z(>>kOGsr3^Lcth51K}sNyNrTD2vG__6a^`x!WwZ=6#RrwvIap# z)Cp1G0g=y;Y~mo`L?!qo#!^0FP@=1>->j<}Vb@^AY$TYViF>zZs%B1gzx(bz_uTW| zT<_*@cJmdHZs&l-X+YukKt?yUz)oMR zIO7ep{LEf(Uff#dp)v)`hpp!<;5!?q79{~|g3xQ;0h~<2gN|_^ItL^1dx2BoY`rR! zyU7|=Ud01ka3Jxkd%?w}8-N{|Tw>pkz-|vNdCCt!=Vb?W^>Itz8Kd0aaBIi>fFQw4PEv{Cb>r4e|Q&krR`v7~IYT(~*05P2ES}!TNGePyF@dVInQjPlmiSUlhG!0}HsMXt4gxnENnMOiMa)0EHc z1rANsRGo|hY**Q3&0f`Mu;qeRv0nJ2cPv>lLzv;x59Fi?F|{EiMTwB`_-i1fOIT3y zcVNFHEDG--yS*;FIc5RgIU_9NT!8jX!t!3S;gmZ<;WmnQsuPM_sA%&+p>E|_U}22# z@jk+f2LxNhBVe22!2Jb6i*r3mIWA1&e3^%6N*)z`+o(d)MKP#?4A#0ze66?&$UP_~ z<|fer_=A{qhR#}{rn+-m$os(MGVP0d)W=^oIIzX+!1E*8-#8EIK&@8R0B?E1qAg%c}3b)tP=bq3my zJS?rdwU9>KES0?63RtI0WikacxJqR{DfFBtv7l0NyIE?t)4JO{>B5AgH1c*Idjbb`O252G_0H$Z{1#F&Z&c=Q$UxbcEN(TB&Hf`#yhL&0C$f7NrT?(dfhU}0 ze^V3fbCCl>J4j$6!+>d~l6>&@be{|>DeKjM z`>)FC-d3V!Ws`I!=WmpjyePnVyV7=!glM|S5_P5+yK;Z(QtI&%4pxWN%`}bkWl~_3v*dMglN1w|&t@s6_!|aH{2dv=U1K8%jSqluhceLw6_i+!|gT~t~M#wN?3&K#O1$_ki=CA?l aO2sG}w@fek|8H0k`{#$Q9Z5|)WB&&oi9TBZ diff --git a/src/vorta/i18n/qm/vorta.de.qm b/src/vorta/i18n/qm/vorta.de.qm index 5fa82a3aa7a2e8331f1283b35f6b572fd59f150b..661a49f9343a50564c3023fa9c4bb4a1eb4470de 100644 GIT binary patch delta 6168 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 delta 6204 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 diff --git a/src/vorta/i18n/qm/vorta.es.qm b/src/vorta/i18n/qm/vorta.es.qm index 93617797e20c46096fdebf80ef7435a85d249892..6019f003fcd949fd511d10f5687afc7e4911e137 100644 GIT binary patch delta 15208 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 ONI%!A^N=quj{AS#j$p7B5bbMD1$7ew!six&GA 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 diff --git a/src/vorta/i18n/qm/vorta.fi.qm b/src/vorta/i18n/qm/vorta.fi.qm index d95c5c3930293512fd6027bffd2ab4f354cd6f85..351555f51d919f935f7ade56608df91ffe9d540b 100644 GIT binary patch delta 7710 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 delta 1829 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 delta 1753 zcmX9;c~p&g7=FI{-TQ5K5h*fMWPDlEAXK_zZL(y_c5Kl^(ISyB)QwWQw0@PLmC900 zWwI-rag&K*PSbSEm~j~CpytdRLo;NU*SY_E&;2d;ec$JKpZ9k;oo!5Kv*x)cKyB{> z=)0jlz7y!@ZAGOS>XQ|K`+O^|JqGnD7RWxCz1xY2%r0~iqoFyd2J$auTYOC^ki#AT z7e`}a|ABxp4jw;T0@g6v z9dWKy(4|bof7p-BgE=h+*y@jT%TZuLBT5U1Z)zthELVUbFL5EzA6V*%rpZY_>0CUj ze*<`Qq027=I66-0X535xjGUAVL=0l&dK)0PmKimLPRqA3V@DIMeMyXOkO7GHV&*r$ zpsWr|z`(zNvIHwu?PHej=m8wdn2kLnfR86KoBcIFVzdhL-l9jIZ_~6_0n}M-St+z z8u}cVG>o;My9qEAvd&B9()t$b66FVKJ3CI*2z+^k^*BwrYSt*;tnA0KsZVI-+{+f- zBcUT&*}K{};B+W^e`^@9cP#t-M~W)lVtelsll{xp?CNd6jvh5@UJ49tQ)^nNQ8`+r zn)PK`m6!BwRJ_$reVa6{)#X%ot;Va4@@9o8k!pG5VNJO0BcQli6K|gnjGw7V_8~U4 zPMU*rnyAsanw;}KK<0JDqTbGQD&88W;H%suG6wRWaZ~JCfR$m~^!#zeXa_g1`wk#` zaSIcF0+LF(Ko1Kr>_2Wrj~Q4$ja$js0Y^@7+xO5t!)IK)9p%a@NZFCni2nf#b6>TFIe|EY=mv+4jpf0I8Mwt@F=hy_AU@N3p=0*>9`qXy9aH>>#l zeO^+|t9G7dPy3#KRblyi$vl;}y?+z?6xiK3Jfk{&iHE3O)1 z#duMu`dy@9Didm_Jt3EILgT@kK=?zUIruQ3i4ksJ4<#4o2pt}kq{};@GlBL^qK)-O zN?)PN>meo172ZA|vSB(aR+b3wn87rin?*M0Ea1(GteMP^DvM*=2hr3AiynQ0fND`R zde_jaa7|oRafj}AiXr)Ajy0a*hR4*@^DScdIwCgswiscc`^FwI;@;;l;m{{y;`;=0 zSGkxX(S?*UQ5i#XH@-u>VN(Yr&l1}sXun^K*mac(h~})AoG*5VIszGS;_DTZuyUlt zwv$3z?n``vfm~H1+1{daK1cfKTl#;JV8s##$yF`}PF>zMrRP20 zkJ~?;0s9u+`leGfP!pB;qAV8cDz6dcv_F*HqCCAw_i)hP zR0pSfvzmz82k3fJI{=?F-CLmzILGN3_jADTrFy1U zICPZ$z+S2(#8#iZjbc~6)t~Ahqp5D`t9DVejAzO$NoMMmNJ*ah)BxLlK-F!-*i?U@ zk8Ci`{tB2Unverschlüsselt (nicht empfohlen) - + Please enter a valid repo URL or select a local path. Bitte eine gültige Repo-URL eingeben oder einen lokalen Pfad auswählen. - + This repo has already been added. Dieses Repository wurde bereits hinzugefügt. Repokey-ChaCha20-Poly1305 (Recommended, key stored in repository) - + Repokey-ChaCha20-Poly1305 (empfohlen, Schlüssel wird im Repository gespeichert) Keyfile-ChaCha20-Poly1305 (Key stored in home directory) - + Keyfile-ChaCha20-Poly1305 (Schlüssel wird im Home-Verzeichnis gespeichert) Repokey-AES256-OCB - + Repokey-AES256-OCB Keyfile-AES256-OCB - + Keyfile-AES256-OCB @@ -195,325 +195,240 @@ ssh://abc123@abc123.repo.borgbase.com/./repo - + ssh://abc123@abc123.repo.borgbase.com/./repo ArchiveTab - + Copy Kopieren - + Action cancelled. Vorgang abgebrochen. - + Archives for %s Archive für %s - + Archives Archive - + (Select minimum one archive) (Wähle min. ein Archiv aus) - + (Select two archives) (Wähle zwei Archive aus) - + (Select exactly one archive) (Wähle genau ein Archiv aus) - + Preview: %s Vorschau: %s - + Error in archive name template. Fehler in der Archiv-Namens-Vorlage. - + Pruning finished. Ausdünnen beendet. - + Refreshed archives. Archive aufgefrischt. - + Refreshed archive. Archive aufgefrischt. - + Unmount Aushängen - + Unmount the selected archive from the file system Hängt das gewählte Archiv aus dem Dateisystem aus - + Mount… Einhängen… - + Mount the selected archive as a folder in the file system Hängt das gewählte Archiv in das Dateisystem ein - + Unmount the repository from the file system Hängt das Repository aus dem Dateisystem aus - + Mount the repository as a folder in the file system Bindet das Repository als Verzeichnis ins Dateisystem ein - + Choose Mount Point Einhängepunkt auswählen - + Mounted successfully. Erfolgreich eingehängt. - + Un-mounted successfully. Erfolgreich ausgehängt. - + Unmounting failed. Make sure no programs are using {} Aushängen fehlgeschlagen. Stelle sicher, dass keine Programme {} benutzen. - + Select an archive to restore first. Zuerst ein Archiv zum Wiederherstellen auswählen. - + Processing archive contents Verarbeite Inhalt des Archivs - + Choose Extraction Point Extrahierungs-Punkt auswählen - + Yes Ja - + Cancel Abbrechen - + No archive selected Kein Archiv ausgewählt - + Are you sure you want to delete all the selected archives? Sollen alle ausgewählten Archive gelöscht werden? - + Are you sure you want to delete the selected archive? Soll das ausgewählte Archiv gelöscht werden? - + Confirm deletion Löschen bestätigen - + Archives deleted. Archive gelöscht. - + Archive deleted. Archiv gelöscht. - + Processing diff results. Verarbeite Archivunterschiede. - + Change name Name ändern - + New archive name: Neuer Name für Archiv: - + Archive name cannot be blank. Archivname darf nicht leer sein. - + An archive with this name already exists. Ein Archiv mit diesem Namen existiert bereits. - + Archive renamed. Archiv umbenannt. + + + (borg already running) + (Borg läuft bereits) + BorgBreakJob - - - Breaking repository lock… - Hebe Repositorysperre auf... - - - - Repository lock broken. Please redo your last action. - Sperre des Repositorys wurde aufgehoben. Bitte letzte Aktion wiederholen. - BorgCheckJob - - - Starting consistency check… - Starte Konsistenzprüfung… - - - - Repo check failed. See logs for details. - Überprüfung des Repositorys fehlgeschlagen. Details dazu im Log. - - - - Check completed. - Überprüfung abgeschlossen. - BorgCompactJob - - Starting repository compaction... - Beginne mit dem Defragmentieren des Repositorys. - - - - Errors during compaction. See logs for details. - Defragmentierung fehlgeschlagen. Details finden sich in den Logs. - - - - Compaction completed. - Defragmentierung abgeschlossen. + + Errors during compaction. See the <a href="{0}">logs</a> for details. + Defragmentierung fehlgeschlagen. Details befinden sich in den <a href="{0}">Logs</a>. BorgCreateJob - - - Backup finished with warnings. See logs for details. - Datensicherung mit Warnungen abgeschlossen. Siehe Logdateien für Details. - - - - Backup finished. - Datensicherung abgeschlossen. - - - - Backup started. - Datensicherung gestartet. - BorgDeleteJob - - - Deleting archive… - Lösche Archiv… - - - - Archive deleted. - Archiv gelöscht. - BorgDiffJob - - - Requesting differences between archives… - Fordere die Archivunterschiede an… - - - - Obtained differences between archives. - Unterschiede zwischen den Archiven erhalten. - BorgExtractJob - - - Downloading files from archive… - Lade Dateien aus dem Archiv herunter… - - - - Restored files from archive. - Dateien aus Archiv wiederhergestellt. - BorgInfoArchiveJob - - - Refreshing archive… - Aktualisiere Archivmetadaten… - - - - Refreshing archive done. - Auffrischen des Archivs erledigt. - BorgInfoRepoJob @@ -554,36 +469,16 @@ Komprimiert - + Task started Aufgabe gestartet BorgListArchiveJob - - - Getting archive content… - Rufe Inhalt des Archivs ab… - - - - Done getting archive content. - Archiv-Inhalt abrufen erledigt. - BorgListRepoJob - - - Refreshing archives… - Aktualisiere Archivliste… - - - - Refreshing archives done. - Auffrischen der Archive erledigt. - BorgMountJob @@ -595,16 +490,6 @@ BorgPruneJob - - - Pruning old archives… - Dünne alte Archive aus… - - - - Pruning done. - Ausdünnen erledigt. - BorgUmountJob @@ -808,18 +693,18 @@ Folders First - + Ordner zuerst DiffResultDialog - + Copy Kopieren - + Expand recursively Rekursiv Aufklappen @@ -827,72 +712,72 @@ DiffTree - + Name Name - + Change Änderung - + Size Größe - + Balance Bilanz - + Added {}, deleted {} Neu {}, entfernt {} - + File Datei - + Directory Verzeichnis - + Link Verknüpfung - + Block device file Blockorientierte Gerätedatei - + Character device file Zeichenorientierte Gerätedatei - + unchanged unverändert - + modified modifiziert - + removed gelöscht - + added hinzugefügt @@ -908,17 +793,17 @@ ExistingRepoWindow - + Connect to existing Repository Mit existierendem Repository verbinden - + Show my password Mein Passwort anzeigen - + Hide my password Mein Passwort verstecken @@ -964,17 +849,17 @@ ExtractDialog - + Extract Entpacken - + Copy Kopieren - + Expand recursively Rekursiv Aufklappen @@ -982,77 +867,77 @@ ExtractTree - + Name Name - + Last Modified Letzte Änderung - + Size Größe - + Health Zustand - + File Datei - + Directory Verzeichnis - + Symbolic link Symbolische Verknüpfung - + FIFO pipe FIFO Pipe - + Hard link Harte Verknüpfung - + Socket Socket - + Block special file Blockorientierte Gerätedatei - + Character special file Zeichenorientierte Gerätedatei - + healthy gesund - + broken kaputt - + Linked to: {} Zeigt auf: {} @@ -1572,7 +1457,7 @@ “<int><char>”, where char is “H”, “d”, “w”, “m”, “y” - + “<int><char>”, wobei Zeichen eines der folgenden Einheiten sein muss: “H”, “d”, “w”, “m”, “y” @@ -1604,11 +1489,11 @@ - Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: - {0} + Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: + {0} {1} - Schema-Upgrade Fehler, erstelle einen Bugreport auf dem Link um "Misc"-Tab, mit folgendem Fehler: - {0} + Schema-Upgrade Fehler, erstelle einen Bugreport auf dem Link um "Misc"-Tab, mit folgendem Fehler: + {0} {1} @@ -1760,16 +1645,24 @@ Abbrechen - + Latest Neuestes - + Reset App App zurücksetzen + + RepoCheckJob + + + Repo check failed. See the <a href="{0}">logs</a> for details. + Überprüfung des Repositorys fehlgeschlagen, Details befinden sich in den <a href="{0}">logs</a>. + + RepoTab @@ -1834,37 +1727,37 @@ Versuche, die Metadaten eines Archivs zu aktualisieren. - + Automatically choose SSH Key (default) SSH-Schlüssel automatisch auswählen (Standardeinstellung) - + Public Key Copied to Clipboard Öffentlicher Schlüssel auf Zwischenablage kopiert - + The selected public SSH key was copied to the clipboard. Use it to set up remote repo permissions. Der ausgewählte öffentliche SSH-Schlüssel wurde auf die Zwischenablage kopiert. Benutze dies, um die Zugriffsrechte des fernen Repositories einzurichten. - + Could not find public key. Konnte öffentlichen Schlüssel nicht finden. - + Select a public key from the dropdown first. Wähle zuerst einen öffentlichen Schlüssel aus der Liste aus. - + Repository was Unlinked Repository-Verbindung wurde gelöst - + You can always connect it again later. Sie können es jederzeit später wieder verbinden. @@ -1872,47 +1765,47 @@ SSHAddWindow - + Generate and copy to clipboard Erzeugen und in die Zwischenablage kopieren - + ED25519 (Recommended) ED25519 (Empfohlen) - + RSA (Legacy) RSA (alt) - + ECDSA ECDSA - + High (Recommended) Hoch (Empfohlen) - + Medium Mittel - + Key file already exists. Not overwriting. Schlüssel-Datei existiert bereits, überschreibe nicht. - + New key was copied to clipboard and written to %s. Neuer Schlüssel wurde auf die Zwischenablage kopiert und geschrieben nach %s. - + Error during key generation. Fehler bei der Schlüssel-Erzeugung. @@ -1953,52 +1846,52 @@ SourceTab - + Files Dateien - + Folders Ordner - + Paste Aus der Zwischenablage einfügen - + Copy Kopieren - + Remove Entfernen - + Calculating… Berechne... - + You don't have read access to {dir}. Sie haben keinen Lesezugriff auf {dir}. - + Choose directory to back up Zu sicherndes Verzeichnis auswählen - + Choose file(s) to back up Datei(en) für die Sicherung auswählen - + Some of your sources are invalid: Folgende Datenquellen sind ungültig: @@ -2039,120 +1932,120 @@ VortaApp - + Vorta Backup Vorta Datensicherung - + No Borg Binary Found Borg-Programm wurde nicht gefunden. - + Vorta was unable to locate a usable Borg Backup binary. Vorta konnte keine ausführbare Borg Backup Datei finden. - + Vorta needs Full Disk Access for complete Backups Für komplette Sicherungen benötigt Vorta Vollzugriff auf die Festplatte - + Without this, some files will not be accessible and you may end up with an incomplete backup. Please set <b>Full Disk Access</b> permission for Vorta in <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>System Preferences > Security & Privacy</a>. Auf einige Dateien kann ohne diese Berechtigung nicht zugegriffen werden. Dies kann zu unvollständigen Sicherungen führen. Gewähren Sie Vorta bitte den <b>Vollzugriff auf die Festplatte</b> unter <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>Systemeinstellungen > Sicherheit & Privatsphäre</a>. - + Repository In Use Repository wird verwendet - + Abort Abbrechen - + Continue Fortsetzen - + The repository at {repo_url} might be in use elsewhere. Das Repository {repo_url} wird möglicherweise bereits verwendet. - + Only break the lock if you are certain no other Borg process on any machine is accessing the repository. Abort or break the lock? Hebe die Sperre nur auf, wenn du sichergestellt hast, dass keine weiteren Borg-Prozesse auf dem System auf das Repository zugreifen. Abbrechen oder Sperre aufheben? - + You do not have permission to access the repository at {repo_url}. Gain access and try again. Du hast keine Berechtigung, um auf das Repository auf {repo_url} zuzugreifen. Erhalte Zugang und versuche es erneut. - + No Repository Permissions Keine Berechtigung für Repository - + Failed to import profile Importieren des Profils fehlgeschlagen - + Failed to import a profile from {}: Importieren eines Profils fehlgeschlagen von {}: - + Consider removing or repairing this file to get rid of this message. Diese Datei sollte entfernt oder repariert werden, um diese Nachricht loszuwerden. - + Profile import successful! Profil erfolgreich importiert! - + Profile {} imported. Profil {} importiert. - + Repo Check Failed Überprüfung des Repositorys fehlgeschlagen - - Borg exited with a warning message. See logs for details. - Datensicherung mit Warnmeldungen beendet. Details finden sich in den Logs. - - - + Repository data check for repo was killed by signal %s. Überprüfung des Repositorys wurde durch Signal %s abgebrochen. - + The process running the check job got a kill signal. Try again. Der Prozess, der die Überprüfung ausführt empfing a "kill"-Signal. Versuchen Sie es erneut. - + Repository data check for repo %s failed. Error code %s Überprüfung des Repositorys %s fehlgeschlagen. Fehlercode %s - + Consider repairing or recreating the repository soon to avoid missing data. Möglicherweise sollte das Repository zeitnah repariert oder neu erstellt werden, um einen Datenverlust zu verhindern. + + + Borg exited with warning status (rc 1). See the <a href="{0}">logs</a> for details. + Borg wurde mit einer Warnung beendet (rc 1). Details befinden sich in den <a href="{0}">logs</a>. + VortaScheduler @@ -2213,37 +2106,37 @@ Ihre Borg Version ist zu alt. >=1.1.0 ist notwendig. - + Add some folders to back up first. Füge zuerst einige zu sichernde Ordner hinzu. - + Current Wifi is not allowed. Aktuelles WLAN ist nicht erlaubt. - + Not running backup over metered connection. Sicherung über kostenpflichtige Verbindung wird nicht durchgeführt. - + Pre-backup command returned non-zero exit code. Pre-backup-Kommando hat einen Return-Code ungleich Null zurückgegeben. - + Repo folder not mounted or moved. Repo-Ordner nicht eingehängt oder verschoben. - + Starting backup… Starte Datensicherung… - + This feature needs Borg 1.2.0 or higher. Diese Funktionalität benötigt mindestens Version 1.2.0 von Borg. @@ -2255,7 +2148,7 @@ Mount point not active. - + Einhängepunkt bereits ausgehängt. @@ -2285,51 +2178,81 @@ Display notifications when background tasks fail Benachrichtigungen anzeigen, falls Hintergrund-Aufgaben fehlschlagen - - - Also notify about successful background tasks - Auch über erfolgreiche Hintergrund-Aufgaben benachrichtigen - Automatically start Vorta at login Vorta automatisch bei der Anmeldung starten - + Open main window on startup Hauptfenster beim Starten öffnen - + Get statistics of file/folder when added Größe berechnen, wenn neue Dateien oder Ordner hinzugefügt werden - + Check for updates on startup Prüfe beim Start auf Aktualisierungen - + Include pre-release versions when checking for updates Auch Vorab-Versionen bei der Prüfung auf Aktualisierungen miteinbeziehen + + + Notify about successful background tasks + Benachrichtigungen bei erfolgreich abgeschlossenen Hintergrundaufgaben aktivieren + + + + Add Vorta to the systems autostart list + Vorta zum Autostart hinzufügen + + + + Open main window when the application is launched + Das Hauptfenster öffnen, wenn die Applikation gestartet wird + + + + When adding a new source, calculate its size and the number of files. + Beim Hinzufügen einer neuen Quelle die Grösse und Anzahl der Dateien berechnen. + + + + Otherwise Vorta's configuration database stores the password in plaintext. + Ansonsten wird das Passwort im Klartext in der Konfiguration gespeichert. + + + + Set owner to current user and umask to 0277 + Setze den Besitzer auf den aktuellen Benutzer und umask auf 0277 + + + + Alerts user when full disk access permission has not been provided + Den Benutzer benachrichtigen, falls kein vollständiger Datenspeicherzugriff gewährt wurde. + utils - + Passwords must be identical and greater than 8 characters long. Passwörter müssen übereinstimmen und mindestens 8 Zeichen lang sein. - + Passwords must be identical. Passwörter müssen übereinstimmen. - + Passwords must be greater than 8 characters long. Passwörter müssen länger als 8 Zeichen sein. @@ -2344,4 +2267,4 @@ Speichere Kennwort in der Vortakonfiguration - + \ No newline at end of file diff --git a/src/vorta/i18n/ts/vorta.es.ts b/src/vorta/i18n/ts/vorta.es.ts index c8868d173..e7aaf64ef 100644 --- a/src/vorta/i18n/ts/vorta.es.ts +++ b/src/vorta/i18n/ts/vorta.es.ts @@ -14,12 +14,12 @@ Please enter a profile name. - Por favor introduzca un nombre de perfil. + Por favor, introduzca un nombre de perfil. A profile with this name already exists. - Un perfil con este nombre ya existe. + Ya existe un perfil con este nombre. @@ -42,12 +42,12 @@ Choose Location of Borg Repository - Seleccione ubicación del repositorio Borg + Seleccione la ubicación del repositorio Borg Autofilled password from password manager. - Auto completado de contraseña desde el administrador de contraseñas. + Autocompletado de contraseña desde el administrador de contraseñas. @@ -62,7 +62,7 @@ Unable to add your repository. - No se pudo agregar su repositorio. + No se ha podido añadir su repositorio. @@ -82,7 +82,7 @@ Keyfile - Fichero de la llave + Archivo de llave @@ -90,34 +90,34 @@ Ninguno (no recomendado) - + Please enter a valid repo URL or select a local path. - Por favor introduzca un URL valido para el repositorio o seleccione una ruta local. + Por favor, introduzca una URL válida para el repositorio o seleccione una ruta local. - + This repo has already been added. - El repositorio ya ha sido agregado. + Este repositorio ya se ha añadido. Repokey-ChaCha20-Poly1305 (Recommended, key stored in repository) - + Repokey-ChaCha20-Poly1305 (Recomendado, llave almacenada en el repositorio) Keyfile-ChaCha20-Poly1305 (Key stored in home directory) - + Keyfile-ChaCha20-Poly1305 (Llave almacenada en el repositorio de inicio) Repokey-AES256-OCB - + Repokey-AES256-OCB Keyfile-AES256-OCB - + Keyfile-AES256-OCB @@ -130,7 +130,7 @@ Initialize New Backup Repository - Inicializar nuevo repositorio de respaldos + Iniciar nuevo repositorio de respaldos @@ -201,326 +201,241 @@ ArchiveTab - + Copy Copiar - + Action cancelled. Acción cancelada. - + Archives for %s Archivos para %s - + Archives - Archivos + Instantáneas - + (Select minimum one archive) - + (Seleccione al menos una instantánea) - + (Select two archives) - (Seleccionar dos archivos) + (Seleccione dos instantáneas) - + (Select exactly one archive) - (Seleccione exactamente un archivo) + (Seleccione exactamente una instantánea) - + Preview: %s Vista previa: %s - + Error in archive name template. - Error en el nombre del la plantilla del archivo. + Error en el nombre de la plantilla de la instantánea. - + Pruning finished. - Eliminación terminada. + Limpieza terminada. - + Refreshed archives. - Archivos actualizados. + Instantáneas actualizadas. - + Refreshed archive. - Archivo refrescado. + Instantánea actualizada. - + Unmount Desmontar - + Unmount the selected archive from the file system - + Desmontar la instantánea seleccionada del sistema de archivos. - + Mount… Montar... - + Mount the selected archive as a folder in the file system - + Montar la instantánea seleccionada como una carpeta en el sistema de archivos. - + Unmount the repository from the file system - + Desmontar el repositorio del sistema de archivos - + Mount the repository as a folder in the file system - + Montar el repositorio como una carpeta en el sistema de archivos - + Choose Mount Point Seleccione un punto de montaje - + Mounted successfully. Montaje exitoso. - + Un-mounted successfully. Desmontado exitoso. - + Unmounting failed. Make sure no programs are using {} - Desmontado fallido. Asegúrese que ningún programa este utilizando {} + Desmontado fallido. Asegúrese de que ningún programa esté utilizando {} - + Select an archive to restore first. - Seleccione un archivo para restaurar. + Seleccione una instantánea que restaurar. - + Processing archive contents - + Procesando los contenidos de la instantánea. - + Choose Extraction Point Seleccione punto de extracción - + Yes - Si + - + Cancel Cancelar - + No archive selected - No se seleccionó archivo + No se ha seleccionado ninguna instantánea. - + Are you sure you want to delete all the selected archives? - + ¿Seguro que desea eliminar todas las instantáneas seleccionadas? - + Are you sure you want to delete the selected archive? - + ¿Seguro que desea eliminar la instantánea seleccionada? - + Confirm deletion Confirmar eliminación - + Archives deleted. - + Instantáneas eliminadas. - + Archive deleted. - Archivo eliminado. + Instantánea eliminada. - + Processing diff results. - + Procesando resultados de la comparación - + Change name Cambiar nombre - + New archive name: - Nuevo nombre de archivo: + Nuevo nombre de la instantánea: - + Archive name cannot be blank. - El nombre del archivo no puede estar vacion. + El nombre de la instantánea no puede estar vacío. - + An archive with this name already exists. - Un archivo con este nombre ya existe. + Ya existe una instantánea con este nombre. - + Archive renamed. - Archivo renombrado. + instantánea renombrada. + + + + (borg already running) + (borg ya se está ejecutando) BorgBreakJob - - - Breaking repository lock… - Rompiendo el bloqueo del repositorio... - - - - Repository lock broken. Please redo your last action. - Bloqueo del repositorio roto. Vuelva a realizar su última acción. - BorgCheckJob - - - Starting consistency check… - Iniciando verificación de consistencia... - - - - Repo check failed. See logs for details. - Verificación fallida. Vea los registros para mas detalles. - - - - Check completed. - Verificación completada. - BorgCompactJob - - Starting repository compaction... - Iniciando compactación del repositorio... - - - - Errors during compaction. See logs for details. - Se encontraron errores durante la compactación. Ver el registro para mas detalles. - - - - Compaction completed. - Compactación terminada. + + Errors during compaction. See the <a href="{0}">logs</a> for details. + Ha habido errores durante la compactación. Vea los <a href="{0}">registros</a> para más detalles. BorgCreateJob - - - Backup finished with warnings. See logs for details. - El respaldo terminó con advertencias. Vea los registros para mas detalles. - - - - Backup finished. - Respaldo terminado. - - - - Backup started. - Respaldo iniciado. - BorgDeleteJob - - - Deleting archive… - Borrando archivo... - - - - Archive deleted. - Archivo eliminado. - BorgDiffJob - - - Requesting differences between archives… - Solicitando las diferencias entre los archivos… - - - - Obtained differences between archives. - Diferencias entre archivos obtenidas. - BorgExtractJob - - - Downloading files from archive… - Descargando ficheros del archivo... - - - - Restored files from archive. - Ficheros del archivo restaurados. - BorgInfoArchiveJob - - - Refreshing archive… - Actualizando archivo... - - - - Refreshing archive done. - Actualización de archivo terminada. - BorgInfoRepoJob Validating existing repo… - Validando repositorio... + Validando el repositorio existente... @@ -536,7 +451,7 @@ Files - Ficheros + Archivos @@ -546,7 +461,7 @@ Deduplicated - Redundante + Deduplicado @@ -554,64 +469,34 @@ Comprimido - + Task started Tarea iniciada BorgListArchiveJob - - - Getting archive content… - Obteniendo contenido del archivo... - - - - Done getting archive content. - Obtención del contenido del archivo terminada. - BorgListRepoJob - - - Refreshing archives… - Actualizando archivos... - - - - Refreshing archives done. - Actualización de archivos terminada. - BorgMountJob Mounting archive into folder… - Montando archivo en la carpeta... + Montando instantánea en la carpeta... BorgPruneJob - - - Pruning old archives… - Eliminando archivos antiguos... - - - - Pruning done. - Supresión terminada. - BorgUmountJob Unmounting archive… - Desmontando archivo... + Desmontando instantánea... @@ -639,12 +524,12 @@ Add Profile - Agregar pefil + Añadir perfil Add Backup Profile - Agregar perfil de respaldo + Añadir perfil de respaldo @@ -654,23 +539,23 @@ </body></html> <html><head/><body> <p>Los perfiles permiten diferentes configuraciones de respaldo y repositorio, incluyendo diferentes calendarizaciones.</p> -<p>Todos los perfiles podrán accesar los mismos repositorios así como las mismas llaves <span style=" font-style:italic;">ssh</span>. La configuración global en <span style=" font-style:italic;">Varios</span> es compartida entre los perfiles.</p> +<p>Todos los perfiles podrán acceder a los mismos repositorios, así como a las mismas llaves <span style=" font-style:italic;">ssh</span>. La configuración global en <span style=" font-style:italic;">Varios</span> se comparte entre los perfiles.</p> </body></html> Profile Name: - Nombre de perfil: + Nombre del perfil: Choose archives for diff - Seleccionar archivos para comparar + Seleccionar instantáneas para comparar Select two archives - Seleccionar dos archivos + Seleccionar dos instantáneas @@ -710,12 +595,12 @@ Choose files to extract - Seleccionar ficheros a extraer + Seleccionar archivos que extraer Archive: - Archivo: + Instantánea: @@ -725,32 +610,32 @@ Keep folders on top when sorting - + Mantener carpetas en la parte superior al ordenar Set display mode of diff view - + Establecer modo de visualización de la comparación Tree - + Árbol Tree, simplified - + Árbol, simplificado. Collapse All - + Plegar todo Include Borg passphrase in export. Use with caution! - Incluir contraseña de Borg en la exportación. Usar con cuidado! + Incluir contraseña de Borg en el archivo exportado. ¡Usar con cuidado! @@ -760,7 +645,7 @@ Diff Result - Resultado de comparación + Resultado de la comparación @@ -775,7 +660,7 @@ Flat - + Lista @@ -810,93 +695,93 @@ Folders First - + Carpetas primero DiffResultDialog - + Copy - + Copiar - + Expand recursively - + Expandir recursivamente DiffTree - + Name - + Nombre - + Change - + Cambiar - + Size - + Tamaño - + Balance - + Added {}, deleted {} - + Añadido {}, eliminado {} - + File - + Archivo - + Directory - + Directorio - + Link - + Enlace - + Block device file - + Character device file - + unchanged - + no modificado - + modified - + modificado - + removed - + eliminado - + added - + añadido @@ -904,23 +789,23 @@ Rename Profile - Cambiar nombre de perfil + Cambiar nombre del perfil ExistingRepoWindow - + Connect to existing Repository - Conectar a un repositorio existente + Conectar un repositorio existente - + Show my password Mostrar mi contraseña - + Hide my password Ocultar mi contraseña @@ -935,7 +820,7 @@ Disclose your borg passphrase (No passphrase set) - Divulgar su contraseña de borg (No se ha guardado contraseña) + Divulgar su contraseña de borg (No se ha establecido una contraseña) @@ -945,118 +830,118 @@ Error while exporting - Se encontró un error al exportar + Ha habido un error al exportar The file {} could not be created. Please choose another location. - El archivo {} no pudo ser creado. Por favor seleccionar otra ubicación. + El archivo {} no se ha podido crear. Por favor, seleccione otra ubicación. Profile export successful! - ¡La exportación del perfil fue exitosa! + ¡La exportación del perfil ha sido exitosa! Profile export written to {}. - El perfil fue exportado en {}. + El perfil se ha exportado a {}. ExtractDialog - + Extract Extraer - + Copy - + Copiar - + Expand recursively - + Expandir recursivamente ExtractTree - + Name - + Nombre - + Last Modified - + Última modificación - + Size - + Tamaño - + Health - + Estado - + File - + Archivo - + Directory - + Directorio - + Symbolic link - + Enlace simbólico - + FIFO pipe - + Hard link - + Enlace físico - + Socket - + Block special file - + Character special file - + Archivo de carácter especial - + healthy - + perfecto - + broken - + roto - + Linked to: {} - + Enlazado a: @@ -1084,7 +969,7 @@ Backup periodically - Respaldar periodicamente + Respaldar periódicamente @@ -1094,7 +979,7 @@ Backup daily - Respaldar diaramente: + Respaldar diariamente: @@ -1109,17 +994,17 @@ Run missed backups on startup or wakeup - Ejecutar respaldos omitidos al inicio o al despertar + Ejecutar respaldos omitidos al inicio o tras la suspensión. Autopruning: - Auto-eliminación: + Autolimpieza: Prune after each backup - Eliminar después de cada respaldo + Limpiar después de cada respaldo @@ -1134,7 +1019,7 @@ weeks - semana(s) + semanas @@ -1164,7 +1049,7 @@ Log - Histórico + Registro @@ -1179,7 +1064,7 @@ Subcommand - Sub-comando + Suborden @@ -1194,17 +1079,17 @@ Shell Commands - Comandos de terminal + Órdenes de terminal <html><head/><body><p>Run custom shell commands before and after each backup. The actual backup and post-backup command will only run, if the pre-backup command exits without error (return code 0). Available variables: <span style=" font-family:'Courier';">$repo_url, $profile_name, $profile_slug, $returncode</span></p></body></html> - <html><head/><body><p>Ejecutar comandos de terminal antes y después de cada respaldo. El respaldo y el comando posterior al respaldo solo se ejecutarán si el comando previo al respaldo termina sin errores (código de error 0). Variables disponibles: <span style=" font-family:'Courier';">$repo_url, $profile_name, $profile_slug, $returncode</span></p></body></html> + <html><head/><body><p>Ejecutar órdenes de terminal antes y después de cada respaldo. El respaldo y la orden posterior al respaldo solo se ejecutarán si la orden previa al respaldo termina sin errores (código de error 0). Variables disponibles: <span style=" font-family:'Courier';">$repo_url, $profile_name, $profile_slug, $returncode</span></p></body></html> Pre-backup: - Pre-Respaldo: + Prerespaldo: @@ -1214,7 +1099,7 @@ Post-backup: - Post-Respaldo: + Posrespaldo: @@ -1284,7 +1169,7 @@ Deduplicated Size: - Tamaño de-duplicado: + Tamaño deduplicado: @@ -1294,7 +1179,7 @@ Archives - Archivos + Instantáneas @@ -1304,7 +1189,7 @@ Check the consistency of the repository - + Verificar la consistencia del repositorio @@ -1314,17 +1199,17 @@ Prune the archives in this repository - Eliminar los archivos en este repositorio + Limpiar las instantáneas de este repositorio Prune - Suprimir + Limpiar Optimize disk space by defragmenting the repository - + Optimizar espacio en el disco al desfragmentar el repositorio @@ -1359,7 +1244,7 @@ Refresh selected archive - Actualizar el archivo seleccionado + Actualizar la instantánea seleccionada. @@ -1369,7 +1254,7 @@ Extract selected archive - Extraer el archivo seleccionado + Extraer la instantánea seleccionada @@ -1379,7 +1264,7 @@ Rename selected archive - Renombrar archivo seleccionado + Renombrar instantánea seleccionada @@ -1389,17 +1274,17 @@ Compare two archives - Comparar dos archivos + Comparar dos instantáneas Diff - Diferencia + Comparar Delete selected archive(s) - + Borrar instantánea(s) seleccionada(s) @@ -1409,17 +1294,17 @@ <html><head/><body><p>To mount archives, first install &quot;FUSE for macOS&quot; from <a href="https://osxfuse.github.io/"><span style=" text-decoration: underline; color:#0984e3;">here</span></a>.</p></body></html> - <html><head/><body><p>Para montar archivos, primero instale &quot;FUSE para macOS&quot; desde <a href="https://osxfuse.github.io/"><span style=" text-decoration: underline; color:#0984e3;">aquí</span></a>.</p></body></html> + <html><head/><body><p>Para montar las instantáneas, primero instale &quot;FUSE para macOS&quot; desde <a href="https://osxfuse.github.io/"><span style=" text-decoration: underline; color:#0984e3;">aquí</span></a>.</p></body></html> Prune Options and Archive Naming - Opciones de eliminación y nombrado de archivos + Opciones de limpieza y nombrado de archivos <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> - <html><head/><body><p>La eliminación borra archivos viejos. Puede elegir el número de archivos por hora, por día, etc. que desee conservar. Por lo general, se recomienda conservar los archivos nuevos y algunos antiguos. Leer <a href="https://borgbackup.readthedocs.io/en/stable/usage/prune.html"><span style=" text-decoration: underline; color:#FF4500;">para saber mas</span></a>.</p></body></html> + <html><head/><body><p>La limpiezaborra archivos viejos. Puede elegir el número de archivos por hora, por día, etc. que desee conservar. Por lo general, se recomienda conservar los archivos nuevos y algunos antiguos. Lea <a href="https://borgbackup.readthedocs.io/en/stable/usage/prune.html"><span style=" text-decoration: underline; color:#FF4500;">para saber más</span></a>.</p></body></html> @@ -1454,7 +1339,7 @@ No matter what, keep all archives of the last: - Sin importar, conservar todos los archivos dentro de: + Conservar todas las instantáneas durante un periodo de: @@ -1464,7 +1349,7 @@ Available variables: hostname, profile_id, profile_slug, now, utc_now, user - Variables disponibles : hostname, profile_id, profile_slug, now, utc_now, user + Variables disponibles: hostname, profile_id, profile_slug, now, utc_now, user @@ -1474,7 +1359,7 @@ Prune Prefix: - Prefijo de eliminación: + Prefijo de limpieza: @@ -1484,12 +1369,12 @@ Archive Name: - Nombre de archivo: + Nombre de la instantánea: Source Folders and Files to Back Up: - Carpetas y archivos para respaldar: + Carpetas y archivos que respaldar: @@ -1504,22 +1389,22 @@ Recalculate source size and file count - Re-calcular el tamaño de la fuente y número de ficheros + Recalcular el tamaño de la fuente y su número de archivos Add sources - Agregar fuentes + Añadir fuentes Remove the selected source - Remover la fuente seleccionada + Eliminar la fuente seleccionada <html><head/><body><p>Exclude Patterns (<a href="https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns"><span style=" text-decoration: underline; color:#0984e3;">more</span></a>):</p></body></html> - <html><head/><body><p>Patrones a excluir (<a href="https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns"><span style=" text-decoration: underline; color:#0984e3;">más información</span></a>):</p></body></html> + <html><head/><body><p>Patrones que excluir (<a href="https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns"><span style=" text-decoration: underline; color:#0984e3;">más información</span></a>):</p></body></html> @@ -1549,7 +1434,7 @@ <html><head/><body><p>| <a href="https://github.com/borgbase/vorta/issues/new/choose"><span style=" text-decoration: underline; color:#0984e3;">Report</span></a> a Bug |</p></body></html> - <html><head/><body><p>| <a href="https://github.com/borgbase/vorta/issues/new/choose"><span style=" text-decoration: underline; color:#0984e3;">Reportar </span></a>un Error |</p></body></html> + <html><head/><body><p>| <a href="https://github.com/borgbase/vorta/issues/new/choose"><span style=" text-decoration: underline; color:#0984e3;">Informar de </span></a>un error |</p></body></html> @@ -1574,7 +1459,7 @@ “<int><char>”, where char is “H”, “d”, “w”, “m”, “y” - + “<int><char>”, donde char es “H”, “d”, “w”, “m”, “y” @@ -1587,17 +1472,17 @@ Enter passphrase (already loaded from the export file) - Introduzca contraseña ( ya cargada desde el fichero exportado) + Introduzca la contraseña (ya cargada desde el archivo exportado) Enter passphrase (already loaded from your keyring) - Introduzca la contraseña ( ya cargada desde el llavero) + Introduzca la contraseña (ya cargada desde su gestor de claves) (Name is not used yet) - (El nombre no es usado todavía) + (El nombre no se ha usado todavía) @@ -1606,27 +1491,27 @@ - Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: - {0} + Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: + {0} {1} - Falla al actualizar esquema, registre un reporte de error con el enlace en la pestaña Varios con el siguiente error: + Fallo al actualizar el esquema, rellene un informe de error con el enlace de la pestaña «Varios» con el siguiente error: {0} {1} Newer profile_export export files cannot be used on older versions. - Ficheros de perfil recientes no pueden ser utilizados en versiones anteriores. + Los archivos de perfil recientes no se pueden utilizar en versiones anteriores. Cannot read profile_export export file due to permission error. - No se puede leer fichero de perfil exportado debido a un error de permisos. + No se puede leer el archivo de perfil exportado debido a un error de permisos. Profile export file not found. - No se puede encontrar fichero de perfil exportado. + No se ha podido encontrar un archivo de perfil exportado. @@ -1639,12 +1524,12 @@ Import from file… - Importar del fichero... + Importar desde archivo... Are you sure you want to delete profile '{}'? - ¿Está seguro de que desea eliminar el perfil '{}'? + ¿Seguro que desea eliminar el perfil '{}'? @@ -1659,7 +1544,7 @@ Profile import successful! - ¡La importación del perfil fue exitosa! + ¡La importación del perfil ha sido exitosa! @@ -1674,12 +1559,12 @@ JSON (*.json);;All files (*) - JSON (*.json);;Todos los ficheros (*) + JSON (*.json);;Todos los archivos (*) Failed to import profile - La importación del perfil falló + La importación del perfil ha fallado @@ -1689,7 +1574,7 @@ Should Vorta continue to run in the background? - ¿Debe Vorta continuar ejecutándose en segundo plano? + ¿Continuar ejecutando Vorta en segundo plano? @@ -1714,7 +1599,7 @@ Add a new profile (Dropdown: Import from file) - Agregar un nuevo perfil ( Desplegar: Importar del fichero) + Añadir un nuevo perfil (Desplegable: Importar desde archivo) @@ -1729,7 +1614,7 @@ Delete current profile - Eliminar el perfil actual + Borrar el perfil actual @@ -1749,12 +1634,12 @@ Archives - Archivos + Instantáneas Misc - Varios + Miscelánea @@ -1762,22 +1647,30 @@ Cancelar - + Latest Más reciente - + Reset App Restablecer aplicación + + RepoCheckJob + + + Repo check failed. See the <a href="{0}">logs</a> for details. + La verificación del repositorio ha fallado. Consulte los <a href="{0}">registros</a> para más detalles. + + RepoTab New Repository… - Nuevo Repositorio... + Nuevo repositorio... @@ -1802,22 +1695,22 @@ ZLIB Level 6 (auto, legacy) - ZLIB Nivel 6 (auto, original) + ZLIB Nivel 6 (auto, antigua) LZMA Level 6 (auto, legacy) - LZMA Nivel 6 (auto, original) + LZMA Nivel 6 (auto, antigua) No Compression - Sin Compresión + Sin compresión No repository selected - + Ningún repositorio seleccionado @@ -1828,45 +1721,45 @@ Select a repository first. - Seleccionar un repositorio primero. + Seleccione un repositorio primero. Try refreshing the metadata of any archive. - Intente actualizar los metadatos de cualquier archivo. + Intentar actualizar los metadatos de cualquier instantánea. - + Automatically choose SSH Key (default) Seleccionar llave SSH automáticamente (predeterminado) - + Public Key Copied to Clipboard Llave pública copiada al portapapeles - + The selected public SSH key was copied to the clipboard. Use it to set up remote repo permissions. - La llave pública SSH seleccionada se copió al portapapeles. Utilizala para configurar los permisos en el repositorio remoto. + La llave pública SSH seleccionada se ha copiado al portapapeles. Utilícela para configurar los permisos en el repositorio remoto. - + Could not find public key. - No se puede encontrar llave pública. + No se ha podido encontrar una llave pública. - + Select a public key from the dropdown first. - Seleccione un llave pública de la lista primero. + Seleccione una llave pública desde la lista primero. - + Repository was Unlinked - El repositorio fue desvinculado + El repositorio ha sido desvinculado - + You can always connect it again later. Siempre puede conectarlo de nuevo después. @@ -1874,49 +1767,49 @@ SSHAddWindow - + Generate and copy to clipboard Generar y copiar al portapapeles - + ED25519 (Recommended) ED25519 (Recomendado) - + RSA (Legacy) - RSA(Original) + RSA (Antiguo) - + ECDSA ECDSA - + High (Recommended) Alto (Recomendado) - + Medium Medio - + Key file already exists. Not overwriting. - La llave ya existe. No se sobre-escribió. + La llave ya existe. No se ha sobrescrito. - + New key was copied to clipboard and written to %s. - La nueva llave se copió al portapapeles y se guardó en %s. + La nueva llave se ha copiado al portapapeles y se ha guardado en %s. - + Error during key generation. - Se encontró un error al generar la llave. + Ha habido un error al generar la llave. @@ -1944,65 +1837,65 @@ Run a manual backup first - + Ejecute un respaldo manual primero None scheduled - + No calendarizado SourceTab - + Files - Ficheros + Archivos - + Folders Carpetas - + Paste Pegar - + Copy Copiar - + Remove - Remover + Eliminar - + Calculating… Calculando... - + You don't have read access to {dir}. No tiene permiso de acceder a {dir}. - + Choose directory to back up - Seleccionar carpeta para respaldar + Seleccionar carpeta que respaldar - + Choose file(s) to back up - Seleccionar fichero(s) para respaldar + Seleccionar archivos(s) que respaldar - + Some of your sources are invalid: - Algunas de las fuentes son invalidas: + Algunas de las fuentes establecidas son inválidas: @@ -2010,7 +1903,7 @@ Vorta for Borg Backup - Vorta para respaldo de Borg + Abrir ventana principal de Vorta @@ -2020,12 +1913,12 @@ Cancel Backup - Cancelar Respaldo + Cancelar respaldo Next Task: %s - Siguiente Tarea: %s + Siguiente tarea: %s @@ -2041,119 +1934,119 @@ VortaApp - + Vorta Backup Respaldo Vorta - + No Borg Binary Found - No se encontró binario de Borg + No se ha encontrado ningún binario de Borg - + Vorta was unable to locate a usable Borg Backup binary. - Vorta no puede encontrar un archivo binario de Borg. + Vorta no ha podido encontrar un archivo binario de Borg. - + Vorta needs Full Disk Access for complete Backups - Vorta necesita acceso completo al disco para respaldos completos + Vorta necesita un acceso completo al disco para realizar respaldos completos - + Without this, some files will not be accessible and you may end up with an incomplete backup. Please set <b>Full Disk Access</b> permission for Vorta in <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>System Preferences > Security & Privacy</a>. - Sin eso, algunos ficheros no serán accesibles y usted puede terminar con un respaldo incompleto. Por favor de dar el permiso <b>Acceso completo al disco</b> para Vorta en <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>Preferencias del sistema > Seguridad y privacidad</a>. + Sin eso, algunos archivos no serán accesibles y el respaldo estará incompleto. Por favor, establezca permisos de <b>acceso completo al disco</b> para Vorta en <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>Preferencias del sistema > Seguridad y privacidad</a>. - + Repository In Use - El repositorio esta siendo utilizado + El repositorio está en uso. - + Abort Abortar - + Continue Continuar - + The repository at {repo_url} might be in use elsewhere. - El repositorio localizado en {repo_url} tal vez esta siendo utilizado en otro lado. + El repositorio localizado en {repo_url} tal vez esté en uso en otro lado. - + Only break the lock if you are certain no other Borg process on any machine is accessing the repository. Abort or break the lock? - Solo rompa el bloqueo si está seguro de que ningún otro proceso Borg en otra máquina está accediendo al repositorio. ¿Abortar o romper el bloqueo? + Rompa el bloqueo únicamente si está seguro de que ningún otro proceso de Borg en otra máquina está accediendo al repositorio. ¿Abortar o romper el bloqueo? - + You do not have permission to access the repository at {repo_url}. Gain access and try again. No tiene permiso para acceder al repositorio en {repo_url}. Obtenga acceso y vuelva a intentarlo. - + No Repository Permissions Sin permisos en el repositorio - + Failed to import profile - No se pudo importar el perfil + No se ha podido importar el perfil - + Failed to import a profile from {}: - No se pudo importar el perfil desde {}: + No se ha podido importar el perfil desde {}: - + Consider removing or repairing this file to get rid of this message. - Considere remover o reparar este fichero para no recibir este mensaje. + Considere eliminar o reparar este archivo para deshacerse de este mensaje. - + Profile import successful! - ¡La importación de perfil fue exitosa! + ¡La importación de perfil ha sido exitosa! - + Profile {} imported. Perfil {} importado. - - - Repo Check Failed - No se pudo verificar el repositorio - - Borg exited with a warning message. See logs for details. - Borg terminó con un mensaje de advertencia. Ver los registros para mas detalles. + Repo Check Failed + No se ha podido verificar el repositorio - + Repository data check for repo was killed by signal %s. - La verificación de datos en el repositorio fue terminada por la señal %s. + La verificación de datos en el repositorio ha terminado por la señal %s. - + The process running the check job got a kill signal. Try again. - El proceso ejecutando el trabajo de verificación recibió una señal de terminación. Intente de nuevo. + El proceso que ejecuta la verificación ha recibido una señal de terminación. Inténtelo de nuevo. - + Repository data check for repo %s failed. Error code %s - La verificación de datos del repositorio %s falló. Código de error %s + La verificación de datos del repositorio %s ha fallado. Código de error %s - + Consider repairing or recreating the repository soon to avoid missing data. - Considere reparar o recrear el repositorio pronto para evitar perdida de datos. + Considere reparar o recrear el repositorio lo antes posible para evitar una pérdida de datos. + + + + Borg exited with warning status (rc 1). See the <a href="{0}">logs</a> for details. + Borg ha terminado con una advertencia (rc1). Consulte los <a href="{0}">registros</a> para más detalles. @@ -2166,7 +2059,7 @@ Vorta Backup - Respaldo Vorta + Respaldo de Vorta @@ -2176,12 +2069,12 @@ Backup successful for %s. - El respaldo para %s fue exitoso. + El respaldo para %s ha sido exitoso. Error during backup creation. - Se encontró un error durante la creación del respaldo. + Ha habido un error durante la creación del respaldo. @@ -2189,7 +2082,7 @@ Fatal Error - Error Fatal + Error fatal @@ -2197,67 +2090,67 @@ No active Borg mounts found. - No se encontraron monturas Borg activas. + No se han encontrado puntos de montaje de Borg activos. Borg binary was not found. - No se encontró el archivo binario de Borg. + No se ha encontrado el archivo binario de Borg. Select a backup repository first. - + Seleccione un repositorio de respaldo primero. Your Borg version is too old. >=1.1.0 is required. - La versión de Borg es demasiado antigua. Se requiere >=1.1.0. + Su versión de Borg es demasiado antigua. Se requiere una igual o superior a la 1.1.0. - + Add some folders to back up first. - Agregue algunas carpetas para respaldar primero. + Añada algunas carpetas que respaldar primero. - + Current Wifi is not allowed. - La conexión Wifi no está permitida. + La conexión Wifi actual no está permitida. - + Not running backup over metered connection. No se puede ejecutar el respaldo a través de una conexión limitada. - + Pre-backup command returned non-zero exit code. - El comando previo al respaldo regresó un código de salida distinto de cero. + La orden previa al respaldo devolvió un código de salida distinto de cero. - + Repo folder not mounted or moved. - La carpeta del repositorio no está montada o cambio de lugar. + La carpeta del repositorio no está montada o ha cambiado de lugar. - + Starting backup… Iniciando respaldo... - + This feature needs Borg 1.2.0 or higher. - Esta opción necesita la versión 1.2.0 de Borg o mas nueva. + Esta característica necesita la versión 1.2.0 de Borg o una más nueva. Please unlock your password manager. - Por favor desbloquee su administrador de contraseñas. + Por favor, desbloquee su administrador de contraseñas. Mount point not active. - + Punto de montaje inactivo. @@ -2285,65 +2178,95 @@ Display notifications when background tasks fail - Mostrar las notificaciones cuando las tareas en el segundo plano fallan - - - - Also notify about successful background tasks - También notificar sobre las tareas exitosas en el segundo plano + Mostrar una notificación cuando fallen las tareas en el segundo plano. Automatically start Vorta at login - Iniciar Vorta automáticamente al iniciar una sesión en la computadora + Iniciar Vorta automáticamente al iniciar sesión. - + Open main window on startup Abrir la ventana principal al inicio - + Get statistics of file/folder when added - Obtener estadísticas del fichero o la carpeta cuando se agreguen + Obtener estadísticas del archivo o de la carpeta cuando se añaden - + Check for updates on startup - Revisar actualizaciones al inicio + Comprobar actualizaciones al inicio - + Include pre-release versions when checking for updates - Incluir versiones preliminares al buscar actualizaciones + Incluir versiones beta al buscar actualizaciones + + + + Notify about successful background tasks + Notificar tareas exitosas que se ejecutan en segundo plano. + + + + Add Vorta to the systems autostart list + Añadir Vorta a la lista de programas que arrancan al inicio de la sesión. + + + + Open main window when the application is launched + Abrir la ventana principal cuando se arranca el programa. + + + + When adding a new source, calculate its size and the number of files. + Al añadir una nueva fuente, calcular el tamaño y su número de archivos. + + + + Otherwise Vorta's configuration database stores the password in plaintext. + De lo contrario, la base de datos de configuración de Vorta almacena la contraseña en texto sin formato. + + + + Set owner to current user and umask to 0277 + Establecer como propietario al usuario actual y conceder permisos de lectura al grupo + + + + Alerts user when full disk access permission has not been provided + Alerta al usuario cuando no se ha concedido permiso de acceso completo al disco. utils - + Passwords must be identical and greater than 8 characters long. Las contraseñas deben ser idénticas y de más de 8 caracteres. - + Passwords must be identical. Las contraseñas deben ser idénticas. - + Passwords must be greater than 8 characters long. Las contraseñas deben ser mayor a 8 caracteres. Storing password in your password manager. - + Almacenar contraseñas en su gestor de contraseñas. Saving password with Vorta settings. - + Guardar contraseñas con los ajustes de Vorta. - + \ No newline at end of file diff --git a/src/vorta/i18n/ts/vorta.fi.ts b/src/vorta/i18n/ts/vorta.fi.ts index b52fa3c7a..3415ff608 100644 --- a/src/vorta/i18n/ts/vorta.fi.ts +++ b/src/vorta/i18n/ts/vorta.fi.ts @@ -90,34 +90,34 @@ Ei mitään (ei suositeltu) - + Please enter a valid repo URL or select a local path. Anna kelvollinen tietovaraston osoite tai valitse paikallinen polku. - + This repo has already been added. Tämä tietovarasto on jo lisätty. Repokey-ChaCha20-Poly1305 (Recommended, key stored in repository) - + Repokey-ChaCha20-Poly1305 (Suositeltu, avain säilötään tietovarastoon) Keyfile-ChaCha20-Poly1305 (Key stored in home directory) - + Keyfile-ChaCha20-Poly1305 (Avain säilötään kotihakemistoon) Repokey-AES256-OCB - + Repokey-AES256-OCB Keyfile-AES256-OCB - + Keyfile-AES256-OCB @@ -195,325 +195,240 @@ ssh://abc123@abc123.repo.borgbase.com/./repo - + ssh://abc123@abc123.repo.borgbase.com/./repo ArchiveTab - + Copy Kopioi - + Action cancelled. Toimenpide peruttu. - + Archives for %s Arkistot tietovarastolle %s - + Archives Arkistot - + (Select minimum one archive) - + (Valitse vähintään yksi arkisto) - + (Select two archives) (Valitse kaksi arkistoa) - + (Select exactly one archive) (Valitse tarkalleen yksi arkisto) - + Preview: %s Esikatselu: %s - + Error in archive name template. Virhe arkistonimen kaavassa. - + Pruning finished. Karsiminen valmistui. - + Refreshed archives. Arkistot päivitetty. - + Refreshed archive. Arkisto päivitetty. - + Unmount Irrota liitos - + Unmount the selected archive from the file system - + Poista valitun arkiston liitos tiedostojärjestelmästä - + Mount… Liitä... - + Mount the selected archive as a folder in the file system - + Liitä valittu arkisto hakemistoksi tiedostojärjestelmään - + Unmount the repository from the file system - + Mount the repository as a folder in the file system - + Choose Mount Point Valitse liitospiste - + Mounted successfully. Liitetty onnistuneesti. - + Un-mounted successfully. Liitos irrotettu onnistuneesti. - + Unmounting failed. Make sure no programs are using {} Käytöstä poisto epäonnistui. Varmista, että mikään ohjelma ei käytä kohdetta {} - + Select an archive to restore first. Valitse ensin palautettava arkisto. - + Processing archive contents - + Käsitellään arkiston sisältöä - + Choose Extraction Point Valitse purkupiste - + Yes Kyllä - + Cancel Peru - + No archive selected Arkistoa ei valittu - + Are you sure you want to delete all the selected archives? - + Haluatko varmasti poistaa kaikki valitut arkistot? - + Are you sure you want to delete the selected archive? - + Haluatko varmasti poistaa valitun arkiston? - + Confirm deletion Vahvista poistaminen - + Archives deleted. - + Arkistot poistettu. - + Archive deleted. Arkisto poistettu. - + Processing diff results. - + Käsitellään diff-tuloksia. - + Change name Vaihda nimi - + New archive name: Uusi arkiston nimi: - + Archive name cannot be blank. Arkiston nimi ei voi olla tyhjä. - + An archive with this name already exists. Arkisto tällä nimellä on jo olemassa. - + Archive renamed. Arkisto nimetty uudelleen. + + + (borg already running) + (borg on jo käynnissä) + BorgBreakJob - - - Breaking repository lock… - Puretaan tietovaraston lukitus... - - - - Repository lock broken. Please redo your last action. - Tietovaraston lukitus murrettu. Tee uudelleen viimeisin toimenpide. - BorgCheckJob - - - Starting consistency check… - Aloitetaan yhdenmukaisuuden tarkistus... - - - - Repo check failed. See logs for details. - Tietovaraston tarkistus epäonnistui. Katso lisätietoja lokitiedostoista. - - - - Check completed. - Tarkistus valmistui. - BorgCompactJob - - Starting repository compaction... - Aloitetaan tietovaraston pakkaus... - - - - Errors during compaction. See logs for details. - Virheitä pakkauksen aikana. Katso lisätietoja lokeista. - - - - Compaction completed. - Pakkaus suoritettu. + + Errors during compaction. See the <a href="{0}">logs</a> for details. + BorgCreateJob - - - Backup finished with warnings. See logs for details. - Varmuuskopiointi valmistui varoituksin. Katso lokista lisätietoja. - - - - Backup finished. - Varmuuskopiointi valmistui. - - - - Backup started. - Varmuuskopiointi käynnistetty. - BorgDeleteJob - - - Deleting archive… - Poistetaan arkisto... - - - - Archive deleted. - Arkisto poistettu. - BorgDiffJob - - - Requesting differences between archives… - Haetaan tietoja arkistojen eroista... - - - - Obtained differences between archives. - Arkistojen väliset eroavaisuudet haettu. - BorgExtractJob - - - Downloading files from archive… - Ladataan tiedostoja arkistosta... - - - - Restored files from archive. - Palautettiin tiedostot arkistosta. - BorgInfoArchiveJob - - - Refreshing archive… - Päivitetään arkistoa... - - - - Refreshing archive done. - Arkiston päivittäminen onnistui. - BorgInfoRepoJob @@ -554,36 +469,16 @@ Pakattu - + Task started Tehtävä käynnistetty BorgListArchiveJob - - - Getting archive content… - Haetaan arkiston sisältöä... - - - - Done getting archive content. - Saatiin arkiston sisältö. - BorgListRepoJob - - - Refreshing archives… - Päivitetään arkistoja... - - - - Refreshing archives done. - Arkistojen päivittäminen valmistui. - BorgMountJob @@ -595,16 +490,6 @@ BorgPruneJob - - - Pruning old archives… - Karsitaan vanhoja arkistoja... - - - - Pruning done. - Karsiminen valmistui. - BorgUmountJob @@ -725,27 +610,27 @@ Keep folders on top when sorting - + Pidä kansiot päällimäisenä järjestäessä Set display mode of diff view - + Aseta diff-näkymän näkymätila Tree - + Puu Tree, simplified - + Puu, yksinkertaistettu Collapse All - + Laajenna kaikki @@ -760,7 +645,7 @@ Diff Result - + Diff-tulos @@ -775,7 +660,7 @@ Flat - + Tasainen @@ -810,93 +695,93 @@ Folders First - + Kansiot ensin DiffResultDialog - + Copy - + Expand recursively - + Laajenna rekursiivisesti DiffTree - + Name - + Nimi - + Change - + Size - + Koko - + Balance - + Added {}, deleted {} - + Lisätty {}, poistettu {} - + File - + Tiedosto - + Directory - + Kansio - + Link - + Linkki - + Block device file - + Lohkolaitetiedosto - + Character device file - + unchanged - + muuttumaton - + modified - + muokattu - + removed - + poistettu - + added - + lisätty @@ -910,17 +795,17 @@ ExistingRepoWindow - + Connect to existing Repository Yhdistä olemassa olevaan tietovarastoon - + Show my password Näytä salasana - + Hide my password Piilota salasana @@ -966,95 +851,95 @@ ExtractDialog - + Extract Pura - + Copy - + Expand recursively - + Laajenna rekursiivisesti ExtractTree - + Name - + Nimi - + Last Modified - + Viimeksi muokattu - + Size - + Koko - + Health - + File - + Tiedosto - + Directory - + Symbolic link - + Symbolinen linkki - + FIFO pipe - + Hard link - + Socket - + Block special file - + Character special file - + healthy - + broken - + Linked to: {} @@ -1239,7 +1124,7 @@ <html><head/><body><p>For simple and secure backup hosting, try <a href="https://www.borgbase.com/?utm_source=vorta&utm_medium=app"><span style=" text-decoration: underline; color:#0984e3;">BorgBase</span></a>.</p></body></html> - <html><head/><body><p>Kokeile<a href="https://www.borgbase.com/?utm_source=vorta&utm_medium=app"><span style=" text-decoration: underline; color:#0984e3;">BorgBasea</span></a>, yksinkertaista ja turvallista varmuuskopiointipalvelua.</p></body></html> + <html><head/><body><p>Kokeile <a href="https://www.borgbase.com/?utm_source=vorta&utm_medium=app"><span style=" text-decoration: underline; color:#0984e3;">BorgBasea</span></a>, yksinkertaista ja turvallista varmuuskopiointipalvelua.</p></body></html> @@ -1304,7 +1189,7 @@ Check the consistency of the repository - + Tarkista tietovaraston yhtenäisyys @@ -1399,7 +1284,7 @@ Delete selected archive(s) - + Poista valitut arkistot @@ -1606,11 +1491,11 @@ - Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: - {0} + Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: + {0} {1} - Skeeman päivitys epäonnistui, lähetä virheraportti Sekalaiset-välilehdellä olevasta linkistä. Liitä raporttiin seuraavat tiedot: - {0} + Skeeman päivitys epäonnistui, lähetä virheraportti Sekalaiset-välilehdellä olevasta linkistä. Liitä raporttiin seuraavat tiedot: + {0} {1} @@ -1762,16 +1647,24 @@ Peru - + Latest Viimeisin - + Reset App Nollaa asetukset + + RepoCheckJob + + + Repo check failed. See the <a href="{0}">logs</a> for details. + + + RepoTab @@ -1817,7 +1710,7 @@ No repository selected - + Tietovarastoa ei ole valittu @@ -1836,37 +1729,37 @@ Yritä päivittää minkä tahansa arkiston metatiedot. - + Automatically choose SSH Key (default) Valitse SSH-avain automaattisesti (oletus) - + Public Key Copied to Clipboard Julkinen avain kopioitu leikepöydälle - + The selected public SSH key was copied to the clipboard. Use it to set up remote repo permissions. Valittu julkinen SSH-avain kopioitiin leikepöydälle. Käytä sitä tietovaraston käyttöoikeuksien asettamiseen. - + Could not find public key. Julkista avainta ei löytynyt. - + Select a public key from the dropdown first. Valitse ensin julkinen avain pudotusvalikosta. - + Repository was Unlinked Linkitys tietovarastoon poistettiin - + You can always connect it again later. Voit yhdistää siihen aina uudelleen. @@ -1874,47 +1767,47 @@ SSHAddWindow - + Generate and copy to clipboard Luo ja kopioi leikepöydälle - + ED25519 (Recommended) ED25519 (suositeltu) - + RSA (Legacy) RSA (vanha) - + ECDSA ECDSA - + High (Recommended) Korkea (suositeltu) - + Medium Keskitaso - + Key file already exists. Not overwriting. Avaintiedosto on jo olemassa. Ei korvata. - + New key was copied to clipboard and written to %s. Uusi avain kopioitiin leikepöydälle ja kirjoitettiin sijaintiin %s. - + Error during key generation. Virhe avainta luotaessa. @@ -1944,63 +1837,63 @@ Run a manual backup first - + Suorita manuaalinen varmuuskopiointi ensin None scheduled - + Ei ajastuksia SourceTab - + Files Tiedostot - + Folders Kansiot - + Paste Liitä - + Copy Kopioi - + Remove Poista - + Calculating… Lasketaan... - + You don't have read access to {dir}. Ei lukuoikeutta kohteeseen {dir}. - + Choose directory to back up Valitse varmuuskopioitava kansio - + Choose file(s) to back up Valitse varmuuskopioitava(t) tiedosto(t) - + Some of your sources are invalid: Jotkin lähteistä eivät ole kelvollisia: @@ -2041,120 +1934,120 @@ VortaApp - + Vorta Backup Vorta-varmuuskopiointi - + No Borg Binary Found Borg-binääriä ei löytynyt - + Vorta was unable to locate a usable Borg Backup binary. Vorta ei kyennyt paikallistamaan Borg-varmuuskopioinnin binääritiedostoa. - + Vorta needs Full Disk Access for complete Backups Vorta tarvitsee koko levyn käytön täydellisiin varmuuskopioihin - + Without this, some files will not be accessible and you may end up with an incomplete backup. Please set <b>Full Disk Access</b> permission for Vorta in <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>System Preferences > Security & Privacy</a>. Ilman tätä käyttöoikeutta kaikkia tiedostoja ei voida varmuuskopioida joten varmuuskopiot saattavat jäädä vajaiksi. Salli Vortalle <b>koko levyn käyttö</b> avaamalla <a href='x-apple.systempreferences:com.apple.preference.security?Privacy'>Järjestelmäasetukset > Suojaus ja yksityisyys</a>. - + Repository In Use Tietovarasto käytössä - + Abort Keskeytä - + Continue Jatka - + The repository at {repo_url} might be in use elsewhere. Tietovarasto osoitteessa {repo_url} saattaa olla käytössä jossain muualla. - + Only break the lock if you are certain no other Borg process on any machine is accessing the repository. Abort or break the lock? Pura lukitus vain, jos olet varma että mikään muu Borg-prosessi ei käytä tietovarastoa. Perutaanko vai puretaanko lukitus? - + You do not have permission to access the repository at {repo_url}. Gain access and try again. Sinulla ei ole käyttöoikeutta tietovaraston {repo_url} käyttämiseksi. Hanki käyttöoikeudet ja yritä uudelleen. - + No Repository Permissions Ei tietovaraston käyttöoikeuksia - + Failed to import profile Profiilin tuonti epäonnistui - + Failed to import a profile from {}: Profiilin tuonti epäonnistui kohteesta {}: - + Consider removing or repairing this file to get rid of this message. Harkitse tämän tiedoston poistamista tai korjaamista välttääksesi tämän viestin. - + Profile import successful! Profiilin tuonti onnistui! - + Profile {} imported. Profiili {} tuotu. - + Repo Check Failed Tietovarastun tarkistus epäonnistui - - Borg exited with a warning message. See logs for details. - Borg päättyi virheilmoitukseen. Katso lisätietoja lokeista. - - - + Repository data check for repo was killed by signal %s. - + The process running the check job got a kill signal. Try again. - + Repository data check for repo %s failed. Error code %s Tietovaraston %s tietojen tarkistus epäonnistui. Virhekoodi %s - + Consider repairing or recreating the repository soon to avoid missing data. Harkitse pian tietovaraston korjaamista tai uudelleen luomista, jotta vältytään tietojen katoamiselta. + + + Borg exited with warning status (rc 1). See the <a href="{0}">logs</a> for details. + + VortaScheduler @@ -2207,7 +2100,7 @@ Select a backup repository first. - + Valitse ensin varmuuskopion tietovarasto. @@ -2215,37 +2108,37 @@ Käyttämäsi Borg-versio on liian vanha. >=1.1.0 vaaditaan. - + Add some folders to back up first. Lisää ensin joitain kansioita varmuuskopioon. - + Current Wifi is not allowed. Nykyinen wifi-verkko ei ole sallittu. - + Not running backup over metered connection. Varmuuskopiointia ei suoriteta laskutettavalla yhteydellä. - + Pre-backup command returned non-zero exit code. Varmuuskopioinnin esikomento palautti poistumiskoodiksi muun kuin nollan. - + Repo folder not mounted or moved. Tietovaraston kansiota ei ole liitetty tai se on siirretty. - + Starting backup… Käynnistetään varmuuskopiota... - + This feature needs Borg 1.2.0 or higher. Tämä ominaisuus vaatii Borg-version 1.2.0 tai uudemman. @@ -2257,7 +2150,7 @@ Mount point not active. - + Liitospiste ei ole aktiivinen. @@ -2287,63 +2180,93 @@ Display notifications when background tasks fail Näytä ilmoitukset epäonnistuneista taustatehtävistä - - - Also notify about successful background tasks - Ilmoita myös onnistuneista taustatehtävistä - Automatically start Vorta at login Käynnistä Vorta automaattisesti kirjautumisen yhteydessä - + Open main window on startup Avaa pääikkuna sovelluksen käynnistyessä - + Get statistics of file/folder when added Hae lisätyn tiedoston/kansion tiedot taulukkoon - + Check for updates on startup Tarkista päivitykset sovelluksen käynnistyessä - + Include pre-release versions when checking for updates Sisällytä esijulkaisuversiot päivityksiä tarkistettaessa + + + Notify about successful background tasks + Ilmoita onnistuneista taustatehtävistä + + + + Add Vorta to the systems autostart list + Lisää Vorta järjestelmän automaattikäynnistyksen listaan + + + + Open main window when the application is launched + Avaa pääikkuna kun sovellus käynnistetään + + + + When adding a new source, calculate its size and the number of files. + Kun uusi lähde lisätään, laske sen koko ja tiedostojen määrä. + + + + Otherwise Vorta's configuration database stores the password in plaintext. + Muussa tapauksessa Vortan asetustietokanta tallettaa salasanan selväkielisenä. + + + + Set owner to current user and umask to 0277 + Aseta omistajaksi nykyinen käyttäjä ja umaskin arvoksi 0277 + + + + Alerts user when full disk access permission has not been provided + + utils - + Passwords must be identical and greater than 8 characters long. Salasanojen tulee olla identtiset ja pidempiä kuin 8 merkkiä. - + Passwords must be identical. Salasanojen tulee olla identtiset. - + Passwords must be greater than 8 characters long. Salasanojen tulee olla pidempiä kuin 8 merkkiä. Storing password in your password manager. - + Tallennetaan salasana salasanahallinnan sovellukseen. Saving password with Vorta settings. - + Tallennetaan salasana Vortan asetuksiin. - + \ No newline at end of file From 60f9fc27b4a27f57d1d567b25a50b37de611b19a Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Sun, 1 Oct 2023 01:19:39 -0700 Subject: [PATCH 32/52] Unit test improvements and coverage increase. By @bigtedde (#1787) --- src/vorta/application.py | 2 +- src/vorta/i18n/ts/vorta.de.ts | 10 +-- src/vorta/i18n/ts/vorta.es.ts | 6 +- src/vorta/i18n/ts/vorta.fi.ts | 10 +-- tests/unit/test_archives.py | 16 +++++ tests/unit/test_diff.py | 123 +++++++++++++++++----------------- tests/unit/test_misc.py | 80 ++++++++++++++++------ tests/unit/test_profile.py | 42 +++++++----- tests/unit/test_repo.py | 103 ++++++++++++++++++++++++++++ tests/unit/test_source.py | 32 ++++++++- tests/unit/test_utils.py | 32 ++++++++- 11 files changed, 337 insertions(+), 119 deletions(-) diff --git a/src/vorta/application.py b/src/vorta/application.py index 921fe02f2..357eb2dfa 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -325,7 +325,7 @@ def check_failed_response(self, result: Dict[str, Any]): # No fail logger.warning('VortaApp.check_failed_response was called with returncode 0') elif returncode == 130: - # Keyboard interupt + # Keyboard interrupt pass else: # Real error # Create QMessageBox diff --git a/src/vorta/i18n/ts/vorta.de.ts b/src/vorta/i18n/ts/vorta.de.ts index 1bd2463d1..aa49dddd2 100644 --- a/src/vorta/i18n/ts/vorta.de.ts +++ b/src/vorta/i18n/ts/vorta.de.ts @@ -1489,11 +1489,11 @@ - Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: - {0} + Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: + {0} {1} - Schema-Upgrade Fehler, erstelle einen Bugreport auf dem Link um "Misc"-Tab, mit folgendem Fehler: - {0} + Schema-Upgrade Fehler, erstelle einen Bugreport auf dem Link um "Misc"-Tab, mit folgendem Fehler: + {0} {1} @@ -2267,4 +2267,4 @@ Speichere Kennwort in der Vortakonfiguration - \ No newline at end of file + diff --git a/src/vorta/i18n/ts/vorta.es.ts b/src/vorta/i18n/ts/vorta.es.ts index e7aaf64ef..9d7bc65f8 100644 --- a/src/vorta/i18n/ts/vorta.es.ts +++ b/src/vorta/i18n/ts/vorta.es.ts @@ -1491,8 +1491,8 @@ - Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: - {0} + Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: + {0} {1} Fallo al actualizar el esquema, rellene un informe de error con el enlace de la pestaña «Varios» con el siguiente error: {0} @@ -2269,4 +2269,4 @@ Guardar contraseñas con los ajustes de Vorta. - \ No newline at end of file + diff --git a/src/vorta/i18n/ts/vorta.fi.ts b/src/vorta/i18n/ts/vorta.fi.ts index 3415ff608..5ae1e50c7 100644 --- a/src/vorta/i18n/ts/vorta.fi.ts +++ b/src/vorta/i18n/ts/vorta.fi.ts @@ -1491,11 +1491,11 @@ - Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: - {0} + Schema upgrade failure, file a bug report with the link in the Misc tab with the following error: + {0} {1} - Skeeman päivitys epäonnistui, lähetä virheraportti Sekalaiset-välilehdellä olevasta linkistä. Liitä raporttiin seuraavat tiedot: - {0} + Skeeman päivitys epäonnistui, lähetä virheraportti Sekalaiset-välilehdellä olevasta linkistä. Liitä raporttiin seuraavat tiedot: + {0} {1} @@ -2269,4 +2269,4 @@ Tallennetaan salasana Vortan asetuksiin. - \ No newline at end of file + diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index d965cd02e..e0a7fb1aa 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -6,6 +6,7 @@ import vorta.utils import vorta.views.archive_tab from PyQt6 import QtCore +from PyQt6.QtWidgets import QMenu from vorta.store.models import ArchiveModel, BackupProfileModel @@ -202,3 +203,18 @@ def test_inline_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_en # Successful rename case qtbot.waitUntil(lambda: tab.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) assert tab.archiveTable.model().index(0, 4).data() == new_archive_name + + +def test_archiveitem_contextmenu(qapp, qtbot, archive_env): + main, tab = archive_env + + pos = tab.archiveTable.visualRect(tab.archiveTable.model().index(0, 0)).center() + tab.archiveTable.customContextMenuRequested.emit(pos) + qtbot.waitUntil(lambda: tab.archiveTable.findChild(QMenu) is not None, timeout=2000) + + context_menu = tab.archiveTable.findChild(QMenu) + + assert context_menu is not None + expected_actions = ['Copy', 'Recalculate', 'Mount…', 'Extract…', 'Rename…', 'Delete', 'Diff'] + for action in expected_actions: + assert any(menu_actions.text() == action for menu_actions in context_menu.actions()) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 91c1e1cd4..cc001f02b 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -5,6 +5,7 @@ import vorta.utils import vorta.views.archive_tab from PyQt6.QtCore import QDateTime, QItemSelectionModel, Qt +from PyQt6.QtWidgets import QMenu from vorta.views.diff_result import ( ChangeType, DiffData, @@ -15,12 +16,8 @@ ) -@pytest.mark.parametrize( - 'json_mock_file,folder_root', [('diff_archives', 'test'), ('diff_archives_dict_issue', 'Users')] -) -def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, folder_root, archive_env): - main, tab = archive_env - +def setup_diff_result_window(qtbot, mocker, tab, borg_json_output, json_mock_file="diff_archives"): + """Sets up the diff result window.""" stdout, stderr = borg_json_output(json_mock_file) popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) @@ -46,14 +43,70 @@ def check(feature_name): tab.diff_action() qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults) + assert hasattr(tab, '_resultwindow') + + +@pytest.mark.parametrize( + 'json_mock_file, folder_root', [('diff_archives', 'test'), ('diff_archives_dict_issue', 'Users')] +) +def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, folder_root, archive_env): + """Tests basic functionality of archive diff.""" + main, tab = archive_env + setup_diff_result_window(qtbot, mocker, tab, borg_json_output, json_mock_file) model = tab._resultwindow.treeView.model().sourceModel() assert model.root.children[0].subpath == folder_root - assert tab._resultwindow.archiveNameLabel_1.text() == 'test-archive' tab._resultwindow.accept() +def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output, archive_env): + """Tests copy action by row selection and when passed an index.""" + main, tab = archive_env + setup_diff_result_window(qtbot, mocker, tab, borg_json_output) + + # mock the clipboard to ensure no changes are made to it during testing + mocker.patch.object(qapp.clipboard(), "setMimeData") + clipboard_spy = mocker.spy(qapp.clipboard(), "setMimeData") + + # test 'diff_item_copy()' by passing it an item to copy + index = tab._resultwindow.treeView.model().index(0, 0) + assert index is not None + tab._resultwindow.diff_item_copy(index) + clipboard_data = clipboard_spy.call_args[0][0] + assert clipboard_data.hasText() + assert clipboard_data.text() == "/test" + + clipboard_spy.reset_mock() + + # test 'diff_item_copy()' by selecting a row to copy + flags = QItemSelectionModel.SelectionFlag.Rows + flags |= QItemSelectionModel.SelectionFlag.Select + tab._resultwindow.treeView.selectionModel().select(tab._resultwindow.treeView.model().index(0, 0), flags) + tab._resultwindow.diff_item_copy() + clipboard_data = clipboard_spy.call_args[0][0] + assert clipboard_data.hasText() + assert clipboard_data.text() == "/test" + + +def test_treeview_context_menu(qapp, qtbot, mocker, borg_json_output, archive_env): + """Tests the diff result window context menu for expected actions.""" + main, tab = archive_env + setup_diff_result_window(qtbot, mocker, tab, borg_json_output) + + # Load the context menu at the first result in window + pos = tab._resultwindow.treeView.visualRect(tab._resultwindow.treeView.model().index(0, 0)).center() + tab._resultwindow.treeview_context_menu(pos) + qtbot.waitUntil(lambda: tab._resultwindow.findChild(QMenu) is not None, **pytest._wait_defaults) + context_menu = tab._resultwindow.findChild(QMenu) + assert context_menu is not None + + # assert the actions are available in the context menu + expected_actions = ['Copy', 'Expand recursively'] + for action in expected_actions: + assert any(menu_actions.text() == action for menu_actions in context_menu.actions()) + + @pytest.mark.parametrize( 'line, expected', [ @@ -404,59 +457,3 @@ def test_archive_diff_json_parser(line, expected): assert item.path == PurePath(expected[0]).parts assert item.data == DiffData(*expected[1:]) - - -def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - - tab.populate_from_profile() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) - - stdout, stderr = borg_json_output("diff_archives") - popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) - mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) - - compat = vorta.utils.borg_compat - - def check(feature_name): - if feature_name == 'DIFF_JSON_LINES': - return False - return vorta.utils.BorgCompatibility.check(compat, feature_name) - - mocker.patch.object(vorta.utils.borg_compat, 'check', check) - - selection_model: QItemSelectionModel = tab.archiveTable.selectionModel() - model = tab.archiveTable.model() - - flags = QItemSelectionModel.SelectionFlag.Rows - flags |= QItemSelectionModel.SelectionFlag.Select - - selection_model.select(model.index(0, 0), flags) - selection_model.select(model.index(1, 0), flags) - - tab.diff_action() - - qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults) - - # mock the clipboard to ensure no changes are made to it during testing - mocker.patch.object(qapp.clipboard(), "setMimeData") - clipboard_spy = mocker.spy(qapp.clipboard(), "setMimeData") - - # test 'diff_item_copy()' by passing it an item to copy - index = tab._resultwindow.treeView.model().index(0, 0) - assert index is not None - tab._resultwindow.diff_item_copy(index) - clipboard_data = clipboard_spy.call_args[0][0] - assert clipboard_data.hasText() - assert clipboard_data.text() == "/test" - - clipboard_spy.reset_mock() - - # test 'diff_item_copy()' by selecting a row to copy - tab._resultwindow.treeView.selectionModel().select(tab._resultwindow.treeView.model().index(0, 0), flags) - tab._resultwindow.diff_item_copy() - clipboard_data = clipboard_spy.call_args[0][0] - assert clipboard_data.hasText() - assert clipboard_data.text() == "/test" diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index eb1a5ac1f..e4842283a 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -6,31 +6,72 @@ import pytest import vorta.store.models from PyQt6 import QtCore -from PyQt6.QtWidgets import QCheckBox, QFormLayout +from PyQt6.QtGui import QCloseEvent +from PyQt6.QtWidgets import QCheckBox, QFormLayout, QMessageBox +from vorta.store.models import SettingsModel + + +def test_toggle_all_settings(qapp, qtbot): + """Toggle each setting twice as a basic sanity test to ensure app does crash.""" + groups = ( + SettingsModel.select(SettingsModel.group) + .distinct(True) + .where(SettingsModel.group != '') + .order_by(SettingsModel.group.asc()) + ) + + settings = [ + setting + for group in groups + for setting in SettingsModel.select().where( + SettingsModel.type == 'checkbox', SettingsModel.group == group.group + ) + ] + + for setting in settings: + for _ in range(2): + _click_toggle_setting(setting.label, qapp, qtbot) -def test_autostart(qapp, qtbot): - """Check if file exists only on Linux, otherwise just check it doesn't crash""" +@pytest.mark.skipif(sys.platform != "linux", reason="testing autostart path for Linux only") +def test_autostart_linux(qapp, qtbot): + """Checks that autostart path is added correctly on Linux when setting is enabled.""" setting = "Automatically start Vorta at login" + # ensure file is present when autostart is enabled + _click_toggle_setting(setting, qapp, qtbot) + autostart_path = ( + Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~") + '/.config') + "/autostart") / "vorta.desktop" + ) + qtbot.waitUntil(lambda: autostart_path.exists(), **pytest._wait_defaults) + with open(autostart_path) as desktop_file: + desktop_file_text = desktop_file.read() + assert desktop_file_text.startswith("[Desktop Entry]") + + # ensure file is removed when autostart is disabled _click_toggle_setting(setting, qapp, qtbot) - if sys.platform == 'linux': - autostart_path = ( - Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~") + '/.config') + "/autostart") - / "vorta.desktop" - ) - qtbot.waitUntil(lambda: autostart_path.exists(), **pytest._wait_defaults) + assert not os.path.exists(autostart_path) - with open(autostart_path) as desktop_file: - desktop_file_text = desktop_file.read() - assert desktop_file_text.startswith("[Desktop Entry]") +def test_enable_background_question(qapp, monkeypatch, mocker): + """Tests that 'enable background question' correctly prompts user.""" + main = qapp.main_window + close_event = Mock(value=QCloseEvent()) - _click_toggle_setting(setting, qapp, qtbot) + # disable system trey and enable setting to test + monkeypatch.setattr("vorta.views.main_window.is_system_tray_available", lambda: False) + mocker.patch.object(vorta.store.models.SettingsModel, "get", return_value=Mock(value=True)) + mocker.patch.object(QMessageBox, "exec") # prevent QMessageBox from stopping test - if sys.platform == 'linux': - assert not os.path.exists(autostart_path) + # Create a mock for QMessageBox and its setText method + mock_msgbox = mocker.Mock(spec=QMessageBox) + mocker.patch("vorta.views.main_window.QMessageBox", return_value=mock_msgbox) + + main.closeEvent(close_event) + + mock_msgbox.setText.assert_called_once_with("Should Vorta continue to run in the background?") + close_event.accept.assert_called_once() def test_enable_fixed_units(qapp, qtbot, mocker): @@ -61,14 +102,13 @@ def test_enable_fixed_units(qapp, qtbot, mocker): assert kwargs_list['fixed_unit'] is None # use the qt bot to click the setting and see that the refresh_archive emit works as intended. - with qtbot.waitSignal(qapp.main_window.miscTab.refresh_archive, timeout=5000): + with qtbot.waitSignal(qapp.main_window.miscTab.refresh_archive, **pytest._wait_defaults): _click_toggle_setting(setting, qapp, qtbot) @pytest.mark.skipif(sys.platform != 'darwin', reason="Full Disk Access check only on Darwin") def test_check_full_disk_access(qapp, qtbot, mocker): - """Enables/disables 'Check for Full Disk Access on startup' setting and ensures functionality""" - setting = "Check for Full Disk Access on startup" + """Tests if the full disk access warning is properly silenced with the setting enabled""" # Set mocks for setting enabled mocker.patch.object(vorta.store.models.SettingsModel, "get", return_value=Mock(value=True)) @@ -88,10 +128,6 @@ def test_check_full_disk_access(qapp, qtbot, mocker): qapp.check_darwin_permissions() mock_qmessagebox.assert_not_called() - # Checks that setting doesn't crash program when click toggled on then off""" - _click_toggle_setting(setting, qapp, qtbot) - _click_toggle_setting(setting, qapp, qtbot) - def _click_toggle_setting(setting, qapp, qtbot): """Toggle setting checkbox in the misc tab""" diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py index f7c58bb7a..04cc9782c 100644 --- a/tests/unit/test_profile.py +++ b/tests/unit/test_profile.py @@ -1,37 +1,49 @@ from PyQt6 import QtCore -from PyQt6.QtWidgets import QDialogButtonBox +from PyQt6.QtWidgets import QDialogButtonBox, QMessageBox, QToolTip from vorta.store.models import BackupProfileModel -def test_profile_add(qapp, qtbot): +def test_profile_add_delete(qapp, qtbot, mocker): + """Tests adding and deleting profiles.""" main = qapp.main_window - qtbot.mouseClick(main.profileAddButton, QtCore.Qt.MouseButton.LeftButton) + # add profile and ensure it is created as intended + qtbot.mouseClick(main.profileAddButton, QtCore.Qt.MouseButton.LeftButton) add_profile_window = main.window - # qtbot.addWidget(add_profile_window) - qtbot.keyClicks(add_profile_window.profileNameField, 'Test Profile') - qtbot.mouseClick( - add_profile_window.buttonBox.button(QDialogButtonBox.StandardButton.Save), QtCore.Qt.MouseButton.LeftButton - ) - + save_button = add_profile_window.buttonBox.button(QDialogButtonBox.StandardButton.Save) + qtbot.mouseClick(save_button, QtCore.Qt.MouseButton.LeftButton) assert BackupProfileModel.get_or_none(name='Test Profile') is not None assert main.profileSelector.currentText() == 'Test Profile' + # delete the new profile and ensure it is no longer available. + mocker.patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes) + qtbot.mouseClick(main.profileDeleteButton, QtCore.Qt.MouseButton.LeftButton) + assert BackupProfileModel.get_or_none(name='Test Profile') is None + assert main.profileSelector.currentText() == 'Default' + + # attempt to delete the last remaining profile + # see that it cannot be deleted, a warning is displayed, and the profile remains + warning = mocker.patch.object(QToolTip, 'showText') + qtbot.mouseClick(main.profileDeleteButton, QtCore.Qt.MouseButton.LeftButton) + assert "Cannot delete the last profile." in warning.call_args[0][1] + assert BackupProfileModel.get_or_none(name='Default') is not None + assert main.profileSelector.currentText() == 'Default' + def test_profile_edit(qapp, qtbot): + """Tests editing/renaming a profile""" main = qapp.main_window - qtbot.mouseClick(main.profileRenameButton, QtCore.Qt.MouseButton.LeftButton) + # click to rename profile, clear the name field, type new profile name + qtbot.mouseClick(main.profileRenameButton, QtCore.Qt.MouseButton.LeftButton) edit_profile_window = main.window - # qtbot.addWidget(edit_profile_window) - edit_profile_window.profileNameField.setText("") qtbot.keyClicks(edit_profile_window.profileNameField, 'Test Profile') - qtbot.mouseClick( - edit_profile_window.buttonBox.button(QDialogButtonBox.StandardButton.Save), QtCore.Qt.MouseButton.LeftButton - ) + save_button = edit_profile_window.buttonBox.button(QDialogButtonBox.StandardButton.Save) + qtbot.mouseClick(save_button, QtCore.Qt.MouseButton.LeftButton) + # assert a profile by the old name no longer exists, and the newly named profile does exist and is selected. assert BackupProfileModel.get_or_none(name='Default') is None assert BackupProfileModel.get_or_none(name='Test Profile') is not None assert main.profileSelector.currentText() == 'Test Profile' diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index 8cbb29a3e..e072119d1 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -1,5 +1,6 @@ import os import uuid +from typing import Any, Dict import pytest import vorta.borg.borg_job @@ -187,6 +188,55 @@ def test_ssh_dialog_failure(qapp, qtbot, mocker, monkeypatch, tmpdir): assert tab.sshComboBox.count() == 1 +def test_ssh_copy_to_clipboard_action(qapp, qtbot, mocker, tmpdir): + """Testing the proper QMessageBox dialogue appears depending on the copy action circumstances.""" + tab = qapp.main_window.repoTab + + # set mocks to test assertions and prevent test interruptions + text = mocker.patch.object(QMessageBox, "setText") + mocker.patch.object(QMessageBox, "show") + mocker.patch.object(qapp.clipboard(), "setText") + + qtbot.mouseClick(tab.bAddSSHKey, QtCore.Qt.MouseButton.LeftButton) + ssh_dialog = tab._window + ssh_dialog_closed = mocker.spy(ssh_dialog, 'reject') + ssh_dir = tmpdir + key_tmpfile = ssh_dir.join("id_rsa-test") + pub_tmpfile = ssh_dir.join("id_rsa-test.pub") + key_tmpfile_full = os.path.join(key_tmpfile.dirname, key_tmpfile.basename) + ssh_dialog.outputFileTextBox.setText(key_tmpfile_full) + ssh_dialog.generate_key() + + # Ensure new key file was created + qtbot.waitUntil(lambda: ssh_dialog_closed.called, **pytest._wait_defaults) + assert len(ssh_dir.listdir()) == 2 + # populate the ssh combobox with the ssh key we created in tmpdir + mock_expanduser = mocker.patch('os.path.expanduser', return_value=str(tmpdir)) + tab.init_ssh() + assert tab.sshComboBox.count() == 2 + + # test when no ssh key is selected to copy + assert tab.sshComboBox.currentIndex() == 0 + qtbot.mouseClick(tab.sshKeyToClipboardButton, QtCore.Qt.MouseButton.LeftButton) + message = "Select a public key from the dropdown first." + text.assert_called_with(message) + + # Select a key and copy it + mock_expanduser.return_value = pub_tmpfile + tab.sshComboBox.setCurrentIndex(1) + assert tab.sshComboBox.currentIndex() == 1 + qtbot.mouseClick(tab.sshKeyToClipboardButton, QtCore.Qt.MouseButton.LeftButton) + message = "The selected public SSH key was copied to the clipboard. Use it to set up remote repo permissions." + text.assert_called_with(message) + + # handle ssh key file not found + mock_expanduser.return_value = "foobar" + assert tab.sshComboBox.currentIndex() == 1 + qtbot.mouseClick(tab.sshKeyToClipboardButton, QtCore.Qt.MouseButton.LeftButton) + message = "Could not find public key." + text.assert_called_with(message) + + def test_create(qapp, borg_json_output, mocker, qtbot): main = qapp.main_window stdout, stderr = borg_json_output('create') @@ -202,3 +252,56 @@ def test_create(qapp, borg_json_output, mocker, qtbot): assert main.createStartBtn.isEnabled() assert main.archiveTab.archiveTable.rowCount() == 3 assert main.scheduleTab.logTableWidget.rowCount() == 1 + + +@pytest.mark.parametrize( + "response", + [ + { + "return_code": 0, # no error + "error": "", + "icon": None, + "info": None, + }, + { + "return_code": 1, # warning + "error": "Borg exited with warning status (rc 1).", + "icon": QMessageBox.Icon.Warning, + "info": "", + }, + { + "return_code": 2, # critical error + "error": "Repository data check for repo test_repo_url failed. Error code 2", + "icon": QMessageBox.Icon.Critical, + "info": "Consider repairing or recreating the repository soon to avoid missing data.", + }, + { + "return_code": 135, # 128 + n = kill signal n + "error": "killed by signal 7", + "icon": QMessageBox.Icon.Critical, + "info": "The process running the check job got a kill signal. Try again.", + }, + {"return_code": 130, "error": "", "icon": None, "info": None}, # keyboard interrupt + ], +) +def test_repo_check_failed_response(qapp, qtbot, mocker, response): + """Test the processing of the signal that a repo consistency check has failed.""" + mock_result: Dict[str, Any] = { + 'params': {'repo_url': 'test_repo_url'}, + 'returncode': response["return_code"], + 'errors': [(0, 'test_error_message')] if response["return_code"] not in [0, 130] else None, + } + + mock_exec = mocker.patch.object(QMessageBox, "exec") + mock_text = mocker.patch.object(QMessageBox, "setText") + mock_info = mocker.patch.object(QMessageBox, "setInformativeText") + mock_icon = mocker.patch.object(QMessageBox, "setIcon") + + qapp.check_failed_response(mock_result) + + # return codes 0 and 130 do not provide a message + # for all other return codes, assert the message is formatted correctly + if mock_exec.call_count != 0: + mock_icon.assert_called_with(response["icon"]) + assert response["error"] in mock_text.call_args[0][0] + assert response["info"] in mock_info.call_args[0][0] diff --git a/tests/unit/test_source.py b/tests/unit/test_source.py index 10fa0ed43..70b019616 100644 --- a/tests/unit/test_source.py +++ b/tests/unit/test_source.py @@ -2,6 +2,8 @@ import vorta.views from PyQt6 import QtCore from PyQt6.QtWidgets import QMessageBox +from vorta.views.main_window import MainWindow +from vorta.views.source_tab import SourceTab @pytest.fixture() @@ -9,11 +11,11 @@ def source_env(qapp, qtbot, monkeypatch, choose_file_dialog): """ Handles common setup and teardown for unit tests involving the source tab. """ - monkeypatch.setattr(vorta.views.source_tab, "choose_file_dialog", choose_file_dialog) - main = qapp.main_window + main: MainWindow = qapp.main_window + tab: SourceTab = main.sourceTab main.tabWidget.setCurrentIndex(1) - tab = main.sourceTab qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 1, timeout=2000) + monkeypatch.setattr(vorta.views.source_tab, "choose_file_dialog", choose_file_dialog) yield main, tab @@ -100,3 +102,27 @@ def test_sources_update(qapp, qtbot, mocker, source_env): qtbot.mouseClick(tab.updateButton, QtCore.Qt.MouseButton.LeftButton) assert tab.sourceFilesWidget.rowCount() == 2 assert update_path_info_spy.call_count == 2 + + +def test_source_copy(qapp, qtbot, monkeypatch, mocker, source_env): + """ + Test source_copy() with and without an index passed. + If no index is passed, it should copy the first selected source + """ + main, tab = source_env + + mock_clipboard = mocker.patch.object(qapp.clipboard(), "setMimeData") + tab.source_add(want_folder=True) + qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 2, **pytest._wait_defaults) + + tab.sourceFilesWidget.selectRow(0) + tab.source_copy() + assert mock_clipboard.call_count == 1 + source = mock_clipboard.call_args[0][0] # retrieves the QMimeData() object used in method call + assert source.text() == "/tmp" + + index = tab.sourceFilesWidget.model().index(1, 0) + tab.source_copy(index) + assert mock_clipboard.call_count == 2 + source = mock_clipboard.call_args[0][0] # retrieves the QMimeData() object used in method call + assert source.text() == "/tmp/another" diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index cbb971b85..ea529c089 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -9,6 +9,7 @@ is_system_tray_available, normalize_path, pretty_bytes, + sort_sizes, ) @@ -21,6 +22,33 @@ def test_keyring(): assert keyring.get_password("vorta-repo", REPO) == UNICODE_PW +@pytest.mark.parametrize( + "input_sizes, expected_sorted", + [ + # Basic ordering + (["1.0 GB", "2.0 MB", "3.0 KB"], ["3.0 KB", "2.0 MB", "1.0 GB"]), + # Multiple same units + (["3.0 GB", "2.0 GB", "1.0 GB"], ["1.0 GB", "2.0 GB", "3.0 GB"]), + # Multiple different units + (["2.0 MB", "3.0 GB", "1.0 KB", "5.0 GB"], ["1.0 KB", "2.0 MB", "3.0 GB", "5.0 GB"]), + # Larger to smaller units + (["1.0 YB", "1.0 ZB", "1.0 EB", "1.0 PB"], ["1.0 PB", "1.0 EB", "1.0 ZB", "1.0 YB"]), + # Skipping non-numeric sizes + (["2x MB", "3.0 KB", "apple GB", "1.0 GB"], ["3.0 KB", "1.0 GB"]), + # Skipping invalid suffix + (["1.0 XX", "5.0 YY", "9.0 ZZ", "1.0 MB"], ["1.0 MB"]), + # Floats with decimals + (["2.5 GB", "2.3 GB", "1.1 MB"], ["1.1 MB", "2.3 GB", "2.5 GB"]), + # Checking the same sizes across different units + (["1.0 MB", "1000.0 KB"], ["1000.0 KB", "1.0 MB"]), + # Handle empty lists + ([], []), + ], +) +def test_sort_sizes(input_sizes, expected_sorted): + assert sort_sizes(input_sizes) == expected_sorted + + @pytest.mark.parametrize( "precision, expected_unit", [ @@ -60,7 +88,7 @@ def test_best_unit_for_sizes_nonmetric(sizes, expected_unit): ) def test_pretty_bytes_fixed_units(size, metric, precision, fixed_unit, expected_output): """ - test pretty bytes when specifying a fixed unit of measurement + Test pretty bytes when specifying a fixed unit of measurement """ output = pretty_bytes(size, metric=metric, precision=precision, fixed_unit=fixed_unit) assert output == expected_output @@ -131,7 +159,7 @@ def test_get_path_datasize(tmpdir): def test_is_system_tray_available(mocker): """ - sanity check to ensure proper behavior + Sanity check to ensure proper behavior """ mocker.patch('PyQt6.QtWidgets.QSystemTrayIcon.isSystemTrayAvailable', return_value=False) assert is_system_tray_available() is False From 071dd86dedd0523d84a40dd392fa557e2c5ad312 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Tue, 24 Oct 2023 01:36:50 -0700 Subject: [PATCH 33/52] Profile sidebar and new setting interface. By @bigtedde (#1809) --- src/vorta/application.py | 2 +- src/vorta/assets/UI/abouttab.ui | 315 +++++++++++++++++ src/vorta/assets/UI/mainwindow.ui | 407 +++++++++++++--------- src/vorta/assets/UI/misctab.ui | 107 ------ src/vorta/assets/icons/gpl_logo.svg | 315 +++++++++++++++++ src/vorta/assets/icons/python_logo.svg | 265 ++++++++++++++ src/vorta/assets/icons/settings_wheel.svg | 1 + src/vorta/views/about_tab.py | 34 ++ src/vorta/views/main_window.py | 84 +++-- src/vorta/views/misc_tab.py | 11 - src/vorta/views/utils.py | 10 +- tests/unit/test_misc.py | 2 +- tests/unit/test_profile.py | 10 +- 13 files changed, 1247 insertions(+), 316 deletions(-) create mode 100644 src/vorta/assets/UI/abouttab.ui create mode 100644 src/vorta/assets/icons/gpl_logo.svg create mode 100644 src/vorta/assets/icons/python_logo.svg create mode 100644 src/vorta/assets/icons/settings_wheel.svg create mode 100644 src/vorta/views/about_tab.py diff --git a/src/vorta/application.py b/src/vorta/application.py index 357eb2dfa..34c1acf91 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -173,7 +173,7 @@ def set_borg_details_result(self, result): """ if 'version' in result['data']: borg_compat.set_version(result['data']['version'], result['data']['path']) - self.main_window.miscTab.set_borg_details(borg_compat.version, borg_compat.path) + self.main_window.aboutTab.set_borg_details(borg_compat.version, borg_compat.path) self.main_window.repoTab.toggle_available_compression() self.main_window.archiveTab.toggle_compact_button_visibility() self.scheduler.reload_all_timers() # Start timer after Borg version is set. diff --git a/src/vorta/assets/UI/abouttab.ui b/src/vorta/assets/UI/abouttab.ui new file mode 100644 index 000000000..791b72915 --- /dev/null +++ b/src/vorta/assets/UI/abouttab.ui @@ -0,0 +1,315 @@ + + + Form + + + + 0 + 0 + 791 + 497 + + + + Form + + + + 12 + + + 12 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 5 + + + 0 + + + + + + + Qt::AlignHCenter + + + 5 + + + 10 + + + + + Vorta Version: + + + + + + + 0.0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + Qt::AlignHCenter + + + 5 + + + 10 + + + 10 + + + + + Borg Version: + + + + + + + 1.1.8 + + + + + + + + + 10 + + + + + /usr/bin/borg + + + + + + + + + QFrame::HLine + + + QFrame::Sunken + + + + + + + Qt::AlignHCenter + + + 5 + + + 10 + + + + + <html><head/><body><p><a href="https://github.com/borgbase/vorta/issues/new/choose"><span style=" text-decoration: underline; color:#0984e3;">Click here</span></a> to report a bug.</p></body></html> + + + true + + + + + + + + + Qt::AlignHCenter + + + 10 + + + + + <html><head/><body><p><a href="file:///"><span style=" text-decoration: underline; color:#0984e3;">View the logs</span></a></p></body></html> + + + 0 + + + true + + + + + + + + + Qt::AlignHCenter + + + 5 + + + 10 + + + + + <html><head/><body><p><a href="https://borgbackup.readthedocs.io/en/master/index.html"><span style=" text-decoration: underline; color:#0984e3;"> Click here</span></a> to view the docs.</p></body></html> + + + true + + + + + + + + + Qt::AlignHCenter + + + 5 + + + 10 + + + 10 + + + + + <html><head/><body><p><a href="https://github.com/borgbase/vorta"><span style=" text-decoration: underline; color:#0984e3;">Click here</span></a> for view Git repo.</p></body></html> + + + true + + + + + + + + + QFrame::HLine + + + QFrame::Sunken + + + + + + + Qt::AlignHCenter + + + 20 + + + + + + Vorta is a cross-platform, open-source client designed to simplify the management of Borg backups. + + Copyright (C) 2018-2020 Manuel Riel and Vorta contributors (see CONTRIBUTORS.md) + + + + 0 + + + + + + + + + 10 + + + 10 + + + 20 + + + Qt::AlignHCenter + + + + + + + + + + + + + + + Qt::Vertical + + + + 40 + 20 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/src/vorta/assets/UI/mainwindow.ui b/src/vorta/assets/UI/mainwindow.ui index a8b67caea..911c5ea67 100644 --- a/src/vorta/assets/UI/mainwindow.ui +++ b/src/vorta/assets/UI/mainwindow.ui @@ -12,7 +12,7 @@ - 800 + 1000 600 @@ -23,28 +23,9 @@ 1.000000000000000 - + - - - 12 - - - 0 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - + @@ -53,184 +34,282 @@ - - + + - 300 - 0 + 200 + 400 - - QComboBox::AdjustToContents - - - - - - - Add a new profile (Dropdown: Import from file) - - - - 16 - 16 - - - - QToolButton::MenuButtonPopup - - - - - - - Rename current profile - - - - 16 - 16 - - - - - - - - Export current profile - - - + + Qt::ScrollBarAsNeeded - - - Delete current profile - - - - - + + + + + + + + + 20 + 20 + + + + QToolButton::InstantPopup + + + + + + + Delete current profile + + + + 20 + 20 + + + + + + + + + + + Qt::Vertical + + + + 40 + 40 + + + + + + + + Rename current profile + + + + 20 + 20 + + + + + + + + Export current profile + + + + 20 + 20 + + + + + + + + - + - Qt::Horizontal + Qt::Vertical - 40 - 20 + 20 + 40 - - - - - - - 0 - 0 - - - - false - - - false - - - - - 0 - 0 - - - - Repository - - - - - Sources - - - - - Schedule - - - - - Archives - - - - - Misc - - - - - - - - - - false + + + + Settings / About 150 - 0 + 40 - - false + + Qt::NoFocus - - Cancel + + QPushButton:focus { border: none; outline: none; } false - - - - - 0 - 0 - - - - true - - - - - - - - 0 - 0 - + + + + Qt::Vertical - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + QSizePolicy::Fixed - - true + + + 20 + 20 + - + + + + + + + + 0 + 0 + + + + false + + + false + + + + + 0 + 0 + + + + Settings + + + + + About + + + + + + + + + 0 + 0 + + + + false + + + false + + + + + 0 + 0 + + + + Repository + + + + + Sources + + + + + Schedule + + + + + Archives + + + + + + + + + + false + + + + 150 + 0 + + + + false + + + Cancel + + + false + + + + + + + + 0 + 0 + + + + true + + + + + + + + 0 + 0 + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + diff --git a/src/vorta/assets/UI/misctab.ui b/src/vorta/assets/UI/misctab.ui index f60dfbe7d..6ec1d908b 100644 --- a/src/vorta/assets/UI/misctab.ui +++ b/src/vorta/assets/UI/misctab.ui @@ -43,113 +43,6 @@ - - - - 5 - - - 0 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Vorta Version: - - - - - - - 0.0 - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - <html><head/><body><p>| <a href="https://github.com/borgbase/vorta/issues/new/choose"><span style=" text-decoration: underline; color:#0984e3;">Report</span></a> a Bug |</p></body></html> - - - true - - - - - - - <html><head/><body><p><a href="file:///"><span style=" text-decoration: underline; color:#0984e3;">Log</span></a></p></body></html> - - - 0 - - - true - - - - - - - - - 5 - - - 0 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Borg Version: - - - - - - - 1.1.8 - - - - - - - /usr/bin/borg - - - - - diff --git a/src/vorta/assets/icons/gpl_logo.svg b/src/vorta/assets/icons/gpl_logo.svg new file mode 100644 index 000000000..a62fdacbb --- /dev/null +++ b/src/vorta/assets/icons/gpl_logo.svg @@ -0,0 +1,315 @@ + + + + + GPLv3 or Later + + + + image/svg+xml + + GPLv3 or Later + 2018-11-26 + + + Aryeom Han + + + + + Creative Commons by-sa + + + + + GPLv3 or Later logo made by Aryeom Han for the Free Software Foundation 2019 fundraising. +About the author, see: https://film.zemarmot.net/ + + + LILA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vorta/assets/icons/python_logo.svg b/src/vorta/assets/icons/python_logo.svg new file mode 100644 index 000000000..467b07b26 --- /dev/null +++ b/src/vorta/assets/icons/python_logo.svg @@ -0,0 +1,265 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vorta/assets/icons/settings_wheel.svg b/src/vorta/assets/icons/settings_wheel.svg new file mode 100644 index 000000000..326c4c686 --- /dev/null +++ b/src/vorta/assets/icons/settings_wheel.svg @@ -0,0 +1 @@ + diff --git a/src/vorta/views/about_tab.py b/src/vorta/views/about_tab.py new file mode 100644 index 000000000..41928b400 --- /dev/null +++ b/src/vorta/views/about_tab.py @@ -0,0 +1,34 @@ +import logging + +from PyQt6 import QtCore, uic + +from vorta import config +from vorta._version import __version__ +from vorta.store.models import BackupProfileMixin +from vorta.utils import get_asset +from vorta.views.utils import get_colored_icon + +uifile = get_asset('UI/abouttab.ui') +AboutTabUI, AboutTabBase = uic.loadUiType(uifile) + +logger = logging.getLogger(__name__) + + +class AboutTab(AboutTabBase, AboutTabUI, BackupProfileMixin): + refresh_archive = QtCore.pyqtSignal() + + def __init__(self, parent=None): + """Init.""" + super().__init__(parent) + self.setupUi(parent) + self.versionLabel.setText(__version__) + self.logLink.setText( + f'Click here to view the logs.' + ) + self.gpl_logo.setPixmap(get_colored_icon('gpl_logo', scaled_height=40, return_qpixmap=True)) + self.python_logo.setPixmap(get_colored_icon('python_logo', scaled_height=40, return_qpixmap=True)) + + def set_borg_details(self, version, path): + self.borgVersion.setText(version) + self.borgPath.setText(f"
Path to Borg: {path}
") diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index 1f0c42e73..78cf40f47 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -2,13 +2,13 @@ from pathlib import Path from PyQt6 import QtCore, uic -from PyQt6.QtCore import QPoint +from PyQt6.QtCore import QPoint, Qt from PyQt6.QtGui import QFontMetrics, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QApplication, QCheckBox, QFileDialog, - QMenu, + QListWidgetItem, QMessageBox, QToolTip, ) @@ -24,6 +24,7 @@ from vorta.views.partials.loading_button import LoadingButton from vorta.views.utils import get_colored_icon +from .about_tab import AboutTab from .archive_tab import ArchiveTab from .export_window import ExportWindow from .import_window import ImportWindow @@ -71,8 +72,10 @@ def __init__(self, parent=None): self.sourceTab = SourceTab(self.sourceTabSlot) self.archiveTab = ArchiveTab(self.archiveTabSlot, app=self.app) self.scheduleTab = ScheduleTab(self.scheduleTabSlot) - self.miscTab = MiscTab(self.miscTabSlot) - self.miscTab.set_borg_details(borg_compat.version, borg_compat.path) + self.miscTab = MiscTab(self.SettingsTabSlot) + self.aboutTab = AboutTab(self.AboutTabSlot) + self.aboutTab.set_borg_details(borg_compat.version, borg_compat.path) + self.miscWidget.hide() self.tabWidget.setCurrentIndex(0) self.repoTab.repo_changed.connect(self.archiveTab.populate_from_profile) @@ -80,6 +83,7 @@ def __init__(self, parent=None): self.repoTab.repo_added.connect(self.archiveTab.refresh_archive_list) self.miscTab.refresh_archive.connect(self.archiveTab.populate_from_profile) + self.miscButton.clicked.connect(self.toggle_misc_visibility) self.createStartBtn.clicked.connect(self.app.create_backup_action) self.cancelButton.clicked.connect(self.app.backup_cancelled_event.emit) @@ -94,14 +98,13 @@ def __init__(self, parent=None): # Init profile list self.populate_profile_selector() - self.profileSelector.currentIndexChanged.connect(self.profile_select_action) + self.profileSelector.itemClicked.connect(self.profile_clicked_action) + self.profileSelector.currentItemChanged.connect(self.profile_selection_changed_action) self.profileRenameButton.clicked.connect(self.profile_rename_action) self.profileExportButton.clicked.connect(self.profile_export_action) self.profileDeleteButton.clicked.connect(self.profile_delete_action) - profile_add_menu = QMenu() - profile_add_menu.addAction(self.tr('Import from file…'), self.profile_import_action) - self.profileAddButton.setMenu(profile_add_menu) - self.profileAddButton.clicked.connect(self.profile_add_action) + self.profileAddButton.addAction(self.tr("Create new profile"), self.profile_add_action) + self.profileAddButton.addAction(self.tr("Import from file…"), self.profile_import_action) # OS-specific startup options: if not get_network_status_monitor().is_network_status_available(): @@ -129,7 +132,8 @@ def set_icons(self): self.profileAddButton.setIcon(get_colored_icon('plus')) self.profileRenameButton.setIcon(get_colored_icon('edit')) self.profileExportButton.setIcon(get_colored_icon('file-import-solid')) - self.profileDeleteButton.setIcon(get_colored_icon('trash')) + self.profileDeleteButton.setIcon(get_colored_icon('minus')) + self.miscButton.setIcon(get_colored_icon('settings_wheel')) def set_progress(self, text=''): self.progressText.setText(text) @@ -150,14 +154,29 @@ def _toggle_buttons(self, create_enabled=True): self.cancelButton.repaint() def populate_profile_selector(self): + # Clear the previous entries self.profileSelector.clear() + + # Keep track of the current item to be selected (if any) + current_item = None + + # Add items to the QListWidget for profile in BackupProfileModel.select().order_by(BackupProfileModel.name): - self.profileSelector.addItem(profile.name, profile.id) - current_profile_index = self.profileSelector.findData(self.current_profile.id) - self.profileSelector.setCurrentIndex(current_profile_index) + item = QListWidgetItem(profile.name) + item.setData(Qt.ItemDataRole.UserRole, profile.id) + + self.profileSelector.addItem(item) + + if profile.id == self.current_profile.id: + current_item = item - def profile_select_action(self, index): - backup_profile_id = self.profileSelector.currentData() + # Set the current profile as selected + if current_item: + self.profileSelector.setCurrentItem(current_item) + + def profile_selection_changed_action(self, index): + profile = self.profileSelector.currentItem() + backup_profile_id = profile.data(Qt.ItemDataRole.UserRole) if profile else None if not backup_profile_id: return self.current_profile = BackupProfileModel.get(id=backup_profile_id) @@ -170,8 +189,13 @@ def profile_select_action(self, index): ).execute() self.archiveTab.toggle_compact_button_visibility() + def profile_clicked_action(self): + if self.miscWidget.isVisible(): + self.toggle_misc_visibility() + def profile_rename_action(self): - window = EditProfileWindow(rename_existing_id=self.profileSelector.currentData()) + backup_profile_id = self.profileSelector.currentItem().data(Qt.ItemDataRole.UserRole) + window = EditProfileWindow(rename_existing_id=backup_profile_id) self.window = window # For tests window.setParent(self, QtCore.Qt.WindowType.Sheet) window.open() @@ -180,7 +204,7 @@ def profile_rename_action(self): def profile_delete_action(self): if self.profileSelector.count() > 1: - to_delete_id = self.profileSelector.currentData() + to_delete_id = self.profileSelector.currentItem().data(Qt.ItemDataRole.UserRole) to_delete = BackupProfileModel.get(id=to_delete_id) msg = self.tr("Are you sure you want to delete profile '{}'?".format(to_delete.name)) @@ -195,8 +219,8 @@ def profile_delete_action(self): if reply == QMessageBox.StandardButton.Yes: to_delete.delete_instance(recursive=True) self.app.scheduler.remove_job(to_delete_id) # Remove pending jobs - self.profileSelector.removeItem(self.profileSelector.currentIndex()) - self.profile_select_action(0) + self.profileSelector.takeItem(self.profileSelector.currentRow()) + self.profile_selection_changed_action(0) else: warn = self.tr("Cannot delete the last profile.") @@ -259,12 +283,26 @@ def profile_imported_event(profile): def profile_add_edit_result(self, profile_name, profile_id): # Profile is renamed - if self.profileSelector.currentData() == profile_id: - self.profileSelector.setItemText(self.profileSelector.currentIndex(), profile_name) + if self.profileSelector.currentItem().data(Qt.ItemDataRole.UserRole) == profile_id: + self.profileSelector.currentItem().setText(profile_name) # Profile is added else: - self.profileSelector.addItem(profile_name, profile_id) - self.profileSelector.setCurrentIndex(self.profileSelector.count() - 1) + profile = QListWidgetItem(profile_name) + profile.setData(Qt.ItemDataRole.UserRole, profile_id) + self.profileSelector.addItem(profile) + self.profileSelector.setCurrentItem(profile) + + def toggle_misc_visibility(self): + if self.miscWidget.isVisible(): + self.miscWidget.hide() + self.tabWidget.setCurrentIndex(0) + self.miscButton.setStyleSheet("font-weight: normal;") + self.tabWidget.show() + else: + self.tabWidget.hide() + self.miscWidget.setCurrentIndex(0) + self.miscButton.setStyleSheet("font-weight: bold;") + self.miscWidget.show() def backup_started_event(self): self._toggle_buttons(create_enabled=False) diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py index 83c47be64..6a3375352 100644 --- a/src/vorta/views/misc_tab.py +++ b/src/vorta/views/misc_tab.py @@ -12,8 +12,6 @@ QSpacerItem, ) -from vorta import config -from vorta._version import __version__ from vorta.i18n import translate from vorta.store.models import BackupProfileMixin, SettingsModel from vorta.store.settings import get_misc_settings @@ -34,11 +32,6 @@ def __init__(self, parent=None): """Init.""" super().__init__(parent) self.setupUi(parent) - self.versionLabel.setText(__version__) - self.logLink.setText( - f'Log' - ) self.checkboxLayout = QFormLayout(self.frameSettings) self.checkboxLayout.setSpacing(4) @@ -133,7 +126,3 @@ def save_setting(self, key, new_value): setting = SettingsModel.get(key=key) setting.value = bool(new_value) setting.save() - - def set_borg_details(self, version, path): - self.borgVersion.setText(version) - self.borgPath.setText(path) diff --git a/src/vorta/views/utils.py b/src/vorta/views/utils.py index 870a8ccb7..15058f144 100644 --- a/src/vorta/views/utils.py +++ b/src/vorta/views/utils.py @@ -3,7 +3,7 @@ from vorta.utils import get_asset, uses_dark_mode -def get_colored_icon(icon_name): +def get_colored_icon(icon_name, scaled_height=128, return_qpixmap=False): """ Return SVG icon in the correct color. """ @@ -11,7 +11,9 @@ def get_colored_icon(icon_name): svg_str = svg_file.read() if uses_dark_mode(): svg_str = svg_str.replace(b'#000000', b'#ffffff') - # Reduce image size to 128 height - svg_img = QImage.fromData(svg_str).scaledToHeight(128) + svg_img = QImage.fromData(svg_str).scaledToHeight(scaled_height) - return QIcon(QPixmap(svg_img)) + if return_qpixmap: + return QPixmap(svg_img) + else: + return QIcon(QPixmap(svg_img)) diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index e4842283a..d354af05e 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -107,7 +107,7 @@ def test_enable_fixed_units(qapp, qtbot, mocker): @pytest.mark.skipif(sys.platform != 'darwin', reason="Full Disk Access check only on Darwin") -def test_check_full_disk_access(qapp, qtbot, mocker): +def test_check_full_disk_access(qapp, mocker): """Tests if the full disk access warning is properly silenced with the setting enabled""" # Set mocks for setting enabled diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py index 04cc9782c..03ad56e79 100644 --- a/tests/unit/test_profile.py +++ b/tests/unit/test_profile.py @@ -8,19 +8,19 @@ def test_profile_add_delete(qapp, qtbot, mocker): main = qapp.main_window # add profile and ensure it is created as intended - qtbot.mouseClick(main.profileAddButton, QtCore.Qt.MouseButton.LeftButton) + main.profile_add_action() add_profile_window = main.window qtbot.keyClicks(add_profile_window.profileNameField, 'Test Profile') save_button = add_profile_window.buttonBox.button(QDialogButtonBox.StandardButton.Save) qtbot.mouseClick(save_button, QtCore.Qt.MouseButton.LeftButton) assert BackupProfileModel.get_or_none(name='Test Profile') is not None - assert main.profileSelector.currentText() == 'Test Profile' + assert main.profileSelector.currentItem().text() == 'Test Profile' # delete the new profile and ensure it is no longer available. mocker.patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes) qtbot.mouseClick(main.profileDeleteButton, QtCore.Qt.MouseButton.LeftButton) assert BackupProfileModel.get_or_none(name='Test Profile') is None - assert main.profileSelector.currentText() == 'Default' + assert main.profileSelector.currentItem().text() == 'Default' # attempt to delete the last remaining profile # see that it cannot be deleted, a warning is displayed, and the profile remains @@ -28,7 +28,7 @@ def test_profile_add_delete(qapp, qtbot, mocker): qtbot.mouseClick(main.profileDeleteButton, QtCore.Qt.MouseButton.LeftButton) assert "Cannot delete the last profile." in warning.call_args[0][1] assert BackupProfileModel.get_or_none(name='Default') is not None - assert main.profileSelector.currentText() == 'Default' + assert main.profileSelector.currentItem().text() == 'Default' def test_profile_edit(qapp, qtbot): @@ -46,4 +46,4 @@ def test_profile_edit(qapp, qtbot): # assert a profile by the old name no longer exists, and the newly named profile does exist and is selected. assert BackupProfileModel.get_or_none(name='Default') is None assert BackupProfileModel.get_or_none(name='Test Profile') is not None - assert main.profileSelector.currentText() == 'Test Profile' + assert main.profileSelector.currentItem().text() == 'Test Profile' From 8d0870ea3b31e5922953e15a8d7301f2d6edc048 Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Tue, 24 Oct 2023 11:37:40 +0100 Subject: [PATCH 34/52] Update macOS notarization for use with notarytool (#1831) --- package/macos-package-app.sh | 40 ++++++++---------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/package/macos-package-app.sh b/package/macos-package-app.sh index 64950ca1a..1ed7a28e9 100644 --- a/package/macos-package-app.sh +++ b/package/macos-package-app.sh @@ -7,7 +7,8 @@ APP_BUNDLE_ID="com.borgbase.client.macos" APP_BUNDLE="Vorta" # CERTIFICATE_NAME="Developer ID Application: Joe Doe (XXXXXX)" # APPLE_ID_USER="name@example.com" -# APPLE_ID_PASSWORD="@keychain:Notarization" +# APPLE_ID_PASSWORD="CHANGEME" +# APPLE_TEAM_ID="CNMSCAXT48" # Sign app bundle, Sparkle and Borg @@ -37,38 +38,13 @@ create-dmg \ "Vorta.dmg" \ "Vorta.app" - # Notarize DMG -RESULT=$(xcrun altool --notarize-app --type osx \ - --primary-bundle-id $APP_BUNDLE_ID \ - --username $APPLE_ID_USER --password $APPLE_ID_PASSWORD \ - --file "$APP_BUNDLE.dmg" --output-format xml) - -REQUEST_UUID=$(echo "$RESULT" | xpath5.18 "//key[normalize-space(text()) = 'RequestUUID']/following-sibling::string[1]/text()" 2> /dev/null) - -# Poll for notarization status -echo "Submitted notarization request $REQUEST_UUID, waiting for response..." -sleep 60 -while true -do - RESULT=$(xcrun altool --notarization-info "$REQUEST_UUID" \ - --username "$APPLE_ID_USER" \ - --password "$APPLE_ID_PASSWORD" \ - --output-format xml) - STATUS=$(echo "$RESULT" | xpath5.18 "//key[normalize-space(text()) = 'Status']/following-sibling::string[1]/text()" 2> /dev/null) - - if [ "$STATUS" = "success" ]; then - echo "Notarization of $APP_BUNDLE succeeded!" - break - elif [ "$STATUS" = "in progress" ]; then - echo "Notarization in progress..." - sleep 20 - else - echo "Notarization of $APP_BUNDLE failed:" - echo "$RESULT" - exit 1 - fi -done +xcrun notarytool submit \ + --output-format plist --wait --timeout 10m \ + --apple-id $APPLE_ID_USER \ + --password $APPLE_ID_PASSWORD \ + --team-id $APPLE_TEAM_ID \ + "$APP_BUNDLE.dmg" # Staple the notary ticket xcrun stapler staple $APP_BUNDLE.dmg From 4c7b119b3ebdf64cefeb8385a47afb12c8e00cda Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 27 Oct 2023 12:07:59 +0100 Subject: [PATCH 35/52] Minor: add missing notarization env var --- .github/workflows/build-macos.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 3a284022b..795d15486 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -50,6 +50,7 @@ jobs: CERTIFICATE_NAME: ${{ secrets.MACOS_CERTIFICATE_NAME }} APPLE_ID_USER: ${{ secrets.APPLE_ID_USER }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 security create-keychain -p 123 build.keychain From b3550991e331c4589dec9ddfccbf9141acf8efaf Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 27 Oct 2023 13:39:42 +0100 Subject: [PATCH 36/52] Bump version to v0.9.1-beta2 --- src/vorta/_version.py | 2 +- .../metadata/com.borgbase.Vorta.appdata.xml | 11 ++++++++++- src/vorta/i18n/qm/vorta.de.qm | Bin 59148 -> 59142 bytes src/vorta/i18n/qm/vorta.es.qm | Bin 59051 -> 59049 bytes src/vorta/i18n/qm/vorta.fi.qm | Bin 53593 -> 53587 bytes 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/vorta/_version.py b/src/vorta/_version.py index 1500ff3ea..7bce0c8a5 100644 --- a/src/vorta/_version.py +++ b/src/vorta/_version.py @@ -1 +1 @@ -__version__ = '0.9.1-beta1' +__version__ = '0.9.1-beta2' diff --git a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index 8b6ac2789..934c6fd26 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -40,7 +40,16 @@ - + + +
    +
  • Unit test improvements and coverage increase. By @bigtedde (#1787)
  • +
  • Profile sidebar and new setting interface. By @bigtedde (#1809)
  • +
  • Update macOS notarization for use with notarytool (#1831)
  • +
+
+
+
https://github.com/borgbase/vorta/issues diff --git a/src/vorta/i18n/qm/vorta.de.qm b/src/vorta/i18n/qm/vorta.de.qm index 661a49f9343a50564c3023fa9c4bb4a1eb4470de..0db3a0c3c00ba85425ecbea090fa1373aec6645e 100644 GIT binary patch delta 1884 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 QESs}~a?IUJ_OvJb0p%f8ga7~l diff --git a/src/vorta/i18n/qm/vorta.es.qm b/src/vorta/i18n/qm/vorta.es.qm index 6019f003fcd949fd511d10f5687afc7e4911e137..eefdd843d0227b7f990096c290de73651ad53fc8 100644 GIT binary patch delta 1888 zcmX9D2-(OaoT$05VPh0d{b^@_~a3%NAL@hM;e10G?Uq!rb4W-^&9H9%bb*oKSWl zRGtjMsRx>GGL6e{55eSuXy6~Qu-FEHi=pgo&YZafakX{8xje*IJOJi)AilC3X!#Zi zZ>s`@Vu7_1Uge7c$5brs3;_1z zu}7SlYhjag7XK(5dMO#mT!WtQ_kfOBxZeH{_)`Z)5=wwieOQ*x!WA&5&iu;L+_Wjg zt6~)wpKb!|R&IXpUEp0GE@i@P;4tsPW1ZZpWC7TE-i2*bxOHKNfvR&{vECOrkLtdB4rdf?P8mZeYQ5?QC-7UCz2_a+a0zZU{i(*e(~g~@+^0+g2v!L78nEJBEE z`vTZ~l+72+VNpW1=XIbxPq2@x1j1$t8>8v%o8W--!wx*#7$uR z6Bn{h)@q1JP{g2SI`u_wacU{0Vb2fZT*o%xjR|6MCtZY9$Ha^ba^#SYxMF-V@YNl0 zv)esl(Jhvz1p>M2#R@-KubIGRisAnK;`wY+czU>azRL_aTg0E!eSvOH{9ht*=w8m8 zVyGiWO8x8zV4EZ@xvB^HyCmDPejt92ls$eoa3Dm=+xawb`l#f%F^)JCOErZNq~aCn zLrJ02*AbE~V}o31t8<|6e`=?VodlQj{Yd`He1ibfF95!=*2reE^G){2fdh zE}%8d>cZ_aq>;#Lz(Be*a$3lVhUx(G0jGTPrDJIZS@zU(vwX>2i*V)*bVHm2)ornas7y zg-;5Aossg!Pb|O%AGt`O=C@y$ivkzX^LJ!6bt6TpT|UrC`+OJ6pFbcQJ@Vy~Pqk4q zqw>WZ+V8bh9^5$yoZl}GA6g2``APmEjEtJ=CXXB?qV_-+mM@TheBBogd~{rXu=otn zHA&$&kY_6$3V)H*EgVtA0uyj@i{jbu3n(Ls_i-w)t>1<150zj`EpW6-nG)I#yk4R# z+}8|jcvi7$q(o_ol4TpBjx!ZoO*c?*R>}Q<6sU+&8cxyw>NQGJb*Ucs){R}2EsoR5 zt@~dAl3op;NMmKr2QG}8uI8)syzZo$Kl(c^R*MoT_IYt?ZDJ!`8k<_XDT~JHf7SY? zSu`p-)%s7U|4YZz)`}3|n_;zW^OrzUquR@R(U@JP_9anyEkyn9@h_yy0!>UOeg9DL zxt7v6gC^7tZBZ`K^81fw-AsK~8nnOm?4^D>SiNF#|6Xg#kEYCJY0X`vTJ=xtsuKF# zh<0t_&*bV#?an6B;_N}~;ZoAhuT&e|Gz7$L(;i6|fHR*OxduwM{jyQFmC`ftossvV zIoO=U=BwfUvBoX$5~Wq1#dSyW#?)&W1diNgFK97bBipEjbE<vvAdHNQ?1~aQpRO5n#!48x1Bk%$3W5%gcGt ms9>x8yxP-@>J~1CS*tP?#c#uOJN<;&*>h}M`*2lH(*FVJ*+`K9 delta 1930 zcmX9Hh2!n1b1PVk-?3xY{*tC48Q>c$A3~Meb zO$b~D5ri-S0WmQ&(NzK+6gA9M*0hYY&`fF{Q?fO`_UAMI%z5U0-{<|E-}#;Q+?IZJ zcRyP?%X<&7AQiA~1JaHI6Y{}q&ja?Slr1!R-+;Ec5qSO$7v?;IwuJ>6N0(JZGkn=V zs2B{+t_50$RfS3O2*Jbx6R;;1M#~^@F-%>@7}Cv%sjCP6ZAGl(J}~ruOtHLE72SN9&jKW*E)U&{&WDtaV5Z~1~pS-WC~Tg#_+eP%#;w~ z<#?NkHS2-=3}*hxJHYy9nB;M{fkT`Nd%Bnvi5#$Hz=iEonAKs2fT{~jvDObbzKW@x zmj#q8Wm+9ndwadAWi1}J;XT}w5;2HXO|_iOGs zM*-{)EN1lsz9Q=tzl>^mvi>RYzzbG(LT((u-DaQD90iJf*|1e@G?5^7=02+3J4zj5 zjmhiT+DoL+nyc&=@+zS36#Ml%7OLIFUiU8n>Z;jW7pR*4uk3@b?*ZFxYS|?#flaqW_ojb`$houBhEUB)fwvF0U{XPK10LmaSy#V>urE~C81WPqgzM=)r91|>WoB?8+ zgsibUfqfyu^6KY+&wB*hbuZ#jEYuc8ke1&F9|{uPzM7D98586}OT7#2%Y@zEk$zb@ z!rrLsvKGHry2(USX8wIqdWVmqa3}JZIRiOWMVfe=qpp6$E z4pRSlqg>e7t zGk$z}gq2426^^T_9yzhN$Co$x`bC z3eHJ6n@ItONoqVz|7%uD%{8T3;6EPf713xrE8V(x3J|n%xDUlo)MgjPOqKIwTCeYu z^Bz3K#d6U+viX9a+_lkaN(xhzNrgH z*d?E2y(x|}<-PWnm7{^8Z=M*hGC-3d+*n z7V77q+8`M{t|`rVCh}aS(%Ma`)!bIENTG9nR<8QorKzq^e%VM`ocmPyeFhIl=>L$O>59MPzjkW= R)8DXsb&l%QF;vx?@PEw(RSEzA diff --git a/src/vorta/i18n/qm/vorta.fi.qm b/src/vorta/i18n/qm/vorta.fi.qm index 351555f51d919f935f7ade56608df91ffe9d540b..3b1e93207b46eeb9157aff12d626e1cf5db2267e 100644 GIT binary patch delta 1929 zcmX9<4OCQR8h-BFxpU|K%n(5XqQDT=;40u4KO4GW3rP5bp&*+DXhO4shHC_MSQu2~ zX9NW=KO&4;3L%joL!vAgCB#OHcGQEpq2-3Ip=Wosvh|mJ_spC#^UQqTci-=MpZ9s+ zx%>fp;RCihV(G3Y!0Hsh;svCi0eoA*H0J_GmP2ib1YGYz-TEHjaoLHD4yfB&fWTlU zzA*vyQ5L8lt0{?MeykY^7AC<}s)6R#nnx+td^}%7VeI`dS*`+~Z9|B=C$P90VPE$F zx;;+JIEAQHX+TFDmK-<)48$R>?miF^fw=lwdftZk_x=PZZiv6t1gvYr3Kh}yOLSu6 zHLTpE5RJ!JVROg3@Uv8}G-_0i|j~vy{0qcOqd8%LhrU3JI zs(V9E18fe9g=2tc5o=ib7Tq4q8k3ij5T&gD=6HZx!kY6sfO%7F*l}XfeT98-4Xr;F z%C5LrK?0AmGZOc)_HRhUx3;n8q&(onE9|##TZmH!d)>Gjs4iotF3_F2`Rv1Q?*Wzm zYBu#9;LjE{YgOv+OYA3k*hRr}xloG=o6rR(B`bU!kFDSP;-*`98s<0ulimPVrJsUZFLh~B)3~=y{ z=Jnw-fY$|0{dHQwJEcKHs69UI+is(j#FxNDoD^ZAAbc{OcH^PQ`a>M#k;g3H=0{y=ivh}q8l0nG+<`uwd ze^w}-E+QMfE0lDbfKNMxQjyZLEfq>k%sW7fJ!WU0< z0cE|ycs8B))CyPkUjo*>cfD>^7#6K8%-7nD{x@$j!=faw76v0a=S>;PVG z6jvW>C#QTRZjecdk`yt+GD#V)6)kr1%G;lbn<_|w@^P_clzvyWiET~Qz~E-_%Z7Gk zo#1cXAx_=<65xHLa4(t>;j9xQy`@}<*7w?_+=u^1lT^BlJe+Y$IJ-@|LbV`Guqzm-#f3 z*sEfrocQ4j)G|qO(k3;H$38i8_$a0Fu`)|ExlYM#xshb3Xt{mxER8BZB}oi^sbBuy z>nAE`o_wc_WavL3KS(9nykq5uWs}q*+vG>ui$LFmj%gv=ZvC51wTFz;H%G_LpwZO~ zWk?M7Gw610Cn}kzbv5r$>`smD=p>m}bxHS80Y$0RE3+gMV^v}#Q~Yc_+-S&k>GgqS z34mLmJ~*AyY3S5P#qI;b;`Pe|s4#;q`pT8=)F^&>$K6=^GaJ+oHC?9RIHDghkvwge zlpZOZ`A)elh5IdVu_v%3L6u977qMTR=yLMXZeZ4cOaD15#dx5^$Wev?mtT8IfoH2- z9tQZ(?uLz|vcu6RM;9^fjw?2X@fKl30p_C+&TnQEYC$gzy|b0+UF${BSTMhkKFo#6 Nzse1c?Uk2e{s$fJOB?_I delta 1958 zcmX9<4OCR+7TtI5+_`h_of$$25CMlUhEf6ym_#3Ff{G>x^0TrSG%*S^OA%`UGg!#a z2r_sDjf?=E6uQc$2GIotUYtTZ_e3gpMB1Z>}N0T zXKkTNN|eBgBp}TVNEra89RyRG2^@%n(m4!pyaVOIcL0}*cC6}#vZ)d9^S9$Gm!Le% z0+l1>`Qgkj<%9mh7`R7KYg@`6Bw2FdW2P{5r(j6C0(?@4ASYK~o(&;i_5j*4JEnFc zJkA8PEW^V3eL&wbEULHpB1;|qnHC_X(YQi!F(e;Y8W7SWHStkR# zStMGCUUdwTY9oPJ83JBntw5#+l6$>@J}w@A>(^Mqo+Jq;m^#L*D0pGc#rX$K@^K0V-}foz{XVOh2cBEmMkXL z^;h8Fs2z`XGjB$7zJyfb5CyFF`Y3=~$UdX!1I*!UU{(t-bBql+Mr_)zv9lBD zT<&4LV(ZzuZ%M>Awz6MHS-^*h?Dw0}h*LXzL%##Cm9k?alv5kPPJDkCD0xcBCT|9E z(v++EA2b7#4|C~{o)`Gov0*2d>s(27uv~r^ z#qHS3l`I@01LSZ!yTX87SGo6H;(>r2+|f_}p!(vta$$xl*n0sv^rC!=n-(=i6&Er{ zR&rFWylbUNxTJcCc^ue#PxbQ90N^&F+OqXLFbgA~0odk}bRyBtH3~aF3 zu`oxDSA|FU@J}{Vw6L4}OdGjmPd7i$vJ)_Q^U=MTz^jY-loaYq<86MuOEl1x#TPjc zFa2eHSJHIgl?1**Pv>Pu`I5@$W#ccTld?0M_zV5hfV>g@catZ1=QjV>QsU5Vl7o1| zi<8s|T{KL|-s*Ih2H<_GdPDUyr2Dwqa>JQ&x~S{&LP?Vr^SFd@i{*L-PP&`SE!=byX?4oy&YSR zsQ+c$NzZ(NH5~`$3<#{1Tw?7P{6=ZZXwL{CjwYZjNeG|Y3Y-cM)>M*PRu>4bT?+$x z7Yi9WI-l<>WPH0Eu+;eqc@NEGGqaH2ZU8<$E)20_Y3mOe6m2EaIA&y zObe|gIST3A?tPLpa6Gk)WzN=ceQhfzDxmmb*A{j8I3;zow>1S69;~x{zjlp&- z;)GkTdXiNR3HMi?hXt*EBAZQJNS!6JqoiM!v&fruKxdFR`7B)&eiWykq67u99UJ}; zeGT=%2Myv3{|?~gDse??Gdbm~xJE-teg^QKlF^NJh>^8pn1_@p(es@RQs%@X^pgsfl7~~r zrTV3ZfS?GezA%;6>DSVMreN9v=cEJeB*CC0wNyMsQob#<6`ceY^h(3*6e>AN`g9pF zkUXTz|NcqRjB0ojN$ggdnH%yg$>{Z*LtQjW5|lc0x)J*ou?{D`*#WrsIrM&E z0ro?V(1be=INa+j0G_ZpO!#`z>6+I`pyy+Hu*+FkX6y&?6fG$R)IY4n(dR a^it7#y*#mfHLn--*#Wuwj@2c1BK{9sUrxyY From 98b64621c24b4a995042ddffe009f900138ccc60 Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Tue, 14 Nov 2023 07:54:20 -0800 Subject: [PATCH 37/52] Loosen platformdirs dependency (#1843) 4.x is backwards compatible with 3.x except that site_cache_dir has moved to /var/cache. Vorta doesn't use this. https://github.com/platformdirs/platformdirs/releases/tag/4.0.0 --- setup.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index edddb2f15..6cda372a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,8 +37,8 @@ package_dir = include_package_data = true python_requires = >=3.8 install_requires = - platformdirs >=3.0.0, <4.0.0; sys_platform == 'darwin' # for macOS: breaking changes in 3.0.0, - platformdirs >=2.6.0, <4.0.0; sys_platform != 'darwin' # for others: 2.6+ works consistently. + platformdirs >=3.0.0, <5.0.0; sys_platform == 'darwin' # for macOS: breaking changes in 3.0.0, + platformdirs >=2.6.0, <5.0.0; sys_platform != 'darwin' # for others: 2.6+ works consistently. pyqt6 peewee psutil From c9f170aecfd388e68f580a9ddbd162d7781592d3 Mon Sep 17 00:00:00 2001 From: Adwait <111136306+AdwaitSalankar@users.noreply.github.com> Date: Fri, 24 Nov 2023 20:59:28 +0530 Subject: [PATCH 38/52] Backup settings.db before migrations. By @AdwaitSalankar (#1848) --- src/vorta/store/connection.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index c4b67ed70..d27a83c09 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -1,9 +1,11 @@ import os +import shutil from datetime import datetime, timedelta from peewee import Tuple, fn from playhouse import signals +from vorta import config from vorta.autostart import open_app_at_startup from .migrations import run_migrations @@ -83,6 +85,7 @@ def init_db(con=None): if created or current_schema.version == SCHEMA_VERSION: pass else: + backup_current_db(current_schema.version) run_migrations(current_schema, con) # Create missing settings and update labels. @@ -98,3 +101,13 @@ def init_db(con=None): s.tooltip = setting['tooltip'] s.save() + + +def backup_current_db(schema_version): + """ + Creates a backup copy of settings.db + """ + + timestamp = datetime.now().strftime('%Y-%m-%d-%H%M%S') + backup_file_name = f'settings_v{schema_version}_{timestamp}.db' + shutil.copy(config.SETTINGS_DIR / 'settings.db', config.SETTINGS_DIR / backup_file_name) From b502fc3fd36cdb413734d11a09c08d4bcd3aae25 Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Fri, 24 Nov 2023 21:19:28 +0000 Subject: [PATCH 39/52] Exclude GUI. By @diivi (#1846) --- src/vorta/assets/UI/excludedialog.ui | 153 ++++++++ src/vorta/assets/UI/sourcetab.ui | 71 +--- .../assets/exclusion_presets/browsers.json | 71 ++++ src/vorta/assets/exclusion_presets/dev.json | 37 ++ src/vorta/borg/create.py | 36 +- src/vorta/store/connection.py | 2 + src/vorta/store/models.py | 67 ++++ src/vorta/views/exclude_dialog.py | 330 ++++++++++++++++++ src/vorta/views/source_tab.py | 27 +- src/vorta/views/utils.py | 32 ++ tests/test_excludes.py | 26 ++ tests/unit/profile_exports/valid.json | 1 - 12 files changed, 754 insertions(+), 99 deletions(-) create mode 100644 src/vorta/assets/UI/excludedialog.ui create mode 100644 src/vorta/assets/exclusion_presets/browsers.json create mode 100644 src/vorta/assets/exclusion_presets/dev.json create mode 100644 src/vorta/views/exclude_dialog.py create mode 100644 tests/test_excludes.py diff --git a/src/vorta/assets/UI/excludedialog.ui b/src/vorta/assets/UI/excludedialog.ui new file mode 100644 index 000000000..f1b4bab64 --- /dev/null +++ b/src/vorta/assets/UI/excludedialog.ui @@ -0,0 +1,153 @@ + + + Dialog + + + + 0 + 0 + 504 + 426 + + + + Add patterns to exclude + + + + 10 + + + + + 0 + + + + Custom + + + + + + true + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + Presets + + + + + + true + + + + + + + + + + + Raw + + + + + + true + + + + + + + + + + + Preview + + + + + + true + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy to Clipboard + + + + + + + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/src/vorta/assets/UI/sourcetab.ui b/src/vorta/assets/UI/sourcetab.ui index f0af8c385..fb5ae7981 100644 --- a/src/vorta/assets/UI/sourcetab.ui +++ b/src/vorta/assets/UI/sourcetab.ui @@ -13,7 +13,7 @@ Form - + 12 @@ -112,6 +112,16 @@ + + + + Manage Excluded Items… + + + + + + @@ -147,65 +157,6 @@ - - - - 12 - - - - - <html><head/><body><p>Exclude Patterns (<a href="https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns"><span style=" text-decoration: underline; color:#0984e3;">more</span></a>):</p></body></html> - - - true - - - - - - - Exclude If Present (exclude folders with these files): - - - - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustToContentsOnFirstShow - - - - - - E.g. */.cache - - - - - - - - 0 - 0 - - - - QAbstractScrollArea::AdjustToContentsOnFirstShow - - - E.g. .nobackup - - - - - diff --git a/src/vorta/assets/exclusion_presets/browsers.json b/src/vorta/assets/exclusion_presets/browsers.json new file mode 100644 index 000000000..1acf5862b --- /dev/null +++ b/src/vorta/assets/exclusion_presets/browsers.json @@ -0,0 +1,71 @@ +[ + { + "name": "Chromium cache and config files", + "slug": "chromium-cache", + "patterns": + [ + "fm:*/.config/chromium/*/Local Storage", + "fm:*/.config/chromium/*/Session Storage", + "fm:*/.config/chromium/*/Service Worker/CacheStorage", + "fm:*/.config/chromium/*/Application Cache", + "fm:*/.config/chromium/*/History Index *", + "fm:*/snap/chromium/common/.cache", + "fm:*/snap/chromium/*/.config/chromium/*/Service Worker/CacheStorage", + "fm:*/snap/chromium/*/.local/share/" + ], + "tags":["application:chromium", "type:browser", "os:linux"], + "author": "Divi" + }, + { + "name": "Google Chrome cache and config files", + "slug": "google-chrome-cache", + "patterns": + [ + "fm:*/.config/google-chrome/ShaderCache", + "fm:*/.config/google-chrome/*/Local Storage", + "fm:*/.config/google-chrome/*/Session Storage", + "fm:*/.config/google-chrome/*/Application Cache", + "fm:*/.config/google-chrome/*/History Index *", + "fm:*/.config/google-chrome/*/Service Worker/CacheStorage" + ], + "tags": ["application:chrome", "type:browser", "os:linux"], + "author": "Divi" + }, + { + "name": "Brave cache and config files", + "slug": "brave-cache", + "patterns":[ + "fm:*/.config/BraveSoftware/Brave-Browser/*/Feature Engagement Tracker/", + "fm:*/.config/BraveSoftware/Brave-Browser/*/Local Storage/", + "fm:*/.config/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/", + "fm:*/.config/BraveSoftware/Brave-Browser/*/Session Storage/", + "fm:*/.config/BraveSoftware/Brave-Browser/Safe Browsing/", + "fm:*/.config/BraveSoftware/Brave-Browser/ShaderCache/" + ], + "tags": ["application:brave", "type:browser", "os:linux"], + "author": "Divi" + }, + { + "name": "Mozilla Firefox cache and config files", + "slug": "firefox-cache", + "patterns":[ + "fm:*/.mozilla/firefox/*/Cache", + "fm:*/.mozilla/firefox/*/minidumps", + "fm:*/.mozilla/firefox/*/.parentlock", + "fm:*/.mozilla/firefox/*/urlclassifier3.sqlite", + "fm:*/.mozilla/firefox/*/blocklist.xml", + "fm:*/.mozilla/firefox/*/extensions.sqlite", + "fm:*/.mozilla/firefox/*/extensions.sqlite-journal", + "fm:*/.mozilla/firefox/*/extensions.rdf", + "fm:*/.mozilla/firefox/*/extensions.ini", + "fm:*/.mozilla/firefox/*/extensions.cache", + "fm:*/.mozilla/firefox/*/XUL.mfasl", + "fm:*/.mozilla/firefox/*/XPC.mfasl", + "fm:*/.mozilla/firefox/*/xpti.dat", + "fm:*/.mozilla/firefox/*/compreg.dat", + "fm:*/.mozilla/firefox/*/pluginreg.dat" + ], + "tags": ["application:firefox", "type:browser", "os:linux"], + "author": "Divi" + } +] diff --git a/src/vorta/assets/exclusion_presets/dev.json b/src/vorta/assets/exclusion_presets/dev.json new file mode 100644 index 000000000..947cb237d --- /dev/null +++ b/src/vorta/assets/exclusion_presets/dev.json @@ -0,0 +1,37 @@ +[ + { + "name": "Node Modules and package manager cache", + "slug": "node-cache", + "patterns": + [ + "fm:*/node_modules", + "fm:*/.npm" + ], + "tags": ["type:dev", "lang:javascript", "os:linux", "os:darwin"], + "author": "Divi" + }, + { + "name": "Python cache and virtualenv", + "slug": "python-cache", + "patterns": + [ + "fm:*/__pycache__", + "fm:*.pyc", + "fm:*.pyo", + "fm:*/.virtualenvs" + ], + "tags": ["type:dev", "lang:python", "os:linux", "os:darwin"], + "author": "Divi" + }, + { + "name": "Rust artefacts", + "slug": "rust-artefacts", + "patterns": + [ + "fm:*/.cargo", + "fm:*/.rustup" + ], + "tags": ["type:dev", "lang:rust", "os:linux", "os:darwin"], + "author": "Divi" + } +] diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 69dc8b3f1..4b2e8b84b 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -164,24 +164,24 @@ def prepare(cls, profile): # Add excludes # Partly inspired by borgmatic/borgmatic/borg/create.py - if profile.exclude_patterns is not None: - exclude_dirs = [] - for p in profile.exclude_patterns.split('\n'): - if p.strip(): - expanded_directory = os.path.expanduser(p.strip()) - exclude_dirs.append(expanded_directory) - - if exclude_dirs: - pattern_file = tempfile.NamedTemporaryFile('w', delete=True) - pattern_file.write('\n'.join(exclude_dirs)) - pattern_file.flush() - cmd.extend(['--exclude-from', pattern_file.name]) - ret['cleanup_files'].append(pattern_file) - - if profile.exclude_if_present is not None: - for f in profile.exclude_if_present.split('\n'): - if f.strip(): - cmd.extend(['--exclude-if-present', f.strip()]) + exclude_dirs = [] + for p in profile.get_combined_exclusion_string().split('\n'): + if p.strip(): + expanded_directory = os.path.expanduser(p.strip()) + exclude_dirs.append(expanded_directory) + + if exclude_dirs: + pattern_file = tempfile.NamedTemporaryFile('w', delete=True) + pattern_file.write('\n'.join(exclude_dirs)) + pattern_file.flush() + cmd.extend(['--exclude-from', pattern_file.name]) + ret['cleanup_files'].append(pattern_file) + + # Currently not in use, but may be added back to the UI later. + # if profile.exclude_if_present is not None: + # for f in profile.exclude_if_present.split('\n'): + # if f.strip(): + # cmd.extend(['--exclude-if-present', f.strip()]) # Add repo url and source dirs. new_archive_name = format_archive_name(profile, profile.new_archive_name) diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index d27a83c09..e02efe96b 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -14,6 +14,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + ExclusionModel, RepoModel, RepoPassword, SchemaVersion, @@ -54,6 +55,7 @@ def init_db(con=None): WifiSettingModel, EventLogModel, SchemaVersion, + ExclusionModel, ] ) diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 7d853cbea..400299fb2 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -5,14 +5,18 @@ """ import json +import logging from datetime import datetime +from enum import Enum import peewee as pw from playhouse import signals from vorta.utils import slugify +from vorta.views.utils import get_exclusion_presets DB = pw.Proxy() +logger = logging.getLogger(__name__) class JSONField(pw.TextField): @@ -105,6 +109,69 @@ def refresh(self): def slug(self): return slugify(self.name) + def get_combined_exclusion_string(self): + allPresets = get_exclusion_presets() + excludes = "" + + if ( + ExclusionModel.select() + .where( + ExclusionModel.profile == self, + ExclusionModel.enabled, + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ) + .count() + > 0 + ): + excludes = "# custom added rules\n" + + for exclude in ExclusionModel.select().where( + ExclusionModel.profile == self, + ExclusionModel.enabled, + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ): + excludes += f"{exclude.name}\n" + + raw_excludes = self.exclude_patterns + if raw_excludes: + excludes += "\n# raw exclusions\n" + excludes += raw_excludes + excludes += "\n" + + # go through all source=='preset' exclusions, find the name in the allPresets dict, and add the patterns + for exclude in ExclusionModel.select().where( + ExclusionModel.profile == self, + ExclusionModel.enabled, + ExclusionModel.source == ExclusionModel.SourceFieldOptions.PRESET.value, + ): + if exclude.name not in allPresets: + logger.warning("Exclusion preset %s not found in built-in presets.", exclude.name) + continue + excludes += f"\n# {exclude.name}\n" + for pattern in allPresets[exclude.name]['patterns']: + excludes += f"{pattern}\n" + + return excludes + + class Meta: + database = DB + + +class ExclusionModel(BaseModel): + """ + If this is a user created exclusion, the name will be the same as the pattern added. For exclusions added from + presets, the name will be the same as the preset name. Duplicate patterns are already handled by Borg. + """ + + class SourceFieldOptions(Enum): + CUSTOM = 'custom' + PRESET = 'preset' + + profile = pw.ForeignKeyField(BackupProfileModel, backref='exclusions') + name = pw.CharField() + enabled = pw.BooleanField(default=True) + source = pw.CharField(default=SourceFieldOptions.CUSTOM.value) + class Meta: database = DB diff --git a/src/vorta/views/exclude_dialog.py b/src/vorta/views/exclude_dialog.py new file mode 100644 index 000000000..ee058de93 --- /dev/null +++ b/src/vorta/views/exclude_dialog.py @@ -0,0 +1,330 @@ +from PyQt6 import uic +from PyQt6.QtCore import QModelIndex, QObject, Qt +from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtWidgets import ( + QAbstractItemView, + QApplication, + QMenu, + QMessageBox, + QStyledItemDelegate, +) + +from vorta.i18n import translate +from vorta.store.models import ExclusionModel +from vorta.utils import get_asset +from vorta.views.utils import get_colored_icon, get_exclusion_presets + +uifile = get_asset('UI/excludedialog.ui') +ExcludeDialogUi, ExcludeDialogBase = uic.loadUiType(uifile) + + +class MandatoryInputItemModel(QStandardItemModel): + ''' + A model that prevents the user from adding an empty item to the list. + ''' + + def __init__(self, profile, parent=None): + super().__init__(parent) + self.profile = profile + + def setData(self, index: QModelIndex, value, role: int = ...) -> bool: + # When a user-added item in edit mode has no text, remove it from the list. + if role == Qt.ItemDataRole.EditRole and value == '': + self.removeRow(index.row()) + return True + if role == Qt.ItemDataRole.EditRole and ExclusionModel.get_or_none(name=value, profile=self.profile): + self.removeRow(index.row()) + QMessageBox.critical( + self.parent(), + 'Error', + 'This exclusion already exists.', + ) + return False + + return super().setData(index, value, role) + + +class ExcludeDialog(ExcludeDialogBase, ExcludeDialogUi): + def __init__(self, profile, parent=None): + super().__init__(parent) + self.setupUi(self) + self.profile = profile + + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.allPresets = get_exclusion_presets() + self.buttonBox.rejected.connect(self.close) + + self.customExclusionsModel = MandatoryInputItemModel(profile=profile) + self.customExclusionsList.setModel(self.customExclusionsModel) + self.customExclusionsModel.itemChanged.connect(self.custom_item_changed) + self.customExclusionsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.customExclusionsList.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.customExclusionsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.customExclusionsList.setAlternatingRowColors(True) + self.customExclusionsListDelegate = QStyledItemDelegate() + self.customExclusionsList.setItemDelegate(self.customExclusionsListDelegate) + self.customExclusionsListDelegate.closeEditor.connect(self.custom_pattern_editing_finished) + # allow removing items with the delete key with event filter + self.installEventFilter(self) + # context menu + self.customExclusionsList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customExclusionsList.customContextMenuRequested.connect(self.custom_exclusions_context_menu) + + self.exclusionPresetsModel = QStandardItemModel() + self.exclusionPresetsList.setModel(self.exclusionPresetsModel) + self.exclusionPresetsModel.itemChanged.connect(self.preset_item_changed) + self.exclusionPresetsList.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.exclusionPresetsList.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.exclusionPresetsList.setAlternatingRowColors(True) + + self.exclusionsPreviewText.setReadOnly(True) + + self.rawExclusionsText.textChanged.connect(self.raw_exclusions_saved) + + self.bRemovePattern.clicked.connect(self.remove_pattern) + self.bRemovePattern.setIcon(get_colored_icon('minus')) + self.bPreviewCopy.clicked.connect(self.copy_preview_to_clipboard) + self.bPreviewCopy.setIcon(get_colored_icon('copy')) + self.bAddPattern.clicked.connect(self.add_pattern) + self.bAddPattern.setIcon(get_colored_icon('plus')) + + # help text + self.customPresetsHelpText.setOpenExternalLinks(True) + self.customPresetsHelpText.setText( + translate( + "CustomPresetsHelp", + "Patterns that you add here will be used to exclude files and folders from the backup. For more info on how to use patterns, see the documentation. To add multiple patterns at once, use the \"Raw\" tab.", # noqa: E501 + ) + ) + self.exclusionPresetsHelpText.setText( + translate( + "ExclusionPresetsHelp", + "These presets are provided by the community and are a good starting point for excluding certain types of files. You can enable or disable them as you see fit. To see the patterns that are used for each preset, switch to the \"Preview\" tab after enabling it.", # noqa: E501 + ) + ) + self.rawExclusionsHelpText.setText( + translate( + "RawExclusionsHelp", + "You can use this field to add multiple patterns at once. Each pattern should be on a separate line.", + ) + ) + self.exclusionsPreviewHelpText.setText( + translate( + "ExclusionsPreviewHelp", + "This is a preview of the patterns that will be used to exclude files and folders from the backup.", + ) + ) + + self.populate_custom_exclusions_list() + self.populate_presets_list() + self.populate_raw_exclusions_text() + self.populate_preview_tab() + + def populate_custom_exclusions_list(self): + user_excluded_patterns = { + e.name: e.enabled + for e in self.profile.exclusions.select() + .where(ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value) + .order_by(ExclusionModel.name) + } + + for (exclude, enabled) in user_excluded_patterns.items(): + item = QStandardItem(exclude) + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked) + self.customExclusionsModel.appendRow(item) + + def custom_exclusions_context_menu(self, pos): + # index under cursor + index = self.customExclusionsList.indexAt(pos) + if not index.isValid(): + return + + selected_rows = self.customExclusionsList.selectedIndexes() + + if selected_rows and index not in selected_rows: + return # popup only for selected items + + menu = QMenu(self.customExclusionsList) + menu.addAction( + get_colored_icon('copy'), + self.tr('Copy'), + lambda: QApplication.clipboard().setText(index.data()), + ) + + # Remove and Toggle can work with multiple items selected + menu.addAction( + get_colored_icon('minus'), + self.tr('Remove'), + lambda: self.remove_pattern(index if not selected_rows else None), + ) + menu.addAction( + get_colored_icon('check-circle'), + self.tr('Toggle'), + lambda: self.toggle_custom_pattern(index if not selected_rows else None), + ) + + menu.popup(self.customExclusionsList.viewport().mapToGlobal(pos)) + + def populate_presets_list(self): + for preset_slug in self.allPresets.keys(): + item = QStandardItem(self.allPresets[preset_slug]['name']) + item.setCheckable(True) + item.setData(preset_slug, Qt.ItemDataRole.UserRole) + preset_model = ExclusionModel.get_or_none( + name=preset_slug, + source=ExclusionModel.SourceFieldOptions.PRESET.value, + profile=self.profile, + ) + + if preset_model: + item.setCheckState(Qt.CheckState.Checked if preset_model.enabled else Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + + self.exclusionPresetsModel.appendRow(item) + + def populate_raw_exclusions_text(self): + raw_excludes = self.profile.exclude_patterns + if raw_excludes: + self.rawExclusionsText.setPlainText(raw_excludes) + + def populate_preview_tab(self): + excludes = self.profile.get_combined_exclusion_string() + self.exclusionsPreviewText.setPlainText(excludes) + + def copy_preview_to_clipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Mode.Clipboard) + cb.setText(self.exclusionsPreviewText.toPlainText(), mode=cb.Mode.Clipboard) + + def remove_pattern(self, index=None): + ''' + Remove the selected item(s) from the list and the database. + If there is no index, this was called from the context menu and the indexes are passed in. + ''' + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in reversed(sorted(indexes)): + ExclusionModel.delete().where( + ExclusionModel.name == index.data(), + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ExclusionModel.profile == self.profile, + ).execute() + self.customExclusionsModel.removeRow(index.row()) + else: + ExclusionModel.delete().where( + ExclusionModel.name == index.data(), + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ExclusionModel.profile == self.profile, + ).execute() + self.customExclusionsModel.removeRow(index.row()) + + self.populate_preview_tab() + + def toggle_custom_pattern(self, index=None): + ''' + Toggle the check state of the selected item(s). + If there is no index, this was called from the context menu and the indexes are passed in. + ''' + if not index: + indexes = self.customExclusionsList.selectedIndexes() + for index in indexes: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + else: + item = self.customExclusionsModel.itemFromIndex(index) + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + + def add_pattern(self): + ''' + Add an empty item to the list in editable mode. + Don't add an item if the user is already editing an item. + ''' + if self.customExclusionsList.state() == QAbstractItemView.State.EditingState: + return + item = QStandardItem('') + item.setCheckable(True) + item.setCheckState(Qt.CheckState.Checked) + self.customExclusionsList.model().appendRow(item) + self.customExclusionsList.edit(item.index()) + self.customExclusionsList.scrollToBottom() + + def custom_pattern_editing_finished(self, editor): + ''' + Go through all items in the list and if any of them are empty, remove them. + Handles the case where the user presses the escape key to cancel editing. + ''' + for row in range(self.customExclusionsModel.rowCount()): + item = self.customExclusionsModel.item(row) + if item.text() == '': + self.customExclusionsModel.removeRow(row) + + def custom_item_changed(self, item): + ''' + When the user checks or unchecks an item, update the database. + When the user adds a new item, add it to the database. + ''' + if not ExclusionModel.get_or_none( + name=item.text(), source=ExclusionModel.SourceFieldOptions.CUSTOM.value, profile=self.profile + ): + ExclusionModel.create( + name=item.text(), source=ExclusionModel.SourceFieldOptions.CUSTOM.value, profile=self.profile + ) + + ExclusionModel.update(enabled=item.checkState() == Qt.CheckState.Checked).where( + ExclusionModel.name == item.text(), + ExclusionModel.source == ExclusionModel.SourceFieldOptions.CUSTOM.value, + ExclusionModel.profile == self.profile, + ).execute() + + self.populate_preview_tab() + + def preset_item_changed(self, item): + ''' + Create or update the preset in the database. + If the user unchecks the preset, set enabled to False, otherwise set it to True. + If the preset doesn't exist, create it and set enabled to True. + ''' + preset = ExclusionModel.get_or_none( + name=item.data(Qt.ItemDataRole.UserRole), + source=ExclusionModel.SourceFieldOptions.PRESET.value, + profile=self.profile, + ) + if preset: + preset.enabled = item.checkState() == Qt.CheckState.Checked + preset.save() + else: + ExclusionModel.create( + name=item.data(Qt.ItemDataRole.UserRole), + source=ExclusionModel.SourceFieldOptions.PRESET.value, + profile=self.profile, + enabled=item.checkState() == Qt.CheckState.Checked, + ) + + self.populate_preview_tab() + + def raw_exclusions_saved(self): + ''' + When the user saves changes in the raw exclusions text box, add it to the database. + ''' + raw_excludes = self.rawExclusionsText.toPlainText() + self.profile.exclude_patterns = raw_excludes + self.profile.save() + + self.populate_preview_tab() + + def eventFilter(self, source, event): + ''' + When the user presses the delete key, remove the selected items. + ''' + if event.type() == event.Type.KeyPress and event.key() == Qt.Key.Key_Delete: + self.remove_pattern() + return True + return QObject.eventFilter(self, source, event) diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 68d9c3368..5a839e62f 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -21,6 +21,7 @@ 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') @@ -101,8 +102,7 @@ def __init__(self, parent=None): # Connect signals self.removeButton.clicked.connect(self.source_remove) self.updateButton.clicked.connect(self.sources_update) - self.excludePatternsField.textChanged.connect(self.save_exclude_patterns) - self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) + self.bExclude.clicked.connect(self.show_exclude_dialog) header.sortIndicatorChanged.connect(self.update_sort_order) # Connect to palette change @@ -251,11 +251,7 @@ def add_source_to_table(self, source, update_data=None): def populate_from_profile(self): profile = self.profile() - self.excludePatternsField.textChanged.disconnect() - self.excludeIfPresentField.textChanged.disconnect() self.sourceFilesWidget.setRowCount(0) # Clear rows - self.excludePatternsField.clear() - self.excludeIfPresentField.clear() for source in SourceFileModel.select().where(SourceFileModel.profile == profile): self.add_source_to_table(source, False) @@ -267,11 +263,6 @@ def populate_from_profile(self): # Sort items as per settings self.sourceFilesWidget.sortItems(sourcetab_sort_column, Qt.SortOrder(sourcetab_sort_order)) - self.excludePatternsField.appendPlainText(profile.exclude_patterns) - self.excludeIfPresentField.appendPlainText(profile.exclude_if_present) - self.excludePatternsField.textChanged.connect(self.save_exclude_patterns) - self.excludeIfPresentField.textChanged.connect(self.save_exclude_if_present) - 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( @@ -351,15 +342,11 @@ def source_remove(self): logger.debug(f"Removed source in row {index.row()}") - def save_exclude_patterns(self): - profile = self.profile() - profile.exclude_patterns = self.excludePatternsField.toPlainText() - profile.save() - - def save_exclude_if_present(self): - profile = self.profile() - profile.exclude_if_present = self.excludeIfPresentField.toPlainText() - profile.save() + 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() diff --git a/src/vorta/views/utils.py b/src/vorta/views/utils.py index 15058f144..5a9697a73 100644 --- a/src/vorta/views/utils.py +++ b/src/vorta/views/utils.py @@ -1,3 +1,7 @@ +import json +import os +import sys + from PyQt6.QtGui import QIcon, QImage, QPixmap from vorta.utils import get_asset, uses_dark_mode @@ -17,3 +21,31 @@ def get_colored_icon(icon_name, scaled_height=128, return_qpixmap=False): return QPixmap(svg_img) else: return QIcon(QPixmap(svg_img)) + + +def get_exclusion_presets(): + """ + Loads exclusion presets from JSON files in assets/exclusion_presets. + + Currently the preset name is used as identifier. + """ + allPresets = {} + os_tag = f"os:{sys.platform}" + if getattr(sys, 'frozen', False): + # we are running in a bundle + bundle_dir = os.path.join(sys._MEIPASS, 'assets/exclusion_presets') + else: + # we are running in a normal Python environment + bundle_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../assets/exclusion_presets') + + for preset_file in sorted(os.listdir(bundle_dir)): + with open(os.path.join(bundle_dir, preset_file), 'r') as f: + preset_list = json.load(f) + for preset in preset_list: + if os_tag in preset['tags']: + allPresets[preset['slug']] = { + 'name': preset['name'], + 'patterns': preset['patterns'], + 'tags': preset['tags'], + } + return allPresets diff --git a/tests/test_excludes.py b/tests/test_excludes.py new file mode 100644 index 000000000..303594023 --- /dev/null +++ b/tests/test_excludes.py @@ -0,0 +1,26 @@ +from PyQt6 import QtCore + + +def test_exclusion_preview_populated(qapp, qtbot): + main = qapp.main_window + tab = main.sourceTab + main.tabWidget.setCurrentIndex(1) + + qtbot.mouseClick(tab.bExclude, QtCore.Qt.MouseButton.LeftButton) + qtbot.mouseClick(tab._window.bAddPattern, QtCore.Qt.MouseButton.LeftButton) + + qtbot.keyClicks(tab._window.customExclusionsList.viewport().focusWidget(), "custom pattern") + qtbot.keyClick(tab._window.customExclusionsList.viewport().focusWidget(), QtCore.Qt.Key.Key_Enter) + qtbot.waitUntil(lambda: tab._window.exclusionsPreviewText.toPlainText() == "# custom added rules\ncustom pattern\n") + + tab._window.tabWidget.setCurrentIndex(1) + + tab._window.exclusionPresetsModel.itemFromIndex(tab._window.exclusionPresetsModel.index(0, 0)).setCheckState( + QtCore.Qt.CheckState.Checked + ) + qtbot.waitUntil(lambda: "# Chromium cache and config files" in tab._window.exclusionsPreviewText.toPlainText()) + + tab._window.tabWidget.setCurrentIndex(2) + + qtbot.keyClicks(tab._window.rawExclusionsText, "test raw pattern 1") + qtbot.waitUntil(lambda: "test raw pattern 1\n" in tab._window.exclusionsPreviewText.toPlainText()) diff --git a/tests/unit/profile_exports/valid.json b/tests/unit/profile_exports/valid.json index 9b8fe32f4..ee252a74b 100644 --- a/tests/unit/profile_exports/valid.json +++ b/tests/unit/profile_exports/valid.json @@ -15,7 +15,6 @@ "ssh_key": null, "compression": "zstd,8", "exclude_patterns": null, - "exclude_if_present": ".nobackup", "schedule_mode": "off", "schedule_interval_unit": "hours", "schedule_interval_count": 2, From 3fdc4eca3c03caad8e4be00f0afabe1770772792 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 30 Nov 2023 07:07:32 +0000 Subject: [PATCH 40/52] Bump version to v0.9.1-beta3 --- src/vorta/_version.py | 2 +- .../assets/metadata/com.borgbase.Vorta.appdata.xml | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vorta/_version.py b/src/vorta/_version.py index 7bce0c8a5..55616197f 100644 --- a/src/vorta/_version.py +++ b/src/vorta/_version.py @@ -1 +1 @@ -__version__ = '0.9.1-beta2' +__version__ = '0.9.1-beta3' diff --git a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index 934c6fd26..5c11d044e 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -40,6 +40,15 @@ + + +
    +
  • Exclude GUI. By @diivi (#1846)
  • +
  • Backup settings.db before migrations. By @AdwaitSalankar (#1848)
  • +
  • Loosen platformdirs dependency (#1843)
  • +
+
+
    @@ -49,8 +58,6 @@
- -
https://github.com/borgbase/vorta/issues https://vorta.borgbase.com/usage/ From 1f062359d8d4607f6a4b85e1a08fd4c859a22c57 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 30 Nov 2023 11:34:03 +0000 Subject: [PATCH 41/52] Minor: include exclusion presets for macos package --- package/vorta.spec | 1 + 1 file changed, 1 insertion(+) diff --git a/package/vorta.spec b/package/vorta.spec index 714228c74..4559bc0d1 100644 --- a/package/vorta.spec +++ b/package/vorta.spec @@ -23,6 +23,7 @@ a = Analysis([os.path.join(SRC_DIR, '__main__.py')], datas=[ (os.path.join(SRC_DIR, 'assets/UI/*'), 'assets/UI'), (os.path.join(SRC_DIR, 'assets/icons/*'), 'assets/icons'), + (os.path.join(SRC_DIR, 'assets/exclusion_presets/*'), 'assets/exclusion_presets'), (os.path.join(SRC_DIR, 'i18n/qm/*'), 'vorta/i18n/qm'), ], hiddenimports=[ From 675010e4019fcd7d4fbc43af405a6f93fbec58cb Mon Sep 17 00:00:00 2001 From: TW Date: Tue, 9 Jan 2024 09:06:48 +0100 Subject: [PATCH 42/52] Random cleanups by @ThomasWaldmann (#1879) * fix PEP8 E721 do not compare types, for exact checks use `is` / `is not`, for instance checks use `isinstance()` * remove redundant parentheses * fix SiteWorker.run for empty job queue local variable job is not assigned if queue was empty when calling .run(), but it is used in exception handler. * remove unreachable code in parse_diff_lines * bug fix for unreachable code in is_worker_running the code intended to check if *any* worker is running for any site was *unreachable*. this caused false negative results for site=None. * check_failed_response: remove outdated part of docstring * pull request template: fix relative path to LICENSE.txt * fix typos * use logger.warning, .warn is deprecated --- .github/ISSUE_TEMPLATE/bug_form.yaml | 2 +- .github/ISSUE_TEMPLATE/bug_report.md | 4 +-- .github/pull_request_template.md | 2 +- noxfile.py | 4 +-- .../fix_app_qt_folder_names_for_codesign.py | 18 +++++------ src/vorta/application.py | 7 +---- src/vorta/borg/jobs_manager.py | 31 ++++++++++--------- src/vorta/network_status/abc.py | 2 +- src/vorta/profile_export.py | 2 +- src/vorta/scheduler.py | 2 +- src/vorta/views/archive_tab.py | 2 +- src/vorta/views/diff_result.py | 1 - src/vorta/views/partials/treemodel.py | 2 +- src/vorta/views/profile_add_edit_dialog.py | 2 +- src/vorta/views/repo_tab.py | 2 +- src/vorta/views/source_tab.py | 2 +- tests/unit/test_treemodel.py | 2 +- 17 files changed, 42 insertions(+), 45 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_form.yaml b/.github/ISSUE_TEMPLATE/bug_form.yaml index de98042ff..f010851a0 100644 --- a/.github/ISSUE_TEMPLATE/bug_form.yaml +++ b/.github/ISSUE_TEMPLATE/bug_form.yaml @@ -1,5 +1,5 @@ name: "Bug Report Form" -description: "Report a bug or a similiar issue." +description: "Report a bug or a similar issue." body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7b2d69f95..13ec62b3d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,6 @@ --- name: Bug Report -about: Report a bug or a similiar issue - the classic way +about: Report a bug or a similar issue - the classic way title: '' labels: '' assignees: '' @@ -18,7 +18,7 @@ If you want to suggest a feature or have any other question, please use our #### Description diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d6e05f6a9..231c88ff4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -36,7 +36,7 @@ - [ ] All new and existing tests passed. -*I provide my contribution under the terms of the [license](./../../LICENSE.txt) of this repository and I affirm the [Developer Certificate of Origin][dco].* +*I provide my contribution under the terms of the [license](./../LICENSE.txt) of this repository and I affirm the [Developer Certificate of Origin][dco].* [dco]: https://developercertificate.org/ diff --git a/noxfile.py b/noxfile.py index 804b8d60c..b5f6fdfa0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -28,10 +28,10 @@ @nox.parametrize("borgbackup", supported_borgbackup_versions) def run_tests(session, borgbackup): # install borgbackup - if (sys.platform == 'darwin'): + if sys.platform == 'darwin': # in macOS there's currently no fuse package which works with borgbackup directly session.install(f"borgbackup=={borgbackup}") - elif (borgbackup == "1.1.18"): + elif borgbackup == "1.1.18": # borgbackup 1.1.18 doesn't support pyfuse3 session.install("llfuse") session.install(f"borgbackup[llfuse]=={borgbackup}") diff --git a/package/fix_app_qt_folder_names_for_codesign.py b/package/fix_app_qt_folder_names_for_codesign.py index 0adfb03f9..cbd5805de 100644 --- a/package/fix_app_qt_folder_names_for_codesign.py +++ b/package/fix_app_qt_folder_names_for_codesign.py @@ -18,10 +18,10 @@ def create_symlink(folder: Path) -> None: """Create the appropriate symlink in the MacOS folder pointing to the Resources folder. """ - sibbling = Path(str(folder).replace("MacOS", "")) + sibling = Path(str(folder).replace("MacOS", "")) # PyQt6/Qt/qml/QtQml/Models.2 - root = str(sibbling).partition("Contents")[2].lstrip("/") + root = str(sibling).partition("Contents")[2].lstrip("/") # ../../../../ backward = "../" * (root.count("/") + 1) # ../../../../Resources/PyQt6/Qt/qml/QtQml/Models.2 @@ -41,7 +41,7 @@ def fix_dll(dll: Path) -> None: def match_func(pth: str) -> Optional[str]: """Callback function for MachO.rewriteLoadCommands() that is - called on every lookup path setted in the DLL headers. + called on every lookup path set in the DLL headers. By returning None for system libraries, it changes nothing. Else we return a relative path pointing to the good file in the MacOS folder. @@ -73,7 +73,7 @@ def find_problematic_folders(folder: Path) -> Generator[Path, None, None]: """Recursively yields problematic folders (containing a dot in their name).""" for path in folder.iterdir(): if not path.is_dir() or path.is_symlink(): - # Skip simlinks as they are allowed (even with a dot) + # Skip symlinks as they are allowed (even with a dot) continue if "." in path.name: yield path @@ -83,7 +83,7 @@ def find_problematic_folders(folder: Path) -> Generator[Path, None, None]: def move_contents_to_resources(folder: Path) -> Generator[Path, None, None]: """Recursively move any non symlink file from a problematic folder - to the sibbling one in Resources. + to the sibling one in Resources. """ for path in folder.iterdir(): if path.is_symlink(): @@ -91,10 +91,10 @@ def move_contents_to_resources(folder: Path) -> Generator[Path, None, None]: if path.name == "qml": yield from move_contents_to_resources(path) else: - sibbling = Path(str(path).replace("MacOS", "Resources")) - sibbling.parent.mkdir(parents=True, exist_ok=True) - shutil.move(path, sibbling) - yield sibbling + sibling = Path(str(path).replace("MacOS", "Resources")) + sibling.parent.mkdir(parents=True, exist_ok=True) + shutil.move(path, sibling) + yield sibling def main(args: List[str]) -> int: diff --git a/src/vorta/application.py b/src/vorta/application.py index 34c1acf91..8857a51eb 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -308,11 +308,6 @@ def check_failed_response(self, result: Dict[str, Any]): Displays a `QMessageBox` with an error message depending on the return code of the `BorgJob`. - - Parameters - ---------- - repo_url : str - The url of the repo of concern """ # extract data from the params for the borg job repo_url = result['params']['repo_url'] @@ -344,7 +339,7 @@ def check_failed_response(self, result: Dict[str, Any]): elif returncode > 128: # 128+N - killed by signal N (e.g. 137 == kill -9) signal = returncode - 128 - text = self.tr('Repository data check for repo was killed by signal %s.') % (signal) + text = self.tr('Repository data check for repo was killed by signal %s.') % signal infotext = self.tr('The process running the check job got a kill signal. Try again.') else: # Real error diff --git a/src/vorta/borg/jobs_manager.py b/src/vorta/borg/jobs_manager.py index 2028535d1..4659f3e7f 100644 --- a/src/vorta/borg/jobs_manager.py +++ b/src/vorta/borg/jobs_manager.py @@ -25,9 +25,9 @@ def repo_id(self): @abstractmethod def cancel(self): """ - Cancel can be called when the job is not started. It is the responsability of FuncJob to not cancel job if + Cancel can be called when the job is not started. It is the responsibility of FuncJob to not cancel job if no job is running. - The cancel mehod of JobsManager calls the cancel method on the running jobs only. Other jobs are dequeued. + The cancel method of JobsManager calls the cancel method on the running jobs only. Other jobs are dequeued. """ pass @@ -50,6 +50,7 @@ def __init__(self, jobs): self.current_job = None def run(self): + job = None while True: try: job = self.jobs.get(False) @@ -58,7 +59,8 @@ def run(self): job.run() logger.debug("Finish job for site: %s", job.repo_id()) except queue.Empty: - logger.debug("No more jobs for site: %s", job.repo_id()) + if job is not None: + logger.debug("No more jobs for site: %s", job.repo_id()) return @@ -77,19 +79,20 @@ def __init__(self): def is_worker_running(self, site=None): """ - See if there are any active jobs. The user can't start a backup if a job is - running. The scheduler can. + See if there are any active jobs. + The user can't start a backup if a job is running. The scheduler can. + + If site is None, check if there is any worker active for any site (repo). + If site is not None, only check if there is a worker active for the given site (repo). """ - # Check status for specific site (repo) - if site in self.workers: - return self.workers[site].is_alive() + if site is not None: + if site in self.workers: + if self.workers[site].is_alive(): + return True else: - return False - - # Check if *any* worker is active - for _, worker in self.workers.items(): - if worker.is_alive(): - return True + for _, worker in self.workers.items(): + if worker.is_alive(): + return True return False def add_job(self, job): diff --git a/src/vorta/network_status/abc.py b/src/vorta/network_status/abc.py index 60f9353ac..7f74fc16b 100644 --- a/src/vorta/network_status/abc.py +++ b/src/vorta/network_status/abc.py @@ -24,7 +24,7 @@ def get_network_status_monitor(cls) -> 'NetworkStatusMonitor': def is_network_status_available(self): """Is the network status really available, and not just a dummy implementation?""" - return type(self) != NetworkStatusMonitor + return type(self) is not NetworkStatusMonitor def is_network_metered(self) -> bool: """Is the currently connected network a metered connection?""" diff --git a/src/vorta/profile_export.py b/src/vorta/profile_export.py index fa26ac5c6..a370ce1d7 100644 --- a/src/vorta/profile_export.py +++ b/src/vorta/profile_export.py @@ -36,7 +36,7 @@ def schema_version(self): def repo_url(self): if ( 'repo' in self._profile_dict - and type(self._profile_dict['repo']) == dict + and isinstance(self._profile_dict['repo'], dict) and 'url' in self._profile_dict['repo'] ): return self._profile_dict['repo']['url'] diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 7a3fcee5d..aa8b2859c 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -70,7 +70,7 @@ def __init__(self): self.bus = bus self.bus.connect(service, path, interface, name, "b", self.loginSuspendNotify) else: - logger.warn('Failed to connect to DBUS interface to detect sleep/resume events') + logger.warning('Failed to connect to DBUS interface to detect sleep/resume events') @QtCore.pyqtSlot(bool) def loginSuspendNotify(self, suspend: bool): diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index cb4d41843..d2af5757b 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -875,7 +875,7 @@ def confirm_dialog(self, title, text): 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. + # Since this function modify the UI, we can't put the whole function in a JobQueue. # determine selected archives archives = [] diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py index 5d262efa5..da74ff727 100644 --- a/src/vorta/views/diff_result.py +++ b/src/vorta/views/diff_result.py @@ -381,7 +381,6 @@ def parse_diff_lines(lines: List[str], model: 'DiffTree'): if not parsed_line: raise Exception("Couldn't parse diff output `{}`".format(line)) - continue path = PurePath(parsed_line['path']) file_type = FileType.FILE diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py index a184a5428..ceff3eb46 100644 --- a/src/vorta/views/partials/treemodel.py +++ b/src/vorta/views/partials/treemodel.py @@ -610,7 +610,7 @@ def getItem(self, path: Union[PurePath, PathLike]) -> Optional[FileSystemItem[T] if isinstance(path, PurePath): path = path.parts - return self.root.get_path(path) # handels empty path + return self.root.get_path(path) # handles empty path def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): """ diff --git a/src/vorta/views/profile_add_edit_dialog.py b/src/vorta/views/profile_add_edit_dialog.py index 56d040c1a..75b767d91 100644 --- a/src/vorta/views/profile_add_edit_dialog.py +++ b/src/vorta/views/profile_add_edit_dialog.py @@ -27,7 +27,7 @@ def __init__(self, parent=None): self.name_blank = trans_late('AddProfileWindow', 'Please enter a profile name.') self.name_exists = trans_late('AddProfileWindow', 'A profile with this name already exists.') - # Call validate to set inital messages + # Call validate to set initial messages self.buttonBox.button(QDialogButtonBox.StandardButton.Save).setEnabled(self.validate()) def _set_status(self, text): diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 3b04ffa23..e9f38dbad 100644 --- a/src/vorta/views/repo_tab.py +++ b/src/vorta/views/repo_tab.py @@ -40,7 +40,7 @@ def __init__(self, parent=None): # compression or speed on a unified scale. this is not 1-dimensional and also depends # on the input data. so we just tell what we know for sure. # "auto" is used for some slower / older algorithms to avoid wasting a lot of time - # on uncompressible data. + # on incompressible data. self.repoCompression.addItem(self.tr('LZ4 (modern, default)'), 'lz4') self.repoCompression.addItem(self.tr('Zstandard Level 3 (modern)'), 'zstd,3') self.repoCompression.addItem(self.tr('Zstandard Level 8 (modern)'), 'zstd,8') diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 5a839e62f..ed63be2a7 100644 --- a/src/vorta/views/source_tab.py +++ b/src/vorta/views/source_tab.py @@ -331,7 +331,7 @@ def source_remove(self): profile = self.profile() # sort indexes, starting with lowest indexes.sort() - # remove each selected row, starting with highest index (otherways, higher indexes become invalid) + # 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(), diff --git a/tests/unit/test_treemodel.py b/tests/unit/test_treemodel.py index 1b76d5856..dd2b9717e 100644 --- a/tests/unit/test_treemodel.py +++ b/tests/unit/test_treemodel.py @@ -87,7 +87,7 @@ def test_get(self): item.add(child2) item.add(child3) - # test get inexistent subpath + # test get nonexistent subpath assert item.get('unknown') is None assert item.get('unknown', default='default') == 'default' From be6e08552abb8c93433f6e0aeea0bd2481362b63 Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Wed, 10 Jan 2024 13:11:38 +0000 Subject: [PATCH 43/52] Update screencast for v0.9 (#1881) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59ddbf9fc..b58d1e5e4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vorta is a backup client for macOS and Linux desktops. It integrates the mighty [BorgBackup](https://borgbackup.readthedocs.io) with your desktop environment to protect your data from disk failure, ransomware and theft. -![](https://files.qmax.us/vorta/screencast-8-small.gif) +https://github.com/m3nu/vorta/assets/3916435/a622a148-5373-4ae0-87bc-4ca1d6f6202e ## Why is this great? 🤩 From 1d85cb48dcd1b08c1bc9c26b09a85550afeaebcb Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 10 Jan 2024 13:20:01 +0000 Subject: [PATCH 44/52] Bump version to v0.9.1 --- src/vorta/_version.py | 2 +- src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/vorta/_version.py b/src/vorta/_version.py index 55616197f..8969d4966 100644 --- a/src/vorta/_version.py +++ b/src/vorta/_version.py @@ -1 +1 @@ -__version__ = '0.9.1-beta3' +__version__ = '0.9.1' diff --git a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index 5c11d044e..24b878566 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -49,6 +49,13 @@ + + +
    +
  • First production 0.9 release
  • +
+
+
    From 9cc7a98838d2f6bbcf984dd03ac35994b2c62340 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 11 Jan 2024 08:27:25 +0000 Subject: [PATCH 45/52] Minor: color settings icon --- src/vorta/assets/icons/settings_wheel.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vorta/assets/icons/settings_wheel.svg b/src/vorta/assets/icons/settings_wheel.svg index 326c4c686..05295e599 100644 --- a/src/vorta/assets/icons/settings_wheel.svg +++ b/src/vorta/assets/icons/settings_wheel.svg @@ -1 +1 @@ - + From 466597207666996629e2f49cbd5482760b84c38b Mon Sep 17 00:00:00 2001 From: Manu <3916435+m3nu@users.noreply.github.com> Date: Sat, 20 Jan 2024 10:26:06 +0000 Subject: [PATCH 46/52] Fix issue after Qt6 migration to save allowed Wifis (#1903) --- src/vorta/views/schedule_tab.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vorta/views/schedule_tab.py b/src/vorta/views/schedule_tab.py index 43de6730d..a03b191c7 100644 --- a/src/vorta/views/schedule_tab.py +++ b/src/vorta/views/schedule_tab.py @@ -1,5 +1,5 @@ from PyQt6 import QtCore, uic -from PyQt6.QtCore import QDateTime, QLocale +from PyQt6.QtCore import QDateTime, QLocale, Qt from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, @@ -202,7 +202,7 @@ def populate_wifi(self): def save_wifi_item(self, item): db_item = WifiSettingModel.get(ssid=item.text(), profile=self.profile().id) - db_item.allowed = item.checkState() == 2 + db_item.allowed = item.checkState() == Qt.CheckState.Checked db_item.save() def save_profile_attr(self, attr, new_value): From 0cc15e3d3d647bae1782f2c21eafacbf2c8073c6 Mon Sep 17 00:00:00 2001 From: Hofer-Julian <30049909+Hofer-Julian@users.noreply.github.com> Date: Thu, 25 Jan 2024 11:06:56 +0100 Subject: [PATCH 47/52] Update appdata.xml (#1885) The appdata.xml doesn't pass validation of flathub 1. The `launchable` tag is nowadays required 2. Flatpak doesn't like the beta releases. In the end, it only made sense to remove them from the xml --- .../metadata/com.borgbase.Vorta.appdata.xml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index 24b878566..e24277ad2 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -1,6 +1,7 @@ com.borgbase.Vorta + com.borgbase.Vorta.desktop Vorta GPL-3.0 CC0-1.0 @@ -40,25 +41,13 @@ - +
      +
    • First production 0.9 release
    • Exclude GUI. By @diivi (#1846)
    • Backup settings.db before migrations. By @AdwaitSalankar (#1848)
    • Loosen platformdirs dependency (#1843)
    • -
    -
    -
    - - -
      -
    • First production 0.9 release
    • -
    -
    -
    - - -
    • Unit test improvements and coverage increase. By @bigtedde (#1787)
    • Profile sidebar and new setting interface. By @bigtedde (#1809)
    • Update macOS notarization for use with notarytool (#1831)
    • From 634f984e78ea261825049b2617e62ecf31503b73 Mon Sep 17 00:00:00 2001 From: Jeff Ramnani Date: Fri, 2 Feb 2024 04:05:47 -0800 Subject: [PATCH 48/52] Improve metered connection detection for macOS. By @jramnani (#1902) * Add dependency for pyobjc-CoreWLAN on darwin * Rename existing implementation with Android The current implementation was tested with Android, but does not work with iOS. Move the existing implementation and include android in the name to make room for adding a new iOS metered connection detection strategy. * get_current_wifi works with objc Switch from using command line tools to using the Objective-C Cocoa API to get the Wi-Fi status information. Cocoa has an API to specifically check whether a Wi-Fi connection is using a Personal Hotspot on iOS. I'm using a private method to get the Wi-Fi interface object in Cocoa. The reason for this is that cleaning up mocks on PyObjC/ObjC objects is much harder than mocking out methods on objects in our control. Using test doubles also let's me check for different states the Wi-Fi network could be in. * get_known_wifis works on darwin Use the networksetup command on macOS to get the list of the user's Wi-Fi networks. networksetup -listpreferredwirelessnetworks bsd_device It looks like this command and option has existed on macOS since at least 2013. Also add some type annotations around the PyObjC return values to help the reader know what they're dealing with at each step. * Add test for get_current_wifi when wifi is off The user might have Wi-Fi turned off. Account for that use case. * Add iOS Personal Hotspot support to is_network_metered The DarwinNetworkManager can now determine if the user is connected to a Personal Hotspot Wi-Fi network from iOS. Account for whether the user has Wi-Fi turned on and off. * Refactor to avoid deprecated API in Cocoa According to Apple's developer documentation, creating CWInterface objects directly are discouraged. Instead, they prefer to use CWInterface objects created by CWWiFiClient. This also happens to be more compliant with Apple's application sandbox. Creating CWInterface objects directly accesses raw BSD sockets which is not allowed in the sandbox. More details here: https://developer.apple.com/documentation/corewlan/cwinterface * Add test case for blank Wi-Fi network name I have one of these in my list of networks in Vorta. And this also covers a missing branch in get_known_wifis. * Move private method below public methods This is to provide a little more clarity. Especially since this class is subclassing another one. * Account for when there is no wifi interface When a Mac does not have a Wi-Fi interface, CWWiFiClient.interface() can return None. Update the type annotation to mark it as Optional, and account for the null condition in the other methods. * Fix type annotation error The CI tests failed on python 3.8. I used the wrong type annotation to describe a list of SystemWifiInfo's. The tests now pass for me when I run 'make test-unit' using a python 3.8 interpreter. * Fix linter issue with imports --- setup.cfg | 1 + src/vorta/network_status/darwin.py | 85 +++++++++++++++------- tests/network_manager/test_darwin.py | 101 +++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 28 deletions(-) diff --git a/setup.cfg b/setup.cfg index 6cda372a8..e0cdd9319 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,6 +47,7 @@ install_requires = pyobjc-core < 10; sys_platform == 'darwin' pyobjc-framework-Cocoa < 10; sys_platform == 'darwin' pyobjc-framework-LaunchServices < 10; sys_platform == 'darwin' + pyobjc-framework-CoreWLAN < 10; sys_platform == 'darwin' tests_require = pytest pytest-qt diff --git a/src/vorta/network_status/darwin.py b/src/vorta/network_status/darwin.py index 279fc13aa..1ee2baf11 100644 --- a/src/vorta/network_status/darwin.py +++ b/src/vorta/network_status/darwin.py @@ -1,6 +1,8 @@ import subprocess from datetime import datetime as dt -from typing import Iterator, Optional +from typing import Iterator, List, Optional + +from CoreWLAN import CWInterface, CWNetwork, CWWiFiClient from vorta.log import logger from vorta.network_status.abc import NetworkStatusMonitor, SystemWifiInfo @@ -8,38 +10,65 @@ class DarwinNetworkStatus(NetworkStatusMonitor): def is_network_metered(self) -> bool: - return any(is_network_metered(d) for d in get_network_devices()) + interface: CWInterface = self._get_wifi_interface() + network: Optional[CWNetwork] = interface.lastNetworkJoined() + + if network: + is_ios_hotspot = network.isPersonalHotspot() + else: + is_ios_hotspot = False + + return is_ios_hotspot or any(is_network_metered_with_android(d) for d in get_network_devices()) def get_current_wifi(self) -> Optional[str]: """ - Get current SSID or None if Wifi is off. - - From https://gist.github.com/keithweaver/00edf356e8194b89ed8d3b7bbead000c + Get current SSID or None if Wi-Fi is off. """ - cmd = [ - '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport', - '-I', - ] - process = subprocess.Popen(cmd, stdout=subprocess.PIPE) - out, err = process.communicate() - process.wait() - for line in out.decode(errors='ignore').split('\n'): - split_line = line.strip().split(':') - if split_line[0] == 'SSID': - return split_line[1].strip() - - def get_known_wifis(self): + interface: Optional[CWInterface] = self._get_wifi_interface() + if not interface: + return None + + # If the user has Wi-Fi turned off lastNetworkJoined will return None. + network: Optional[CWNetwork] = interface.lastNetworkJoined() + + if network: + network_name = network.ssid() + return network_name + else: + return None + + def get_known_wifis(self) -> List[SystemWifiInfo]: """ - Listing all known Wifi networks isn't possible any more from macOS 11. Instead we - just return the current Wifi. + Use the program, "networksetup", to get the list of know Wi-Fi networks. """ + wifis = [] - current_wifi = self.get_current_wifi() - if current_wifi is not None: - wifis.append(SystemWifiInfo(ssid=current_wifi, last_connected=dt.now())) + interface: Optional[CWInterface] = self._get_wifi_interface() + if not interface: + return [] + + interface_name = interface.name() + output = call_networksetup_listpreferredwirelessnetworks(interface_name) + + result = [] + for line in output.strip().splitlines(): + if line.strip().startswith("Preferred networks"): + continue + elif not line.strip(): + continue + else: + result.append(line.strip()) + + for wifi_network_name in result: + wifis.append(SystemWifiInfo(ssid=wifi_network_name, last_connected=dt.now())) return wifis + def _get_wifi_interface(self) -> Optional[CWInterface]: + wifi_client: CWWiFiClient = CWWiFiClient.sharedWiFiClient() + interface: Optional[CWInterface] = wifi_client.interface() + return interface + def get_network_devices() -> Iterator[str]: for line in call_networksetup_listallhardwareports().splitlines(): @@ -47,7 +76,7 @@ def get_network_devices() -> Iterator[str]: yield line.split()[1].strip().decode('ascii') -def is_network_metered(bsd_device) -> bool: +def is_network_metered_with_android(bsd_device) -> bool: return b'ANDROID_METERED' in call_ipconfig_getpacket(bsd_device) @@ -66,3 +95,11 @@ def call_networksetup_listallhardwareports(): return subprocess.check_output(cmd) except subprocess.CalledProcessError: logger.debug("Command %s failed", ' '.join(cmd)) + + +def call_networksetup_listpreferredwirelessnetworks(interface) -> str: + command = ['/usr/sbin/networksetup', '-listpreferredwirelessnetworks', interface] + try: + return subprocess.check_output(command).decode(encoding='utf-8') + except subprocess.CalledProcessError: + logger.debug("Command %s failed", " ".join(command)) diff --git a/tests/network_manager/test_darwin.py b/tests/network_manager/test_darwin.py index 70c96cd2e..7d900dd44 100644 --- a/tests/network_manager/test_darwin.py +++ b/tests/network_manager/test_darwin.py @@ -1,25 +1,118 @@ +from unittest.mock import MagicMock + import pytest from vorta.network_status import darwin +def test_get_current_wifi_when_wifi_is_on(mocker): + mock_interface = MagicMock() + mock_network = MagicMock() + mock_interface.lastNetworkJoined.return_value = mock_network + mock_network.ssid.return_value = "Coffee Shop Wifi" + + instance = darwin.DarwinNetworkStatus() + mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface) + + result = instance.get_current_wifi() + + assert result == "Coffee Shop Wifi" + + +def test_get_current_wifi_when_wifi_is_off(mocker): + mock_interface = MagicMock() + mock_interface.lastNetworkJoined.return_value = None + + instance = darwin.DarwinNetworkStatus() + mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface) + + result = instance.get_current_wifi() + + assert result is None + + +def test_get_current_wifi_when_no_wifi_interface(mocker): + instance = darwin.DarwinNetworkStatus() + mocker.patch.object(instance, "_get_wifi_interface", return_value=None) + + result = instance.get_current_wifi() + + assert result is None + + +@pytest.mark.parametrize("is_hotspot_enabled", [True, False]) +def test_network_is_metered_with_ios(mocker, is_hotspot_enabled): + mock_interface = MagicMock() + mock_network = MagicMock() + mock_interface.lastNetworkJoined.return_value = mock_network + mock_network.isPersonalHotspot.return_value = is_hotspot_enabled + + instance = darwin.DarwinNetworkStatus() + mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface) + + result = instance.is_network_metered() + + assert result == is_hotspot_enabled + + +def test_network_is_metered_when_wifi_is_off(mocker): + mock_interface = MagicMock() + mock_interface.lastNetworkJoined.return_value = None + + instance = darwin.DarwinNetworkStatus() + mocker.patch.object(instance, "_get_wifi_interface", return_value=mock_interface) + + result = instance.is_network_metered() + + assert result is False + + @pytest.mark.parametrize( 'getpacket_output_name, expected', [ ('normal_router', False), - ('phone', True), + ('android_phone', True), ], ) -def test_is_network_metered(getpacket_output_name, expected, monkeypatch): +def test_is_network_metered_with_android(getpacket_output_name, expected, monkeypatch): def mock_getpacket(device): assert device == 'en0' return GETPACKET_OUTPUTS[getpacket_output_name] monkeypatch.setattr(darwin, 'call_ipconfig_getpacket', mock_getpacket) - result = darwin.is_network_metered('en0') + result = darwin.is_network_metered_with_android('en0') assert result == expected +def test_get_known_wifi_networks_when_wifi_interface_exists(monkeypatch): + networksetup_output = """ +Preferred networks on en0: + Home Network + Coffee Shop Wifi + iPhone + + Office Wifi + """ + monkeypatch.setattr( + darwin, "call_networksetup_listpreferredwirelessnetworks", lambda interface_name: networksetup_output + ) + + network_status = darwin.DarwinNetworkStatus() + result = network_status.get_known_wifis() + + assert len(result) == 4 + assert result[0].ssid == "Home Network" + + +def test_get_known_wifi_networks_when_no_wifi_interface(mocker): + instance = darwin.DarwinNetworkStatus() + mocker.patch.object(instance, "_get_wifi_interface", return_value=None) + + results = instance.get_known_wifis() + + assert results == [] + + def test_get_network_devices(monkeypatch): monkeypatch.setattr(darwin, 'call_networksetup_listallhardwareports', lambda: NETWORKSETUP_OUTPUT) @@ -55,7 +148,7 @@ def test_get_network_devices(monkeypatch): server_identifier (ip): 172.16.12.1 end (none): """, - 'phone': b"""\ + 'android_phone': b"""\ op = BOOTREPLY htype = 1 flags = 0 From d8cce255eb5b1e924608504dc630a3c788d8e5cd Mon Sep 17 00:00:00 2001 From: Hofer-Julian <30049909+Hofer-Julian@users.noreply.github.com> Date: Thu, 8 Feb 2024 12:29:14 +0100 Subject: [PATCH 49/52] Add developer name to appdata (#1922) * Add developer name to appdata Flathub is getting more and more strict when it comes to metadata. I've added "Vorta developers" no, I can also be more specific if people prefer that. * Update com.borgbase.Vorta.appdata.xml --- src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index e24277ad2..b0c8a76b0 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -2,6 +2,7 @@ com.borgbase.Vorta com.borgbase.Vorta.desktop + Vorta contributors Vorta GPL-3.0 CC0-1.0 From 472c7c8996c2744ef3ed70bf5cad44c08fa1c582 Mon Sep 17 00:00:00 2001 From: Shivansh Singh <89853707+shivansh02@users.noreply.github.com> Date: Wed, 14 Feb 2024 17:05:33 +0530 Subject: [PATCH 50/52] Fix About dialog wording and year. By @shivansh02 (#1936) * fix: about dialogue grammar and copyright year * fix: made about dialogue copyright year dynamic --- src/vorta/assets/UI/abouttab.ui | 4 ++-- src/vorta/views/about_tab.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vorta/assets/UI/abouttab.ui b/src/vorta/assets/UI/abouttab.ui index 791b72915..52f40f7b1 100644 --- a/src/vorta/assets/UI/abouttab.ui +++ b/src/vorta/assets/UI/abouttab.ui @@ -213,7 +213,7 @@ - <html><head/><body><p><a href="https://github.com/borgbase/vorta"><span style=" text-decoration: underline; color:#0984e3;">Click here</span></a> for view Git repo.</p></body></html> + <html><head/><body><p><a href="https://github.com/borgbase/vorta"><span style=" text-decoration: underline; color:#0984e3;">Click here</span></a> to view Git repo.</p></body></html> true @@ -241,7 +241,7 @@ 20 - + Vorta is a cross-platform, open-source client designed to simplify the management of Borg backups. diff --git a/src/vorta/views/about_tab.py b/src/vorta/views/about_tab.py index 41928b400..da6d791a2 100644 --- a/src/vorta/views/about_tab.py +++ b/src/vorta/views/about_tab.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from PyQt6 import QtCore, uic @@ -28,6 +29,9 @@ def __init__(self, parent=None): ) self.gpl_logo.setPixmap(get_colored_icon('gpl_logo', scaled_height=40, return_qpixmap=True)) self.python_logo.setPixmap(get_colored_icon('python_logo', scaled_height=40, return_qpixmap=True)) + copyright_text = self.copyrightLabel.text() + copyright_text = copyright_text.replace('2020', str(datetime.now().year)) + self.copyrightLabel.setText(copyright_text) def set_borg_details(self, version, path): self.borgVersion.setText(version) From b2cf5b1fc9e6758b0d7a2e79a03d6740bee74826 Mon Sep 17 00:00:00 2001 From: Shivansh Singh <89853707+shivansh02@users.noreply.github.com> Date: Thu, 22 Feb 2024 01:41:45 +0530 Subject: [PATCH 51/52] Move log file link below logs table. By @shivansh02 (#1939) --- src/vorta/assets/UI/scheduletab.ui | 13 +++++++++++++ src/vorta/views/schedule_tab.py | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/vorta/assets/UI/scheduletab.ui b/src/vorta/assets/UI/scheduletab.ui index 67dbc2c2f..f7cb7b34e 100644 --- a/src/vorta/assets/UI/scheduletab.ui +++ b/src/vorta/assets/UI/scheduletab.ui @@ -626,6 +626,19 @@ + + + + <html><head/><body><p><a href="file:///"><span style=" text-decoration: underline; color:#0984e3;">View the logs</span></a></p></body></html> + + + 0 + + + true + + + diff --git a/src/vorta/views/schedule_tab.py b/src/vorta/views/schedule_tab.py index a03b191c7..dc97cfa47 100644 --- a/src/vorta/views/schedule_tab.py +++ b/src/vorta/views/schedule_tab.py @@ -8,7 +8,7 @@ QTableWidgetItem, ) -from vorta import application +from vorta import application, config from vorta.i18n import get_locale from vorta.scheduler import ScheduleStatusType from vorta.store.models import BackupProfileMixin, EventLogModel, WifiSettingModel @@ -43,6 +43,10 @@ def __init__(self, parent=None): # Set up log table self.logTableWidget.setAlternatingRowColors(True) header = self.logTableWidget.horizontalHeader() + self.logLink.setText( + f'Click here for complete logs.' + ) header.setVisible(True) [header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) for i in range(5)] header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) From d721011c90c190ff0e0d0be87f650cae917eab39 Mon Sep 17 00:00:00 2001 From: Shivansh Singh <89853707+shivansh02@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:21:42 +0530 Subject: [PATCH 52/52] VSC and Android exclusion patterns. By @shivansh02 (#1967) --- src/vorta/assets/exclusion_presets/dev.json | 25 ++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/vorta/assets/exclusion_presets/dev.json b/src/vorta/assets/exclusion_presets/dev.json index 947cb237d..bf53fb5bb 100644 --- a/src/vorta/assets/exclusion_presets/dev.json +++ b/src/vorta/assets/exclusion_presets/dev.json @@ -5,7 +5,8 @@ "patterns": [ "fm:*/node_modules", - "fm:*/.npm" + "fm:*/.npm", + "fm:*/npm-global" ], "tags": ["type:dev", "lang:javascript", "os:linux", "os:darwin"], "author": "Divi" @@ -33,5 +34,27 @@ ], "tags": ["type:dev", "lang:rust", "os:linux", "os:darwin"], "author": "Divi" + }, + { + "name": "Visual Studio Code cache and config files", + "slug": "vscode-cache", + "patterns": [ + "fm:*/.config/Code", + "fm:*/.vscode/extensions/*" + ], + "tags": ["type:editor", "editor:vscode", "os:linux"], + "author": "shivansh02" + }, + { + "name": "Android Studio Artefacts", + "slug": "android-studio", + "patterns": [ + "fm:*/.android", + "fm:*/.gradle", + "fm:*/Android/Sdk", + "fm:*/.AndroidStudio" + ], + "tags": ["type:dev", "editor:android-studio", "os:linux"], + "author": "shivansh02" } ]