Skip to content

Commit

Permalink
add DeepL Pro artificial intelligence translation service (glebm#294)
Browse files Browse the repository at this point in the history
* add [DeepL Pro](https://www.deepl.com/pro) artificial intelligence translation service ✨
  * DeepL trains artificial intelligence to understand and translate texts.
  * 🇬🇧 🇺🇸 🇩🇪 🇫🇷 🇪🇸 🇮🇹 🇳🇱 🇵🇱
  * [TechCrunch: DeepL schools other online translators with clever machine learning](https://techcrunch.com/2017/08/29/deepl-schools-other-online-translators-with-clever-machine-learning/)
* use [`deepl-rb`](https://github.com/wikiti/deepl-rb) gem for query the API 💎
  * Kudos to @wikiti for helping with the latest `v2.1.0` release that adds `ignore_tags` 💪
  • Loading branch information
neumayr authored and glebm committed Aug 1, 2018
1 parent 0f940e5 commit 46fb95c
Show file tree
Hide file tree
Showing 13 changed files with 279 additions and 25 deletions.
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Add [DeepL Pro](https://www.deepl.com/pro) AI Translation service.

## v0.9.21

Relaxes the `rainbow` dependency version restriction.
Expand Down
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ i18n-tasks helps you find and manage missing and unused translations.
This gem analyses code statically for key usages, such as `I18n.t('some.key')`, in order to:

* Report keys that are missing or unused.
* Pre-fill missing keys, optionally from Google Translate.
* Pre-fill missing keys, optionally from Google Translate or DeepL Pro.
* Remove unused keys.

Thus addressing the two main problems of [i18n gem][i18n-gem] design:
Expand Down Expand Up @@ -83,14 +83,24 @@ Usage: i18n-tasks add-missing [options] [locale ...]

### Google Translate missing keys

Translate missing values with Google Translate ([more below on the API key](#translation-config)).
Translate missing values with Google Translate ([more below on the API key](#google-translation-config)).

```console
$ i18n-tasks translate-missing
# accepts from and locales options:
$ i18n-tasks translate-missing --from base es fr
```

### DeepL Pro Translate missing keys

Translate missing values with DeepL Pro Translate ([more below on the API key](#deepl-translation-config)).

```console
$ i18n-tasks translate-missing
# accepts from and locales options:
$ i18n-tasks translate-missing --backend deepl --from en
```

### Find usages

See where the keys are used with `i18n-tasks find`:
Expand Down Expand Up @@ -295,7 +305,7 @@ data:
- 'config/locales/%{locale}.yml'
```

If you want to have i18n-tasks reorganize your existing keys using `data.write`, either set the router to
If you want to have i18n-tasks reorganize your existing keys using `data.write`, either set the router to
`pattern_router` as above, or run `i18n-tasks normalize -p` (forcing the use of the pattern router for that run).

##### Key pattern syntax
Expand Down Expand Up @@ -340,7 +350,7 @@ For more complex cases, you can implement a [custom scanner][custom-scanner-docs

See the [config file][config] to find out more.

<a name="translation-config"></a>
<a name="google-translation-config"></a>
### Google Translate

`i18n-tasks translate-missing` requires a Google Translate API key, get it at [Google API Console](https://code.google.com/apis/console).
Expand All @@ -357,7 +367,18 @@ Put the key in `GOOGLE_TRANSLATE_API_KEY` environment variable or in the config
```yaml
# config/i18n-tasks.yml
translation:
api_key: <Google Translate API key>
google_translate_api_key: <Google Translate API key>
```
<a name="deepl-translation-config"></a>
### DeepL Pro Translate
`i18n-tasks translate-missing` requires a DeepL Pro API key, get it at [DeepL](https://www.deepl.com/pro).

```yaml
# config/i18n-tasks.yml
translation:
deepl_api_key: <Deep Pro API key>
```

## Interactive console
Expand Down
11 changes: 9 additions & 2 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ en:
strict: >-
Avoid inferring dynamic key usages such as t("cats.#{cat}.name"). Takes precedence over
the config setting if set.
translation_backend: Translation backend (google or deepl)
value: >-
Value. Interpolates: %{value}, %{human_key}, %{key}, %{default}, %{value_or_human_key},
%{value_or_default_or_human_key}
Expand All @@ -47,7 +48,7 @@ en:
normalize: 'normalize translation data: sort and move to the right files'
remove_unused: remove unused keys
rm: remove the keys in locale data that match the given pattern
translate_missing: translate missing keys with Google Translate
translate_missing: translate missing keys with Google Translate or DeepL Pro
tree_convert: convert tree between formats
tree_filter: filter tree by key pattern
tree_merge: merge trees
Expand Down Expand Up @@ -89,10 +90,16 @@ en:
has %{key_count} keys in total. On average, values are %{value_chars_avg} characters long,
keys have %{key_segments_avg} segments.
title: Forest (%{locales})
deepl_translate:
errors:
no_api_key: >-
Setup DeepL Pro API key via DEEPL_AUTH_KEY environment variable or translation.deepl_api_key
in config/i18n-tasks.yml. Get the key at https://www.deepl.com/pro.
no_results: DeepL returned no results.
google_translate:
errors:
no_api_key: >-
Set Google API key via GOOGLE_TRANSLATE_API_KEY environment variable or translation.api_key
Set Google API key via GOOGLE_TRANSLATE_API_KEY environment variable or translation.google_translate_api_key
in config/i18n-tasks.yml. Get the key at https://code.google.com/apis/console.
no_results: >-
Google Translate returned no results. Make sure billing information is set at https://code.google.com/apis/console.
Expand Down
11 changes: 9 additions & 2 deletions config/locales/ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ ru:
out_format: 'Формат вывода: %{valid_text}. %{default_text}.'
pattern_router: 'Использовать pattern_router: ключи распределятся по файлам согласно data.write'
strict: Не угадывать динамические использования ключей, например `t("category.#{category.key}")`
translation_backend: Перевод backend (google или deepl)
value: >-
Значение, интерполируется с %{value}, %{human_key}, %{key}, %{default}, %{value_or_human_key},
%{value_or_default_or_human_key}
Expand All @@ -44,7 +45,7 @@ ru:
normalize: нормализовать файлы переводов (сортировка и распределение)
remove_unused: удалить неиспользуемые ключи
rm: удалить ключи, которые соответствуют заданному шаблону
translate_missing: перевести недостающие переводы с Google Translate
translate_missing: перевести недостающие переводы с Google Translate / DeepL Pro
tree_convert: преобразовать дерево между форматами
tree_filter: фильтровать дерево по ключу
tree_merge: объединенить деревья
Expand Down Expand Up @@ -85,10 +86,16 @@ ru:
text_single_locale: >-
%{key_count} ключей. В среднем, длина строки: %{value_chars_avg}, сегменты ключей: %{key_segments_avg}.
title: 'Данные (%{locales}):'
deepl_translate:
errors:
no_api_key: >-
Задайте ключ API DeepL через переменную окружения DEEPL_AUTH_KEY или translation.deepl_api_key
Получите ключ через https://www.deepl.com/pro.
no_results: DeepL не дал результатов.
google_translate:
errors:
no_api_key: >-
Задайте ключ API Google через переменную окружения GOOGLE_TRANSLATE_API_KEY или translation.api_key
Задайте ключ API Google через переменную окружения GOOGLE_TRANSLATE_API_KEY или translation.google_translate_api_key
в config/i18n-tasks.yml. Получите ключ через https://code.google.com/apis/console.
no_results: >-
Google Translate не дал результатов. Убедитесь в том, что платежная информация добавлена
Expand Down
1 change: 1 addition & 0 deletions i18n-tasks.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ TEXT

s.add_dependency 'activesupport', '>= 4.0.2'
s.add_dependency 'ast', '>= 2.1.0'
s.add_dependency 'deepl-rb', '>= 2.1.0'
s.add_dependency 'easy_translate', '>= 0.5.1'
s.add_dependency 'erubi'
s.add_dependency 'highline', '>= 1.7.3'
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/tasks/base_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require 'i18n/tasks/ignore_keys'
require 'i18n/tasks/missing_keys'
require 'i18n/tasks/unused_keys'
require 'i18n/tasks/deepl_translation'
require 'i18n/tasks/google_translation'
require 'i18n/tasks/locale_pathname'
require 'i18n/tasks/locale_list'
Expand All @@ -31,6 +32,7 @@ class BaseTask
include IgnoreKeys
include MissingKeys
include UnusedKeys
include DeeplTranslation
include GoogleTranslation
include Logging
include Configuration
Expand Down
9 changes: 7 additions & 2 deletions lib/i18n/tasks/command/commands/missing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ def missing(opt = {})
cmd :translate_missing,
pos: '[locale ...]',
desc: t('i18n_tasks.cmd.desc.translate_missing'),
args: [:locales, :locale_to_translate_from, arg(:out_format).from(1)]
args: [:locales, :locale_to_translate_from, arg(:out_format).from(1), :translation_backend]

def translate_missing(opt = {})
missing = i18n.missing_diff_forest opt[:locales], opt[:from]
translated = i18n.google_translate_forest missing, opt[:from]
translated = case opt[:backend]
when 'deepl'
i18n.deepl_translate_forest missing, opt[:from]
when 'google'
i18n.google_translate_forest missing, opt[:from]
end
i18n.data.merge! translated
log_stderr t('i18n_tasks.translate_missing.translated', count: translated.leaves.count)
print_forest translated, opt
Expand Down
8 changes: 8 additions & 0 deletions lib/i18n/tasks/command/options/locales.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ module Locales
t('i18n_tasks.cmd.args.desc.locale_to_translate_from'),
parser: OptionParsers::Locale::Parser,
default: 'base'

TRANSLATION_BACKENDS = %w[google deepl].freeze
arg :translation_backend,
'-b',
'--backend BACKEND',
t('i18n_tasks.cmd.args.desc.translation_backend'),
parser: OptionParsers::Locale::Parser,
default: TRANSLATION_BACKENDS[0]
end
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/i18n/tasks/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ def data_config
def translation_config
@config_sections[:translation] ||= begin
conf = (config[:translation] || {}).with_indifferent_access
conf[:api_key] ||= ENV['GOOGLE_TRANSLATE_API_KEY'] if ENV.key?('GOOGLE_TRANSLATE_API_KEY')
conf[:google_translate_api_key] ||= ENV['GOOGLE_TRANSLATE_API_KEY'] if ENV.key?('GOOGLE_TRANSLATE_API_KEY')
conf[:deepl_api_key] ||= ENV['DEEPL_AUTH_KEY'] if ENV.key?('DEEPL_AUTH_KEY')
conf
end
end
Expand Down
124 changes: 124 additions & 0 deletions lib/i18n/tasks/deepl_translation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# frozen_string_literal: true

require 'deepl'
require 'i18n/tasks/html_keys'

module I18n::Tasks
module DeeplTranslation
# @param [I18n::Tasks::Tree::Siblings] forest to translate to the locales of its root nodes
# @param [String] from locale
# @return [I18n::Tasks::Tree::Siblings] translated forest
def deepl_translate_forest(forest, from)
forest.inject empty_forest do |result, root|
translated = translate_list(root.key_values(root: true), to: root.key, from: from)
result.merge! Data::Tree::Siblings.from_flat_pairs(translated)
end
end

# @param [Array<[String, Object]>] list of key-value pairs
# @return [Array<[String, Object]>] translated list
def translate_list(list, opts) # rubocop:disable Metrics/AbcSize
return [] if list.empty?
opts = opts.dup
opts[:key] ||= translation_config[:deepl_api_key]
validate_translate_api_key! opts[:key]
key_pos = list.each_with_index.inject({}) { |idx, ((k, _v), i)| idx.update(k => i) }
# copy reference keys as is, instead of translating
reference_key_vals = list.select { |_k, v| v.is_a? Symbol } || []
list -= reference_key_vals
result = list.group_by { |k_v| html_key? k_v[0], opts[:from] }.map do |is_html, list_slice|
fetch_translations list_slice, opts.merge(is_html ? { tag_handling: 'xml' } : { preserve_formatting: true })
end.reduce(:+) || []
result.concat(reference_key_vals)
result.sort! { |a, b| key_pos[a[0]] <=> key_pos[b[0]] }
result
end

# @param [Array<[String, Object]>] list of key-value pairs
# @return [Array<[String, Object]>] translated list
def fetch_translations(list, opts)
options = {
ignore_tags: %w[i18n]
}.merge(opts)
deepl_from_values(list, DeepL.translate(deepl_to_values(list), opts[:from], opts[:to], options)).tap do |result|
fail CommandError, I18n.t('i18n_tasks.deepl_translate.errors.no_results') if result.blank?
end
end

private

def validate_translate_api_key!(key)
fail CommandError, I18n.t('i18n_tasks.deepl_translate.errors.no_api_key') if key.blank?
DeepL.configure do |config|
config.auth_key = key
end
end

# @param [Array<[String, Object]>] list of key-value pairs
# @return [Array<String>] values for translation extracted from list
def deepl_to_values(list)
list.map { |l| deepl_dump_value l[1] }.flatten.compact
end

# @param [Array<[String, Object]>] list
# @param [Array<String>] translated_values
# @return [Array<[String, Object]>] translated key-value pairs
def deepl_from_values(list, translated_values)
keys = list.map(&:first)
untranslated_values = list.map(&:last)
translated_values = Array(translated_values).map(&:text)
keys.zip deepl_parse_value(untranslated_values, translated_values.to_enum)
end

# Prepare value for translation.
# @return [String, Array<String, nil>, nil] value for DeepL Translate or nil for non-string values
def deepl_dump_value(value)
case value
when Array
# dump recursively
value.map { |v| deepl_dump_value v }
when String
deepl_replace_interpolations value
end
end

# Parse translated value from the each_translated enumerator
# @param [Object] untranslated
# @param [Enumerator] each_translated
# @return [Object] final translated value
def deepl_parse_value(untranslated, each_translated)
case untranslated
when Array
# implode array
untranslated.map { |from| deepl_parse_value(from, each_translated) }
when String
deepl_restore_interpolations untranslated, each_translated.next
else
untranslated
end
end

INTERPOLATION_KEY_RE = /(%\{[^}]+})/

# @param [String] value
# @return [String] 'hello, %{name}' => 'hello, <i18n>%{name}</i18n>'
def deepl_replace_interpolations(value)
value.gsub(INTERPOLATION_KEY_RE, '<i18n>\1</i18n>')
end

# @param [String] untranslated
# @param [String] translated
# @return [String] 'hello, <i18n>%{name}</i18n>' => 'hello, %{name}'
def deepl_restore_interpolations(untranslated, translated)
return translated if untranslated !~ INTERPOLATION_KEY_RE
translated.gsub(%r{<\/?i18n>}, '')
rescue StandardError => e
raise CommandError.new(e, <<-TEXT.strip)
Error when restoring interpolations:
original: "#{untranslated}"
response: "#{translated}"
error: #{e.message} (#{e.class.name})
TEXT
end
end
end
Loading

0 comments on commit 46fb95c

Please sign in to comment.