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/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/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/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/.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/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. 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59ee4914e..a533eaf86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,49 +26,115 @@ jobs: shell: bash run: make lint - test: + 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] + matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix-unit)}} 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 == 'true' }} + + - 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: + needs: prepare-matrix + timeout-minutes: 20 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix-integration)}} + + 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 }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true'}} - - 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/.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/Makefile b/Makefile index 7b6307b9a..fbbb8b718 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. @@ -60,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/README.md b/README.md index d436d6670..b58d1e5e4 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,9 @@

-🤝 **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) +https://github.com/m3nu/vorta/assets/3916435/a622a148-5373-4ae0-87bc-4ca1d6f6202e ## Why is this great? 🤩 @@ -30,15 +28,16 @@ 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). - 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. -- 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) 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/noxfile.py b/noxfile.py new file mode 100644 index 000000000..b5f6fdfa0 --- /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/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/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 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=[ 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/setup.cfg b/setup.cfg index edddb2f15..e0cdd9319 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 @@ -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/_version.py b/src/vorta/_version.py index e4e49b3bb..8969d4966 100644 --- a/src/vorta/_version.py +++ b/src/vorta/_version.py @@ -1 +1 @@ -__version__ = '0.9.0' +__version__ = '0.9.1' diff --git a/src/vorta/application.py b/src/vorta/application.py index fac38440b..8857a51eb 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -173,8 +173,9 @@ 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. else: self._alert_missing_borg() @@ -307,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'] @@ -324,7 +320,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 @@ -343,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/assets/UI/abouttab.ui b/src/vorta/assets/UI/abouttab.ui new file mode 100644 index 000000000..52f40f7b1 --- /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> to 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/archivetab.ui b/src/vorta/assets/UI/archivetab.ui index 3e3b0b4c5..9c81d49f2 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 + + @@ -207,10 +209,29 @@ - Refresh selected archive + Recalculate selected archive's size(s) + + + Recalculate + + + Qt::ToolButtonTextBesideIcon + + + + + + + + 0 + 0 + + + + Compare two archives - Refresh + Diff Qt::ToolButtonTextBesideIcon @@ -255,7 +276,7 @@ - + @@ -274,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/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/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/UI/repoadd.ui b/src/vorta/assets/UI/repoadd.ui index 643e2a774..b11c1482e 100644 --- a/src/vorta/assets/UI/repoadd.ui +++ b/src/vorta/assets/UI/repoadd.ui @@ -1,278 +1,236 @@ - AddRepository - - - - 0 - 0 - 466 - 274 - - - + AddRepository + + + true + + + Add Repository + + + + 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 + + + 20 + + + + + + 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) + + + + + + + + + + + + + + Advanced + + + + QFormLayout::ExpandingFieldsGrow + + + 5 + + + 5 + + + 5 + + + 5 + + + + + SSH Key: + - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + 0 + 0 + + + + + Automatically choose SSH Key (default) + + + + + + + Extra Borg Arguments: + - - - - - + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + diff --git a/src/vorta/assets/UI/scheduletab.ui b/src/vorta/assets/UI/scheduletab.ui index d7c8ea860..56942c17c 100644 --- a/src/vorta/assets/UI/scheduletab.ui +++ b/src/vorta/assets/UI/scheduletab.ui @@ -633,6 +633,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/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..bf53fb5bb --- /dev/null +++ b/src/vorta/assets/exclusion_presets/dev.json @@ -0,0 +1,60 @@ +[ + { + "name": "Node Modules and package manager cache", + "slug": "node-cache", + "patterns": + [ + "fm:*/node_modules", + "fm:*/.npm", + "fm:*/npm-global" + ], + "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" + }, + { + "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" + } +] 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. 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..3dd95a141 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..e4d04343c 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..488ae73d1 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/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/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 @@ - - - - - - + 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..05295e599 --- /dev/null +++ b/src/vorta/assets/icons/settings_wheel.svg @@ -0,0 +1 @@ + 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/assets/metadata/com.borgbase.Vorta.appdata.xml b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml index 3823c5f21..b0c8a76b0 100644 --- a/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml +++ b/src/vorta/assets/metadata/com.borgbase.Vorta.appdata.xml @@ -1,6 +1,8 @@ com.borgbase.Vorta + com.borgbase.Vorta.desktop + Vorta contributors Vorta GPL-3.0 CC0-1.0 @@ -40,7 +42,18 @@ - + + +
    +
  • First production 0.9 release
  • +
  • Exclude GUI. By @diivi (#1846)
  • +
  • Backup settings.db before migrations. By @AdwaitSalankar (#1848)
  • +
  • Loosen platformdirs dependency (#1843)
  • +
  • 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/borg/borg_job.py b/src/vorta/borg/borg_job.py index d3846897a..9cf6a1551 100644 --- a/src/vorta/borg/borg_job.py +++ b/src/vorta/borg/borg_job.py @@ -27,7 +27,7 @@ db_lock = Lock() logger = logging.getLogger(__name__) -FakeRepo = namedtuple('Repo', ['url', 'id', 'extra_borg_arguments', 'encryption']) +FakeRepo = namedtuple('Repo', ['url', 'name', 'id', 'extra_borg_arguments', 'encryption']) FakeProfile = namedtuple('FakeProfile', ['id', 'repo', 'name', 'ssh_key']) """ @@ -190,6 +190,7 @@ def prepare(cls, profile): ret['ssh_key'] = profile.ssh_key ret['repo_id'] = profile.repo.id ret['repo_url'] = profile.repo.url + ret['repo_name'] = profile.repo.name ret['extra_borg_arguments'] = profile.repo.extra_borg_arguments ret['profile_name'] = profile.name ret['profile_id'] = profile.id 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/borg/create.py b/src/vorta/borg/create.py index 2e7b91965..4b2e8b84b 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() @@ -163,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/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/borg/info_repo.py b/src/vorta/borg/info_repo.py index 4397ba16b..4bbb870bf 100644 --- a/src/vorta/borg/info_repo.py +++ b/src/vorta/borg/info_repo.py @@ -18,7 +18,7 @@ def prepare(cls, params): # Build fake profile because we don't have it in the DB yet. Assume unencrypted. profile = FakeProfile( 999, - FakeRepo(params['repo_url'], 999, params['extra_borg_arguments'], 'none'), + FakeRepo(params['repo_url'], params['repo_name'], 999, params['extra_borg_arguments'], 'none'), 'New Repo', params['ssh_key'], ) @@ -47,6 +47,7 @@ def prepare(cls, params): ret['message'] = trans_late('messages', 'Please unlock your password manager.') return ret + ret['repo_name'] = params['repo_name'] ret['ok'] = True ret['cmd'] = cmd @@ -54,7 +55,9 @@ def prepare(cls, params): def process_result(self, result): if result['returncode'] == 0: - new_repo, _ = RepoModel.get_or_create(url=result['cmd'][-1]) + new_repo, _ = RepoModel.get_or_create( + url=result['cmd'][-1], defaults={'name': result['params']['repo_name']} + ) if 'cache' in result['data']: stats = result['data']['cache']['stats'] new_repo.total_size = stats['total_size'] @@ -64,7 +67,6 @@ def process_result(self, result): new_repo.encryption = result['data']['encryption']['mode'] if new_repo.encryption != 'none': self.keyring.set_password("vorta-repo", new_repo.url, result['params']['password']) - new_repo.extra_borg_arguments = result['params']['extra_borg_arguments'] new_repo.save() diff --git a/src/vorta/borg/init.py b/src/vorta/borg/init.py index 7fa0300a6..a88a5ea0a 100644 --- a/src/vorta/borg/init.py +++ b/src/vorta/borg/init.py @@ -16,6 +16,7 @@ def prepare(cls, params): 999, FakeRepo( params['repo_url'], + params['repo_name'], 999, params['extra_borg_arguments'], params['encryption'], @@ -61,6 +62,7 @@ def process_result(self, result): defaults={ 'encryption': result['params']['encryption'], 'extra_borg_arguments': result['params']['extra_borg_arguments'], + 'name': result['params']['repo_name'], }, ) if new_repo.encryption != 'none': 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/i18n/qm/vorta.cs.qm b/src/vorta/i18n/qm/vorta.cs.qm index 7168f26cd..a4153f884 100644 Binary files a/src/vorta/i18n/qm/vorta.cs.qm and b/src/vorta/i18n/qm/vorta.cs.qm differ diff --git a/src/vorta/i18n/qm/vorta.de.qm b/src/vorta/i18n/qm/vorta.de.qm index 5fa82a3aa..0db3a0c3c 100644 Binary files a/src/vorta/i18n/qm/vorta.de.qm and b/src/vorta/i18n/qm/vorta.de.qm differ diff --git a/src/vorta/i18n/qm/vorta.es.qm b/src/vorta/i18n/qm/vorta.es.qm index 93617797e..eefdd843d 100644 Binary files a/src/vorta/i18n/qm/vorta.es.qm and b/src/vorta/i18n/qm/vorta.es.qm differ diff --git a/src/vorta/i18n/qm/vorta.fi.qm b/src/vorta/i18n/qm/vorta.fi.qm index d95c5c393..3b1e93207 100644 Binary files a/src/vorta/i18n/qm/vorta.fi.qm and b/src/vorta/i18n/qm/vorta.fi.qm differ diff --git a/src/vorta/i18n/qm/vorta.nl.qm b/src/vorta/i18n/qm/vorta.nl.qm index 4e310b7cb..6969ef096 100644 Binary files a/src/vorta/i18n/qm/vorta.nl.qm and b/src/vorta/i18n/qm/vorta.nl.qm differ diff --git a/src/vorta/i18n/qm/vorta.sk.qm b/src/vorta/i18n/qm/vorta.sk.qm index 585e50144..68d9d6a41 100644 Binary files a/src/vorta/i18n/qm/vorta.sk.qm and b/src/vorta/i18n/qm/vorta.sk.qm differ diff --git a/src/vorta/i18n/ts/vorta.de.ts b/src/vorta/i18n/ts/vorta.de.ts index 6156a5a6a..aa49dddd2 100644 --- a/src/vorta/i18n/ts/vorta.de.ts +++ b/src/vorta/i18n/ts/vorta.de.ts @@ -90,34 +90,34 @@ Unverschlü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” @@ -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. diff --git a/src/vorta/i18n/ts/vorta.es.ts b/src/vorta/i18n/ts/vorta.es.ts index c8868d173..9d7bc65f8 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) @@ -1609,24 +1494,24 @@ 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. diff --git a/src/vorta/i18n/ts/vorta.fi.ts b/src/vorta/i18n/ts/vorta.fi.ts index b52fa3c7a..5ae1e50c7 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 @@ -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. 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) 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/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/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 222ccbc56..aa8b2859c 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) @@ -71,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): @@ -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.') diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index dcddcd88d..e02efe96b 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 @@ -12,6 +14,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + ExclusionModel, RepoModel, RepoPassword, SchemaVersion, @@ -21,7 +24,7 @@ ) from .settings import get_misc_settings -SCHEMA_VERSION = 21 +SCHEMA_VERSION = 22 @signals.post_save(sender=SettingsModel) @@ -52,6 +55,7 @@ def init_db(con=None): WifiSettingModel, EventLogModel, SchemaVersion, + ExclusionModel, ] ) @@ -83,6 +87,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 +103,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) diff --git a/src/vorta/store/migrations.py b/src/vorta/store/migrations.py index 5a2d23b9e..55ad81064 100644 --- a/src/vorta/store/migrations.py +++ b/src/vorta/store/migrations.py @@ -232,6 +232,22 @@ def run_migrations(current_schema, db_connection): _apply_schema_update( current_schema, 21, + migrator.add_column( + ArchiveModel._meta.table_name, + 'trigger', + pw.CharField(null=True), + ), + ) + + if current_schema.version < 22: + _apply_schema_update( + current_schema, + 22, + migrator.add_column( + RepoModel._meta.table_name, + 'name', + pw.CharField(default=''), + ), migrator.add_column( BackupProfileModel._meta.table_name, 'allow_new_networks', diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 852296090..a5a2e7bba 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): @@ -39,6 +43,7 @@ class RepoModel(BaseModel): """A single remote repo with unique URL.""" url = pw.CharField(unique=True) + name = pw.CharField(default='') added_at = pw.DateTimeField(default=datetime.now) encryption = pw.CharField(null=True) unique_size = pw.IntegerField(null=True) @@ -105,6 +110,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 @@ -133,6 +201,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/store/settings.py b/src/vorta/store/settings.py index f0559582b..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', @@ -59,6 +62,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 8c53070db..839679497 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -18,13 +18,14 @@ 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 # 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 @@ -141,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 @@ -280,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: @@ -508,21 +501,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/about_tab.py b/src/vorta/views/about_tab.py new file mode 100644 index 000000000..da6d791a2 --- /dev/null +++ b/src/vorta/views/about_tab.py @@ -0,0 +1,38 @@ +import logging +from datetime import datetime + +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)) + 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) + self.borgPath.setText(f"
Path to Borg: {path}
") diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index f5b92c8ee..d2af5757b 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -5,15 +5,15 @@ 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, QHeaderView, - QInputDialog, QLayout, QMenu, QMessageBox, + QStyledItemDelegate, QTableView, QTableWidgetItem, QWidget, @@ -32,8 +32,9 @@ 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, find_best_unit_for_sizes, format_archive_name, @@ -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'] @@ -70,11 +78,17 @@ 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 + ) #: Tooltip dict to save the tooltips set in the designer self.tooltip_dict: Dict[QWidget, str] = {} self.tooltip_dict[self.bDiff] = self.bDiff.toolTip() self.tooltip_dict[self.bDelete] = self.bDelete.toolTip() + self.tooltip_dict[self.bRefreshArchive] = self.bRefreshArchive.toolTip() + self.tooltip_dict[self.compactButton] = self.compactButton.toolTip() header = self.archiveTable.horizontalHeader() header.setVisible(True) @@ -83,7 +97,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. @@ -94,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) @@ -109,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) @@ -136,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')) @@ -165,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.rename_action)) - # 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)) @@ -252,9 +246,20 @@ def populate_from_profile(self): if repo_mount_points: self.repo_mount_point = repo_mount_points[0] - self.toolBox.setItemText(0, self.tr('Archives for %s') % profile.repo.url) + if profile.repo.name: + repo_name = f"{profile.repo.name} ({profile.repo.url})" + else: + repo_name = profile.repo.url + self.toolBox.setItemText(0, self.tr('Archives for {}').format(repo_name)) + archives = [s for s in profile.repo.archives.select().order_by(ArchiveModel.time.desc())] + # if no archive's name can be found in self.mount_points, then hide the mount point column + if not any(a.name in self.mount_points for a in archives): + self.archiveTable.hideColumn(3) + else: + self.archiveTable.showColumn(3) + sorting = self.archiveTable.isSortingEnabled() self.archiveTable.setSortingEnabled(False) best_unit = find_best_unit_for_sizes((a.size for a in archives), precision=SIZE_DECIMAL_DIGITS) @@ -263,9 +268,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: @@ -280,13 +288,24 @@ 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) 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) @@ -321,6 +340,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() @@ -330,14 +353,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: @@ -353,11 +377,13 @@ 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() - 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() @@ -365,14 +391,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() @@ -488,20 +511,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): @@ -767,7 +801,14 @@ 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 self.bRename.isEnabled(): + return + + if not row or not column: + row = self.archiveTable.currentRow() + column = self.archiveTable.currentColumn() + if column == 3: archive_name = self.selected_archive_name() if not archive_name: @@ -778,6 +819,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] @@ -794,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 = [] @@ -912,45 +993,24 @@ 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) + + 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/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/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/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: diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index ca0c2426b..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,14 +72,18 @@ 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) 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.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) @@ -93,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(): @@ -128,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) @@ -149,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) @@ -167,9 +187,15 @@ 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_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() @@ -178,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)) @@ -193,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.") @@ -257,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) @@ -274,7 +314,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) diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py index 2d52880f0..6a3375352 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, @@ -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 @@ -28,15 +26,12 @@ class MiscTab(MiscTabBase, MiscTabUI, 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'Log' - ) self.checkboxLayout = QFormLayout(self.frameSettings) self.checkboxLayout.setSpacing(4) @@ -101,6 +96,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) @@ -129,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/partials/password_input.py b/src/vorta/views/partials/password_input.py new file mode 100644 index 000000000..ce10f30f1 --- /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 at least {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 at least {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/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_add_dialog.py b/src/vorta/views/repo_add_dialog.py index ddf584d36..cd265a333 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,30 +59,13 @@ 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(), - 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(): folder = dialog.selectedFiles() if folder: self.repoURL.setText(folder[0]) + self.repoName.setText(folder[0].split('/')[-1]) self.repoURL.setEnabled(False) self.sshComboBox.setEnabled(False) self.repoLabel.setText(self.tr('Repository Path:')) @@ -102,48 +74,15 @@ 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) + self.repoName.setText('') self.sshComboBox.setEnabled(True) self.extraBorgArgumentsLineEdit.setText('') 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() @@ -156,6 +95,74 @@ 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 65 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.setWindowTitle("Add New Repository") + + 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 = [ @@ -188,60 +195,50 @@ 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 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.setWindowTitle("Add Existing Repository") + + 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/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 2f6b52fb4..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') @@ -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: @@ -196,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) @@ -221,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/schedule_tab.py b/src/vorta/views/schedule_tab.py index a5b7802f7..5e917c305 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, @@ -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) @@ -206,7 +210,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): diff --git a/src/vorta/views/source_tab.py b/src/vorta/views/source_tab.py index 13a48f5e4..ed63be2a7 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( @@ -340,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(), @@ -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() @@ -379,4 +366,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/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/src/vorta/views/utils.py b/src/vorta/views/utils.py index 870a8ccb7..5a9697a73 100644 --- a/src/vorta/views/utils.py +++ b/src/vorta/views/utils.py @@ -1,9 +1,13 @@ +import json +import os +import sys + from PyQt6.QtGui import QIcon, QImage, QPixmap 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 +15,37 @@ 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) + + if return_qpixmap: + 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') - return QIcon(QPixmap(svg_img)) + 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/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..683271f54 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,239 @@ +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 ArchiveTab, 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() +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'): + 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..a32df06d0 --- /dev/null +++ b/tests/integration/test_archives.py @@ -0,0 +1,142 @@ +""" +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, archive_env): + """Test for archive pruning""" + 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, archive_env): + """Test for archive compaction""" + main, tab = archive_env + 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, archive_env): + """Test for archive consistency check""" + main, tab = archive_env + + 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, archive_env): + """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, tab = archive_env + 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, archive_env): + """Test for archive extraction""" + main, tab = archive_env + + 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, archive_env): + """Test for archive deletion""" + main, tab = archive_env + + 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, archive_env): + """Test for archive renaming""" + main, tab = archive_env + + tab.archiveTable.selectRow(0) + new_archive_name = 'idf89d8f9d8fd98' + 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.archiveTable.model().index(0, 4).data() == new_archive_name, **pytest._wait_defaults) diff --git a/tests/integration/test_borg.py b/tests/integration/test_borg.py new file mode 100644 index 000000000..0af84a96b --- /dev/null +++ b/tests/integration/test_borg.py @@ -0,0 +1,56 @@ +""" +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 + + +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, archive_env): + """Check that archive info command works""" + 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..e4751b917 --- /dev/null +++ b/tests/integration/test_repo.py @@ -0,0 +1,22 @@ +""" +Test backup creation +""" + +import pytest +from PyQt6 import QtCore +from vorta.store.models import ArchiveModel, EventLogModel + + +def test_create(qapp, qtbot, archive_env): + """Test for manual archive creation""" + main, tab = archive_env + + 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/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 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/test_misc.py b/tests/test_misc.py deleted file mode 100644 index 680ac9d88..000000000 --- a/tests/test_misc.py +++ /dev/null @@ -1,86 +0,0 @@ -import os -import sys -from pathlib import Path -from unittest.mock import Mock - -import pytest -import vorta.store.models -from PyQt6 import QtCore -from PyQt6.QtWidgets import QCheckBox, QFormLayout - - -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) - - 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) - - with open(autostart_path) as desktop_file: - desktop_file_text = desktop_file.read() - - assert desktop_file_text.startswith("[Desktop Entry]") - - _click_toggle_setting(setting, qapp, qtbot) - - if sys.platform == 'linux': - assert not os.path.exists(autostart_path) - - -@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 - mocker.patch.object(vorta.store.models.SettingsModel, "get", return_value=Mock(value=True)) - mocker.patch('pathlib.Path.exists', return_value=True) - mocker.patch('os.access', return_value=False) - mock_qmessagebox = mocker.patch('vorta.application.QMessageBox') - - # See that pop-up occurs - qapp.check_darwin_permissions() - mock_qmessagebox.assert_called() - - # Reset mocks for setting disabled - mock_qmessagebox.reset_mock() - mocker.patch.object(vorta.store.models.SettingsModel, "get", return_value=Mock(value=False)) - - # See that pop-up does not occur - 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): - """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 diff --git a/tests/test_profile.py b/tests/test_profile.py deleted file mode 100644 index f7c58bb7a..000000000 --- a/tests/test_profile.py +++ /dev/null @@ -1,37 +0,0 @@ -from PyQt6 import QtCore -from PyQt6.QtWidgets import QDialogButtonBox -from vorta.store.models import BackupProfileModel - - -def test_profile_add(qapp, qtbot): - main = qapp.main_window - 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 - ) - - assert BackupProfileModel.get_or_none(name='Test Profile') is not None - assert main.profileSelector.currentText() == 'Test Profile' - - -def test_profile_edit(qapp, qtbot): - main = qapp.main_window - 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 - ) - - 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/test_repo.py b/tests/test_repo.py deleted file mode 100644 index bfbb75c75..000000000 --- a/tests/test_repo.py +++ /dev/null @@ -1,149 +0,0 @@ -import os -import uuid - -import pytest -import vorta.borg.borg_job -from PyQt6 import QtCore -from PyQt6.QtWidgets import QMessageBox -from vorta.keyring.abc import VortaKeyring -from vorta.store.models import ArchiveModel, EventLogModel, RepoModel - -LONG_PASSWORD = 'long-password-long' -SHORT_PASSWORD = 'hunter2' - - -def test_repo_add_failures(qapp, qtbot, mocker, borg_json_output): - # Add new repo window - main = qapp.main_window - main.repoTab.new_repo() - 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.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) - 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.' - - 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) - 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.' - - 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) - qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.passwordLabel.text() == 'Passwords must be identical.' - - -def test_repo_unlink(qapp, qtbot, monkeypatch): - main = qapp.main_window - 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 - - qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton) - # -1 is the repo id in this test - qtbot.waitUntil(lambda: 'Select a backup repository first.' in main.progressText.text(), **pytest._wait_defaults) - assert 'Select a backup repository first.' in main.progressText.text() - - -def test_password_autofill(qapp, qtbot): - main = qapp.main_window - 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 - - keyring = VortaKeyring.get_keyring() - password = str(uuid.uuid4()) - keyring.set_password('vorta-repo', test_repo_url, password) - - qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) - - assert add_repo_window.passwordLineEdit.text() == password - - -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 - test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain - - qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) - qtbot.keyClicks(add_repo_window.passwordLineEdit, LONG_PASSWORD) - qtbot.keyClicks(add_repo_window.confirmLineEdit, LONG_PASSWORD) - - 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) - - add_repo_window.run() - qtbot.waitUntil( - lambda: EventLogModel.select().where(EventLogModel.returncode == 0).count() == 2, **pytest._wait_defaults - ) - - assert RepoModel.get(id=2).url == test_repo_url - - 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 - - -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 - - 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 files exist - qtbot.waitUntil(lambda: ssh_dialog.errors.text().startswith('New key was copied'), **pytest._wait_defaults) - assert len(ssh_dir.listdir()) == 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') - - ssh_dialog.generate_key() - qtbot.waitUntil(lambda: ssh_dialog.errors.text().startswith('Key file already'), **pytest._wait_defaults) - - -def test_create(qapp, borg_json_output, mocker, qtbot): - main = qapp.main_window - stdout, stderr = borg_json_output('create') - popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) - mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) - - 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() == 1 - assert ArchiveModel.select().count() == 3 - assert RepoModel.get(id=1).unique_size == 15520474 - assert main.createStartBtn.isEnabled() - assert main.archiveTab.archiveTable.rowCount() == 3 - assert main.scheduleTab.logTableWidget.rowCount() == 1 diff --git a/tests/test_source.py b/tests/test_source.py deleted file mode 100644 index a4209576a..000000000 --- a/tests/test_source.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -import vorta.views - - -def test_add_folder(qapp, qtbot, mocker, monkeypatch, choose_file_dialog): - monkeypatch.setattr(vorta.views.source_tab, "choose_file_dialog", choose_file_dialog) - main = qapp.main_window - main.tabWidget.setCurrentIndex(1) - tab = main.sourceTab - - tab.source_add(want_folder=True) - qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 2, **pytest._wait_defaults) - - # Test paste button with mocked clipboard - mock_clipboard = mocker.Mock() - mock_clipboard.text.return_value = __file__ - mocker.patch.object(vorta.views.source_tab.QApplication, 'clipboard', return_value=mock_clipboard) - 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) diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 4d9c3d6f3..000000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,82 +0,0 @@ -import uuid - -from vorta.keyring.abc import VortaKeyring -from vorta.utils import find_best_unit_for_sizes, pretty_bytes - - -def test_keyring(): - UNICODE_PW = 'kjalsdfüadsfäadsfß' - REPO = f'ssh://asdf123@vorta-test-repo.{uuid.uuid4()}.com/./repo' # Random repo URL - - keyring = VortaKeyring.get_keyring() - keyring.set_password('vorta-repo', REPO, UNICODE_PW) - assert keyring.get_password("vorta-repo", REPO) == UNICODE_PW - - -def test_best_size_unit_precision0(): - 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" 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..e622a2118 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,120 @@ +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 ArchiveTab, 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__)) + + +@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/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 98% rename from tests/profile_exports/valid.json rename to tests/unit/profile_exports/valid.json index 6f6716a08..636dd2382 100644 --- a/tests/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, diff --git a/tests/test_archives.py b/tests/unit/test_archives.py similarity index 58% rename from tests/test_archives.py rename to tests/unit/test_archives.py index a7627af16..e0a7fb1aa 100644 --- a/tests/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 @@ -30,18 +31,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 +48,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 +60,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 +76,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 +88,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 +116,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 +125,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,29 +145,76 @@ 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_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) new_archive_name = 'idf89d8f9d8fd98' 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() + + 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.mountErrors.text() == 'Archive renamed.', **pytest._wait_defaults) - assert ArchiveModel.select().filter(name=new_archive_name).count() == 1 + 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 - # 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) + +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/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 82% rename from tests/test_diff.py rename to tests/unit/test_diff.py index 7db44b2e7..cc001f02b 100644 --- a/tests/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,17 +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): - main = qapp.main_window - tab = main.archiveTab - main.tabWidget.setCurrentIndex(3) - - tab.populate_from_profile() - qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2) - +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) @@ -51,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', [ 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 70% rename from tests/test_import_export.py rename to tests/unit/test_import_export.py index 47faef97a..d7987ab75 100644 --- a/tests/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): 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/unit/test_misc.py b/tests/unit/test_misc.py new file mode 100644 index 000000000..d354af05e --- /dev/null +++ b/tests/unit/test_misc.py @@ -0,0 +1,145 @@ +import os +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest +import vorta.store.models +from PyQt6 import QtCore +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) + + +@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': + assert not os.path.exists(autostart_path) + + +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()) + + # 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 + + # 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): + """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, **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, mocker): + """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)) + mocker.patch('pathlib.Path.exists', return_value=True) + mocker.patch('os.access', return_value=False) + mock_qmessagebox = mocker.patch('vorta.application.QMessageBox') + + # See that pop-up occurs + qapp.check_darwin_permissions() + mock_qmessagebox.assert_called() + + # Reset mocks for setting disabled + mock_qmessagebox.reset_mock() + mocker.patch.object(vorta.store.models.SettingsModel, "get", return_value=Mock(value=False)) + + # See that pop-up does not occur + qapp.check_darwin_permissions() + mock_qmessagebox.assert_not_called() + + +def _click_toggle_setting(setting, qapp, qtbot): + """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/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/unit/test_password_input.py b/tests/unit/test_password_input.py new file mode 100644 index 000000000..218a1dac4 --- /dev/null +++ b/tests/unit/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 at least 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 at least 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 at least 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/unit/test_profile.py b/tests/unit/test_profile.py new file mode 100644 index 000000000..03ad56e79 --- /dev/null +++ b/tests/unit/test_profile.py @@ -0,0 +1,49 @@ +from PyQt6 import QtCore +from PyQt6.QtWidgets import QDialogButtonBox, QMessageBox, QToolTip +from vorta.store.models import BackupProfileModel + + +def test_profile_add_delete(qapp, qtbot, mocker): + """Tests adding and deleting profiles.""" + main = qapp.main_window + + # add profile and ensure it is created as intended + 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.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.currentItem().text() == '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.currentItem().text() == 'Default' + + +def test_profile_edit(qapp, qtbot): + """Tests editing/renaming a profile""" + main = qapp.main_window + + # 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 + edit_profile_window.profileNameField.setText("") + qtbot.keyClicks(edit_profile_window.profileNameField, 'Test Profile') + 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.currentItem().text() == 'Test Profile' diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py new file mode 100644 index 000000000..e072119d1 --- /dev/null +++ b/tests/unit/test_repo.py @@ -0,0 +1,307 @@ +import os +import uuid +from typing import Any, Dict + +import pytest +import vorta.borg.borg_job +from PyQt6 import QtCore +from PyQt6.QtWidgets import QMessageBox +from vorta.keyring.abc import VortaKeyring +from vorta.store.models import ArchiveModel, EventLogModel, RepoModel + +LONG_PASSWORD = 'long-password-long' +SHORT_PASSWORD = 'hunter2' + + +@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 + tab = main.repoTab + tab.new_repo() + add_repo_window = tab._window + qtbot.addWidget(add_repo_window) + + 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() == 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) + + 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.errorText.text() == error_text + + +def test_repo_unlink(qapp, qtbot, monkeypatch): + main = qapp.main_window + tab = main.repoTab + monkeypatch.setattr(QMessageBox, "show", lambda *args: True) + + qtbot.mouseClick(tab.repoRemoveToolbutton, QtCore.Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: tab.repoSelector.count() == 1, **pytest._wait_defaults) + assert RepoModel.select().count() == 0 + + qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton) + # -1 is the repo id in this test + qtbot.waitUntil(lambda: 'Select a backup repository first.' in main.progressText.text(), **pytest._wait_defaults) + assert 'Select a backup repository first.' in main.progressText.text() + + +def test_password_autofill(qapp, qtbot): + 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 + + keyring = VortaKeyring.get_keyring() + password = str(uuid.uuid4()) + keyring.set_password('vorta-repo', test_repo_url, password) + + qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) + + 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): + 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 + 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) + 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) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + add_repo_window.run() + qtbot.waitUntil( + lambda: EventLogModel.select().where(EventLogModel.returncode == 0).count() == 2, **pytest._wait_defaults + ) + + assert RepoModel.get(id=2).url == test_repo_url + + keyring = VortaKeyring.get_keyring() + assert keyring.get_password("vorta-repo", RepoModel.get(id=2).url) == LONG_PASSWORD + assert tab.repoSelector.currentText() == f"{test_repo_name} - {test_repo_url}" + + +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") + 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 + + # 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: 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_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') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + 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() == 1 + assert ArchiveModel.select().count() == 3 + assert RepoModel.get(id=1).unique_size == 15520474 + 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/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/unit/test_source.py b/tests/unit/test_source.py new file mode 100644 index 000000000..70b019616 --- /dev/null +++ b/tests/unit/test_source.py @@ -0,0 +1,128 @@ +import pytest +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() +def source_env(qapp, qtbot, monkeypatch, choose_file_dialog): + """ + Handles common setup and teardown for unit tests involving the source tab. + """ + main: MainWindow = qapp.main_window + tab: SourceTab = main.sourceTab + main.tabWidget.setCurrentIndex(1) + qtbot.waitUntil(lambda: tab.sourceFilesWidget.rowCount() == 1, timeout=2000) + monkeypatch.setattr(vorta.views.source_tab, "choose_file_dialog", choose_file_dialog) + + 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 + + +@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 = 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() + + 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 + + +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/test_treemodel.py b/tests/unit/test_treemodel.py similarity index 99% rename from tests/test_treemodel.py rename to tests/unit/test_treemodel.py index 1b76d5856..dd2b9717e 100644 --- a/tests/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' diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 000000000..ea529c089 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,167 @@ +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, + sort_sizes, +) + + +def test_keyring(): + UNICODE_PW = 'kjalsdfüadsfäadsfß' + REPO = f'ssh://asdf123@vorta-test-repo.{uuid.uuid4()}.com/./repo' # Random repo URL + + keyring = VortaKeyring.get_keyring() + keyring.set_password('vorta-repo', REPO, UNICODE_PW) + 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", + [ + (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] + 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 + + +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