Skip to content

Commit

Permalink
i18n infrastructure and policy (#134)
Browse files Browse the repository at this point in the history
- 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`
  • Loading branch information
ThomasWaldmann authored and m3nu committed Jan 20, 2019
1 parent 5ef4069 commit e156755
Show file tree
Hide file tree
Showing 36 changed files with 348 additions and 150 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -15,3 +15,4 @@ vorta.egg-info
.vagrant
*.log
htmlcov
*.qm
10 changes: 10 additions & 0 deletions .tx/config
@@ -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

73 changes: 73 additions & 0 deletions CONTRIBUTING.md
Expand Up @@ -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
28 changes: 26 additions & 2 deletions Makefile
@@ -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
Expand All @@ -21,16 +26,35 @@ 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" \
-H "Travis-API-Version: 3" \
-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 changes: 6 additions & 0 deletions requirements.d/Brewfile
@@ -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'
1 change: 1 addition & 0 deletions requirements.d/dev.txt
@@ -1,3 +1,4 @@
transifex-client
pytest
pytest-qt
pytest-mock
Expand Down
5 changes: 4 additions & 1 deletion src/vorta/application.py
Expand Up @@ -5,6 +5,7 @@
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
Expand Down Expand Up @@ -43,6 +44,8 @@ def __init__(self, args_raw, single_app=False):
sys.exit(1)

super().__init__(args_raw)
init_translations(self)

self.setQuitOnLastWindowClosed(False)
self.scheduler = VortaScheduler(self)

Expand All @@ -68,7 +71,7 @@ def create_backup_action(self, profile_id=None):
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()
Expand Down
4 changes: 2 additions & 2 deletions src/vorta/assets/UI/profileadd.ui
Expand Up @@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>532</width>
<height>289</height>
<height>293</height>
</rect>
</property>
<property name="sizePolicy">
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion src/vorta/assets/UI/scheduletab.ui
Expand Up @@ -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>
Expand Down
11 changes: 6 additions & 5 deletions src/vorta/borg/borg_thread.py
Expand Up @@ -10,6 +10,7 @@
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

Expand Down Expand Up @@ -88,22 +89,22 @@ def prepare(cls, profile):

# 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
Expand Down Expand Up @@ -216,7 +217,7 @@ def log_event(self, msg):
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)
2 changes: 1 addition & 1 deletion src/vorta/borg/check.py
Expand Up @@ -8,7 +8,7 @@ def log_event(self, msg):

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)
Expand Down
19 changes: 10 additions & 9 deletions src/vorta/borg/create.py
Expand Up @@ -3,6 +3,7 @@
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
Expand All @@ -11,7 +12,7 @@
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'],
Expand All @@ -21,7 +22,7 @@ def process_result(self, result):
'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'])
Expand All @@ -31,14 +32,14 @@ def process_result(self, result):
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)
Expand Down Expand Up @@ -74,7 +75,7 @@ def prepare(cls, profile):

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()
Expand All @@ -89,11 +90,11 @@ def prepare(cls, profile):
)
)
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]
Expand Down Expand Up @@ -129,10 +130,10 @@ def prepare(cls, profile):
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

Expand Down
4 changes: 2 additions & 2 deletions src/vorta/borg/extract.py
Expand Up @@ -8,12 +8,12 @@ def log_event(self, msg):

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):
Expand Down
2 changes: 1 addition & 1 deletion src/vorta/borg/info.py
Expand Up @@ -10,7 +10,7 @@
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):
Expand Down
2 changes: 1 addition & 1 deletion src/vorta/borg/init.py
Expand Up @@ -7,7 +7,7 @@
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):
Expand Down
4 changes: 2 additions & 2 deletions src/vorta/borg/list_archive.py
Expand Up @@ -8,11 +8,11 @@ def log_event(self, msg):

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
Expand Down

0 comments on commit e156755

Please sign in to comment.