1
0
Fork 0
mirror of https://github.com/borgbase/vorta synced 2025-03-04 02:38:02 +00:00

i18n infrastructure and policy (#134)

- Adds support for translating Python strings and .UI files with Qt's `QTranslator` class.
- Integrates the Transifex cloud translation service for managing translations.
- Adds translation policy and guidelines to `CONTRIBUTING.md`
This commit is contained in:
TW 2019-01-20 04:50:10 +01:00 committed by Manuel Riel
parent 5ef40693b0
commit e15675599e
36 changed files with 346 additions and 148 deletions

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ vorta.egg-info
.vagrant
*.log
htmlcov
*.qm

10
.tx/config Normal file
View file

@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
[vorta.vorta]
file_filter = src/vorta/i18n/ts/vorta.<lang>.ts
minimum_perc = 0
source_file = src/vorta/i18n/ts/vorta.en_US.ts
source_lang = en_US
type = QT

View file

@ -53,3 +53,76 @@ To test for style errors:
```
$ flake8
```
## Working with translations
NOTE: we are currently still working on the original strings.
DO NO TRANSLATION WORK EXCEPT IF YOU ARE WILLING TO DO DOUBLE WORK.
Translations are updated there: https://www.transifex.com/borgbase/vorta/
### Policy for translations
- no google translate or other automated translation.
- only native or as-good-as-native speakers should translate.
- as there is a need for continued maintenance, a translator should be also a
user of vorta, having some own interest in the translation (one-time
translations are not that helpful if there is noone updating them regularly)
- a translation must have >90% translated strings. if a translation falls
and stays below that for a longer time, it will not be used by vorta and
ultimately, it will get removed from the repository also.
### Adding a new language
- Only add a new language if you are willing to also update the translation
in future, when new strings are added and existing strings change.
- Request a new language via transifex.
- TODO: add notes here what the maintainer has to do
### Updating a language
- Please only work on a translation if you are a native speaker or you have
similar language skills.
- Edit the language on transifex.
### Data Flow to/from transifex
- extract: make translations-from-source
- push: make translations-push
- pull: make translations-pull
- compile: make translations-to-qm
### Notes for developers
- original strings in .ui and .py must be American English (en_US)
- in English, not translated:
- log messages (log file as well as log output on console or elsewhere)
- other console output, print().
- docs
- py source code, comments, docstrings
- translated:
- GUI texts / messages
- in Qt (sub)classes, use self.tr("English string"), scope will
be the instance class name.
- elsewhere use vorta.i18n.translate("scopename", "English string")
- to only mark for string extraction, but not immediately translate,
use vorta.i18n.trans_late function.
Later, to translate, use vorta.i18n.translate (giving same scope).
### Required Software
To successfully run the translation-related Makefile targets, the translations
maintainer needs:
- make tool
- pylupdate5
- lrelease
- transifex-client pypi package
(should be already there via requirements.d/dev.txt)
Debian 9 "Stretch":
apt install qttools5-dev-tools pyqt5-dev-tools

View file

@ -1,3 +1,8 @@
export VORTA_SRC := src/vorta
export QT_SELECT=5
.PHONY : help
.DEFAULT_GOAL := help
Vorta.app:
#pyrcc5 -o src/vorta/views/collection_rc.py src/vorta/assets/icons/collection.qrc
@ -21,12 +26,12 @@ pypi-release:
python setup.py sdist
twine upload dist/vorta-0.6.5.tar.gz
bump-version:
bump-version: ## Add new version tag and push to upstream repo.
bumpversion patch
#bumpversion minor
git push upstream
travis-debug:
travis-debug: ## Prepare connecting to Travis instance via SSH.
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
@ -34,3 +39,22 @@ travis-debug:
-H "Authorization: token ${TRAVIS_TOKEN}" \
-d '{ "quiet": true }' \
https://api.travis-ci.org/job/${TRAVIS_JOB_ID}/debug
translations-from-source: ## Extract strings from source code / UI files, merge into .ts.
pylupdate5 -verbose -translate-function trans_late \
$$VORTA_SRC/*.py $$VORTA_SRC/views/*.py $$VORTA_SRC/borg/*.py \
$$VORTA_SRC/assets/UI/*.ui \
-ts $$VORTA_SRC/i18n/ts/vorta.en_US.ts
translations-push: translations-from-source ## Upload .ts to Transifex.
tx push -s
translations-pull: ## Download .ts from Transifex.
tx pull -a
translations-to-qm: ## Compile .ts text files to binary .qm files.
for f in $$(ls $$VORTA_SRC/i18n/ts/vorta.*.ts); do lrelease $$f -qm $$VORTA_SRC/i18n/qm/$$(basename $$f .ts).qm; done
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

6
requirements.d/Brewfile Normal file
View file

@ -0,0 +1,6 @@
# Install required non-Python dev packages using Homebrew on macOS:
# Run `brew bundle` while in this folder
brew 'qt'
brew 'hub'
cask 'qt-creator'

View file

@ -1,3 +1,4 @@
transifex-client
pytest
pytest-qt
pytest-mock

View file

@ -5,6 +5,7 @@ import fcntl
from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication
from .i18n import init_translations, translate
from .tray_menu import TrayMenu
from .scheduler import VortaScheduler
from .models import BackupProfileModel
@ -43,6 +44,8 @@ class VortaApp(QApplication):
sys.exit(1)
super().__init__(args_raw)
init_translations(self)
self.setQuitOnLastWindowClosed(False)
self.scheduler = VortaScheduler(self)
@ -68,7 +71,7 @@ class VortaApp(QApplication):
thread = BorgCreateThread(msg['cmd'], msg, parent=self)
thread.start()
else:
self.backup_log_event.emit(msg['message'])
self.backup_log_event.emit(translate('messages', msg['message']))
def open_main_window_action(self):
self.main_window.show()

View file

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>532</width>
<height>289</height>
<height>293</height>
</rect>
</property>
<property name="sizePolicy">
@ -56,7 +56,7 @@
</font>
</property>
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Backup profiles allow for granular backups from different sources to different destinations. You could e.g. back up essential documents to a remote repository via Wifi, while doing a full backup onto a thumb drive.&lt;/p&gt;&lt;p&gt;Repositories and SSH keys are shared between profiles. Source folders, active destination repo, allowed networks, pruning, validation and scheduling are per-profile.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Backup profiles allow for granular backups from different sources to different destinations. You could e.g. back up essential documents to a remote repository via Wifi, while doing a full backup onto a local storage device.&lt;/p&gt;&lt;p&gt;Repositories and SSH keys are shared between profiles. Source folders, active destination repo, allowed networks, pruning, validation and scheduling are per-profile.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>

View file

@ -244,7 +244,7 @@ font-weight: bold;
<item>
<widget class="QCheckBox" name="pruneCheckBox">
<property name="text">
<string>Prune old Snapshots after each backup.</string>
<string>Prune old Archives after each backup.</string>
</property>
<property name="tristate">
<bool>false</bool>

View file

@ -10,6 +10,7 @@ from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication
from subprocess import Popen, PIPE
from vorta.i18n import trans_late
from vorta.models import EventLogModel, BackupProfileMixin
from vorta.utils import keyring
@ -88,22 +89,22 @@ class BorgThread(QtCore.QThread, BackupProfileMixin):
# Do checks to see if running Borg is possible.
if cls.is_running():
ret['message'] = 'Backup is already in progress.'
ret['message'] = trans_late('messages', 'Backup is already in progress.')
return ret
if cls.prepare_bin() is None:
ret['message'] = 'Borg binary was not found.'
ret['message'] = trans_late('messages', 'Borg binary was not found.')
return ret
if profile.repo is None:
ret['message'] = 'Add a backup repository first.'
ret['message'] = trans_late('messages', 'Add a backup repository first.')
return ret
# Try to get password from chosen keyring backend.
try:
ret['password'] = keyring.get_password("vorta-repo", profile.repo.url)
except Exception:
ret['message'] = 'Please make sure you grant Vorta permission to use the Keychain.'
ret['message'] = trans_late('messages', 'Please make sure you grant Vorta permission to use the Keychain.')
return ret
ret['ssh_key'] = profile.ssh_key
@ -216,7 +217,7 @@ class BorgThread(QtCore.QThread, BackupProfileMixin):
self.updated.emit(msg)
def started_event(self):
self.updated.emit('Task started')
self.updated.emit(self.tr('Task started'))
def finished_event(self, result):
self.result.emit(result)

View file

@ -8,7 +8,7 @@ class BorgCheckThread(BorgThread):
def started_event(self):
self.app.backup_started_event.emit()
self.app.backup_log_event.emit('Starting consistency check..')
self.app.backup_log_event.emit(self.tr('Starting consistency check..'))
def finished_event(self, result):
self.app.backup_finished_event.emit(result)

View file

@ -3,6 +3,7 @@ import tempfile
from dateutil import parser
import subprocess
from ..i18n import trans_late
from ..utils import get_current_wifi, format_archive_name
from ..models import SourceFileModel, ArchiveModel, WifiSettingModel, RepoModel
from .borg_thread import BorgThread
@ -11,7 +12,7 @@ from .borg_thread import BorgThread
class BorgCreateThread(BorgThread):
def process_result(self, result):
if result['returncode'] in [0, 1] and 'archive' in result['data']:
new_snapshot, created = ArchiveModel.get_or_create(
new_archive, created = ArchiveModel.get_or_create(
snapshot_id=result['data']['archive']['id'],
defaults={
'name': result['data']['archive']['name'],
@ -21,7 +22,7 @@ class BorgCreateThread(BorgThread):
'size': result['data']['archive']['stats']['deduplicated_size']
}
)
new_snapshot.save()
new_archive.save()
if 'cache' in result['data'] and created:
stats = result['data']['cache']['stats']
repo = RepoModel.get(id=result['params']['repo_id'])
@ -31,14 +32,14 @@ class BorgCreateThread(BorgThread):
repo.total_unique_chunks = stats['total_unique_chunks']
repo.save()
self.app.backup_log_event.emit('Backup finished.')
self.app.backup_log_event.emit(self.tr('Backup finished.'))
def log_event(self, msg):
self.app.backup_log_event.emit(msg)
def started_event(self):
self.app.backup_started_event.emit()
self.app.backup_log_event.emit('Backup started.')
self.app.backup_log_event.emit(self.tr('Backup started.'))
def finished_event(self, result):
self.app.backup_finished_event.emit(result)
@ -74,7 +75,7 @@ class BorgCreateThread(BorgThread):
n_backup_folders = SourceFileModel.select().count()
if n_backup_folders == 0:
ret['message'] = 'Add some folders to back up first.'
ret['message'] = trans_late('messages', 'Add some folders to back up first.')
return ret
current_wifi = get_current_wifi()
@ -89,11 +90,11 @@ class BorgCreateThread(BorgThread):
)
)
if wifi_is_disallowed.count() > 0 and profile.repo.is_remote_repo():
ret['message'] = 'Current Wifi is not allowed.'
ret['message'] = trans_late('messages', 'Current Wifi is not allowed.')
return ret
if not profile.repo.is_remote_repo() and not os.path.exists(profile.repo.url):
ret['message'] = 'Repo folder not mounted or moved.'
ret['message'] = trans_late('messages', 'Repo folder not mounted or moved.')
return ret
cmd = ['borg', 'create', '--list', '--info', '--log-json', '--json', '--filter=AM', '-C', profile.compression]
@ -129,10 +130,10 @@ class BorgCreateThread(BorgThread):
ret['profile'] = profile
ret['repo'] = profile.repo
if cls.pre_post_backup_cmd(ret) != 0:
ret['message'] = 'Pre-backup command returned non-zero exit code.'
ret['message'] = trans_late('messages', 'Pre-backup command returned non-zero exit code.')
return ret
ret['message'] = 'Starting backup..'
ret['message'] = trans_late('messages', 'Starting backup..')
ret['ok'] = True
ret['cmd'] = cmd

View file

@ -8,12 +8,12 @@ class BorgExtractThread(BorgThread):
def started_event(self):
self.app.backup_started_event.emit()
self.app.backup_log_event.emit('Downloading files from archive..')
self.app.backup_log_event.emit(self.tr('Downloading files from archive..'))
def finished_event(self, result):
self.app.backup_finished_event.emit(result)
self.result.emit(result)
self.app.backup_log_event.emit('Restored files from archive.')
self.app.backup_log_event.emit(self.tr('Restored files from archive.'))
@classmethod
def prepare(cls, profile, archive_name, selected_files, destination_folder):

View file

@ -10,7 +10,7 @@ FakeProfile = namedtuple('FakeProfile', ['repo', 'name', 'ssh_key'])
class BorgInfoThread(BorgThread):
def started_event(self):
self.updated.emit('Validating existing repo...')
self.updated.emit(self.tr('Validating existing repo...'))
@classmethod
def prepare(cls, params):

View file

@ -7,7 +7,7 @@ from vorta.utils import keyring
class BorgInitThread(BorgThread):
def started_event(self):
self.updated.emit('Setting up new repo...')
self.updated.emit(self.tr('Setting up new repo...'))
@classmethod
def prepare(cls, params):

View file

@ -8,11 +8,11 @@ class BorgListArchiveThread(BorgThread):
def started_event(self):
self.app.backup_started_event.emit()
self.app.backup_log_event.emit('Getting archive content..')
self.app.backup_log_event.emit(self.tr('Getting archive content..'))
def finished_event(self, result):
self.app.backup_finished_event.emit(result)
self.app.backup_log_event.emit('Done getting archive content.')
self.app.backup_log_event.emit(self.tr('Done getting archive content.'))
self.result.emit(result)
@classmethod

View file

@ -10,12 +10,12 @@ class BorgListRepoThread(BorgThread):
def started_event(self):
self.app.backup_started_event.emit()
self.app.backup_log_event.emit('Refreshing snapshots..')
self.app.backup_log_event.emit(self.tr('Refreshing archives...'))
def finished_event(self, result):
self.app.backup_finished_event.emit(result)
self.result.emit(result)
self.app.backup_log_event.emit('Refreshing snapshots done.')
self.app.backup_log_event.emit(self.tr('Refreshing archives done.'))
@classmethod
def prepare(cls, profile):
@ -36,21 +36,23 @@ class BorgListRepoThread(BorgThread):
def process_result(self, result):
if result['returncode'] == 0:
repo, created = RepoModel.get_or_create(url=result['cmd'][-1])
remote_snapshots = result['data'].get('archives', [])
if not result['data']:
result['data'] = {} # TODO: Workaround for tests. Can't read mock results 2x.
remote_archives = result['data'].get('archives', [])
# Delete snapshots that don't exist on the remote side
for snapshot in ArchiveModel.select().where(ArchiveModel.repo == repo.id):
if not list(filter(lambda s: s['id'] == snapshot.snapshot_id, remote_snapshots)):
snapshot.delete_instance()
# Delete archives that don't exist on the remote side
for archive in ArchiveModel.select().where(ArchiveModel.repo == repo.id):
if not list(filter(lambda s: s['id'] == archive.snapshot_id, remote_archives)):
archive.delete_instance()
# Add remote snapshots we don't have locally.
for snapshot in result['data'].get('archives', []):
new_snapshot, _ = ArchiveModel.get_or_create(
snapshot_id=snapshot['id'],
# Add remote archives we don't have locally.
for archive in result['data'].get('archives', []):
new_archive, _ = ArchiveModel.get_or_create(
snapshot_id=archive['id'],
defaults={
'repo': repo.id,
'name': snapshot['name'],
'time': parser.parse(snapshot['time'])
'name': archive['name'],
'time': parser.parse(archive['time'])
}
)
new_snapshot.save()
new_archive.save()

View file

@ -4,7 +4,7 @@ from .borg_thread import BorgThread
class BorgMountThread(BorgThread):
def started_event(self):
self.updated.emit('Mounting archive into folder...')
self.updated.emit(self.tr('Mounting archive into folder...'))
@classmethod
def prepare(cls, profile):

View file

@ -9,12 +9,12 @@ class BorgPruneThread(BorgThread):
def started_event(self):
self.app.backup_started_event.emit()
self.app.backup_log_event.emit('Pruning old snapshots..')
self.app.backup_log_event.emit(self.tr('Pruning old archives...'))
def finished_event(self, result):
self.app.backup_finished_event.emit(result)
self.result.emit(result)
self.app.backup_log_event.emit('Pruning done.')
self.app.backup_log_event.emit(self.tr('Pruning done.'))
@classmethod
def prepare(cls, profile):

View file

@ -1,11 +1,12 @@
import psutil
from .borg_thread import BorgThread
from ..i18n import trans_late
class BorgUmountThread(BorgThread):
def started_event(self):
self.updated.emit('Unmounting archive...')
self.updated.emit(self.tr('Unmounting archive...'))
@classmethod
def prepare(cls, profile):
@ -22,7 +23,7 @@ class BorgUmountThread(BorgThread):
ret['active_mount_points'].append(p.mountpoint)
if len(ret['active_mount_points']) == 0:
ret['message'] = 'No active Borg mounts found.'
ret['message'] = trans_late('messages', 'No active Borg mounts found.')
return ret
cmd = ['borg', 'umount', '--log-json']

View file

@ -0,0 +1,40 @@
"""
internationalisation (i18n) support code
"""
import logging
import os
from PyQt5.QtCore import QTranslator, QLocale
logger = logging.getLogger(__name__)
def init_translations(app):
global application, translator, locale # if we don't keep a reference on these, it stops working. pyqt bug?
application = app
translator = QTranslator()
locale = QLocale()
qm_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'qm'))
ui_langs = locale.uiLanguages()
succeeded = translator.load(locale, 'vorta', prefix='.', directory=qm_path) # e.g. vorta/i18n/qm/vorta.de_DE.qm
if succeeded:
app.installTranslator(translator)
logger.debug('Loading translation %s for %r.' % ('succeeded' if succeeded else 'failed', ui_langs))
def translate(*args, **kwargs):
"""
small wrapper around QCoreApplication.translate()
"""
global application # see init_translation
return application.translate(*args, **kwargs)
def trans_late(scope, text):
"""
dummy translate function - only purpose is to mark scope/text for message extraction.
later, at another place, there will be a translate(scope, text) call, with same
scope and text, potentially not literal scope, not literal text, but from a variable.
"""
return text

View file

View file

View file

@ -10,6 +10,7 @@ from datetime import datetime, timedelta
import peewee as pw
from playhouse.migrate import SqliteMigrator, migrate
from vorta.i18n import trans_late
from vorta.utils import slugify
SCHEMA_VERSION = 10
@ -108,7 +109,7 @@ class SourceFileModel(pw.Model):
class ArchiveModel(pw.Model):
"""A snapshot to a specific remote repository."""
"""An archive in a remote repository."""
snapshot_id = pw.CharField(unique=True)
name = pw.CharField()
repo = pw.ForeignKeyField(RepoModel, backref='archives')
@ -191,25 +192,37 @@ def get_misc_settings():
'key': 'use_light_icon',
'value': False,
'type': 'checkbox',
'label': 'Use light system tray icon (applies after restart, useful for dark themes).'
'label': trans_late('settings',
'Use light system tray icon (applies after restart, useful for dark themes).')
},
{
'key': 'enable_notifications', 'value': True, 'type': 'checkbox',
'label': 'Display notifications when background tasks fail.'
'label': trans_late('settings',
'Display notifications when background tasks fail.')
},
{
'key': 'enable_notifications_success', 'value': False, 'type': 'checkbox',
'label': 'Also notify about successful background tasks.'
'label': trans_late('settings',
'Also notify about successful background tasks.')
}
]
if sys.platform == 'darwin':
settings += [
{'key': 'autostart', 'value': False, 'type': 'checkbox',
'label': 'Add Vorta to Login Items in Preferences > Users and Groups > Login Items.'},
{'key': 'check_for_updates', 'value': True, 'type': 'checkbox',
'label': 'Check for updates on startup.'},
{'key': 'updates_include_beta', 'value': False, 'type': 'checkbox',
'label': 'Include pre-release versions when checking for updates.'},
{
'key': 'autostart', 'value': False, 'type': 'checkbox',
'label': trans_late('settings',
'Add Vorta to Login Items in Preferences > Users and Groups > Login Items.')
},
{
'key': 'check_for_updates', 'value': True, 'type': 'checkbox',
'label': trans_late('settings',
'Check for updates on startup.')
},
{
'key': 'updates_include_beta', 'value': False, 'type': 'checkbox',
'label': trans_late('settings',
'Include pre-release versions when checking for updates.')
},
]
return settings

View file

@ -7,6 +7,7 @@ from vorta.borg.check import BorgCheckThread
from vorta.borg.create import BorgCreateThread
from vorta.borg.list_repo import BorgListRepoThread
from vorta.borg.prune import BorgPruneThread
from vorta.i18n import translate
from .models import BackupProfileModel, EventLogModel
from .notifications import VortaNotifications
@ -21,6 +22,10 @@ class VortaScheduler(QtScheduler):
self.start()
self.reload()
def tr(self, *args, **kwargs):
scope = self.__class__.__name__
return translate(scope, *args, **kwargs)
def reload(self):
for profile in BackupProfileModel.select():
trigger = None
@ -47,7 +52,7 @@ class VortaScheduler(QtScheduler):
if self.get_job(job_id) is not None and trigger is not None:
self.reschedule_job(job_id, trigger=trigger)
notifier = VortaNotifications.pick()
notifier.deliver('Vorta Scheduler', 'Background scheduler was changed.')
notifier.deliver(self.tr('Vorta Scheduler'), self.tr('Background scheduler was changed.'))
logger.debug('Job for profile %s was rescheduled.', profile.name)
elif trigger is not None:
self.add_job(
@ -75,13 +80,13 @@ class VortaScheduler(QtScheduler):
profile = BackupProfileModel.get(id=int(jobs[0][1]))
return f"{jobs[0][0].strftime('%H:%M')} ({profile.name})"
else:
return 'None scheduled'
return self.tr('None scheduled')
def next_job_for_profile(self, profile_id):
self.wakeup()
job = self.get_job(str(profile_id))
if job is None:
return 'None scheduled'
return self.tr('None scheduled')
else:
return job.next_run_time.strftime('%Y-%m-%d %H:%M')
@ -90,7 +95,9 @@ class VortaScheduler(QtScheduler):
profile = BackupProfileModel.get(id=profile_id)
logger.info('Starting background backup for %s', profile.name)
notifier.deliver('Vorta Backup', 'Starting background backup for %s.' % profile.name, level='info')
notifier.deliver(self.tr('Vorta Backup'),
self.tr('Starting background backup for %s.') % profile.name,
level='info')
msg = BorgCreateThread.prepare(profile)
if msg['ok']:
@ -99,16 +106,18 @@ class VortaScheduler(QtScheduler):
thread.start()
thread.wait()
if thread.process.returncode in [0, 1]:
notifier.deliver('Vorta Backup', 'Backup successful for %s.' % profile.name, level='info')
notifier.deliver(self.tr('Vorta Backup'),
self.tr('Backup successful for %s.') % profile.name,
level='info')
logger.info('Backup creation successful.')
self.post_backup_tasks(profile_id)
else:
notifier.deliver('Vorta Backup', 'Error during backup creation.', level='error')
notifier.deliver(self.tr('Vorta Backup'), self.tr('Error during backup creation.'), level='error')
logger.error('Error during backup creation.')
else:
logger.error('Conditions for backup not met. Aborting.')
logger.error(msg['message'])
notifier.deliver('Vorta Backup', msg['message'], level='error')
notifier.deliver(self.tr('Vorta Backup'), translate('messages', msg['message']), level='error')
def post_backup_tasks(self, profile_id):
"""
@ -123,7 +132,7 @@ class VortaScheduler(QtScheduler):
prune_thread.start()
prune_thread.wait()
# Refresh snapshots
# Refresh archives
msg = BorgListRepoThread.prepare(profile)
if msg['ok']:
list_thread = BorgListRepoThread(msg['cmd'], msg)

View file

@ -29,26 +29,26 @@ class TrayMenu(QSystemTrayIcon):
status.setEnabled(False)
if BorgThread.is_running():
status.setText('Backup in Progress')
cancel_action = menu.addAction("Cancel Backup")
status.setText(self.tr('Backup in Progress'))
cancel_action = menu.addAction(self.tr('Cancel Backup'))
cancel_action.triggered.connect(self.app.backup_cancelled_event.emit)
else:
status.setText(f'Next Task: {self.app.scheduler.next_job}')
status.setText(self.tr('Next Task: %s') % self.app.scheduler.next_job)
profiles = BackupProfileModel.select()
if profiles.count() > 1:
profile_menu = menu.addMenu('Backup Now')
profile_menu = menu.addMenu(self.tr('Backup Now'))
for profile in profiles:
new_item = profile_menu.addAction(profile.name)
new_item.triggered.connect(lambda state, i=profile.id: self.app.create_backup_action(i))
else:
profile = profiles.first()
profile_menu = menu.addAction('Backup Now')
profile_menu = menu.addAction(self.tr('Backup Now'))
profile_menu.triggered.connect(lambda state, i=profile.id: self.app.create_backup_action(i))
settings_action = menu.addAction("Settings")
settings_action = menu.addAction(self.tr('Settings'))
settings_action.triggered.connect(self.app.open_main_window_action)
menu.addSeparator()
exit_action = menu.addAction("Exit")
exit_action = menu.addAction(self.tr('Exit'))
exit_action.triggered.connect(self.app.quit)

View file

@ -11,6 +11,7 @@ from vorta.borg.mount import BorgMountThread
from vorta.borg.extract import BorgExtractThread
from vorta.borg.umount import BorgUmountThread
from vorta.views.extract_dialog import ExtractDialog
from vorta.i18n import translate
from vorta.utils import get_asset, pretty_bytes, choose_file_dialog, format_archive_name
from vorta.models import BackupProfileMixin, ArchiveModel
@ -77,7 +78,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
profile = self.profile()
if profile.repo is not None:
self.toolBox.setItemText(0, f'Archives for {profile.repo.url}')
self.toolBox.setItemText(0, self.tr('Archives for %s') % profile.repo.url)
archives = [s for s in profile.repo.archives.select().order_by(ArchiveModel.time.desc())]
for row, archive in enumerate(archives):
@ -98,7 +99,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
self._toggle_all_buttons(enabled=True)
else:
self.archiveTable.setRowCount(0)
self.toolBox.setItemText(0, 'Archives')
self.toolBox.setItemText(0, self.tr('Archives'))
self._toggle_all_buttons(enabled=False)
self.archiveNameTemplate.setText(profile.new_archive_name)
@ -107,11 +108,11 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
def save_archive_template(self, tpl, key):
profile = self.profile()
try:
preview = 'Preview: ' + format_archive_name(profile, tpl)
preview = self.tr('Preview: %s') % format_archive_name(profile, tpl)
setattr(profile, key, tpl)
profile.save()
except Exception:
preview = 'Error in archive name template.'
preview = self.tr('Error in archive name template.')
if key == 'new_archive_name':
self.archiveNamePreview.setText(preview)
@ -121,16 +122,16 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
def check_action(self):
params = BorgCheckThread.prepare(self.profile())
if not params['ok']:
self._set_status(params['message'])
self._set_status(translate(params['message']))
return
# Conditions are met (borg binary available, etc)
row_selected = self.archiveTable.selectionModel().selectedRows()
if row_selected:
snapshot_cell = self.archiveTable.item(row_selected[0].row(), 3)
if snapshot_cell:
snapshot_name = snapshot_cell.text()
params['cmd'][-1] += f'::{snapshot_name}'
archive_cell = self.archiveTable.item(row_selected[0].row(), 3)
if archive_cell:
archive_name = archive_cell.text()
params['cmd'][-1] += f'::{archive_name}'
thread = BorgCheckThread(params['cmd'], params, parent=self)
thread.updated.connect(self._set_status)
@ -153,7 +154,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
def prune_result(self, result):
if result['returncode'] == 0:
self._set_status('Pruning finished.')
self._set_status(self.tr('Pruning finished.'))
self.list_action()
else:
self._toggle_all_buttons(True)
@ -170,23 +171,23 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
def list_result(self, result):
self._toggle_all_buttons(True)
if result['returncode'] == 0:
self._set_status('Refreshed snapshots.')
self._set_status(self.tr('Refreshed archives.'))
self.populate_from_profile()
def mount_action(self):
profile = self.profile()
params = BorgMountThread.prepare(profile)
if not params['ok']:
self._set_status(params['message'])
self._set_status(translate(params['message']))
return
# Conditions are met (borg binary available, etc)
row_selected = self.archiveTable.selectionModel().selectedRows()
if row_selected:
snapshot_cell = self.archiveTable.item(row_selected[0].row(), 3)
if snapshot_cell:
snapshot_name = snapshot_cell.text()
params['cmd'][-1] += f'::{snapshot_name}'
archive_cell = self.archiveTable.item(row_selected[0].row(), 3)
if archive_cell:
archive_name = archive_cell.text()
params['cmd'][-1] += f'::{archive_name}'
def receive():
mount_point = dialog.selectedFiles()
@ -200,14 +201,14 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
thread.result.connect(self.mount_result)
thread.start()
dialog = choose_file_dialog(self, "Choose Mount Point")
dialog = choose_file_dialog(self, self.tr("Choose Mount Point"))
dialog.open(receive)
def mount_result(self, result):
self._toggle_all_buttons(True)
if result['returncode'] == 0:
self._set_status('Mounted successfully.')
self.mountButton.setText('Unmount')
self._set_status(self.tr('Mounted successfully.'))
self.mountButton.setText(self.tr('Unmount'))
self.mountButton.clicked.disconnect()
self.mountButton.clicked.connect(self.umount_action)
else:
@ -218,7 +219,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
profile = self.profile()
params = BorgUmountThread.prepare(profile)
if not params['ok']:
self._set_status(params['message'])
self._set_status(translate(params['message']))
return
if self.mount_point in params['active_mount_points']:
@ -228,14 +229,14 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
thread.result.connect(self.umount_result)
thread.start()
else:
self._set_status('Mount point not active. Try restarting Vorta.')
self._set_status(self.tr('Mount point not active. Try restarting Vorta.'))
return
def umount_result(self, result):
self._toggle_all_buttons(True)
if result['returncode'] == 0:
self._set_status('Un-mounted successfully.')
self.mountButton.setText('Mount')
self._set_status(self.tr('Un-mounted successfully.'))
self.mountButton.setText(self.tr('Mount'))
self.mountButton.clicked.disconnect()
self.mountButton.clicked.connect(self.mount_action)
self.mount_point = None
@ -258,7 +259,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
params = BorgListArchiveThread.prepare(profile)
if not params['ok']:
self._set_status(params['message'])
self._set_status(translate(params['message']))
return
params['cmd'][-1] += f'::{archive_name}'
params['archive_name'] = archive_name
@ -270,7 +271,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
thread.result.connect(self.list_archive_result)
thread.start()
else:
self._set_status('Select an archive to restore first.')
self._set_status(self.tr('Select an archive to restore first.'))
def list_archive_result(self, result):
self._set_status('')
@ -295,9 +296,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
thread.result.connect(self.extract_archive_result)
thread.start()
else:
self._set_status(params['message'])
self._set_status(translate(params['message']))
dialog = choose_file_dialog(self, "Choose Extraction Point")
dialog = choose_file_dialog(self, self.tr("Choose Extraction Point"))
dialog.open(receive)
def extract_archive_result(self, result):

View file

@ -51,7 +51,7 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.app.backup_cancelled_event.connect(self.backup_cancelled_event)
# Init profile list
self.profileSelector.addItem('+ Add New Profile', None)
self.profileSelector.addItem(self.tr('+ Add New Profile'), None)
self.profileSelector.insertSeparator(2)
for profile in BackupProfileModel.select():
self.profileSelector.addItem(profile.name, profile.id)
@ -72,7 +72,7 @@ class MainWindow(MainWindowBase, MainWindowUI):
if BorgThread.is_running():
self.createStartBtn.setEnabled(False)
self.cancelButton.setEnabled(True)
self.set_status('Backup in progress.', progress_max=0)
self.set_status(self.tr('Backup in progress.'), progress_max=0)
def on_close_window(self):
self.close()
@ -140,4 +140,4 @@ class MainWindow(MainWindowBase, MainWindowUI):
def backup_cancelled_event(self):
self._toggle_buttons(create_enabled=True)
self.set_status(progress_max=100)
self.set_status('Task cancelled')
self.set_status(self.tr('Task cancelled'))

View file

@ -1,5 +1,7 @@
from PyQt5 import uic
from PyQt5.QtWidgets import QCheckBox
from vorta.i18n import translate
from vorta.utils import get_asset, open_app_at_startup
from vorta.models import SettingsModel, BackupProfileMixin, get_misc_settings
from vorta._version import __version__
@ -19,7 +21,7 @@ class MiscTab(MiscTabBase, MiscTabUI, BackupProfileMixin):
x = filter(lambda s: s['key'] == setting.key, get_misc_settings())
if not list(x): # Skip settings that aren't specified in vorta.models.
continue
b = QCheckBox(setting.label)
b = QCheckBox(translate('settings', setting.label))
b.setCheckState(setting.value)
b.setTristate(False)
b.stateChanged.connect(lambda v, key=setting.key: self.save_setting(key, v))

View file

@ -19,7 +19,7 @@ class AddProfileWindow(AddProfileBase, AddProfileUI):
existing_profile = BackupProfileModel.get(id=rename_existing_id)
self.profileNameField.setText(existing_profile.name)
self.existing_id = rename_existing_id
self.modalTitle.setText('Rename Profile')
self.modalTitle.setText(self.tr('Rename Profile'))
def _set_status(self, text):
self.errorText.setText(text)
@ -36,13 +36,13 @@ class AddProfileWindow(AddProfileBase, AddProfileUI):
name = self.profileNameField.text()
# A name was entered?
if len(name) == 0:
self._set_status('Please enter a profile name.')
self._set_status(self.tr('Please enter a profile name.'))
return False
# Profile with this name already exists?
exists = BackupProfileModel.select().where(BackupProfileModel.name == name).count()
if exists > 0:
self._set_status('A profile with this name already exists.')
self._set_status(self.tr('A profile with this name already exists.'))
return False
return True

View file

@ -1,5 +1,7 @@
import re
from PyQt5 import uic
from ..i18n import translate
from ..utils import get_private_keys, get_asset, choose_file_dialog
from vorta.borg.init import BorgInitThread
from vorta.borg.info import BorgInfoThread
@ -41,17 +43,17 @@ class AddRepoWindow(AddRepoBase, AddRepoUI):
self.repoURL.setText(folder[0])
self.repoURL.setEnabled(False)
self.sshComboBox.setEnabled(False)
self.repoLabel.setText('Repository Path:')
self.repoLabel.setText(self.tr('Repository Path:'))
self.is_remote_repo = False
dialog = choose_file_dialog(self, "Choose Location of Borg Repository")
dialog = choose_file_dialog(self, self.tr("Choose Location of Borg Repository"))
dialog.open(receive)
def use_remote_repo_action(self):
self.repoURL.setText('')
self.repoURL.setEnabled(True)
self.sshComboBox.setEnabled(True)
self.repoLabel.setText('Repository URL:')
self.repoLabel.setText(self.tr('Repository URL:'))
self.is_remote_repo = True
def run(self):
@ -65,7 +67,7 @@ class AddRepoWindow(AddRepoBase, AddRepoUI):
self.thread = thread # Needs to be connected to self for tests to work.
self.thread.start()
else:
self._set_status(params['message'])
self._set_status(translate(params['message']))
def _set_status(self, text):
self.errorText.setText(text)
@ -77,14 +79,19 @@ class AddRepoWindow(AddRepoBase, AddRepoUI):
self.result = result
self.accept()
else:
self._set_status('Unable to add your repository.')
self._set_status(self.tr('Unable to add your repository.'))
def init_encryption(self):
self.encryptionComboBox.addItem('Repokey-Blake2 (Recommended, key stored in repository)', 'repokey-blake2')
self.encryptionComboBox.addItem('Repokey', 'repokey')
self.encryptionComboBox.addItem('Keyfile-Blake2 (Key stored in home directory)', 'keyfile-blake2')
self.encryptionComboBox.addItem('Keyfile', 'keyfile')
self.encryptionComboBox.addItem('None (not recommended', 'none')
self.encryptionComboBox.addItem(self.tr('Repokey-Blake2 (Recommended, key stored in repository)'),
'repokey-blake2')
self.encryptionComboBox.addItem(self.tr('Repokey'),
'repokey')
self.encryptionComboBox.addItem(self.tr('Keyfile-Blake2 (Key stored in home directory)'),
'keyfile-blake2')
self.encryptionComboBox.addItem(self.tr('Keyfile'),
'keyfile')
self.encryptionComboBox.addItem(self.tr('None (not recommended)'),
'none')
def init_ssh_key(self):
keys = get_private_keys()
@ -94,13 +101,13 @@ class AddRepoWindow(AddRepoBase, AddRepoUI):
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('Please enter a valid repo URL or select a local path.')
self._set_status(self.tr('Please enter a valid repo URL or select a local path.'))
return False
if self.__class__ == AddRepoWindow:
if self.values['encryption'] != 'none':
if len(self.values['password']) < 8:
self._set_status('Please use a longer password.')
self._set_status(self.tr('Please use a longer password.'))
return False
return True
@ -111,7 +118,7 @@ class ExistingRepoWindow(AddRepoWindow):
super().__init__()
self.encryptionComboBox.hide()
self.encryptionLabel.hide()
self.title.setText('Connect to existing Repository')
self.title.setText(self.tr('Connect to existing Repository'))
def run(self):
if self.validate():
@ -124,4 +131,4 @@ class ExistingRepoWindow(AddRepoWindow):
self.thread = thread # Needs to be connected to self for tests to work.
self.thread.start()
else:
self._set_status(params['message'])
self._set_status(translate(params['message']))

View file

@ -21,8 +21,8 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
# Populate dropdowns
self.repoSelector.model().item(0).setEnabled(False)
self.repoSelector.addItem('+ Initialize New Repository', 'new')
self.repoSelector.addItem('+ Add Existing Repository', 'existing')
self.repoSelector.addItem(self.tr('+ Initialize New Repository'), 'new')
self.repoSelector.addItem(self.tr('+ Add Existing Repository'), 'existing')
self.repoSelector.insertSeparator(3)
for repo in RepoModel.select():
self.repoSelector.addItem(repo.url, repo.id)
@ -30,10 +30,10 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
self.repoSelector.currentIndexChanged.connect(self.repo_select_action)
self.repoRemoveToolbutton.clicked.connect(self.repo_unlink_action)
self.repoCompression.addItem('LZ4 (default)', 'lz4')
self.repoCompression.addItem('Zstandard (medium)', 'zstd')
self.repoCompression.addItem('LZMA (high)', 'lzma,6')
self.repoCompression.addItem('No Compression', 'none')
self.repoCompression.addItem(self.tr('LZ4 (default)'), 'lz4')
self.repoCompression.addItem(self.tr('Zstandard (medium)'), 'zstd')
self.repoCompression.addItem(self.tr('LZMA (high)'), 'lzma,6')
self.repoCompression.addItem(self.tr('No Compression'), 'none')
self.repoCompression.currentIndexChanged.connect(self.compression_select_action)
self.init_ssh()
@ -71,8 +71,8 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
def init_ssh(self):
keys = get_private_keys()
self.sshComboBox.clear()
self.sshComboBox.addItem('Automatically choose SSH Key (default)', None)
self.sshComboBox.addItem('Create New Key', 'new')
self.sshComboBox.addItem(self.tr('Automatically choose SSH Key (default)'), None)
self.sshComboBox.addItem(self.tr('Create New Key'), 'new')
for key in keys:
self.sshComboBox.addItem(f'{key["filename"]} ({key["format"]})', key['filename'])
@ -104,15 +104,15 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
clipboard = QApplication.clipboard()
clipboard.setText(pub_key)
msg.setText("Public Key Copied to Clipboard")
msg.setInformativeText(
msg.setText(self.tr("Public Key Copied to Clipboard"))
msg.setInformativeText(self.tr(
"The selected public SSH key was copied to the clipboard. "
"Use it to set up remote repo permissions.")
"Use it to set up remote repo permissions."))
else:
msg.setText("Couldn't find public key.")
msg.setText(self.tr("Couldn't find public key."))
else:
msg.setText("Select a public key from the dropdown first.")
msg.setText(self.tr("Select a public key from the dropdown first."))
msg.exec_()
def compression_select_action(self, index):
@ -166,11 +166,11 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
ArchiveModel.delete().where(ArchiveModel.repo_id == repo.id).execute()
profile.repo = None
profile.save()
repo.delete_instance(recursive=True) # This also deletes snapshots.
repo.delete_instance(recursive=True) # This also deletes archives.
self.repoSelector.setCurrentIndex(0)
self.repoSelector.removeItem(selected_repo_index)
msg.setText('Repository was Unlinked')
msg.setInformativeText('You can always connect it again later.')
msg.setText(self.tr('Repository was Unlinked'))
msg.setInformativeText(self.tr('You can always connect it again later.'))
msg.exec_()
self.repo_changed.emit()

View file

@ -43,8 +43,8 @@ class SourceTab(SourceBase, SourceUI, BackupProfileMixin):
self.sourceFilesWidget.addItem(dir[0])
new_source.save()
item = "directory" if want_folder else "file"
dialog = choose_file_dialog(self, "Choose %s to back up" % item, want_folder=want_folder)
msg = self.tr("Choose directory to back up") if want_folder else self.tr("Choose file to back up")
dialog = choose_file_dialog(self, msg, want_folder=want_folder)
dialog.open(receive)
def source_remove(self):

View file

@ -31,9 +31,9 @@ class SSHAddWindow(SSHAddBase, SSHAddUI):
self.init_length()
def init_format(self):
self.formatSelect.addItem('ED25519 (Recommended)', 'ed25519')
self.formatSelect.addItem('RSA (Legacy)', 'rsa')
self.formatSelect.addItem('ECDSA', 'ecdsa')
self.formatSelect.addItem(self.tr('ED25519 (Recommended)'), 'ed25519')
self.formatSelect.addItem(self.tr('RSA (Legacy)'), 'rsa')
self.formatSelect.addItem(self.tr('ECDSA'), 'ecdsa')
self.outputFileTextBox.setText('~/.ssh/id_ed25519')
self.formatSelect.currentIndexChanged.connect(self.format_select_change)
@ -42,8 +42,8 @@ class SSHAddWindow(SSHAddBase, SSHAddUI):
self.outputFileTextBox.setText(new_output)
def init_length(self):
self.lengthSelect.addItem('High (Recommended)', ('4096', '521'))
self.lengthSelect.addItem('Medium', ('2048', '384'))
self.lengthSelect.addItem(self.tr('High (Recommended)'), ('4096', '521'))
self.lengthSelect.addItem(self.tr('Medium'), ('2048', '384'))
def generate_key(self):
format = self.formatSelect.currentData()
@ -56,7 +56,7 @@ class SSHAddWindow(SSHAddBase, SSHAddUI):
output_path = os.path.expanduser(self.outputFileTextBox.text())
if os.path.isfile(output_path):
self.errors.setText('Key file already exists. Not overwriting.')
self.errors.setText(self.tr('Key file already exists. Not overwriting.'))
else:
self.sshproc = QProcess(self)
self.sshproc.finished.connect(self.generate_key_result)
@ -68,9 +68,9 @@ class SSHAddWindow(SSHAddBase, SSHAddUI):
pub_key = open(output_path + '.pub').read().strip()
clipboard = QApplication.clipboard()
clipboard.setText(pub_key)
self.errors.setText(f'New key was copied to clipboard and written to {output_path}.')
self.errors.setText(self.tr('New key was copied to clipboard and written to %s.') % output_path)
else:
self.errors.setText('Error during key generation.')
self.errors.setText(self.tr('Error during key generation.'))
def get_values(self):
return {

View file

@ -0,0 +1,3 @@
{
"archives": []
}

View file

@ -39,9 +39,9 @@ def test_repo_list(app, qtbot, mocker, borg_json_output):
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
mocker.patch.object(vorta.borg.borg_thread, 'Popen', return_value=popen_result)
qtbot.waitUntil(lambda: main.createProgressText.text() == 'Refreshing snapshots done.', timeout=3000)
qtbot.waitUntil(lambda: main.createProgressText.text() == 'Refreshing archives done.', timeout=3000)
assert ArchiveModel.select().count() == 6
assert main.createProgressText.text() == 'Refreshing snapshots done.'
assert main.createProgressText.text() == 'Refreshing archives done.'
assert tab.checkButton.isEnabled()
@ -56,7 +56,7 @@ def test_repo_prune(app, qtbot, mocker, borg_json_output):
qtbot.mouseClick(tab.pruneButton, QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: main.createProgressText.text().startswith('Refreshing snapshots'), timeout=5000)
qtbot.waitUntil(lambda: main.createProgressText.text().startswith('Refreshing archives done.'), timeout=5000)
def test_check(app, mocker, borg_json_output, qtbot):