i18n-tasks 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +85 -2
  3. data/Rakefile +1 -1
  4. data/config/locales/en.yml +11 -4
  5. data/config/locales/ru.yml +11 -4
  6. data/i18n-tasks.gemspec +5 -5
  7. data/lib/i18n/tasks/cli.rb +8 -8
  8. data/lib/i18n/tasks/command/commands/data.rb +14 -0
  9. data/lib/i18n/tasks/command/commands/missing.rb +2 -2
  10. data/lib/i18n/tasks/command/options/common.rb +1 -1
  11. data/lib/i18n/tasks/configuration.rb +3 -1
  12. data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +7 -1
  13. data/lib/i18n/tasks/data/file_system_base.rb +5 -0
  14. data/lib/i18n/tasks/data/router/isolating_router.rb +146 -0
  15. data/lib/i18n/tasks/data/tree/siblings.rb +2 -2
  16. data/lib/i18n/tasks/data/tree/traversal.rb +2 -2
  17. data/lib/i18n/tasks/interpolations.rb +1 -1
  18. data/lib/i18n/tasks/key_pattern_matching.rb +5 -3
  19. data/lib/i18n/tasks/references.rb +3 -3
  20. data/lib/i18n/tasks/reports/base.rb +1 -1
  21. data/lib/i18n/tasks/reports/terminal.rb +7 -1
  22. data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +26 -0
  23. data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +4 -4
  24. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +55 -25
  25. data/lib/i18n/tasks/scanners/pattern_scanner.rb +1 -1
  26. data/lib/i18n/tasks/scanners/relative_keys.rb +1 -1
  27. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +3 -1
  28. data/lib/i18n/tasks/split_key.rb +30 -47
  29. data/lib/i18n/tasks/translation.rb +4 -1
  30. data/lib/i18n/tasks/translators/base_translator.rb +22 -11
  31. data/lib/i18n/tasks/translators/deepl_translator.rb +58 -9
  32. data/lib/i18n/tasks/translators/google_translator.rb +28 -13
  33. data/lib/i18n/tasks/translators/openai_translator.rb +118 -0
  34. data/lib/i18n/tasks/used_keys.rb +5 -5
  35. data/lib/i18n/tasks/version.rb +1 -1
  36. data/templates/config/i18n-tasks.yml +28 -3
  37. data/templates/minitest/i18n_test.rb +6 -6
  38. metadata +40 -30
  39. data/lib/i18n/tasks/scanners/erb_ast_processor.rb +0 -74
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e77c4c19ca040adf1879259e8ad1d963e181eb1682e97ffa99f5b09d1b59e803
4
- data.tar.gz: cc5ed0a16159c8f14a9006ab3908cfdc93b5dbc338603d54846d0ae4f84abb80
3
+ metadata.gz: bd61ceb254dc1c44768bd40672b3f31635c9d42fb237950cbdfe64fef642a850
4
+ data.tar.gz: ae6981dd739636ddb5f1bae1ca9e5cf05ba991fd1345a5488e1cfa8561833877
5
5
  SHA512:
6
- metadata.gz: 978d87d9e73dc5f4e183a7fdebabeec9864c3b1ba530f722bc3a80428b1c422eeda55f2bbd2b7ec8c1a67649ee128db4ae9ab0fc24db4b8cf386f7740b0f34d4
7
- data.tar.gz: 46a0b0cbb262c17aaf1195293edd1fc04d665dd85d5f8d1b399f1ae565db2eb99e3e281fb1a34a5cb3629aadee2c2b279642d8beca623814fcd9b75c0c502b5e
6
+ metadata.gz: 1312910139411cc4695bea0f0de8624d49958efac33c8c7b7bd99fb3f351d408d0a6980789ec81a43452bc29a15292f6de9719ace0e3b902866d63c02f5cab79
7
+ data.tar.gz: e782f1bc1f1ad68cc33583d014c879b3d121ba2e0990db8cc109f864aeb0e98f0a97030300294e31e9041899b131b2b677fe6917f705d09bda6bf07aed588b42
data/README.md CHANGED
@@ -24,7 +24,7 @@ i18n-tasks can be used with any project using the ruby [i18n gem][i18n-gem] (def
24
24
  Add i18n-tasks to the Gemfile:
25
25
 
26
26
  ```ruby
27
- gem 'i18n-tasks', '~> 1.0.12'
27
+ gem 'i18n-tasks', '~> 1.0.14', group: :development
28
28
  ```
29
29
 
30
30
  Copy the default [configuration file](#configuration):
@@ -117,6 +117,17 @@ $ i18n-tasks translate-missing --backend=yandex
117
117
  $ i18n-tasks translate-missing --from=en es fr
118
118
  ```
119
119
 
120
+ ### OpenAI Translate missing keys
121
+
122
+ Translate missing values with OpenAI ([more below on the API key](#openai-translation-config)).
123
+
124
+ ```console
125
+ $ i18n-tasks translate-missing --backend=openai
126
+
127
+ # accepts from and locales options:
128
+ $ i18n-tasks translate-missing --from=en es fr
129
+ ```
130
+
120
131
  ### Find usages
121
132
 
122
133
  See where the keys are used with `i18n-tasks find`:
@@ -327,6 +338,33 @@ data:
327
338
  If you want to have i18n-tasks reorganize your existing keys using `data.write`, either set the router to
328
339
  `pattern_router` as above, or run `i18n-tasks normalize -p` (forcing the use of the pattern router for that run).
329
340
 
341
+ ##### Isolating router
342
+
343
+ Isolating router assumes each YAML file is independent and can contain similar keys.
344
+
345
+ As a result, the translations are written to an alternate target file for each source file
346
+ (only the `%{locale}` part is changed to match target locale). Thus, it is not necessary to
347
+ specify any `write` configuration (in fact, it would be completely ignored).
348
+
349
+ This can be useful for example when using [ViewComponent sidecars](https://viewcomponent.org/guide/translations.html)
350
+ (ViewComponent assigns an implicit scope to each sidecar YAML file but `i18n-tasks` is not aware of
351
+ that logic, resulting in collisions):
352
+
353
+ * `app/components/movies_component.en.yml`:
354
+ ```yaml
355
+ en:
356
+ title: Movies
357
+ ```
358
+
359
+ * `app/components/games_component.en.yml`
360
+ ```yaml
361
+ en:
362
+ title: Games
363
+ ```
364
+
365
+ This router has a limitation, though: it does not support detecting missing keys from code usage
366
+ (since it is not aware of the implicit scope logic).
367
+
330
368
  ##### Key pattern syntax
331
369
 
332
370
  A special syntax similar to file glob patterns is used throughout i18n-tasks to match translation keys:
@@ -335,6 +373,7 @@ A special syntax similar to file glob patterns is used throughout i18n-tasks to
335
373
  |:------------:|:----------------------------------------------------------|
336
374
  | `*` | matches everything |
337
375
  | `:` | matches a single key |
376
+ | `*:` | matches part of a single key |
338
377
  | `{a, b.c}` | match any in set, can use `:` and `*`, match is captured |
339
378
 
340
379
  Example of usage:
@@ -399,6 +438,12 @@ translation:
399
438
  google_translate_api_key: <Google Translate API key>
400
439
  ```
401
440
 
441
+ or via environment variable:
442
+
443
+ ```bash
444
+ GOOGLE_TRANSLATE_API_KEY=<Google Translate API key>
445
+ ```
446
+
402
447
  <a name="deepl-translation-config"></a>
403
448
  ### DeepL Pro Translate
404
449
 
@@ -410,6 +455,19 @@ translation:
410
455
  deepl_api_key: <DeepL Pro API key>
411
456
  deepl_host: <optional>
412
457
  deepl_version: <optional>
458
+ deepl_glossary_ids:
459
+ - f28106eb-0e06-489e-82c6-8215d6f95089
460
+ - 2c6415be-1852-4f54-9e1b-d800463496b4
461
+ deepl_options:
462
+ formality: prefer_less
463
+ ```
464
+
465
+ or via environment variables:
466
+
467
+ ```bash
468
+ DEEPL_API_KEY=<DeepL Pro API key>
469
+ DEEPL_HOST=<optional>
470
+ DEEPL_VERSION=<optional>
413
471
  ```
414
472
 
415
473
  <a name="yandex-translation-config"></a>
@@ -423,6 +481,31 @@ translation:
423
481
  yandex_api_key: <Yandex API key>
424
482
  ```
425
483
 
484
+ or via environment variable:
485
+
486
+ ```bash
487
+ YANDEX_API_KEY=<Yandex API key>
488
+ ```
489
+
490
+ <a name="openai-translation-config"></a>
491
+ ### OpenAI Translate
492
+
493
+ `i18n-tasks translate-missing` requires a OpenAI API key, get it at [OpenAI](https://openai.com/).
494
+
495
+ ```yaml
496
+ # config/i18n-tasks.yml
497
+ translation:
498
+ openai_api_key: <OpenAI API key>
499
+ openai_model: <optional>
500
+ ```
501
+
502
+ or via environment variable:
503
+
504
+ ```bash
505
+ OPENAI_API_KEY=<OpenAI API key>
506
+ OPENAI_MODEL=<optional>
507
+ ```
508
+
426
509
  ## Interactive console
427
510
 
428
511
  `i18n-tasks irb` starts an IRB session in i18n-tasks context. Type `guide` for more information.
@@ -440,7 +523,7 @@ Custom tasks can be added easily, see the examples [on the wiki](https://github.
440
523
 
441
524
  - Install dependencies using `bundle install`
442
525
  - Run tests using `bundle exec rspec`
443
- - Install [Overcommit](overcommit) by running `overcommit --install`
526
+ - Install [Overcommit](https://github.com/sds/overcommit) by running `overcommit --install`
444
527
 
445
528
  ## Skip Overcommit-hooks
446
529
 
data/Rakefile CHANGED
@@ -9,5 +9,5 @@ task :irb do
9
9
  # $: << File.expand_path('lib', __FILE__)
10
10
  require 'i18n/tasks'
11
11
  require 'i18n/tasks/commands'
12
- ::I18n::Tasks::Commands.new(::I18n::Tasks::BaseTask.new).irb
12
+ I18n::Tasks::Commands.new(I18n::Tasks::BaseTask.new).irb
13
13
  end
@@ -16,12 +16,10 @@ en:
16
16
  data_format: 'Data format: %{valid_text}.'
17
17
  keep_order: Keep the order of the keys
18
18
  key_pattern: Filter by key pattern (e.g. 'common.*')
19
- key_pattern_to_rename: Full key (pattern) to rename. Required
20
19
  locale: :i18n_tasks.common.locale
21
20
  locale_to_translate_from: Locale to translate from
22
21
  locales_filter: 'Locale(s) to process. Special: base'
23
22
  missing_types: 'Filter by types: %{valid}'
24
- new_key_name: New name, interpolates original name as %{key}. Required
25
23
  nostdin: Do not read from stdin
26
24
  out_format: 'Output format: %{valid_text}'
27
25
  pattern_router: 'Use pattern router: keys moved per config data.write'
@@ -30,13 +28,14 @@ en:
30
28
  the config setting if set.
31
29
  translation_backend: Translation backend (google or deepl)
32
30
  value: >-
33
- Value. Interpolates: %{value}, %{human_key}, %{key}, %{default}, %{value_or_human_key},
34
- %{value_or_default_or_human_key}
31
+ Value. Interpolates: %%{value}, %%{human_key}, %%{key}, %%{default}, %%{value_or_human_key},
32
+ %%{value_or_default_or_human_key}
35
33
  desc:
36
34
  add_missing: add missing keys to locale data, optionally match a pattern
37
35
  check_consistent_interpolations: verify that all translations use correct interpolation variables
38
36
  check_normalized: verify that all translation data is normalized
39
37
  config: display i18n-tasks configuration
38
+ cp: copy the keys in locale data that match the given pattern
40
39
  data: show locale data
41
40
  data_merge: merge locale data with trees
42
41
  data_remove: remove keys present in tree from data
@@ -96,6 +95,8 @@ en:
96
95
  Setup DeepL Pro API key via DEEPL_AUTH_KEY environment variable or translation.deepl_api_key
97
96
  in config/i18n-tasks.yml. Get the key at https://www.deepl.com/pro.
98
97
  no_results: DeepL returned no results.
98
+ specific_target_missing: You must supply a specific variant for the given target language
99
+ e.g. en-us instead of en.
99
100
  google_translate:
100
101
  errors:
101
102
  no_api_key: >-
@@ -110,6 +111,12 @@ en:
110
111
  missing:
111
112
  details_title: Value in other locales or source
112
113
  none: No translations are missing.
114
+ openai_translate:
115
+ errors:
116
+ no_api_key: >-
117
+ Set OpenAI API key via OPENAI_API_KEY environment variable or translation.openai_api_key
118
+ in config/i18n-tasks.yml. Get the key at https://openai.com/.
119
+ no_results: OpenAI returned no results.
113
120
  remove_unused:
114
121
  confirm:
115
122
  one: "%{count} translation will be removed from %{locales}."
@@ -13,28 +13,27 @@ ru:
13
13
  data_format: 'Формат данных: %{valid_text}.'
14
14
  keep_order: Keep the order of the keys
15
15
  key_pattern: Маска ключа (например, common.*)
16
- key_pattern_to_rename: Полный ключ (шаблон) для переименования. Необходимый параметр.
17
16
  locale: 'Язык. По умолчанию: base'
18
17
  locale_to_translate_from: 'Язык, с которого переводить (по умолчанию: base)'
19
18
  locales_filter: >-
20
19
  Список языков для обработки, разделенный запятыми (,). По умолчанию: все. Специальное
21
20
  значение: base.
22
21
  missing_types: 'Типы недостающих переводов: %{valid}. По умолчанию: все'
23
- new_key_name: Новое имя, интерполирует оригинальное название как %{key}. Необходимый параметр.
24
22
  nostdin: Не читать дерево из стандартного ввода
25
23
  out_format: 'Формат вывода: %{valid_text}.'
26
24
  pattern_router: 'Использовать pattern_router: ключи распределятся по файлам согласно data.write'
27
25
  strict: Не угадывать динамические использования ключей, например `t("category.#{category.key}")`
28
26
  translation_backend: Движок перевода (google или deepl)
29
27
  value: >-
30
- Значение, интерполируется с %{value}, %{human_key}, %{key}, %{default}, %{value_or_human_key},
31
- %{value_or_default_or_human_key}
28
+ Значение, интерполируется с %%{value}, %%{human_key}, %%{key}, %%{default}, %%{value_or_human_key},
29
+ %%{value_or_default_or_human_key}
32
30
  desc:
33
31
  add_missing: добавить недостающие ключи к переводам
34
32
  check_consistent_interpolations: убедитесь, что во всех переводах используются правильные
35
33
  интерполяционные переменные
36
34
  check_normalized: проверить, что все файлы переводов нормализованы
37
35
  config: показать конфигурацию
36
+ cp: скопируйте ключи в данных локали, соответствующие заданному шаблону
38
37
  data: показать данные переводов
39
38
  data_merge: добавить дерево к переводам
40
39
  data_remove: удалить ключи, которые есть в дереве, из данных
@@ -94,6 +93,8 @@ ru:
94
93
  Задайте ключ API DeepL через переменную окружения DEEPL_AUTH_KEY или translation.deepl_api_key
95
94
  Получите ключ через https://www.deepl.com/pro.
96
95
  no_results: DeepL не дал результатов.
96
+ specific_target_missing: You must supply a specific variant for the given target language
97
+ e.g. en-us instead of en.
97
98
  google_translate:
98
99
  errors:
99
100
  no_api_key: >-
@@ -109,6 +110,12 @@ ru:
109
110
  missing:
110
111
  details_title: На других языках или в коде
111
112
  none: Всё переведено.
113
+ openai_translate:
114
+ errors:
115
+ no_api_key: |-
116
+ Установить ключ API Яндекса с помощью переменной среды OPENAI_API_KEY или translation.openai_api_key
117
+ в config / i18n-tasks.yml. Получите ключ по адресу https://openai.com/.
118
+ no_results: Яндекс не дал результатов.
112
119
  remove_unused:
113
120
  confirm:
114
121
  few: Переводы (%{count}) будут удалены из %{locales}.
data/i18n-tasks.gemspec CHANGED
@@ -4,7 +4,7 @@ lib = File.expand_path('lib', __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
  require 'i18n/tasks/version'
6
6
 
7
- Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
7
+ Gem::Specification.new do |s|
8
8
  s.name = 'i18n-tasks'
9
9
  s.version = I18n::Tasks::VERSION
10
10
  s.authors = ['glebm']
@@ -35,16 +35,14 @@ Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
35
35
  s.files = `git ls-files`.split($/)
36
36
  s.files -= s.files.grep(%r{^(doc/|\.|spec/)}) + %w[CHANGES.md config/i18n-tasks.yml Gemfile]
37
37
  s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } - %w[i18n-tasks.cmd]
38
- s.test_files = s.files.grep(%r{^(test|spec|features)/})
39
38
  s.require_paths = ['lib']
40
39
 
41
40
  s.add_dependency 'activesupport', '>= 4.0.2'
42
41
  s.add_dependency 'ast', '>= 2.1.0'
43
- s.add_dependency 'better_html', '>= 1.0', '< 3.0'
44
42
  s.add_dependency 'erubi'
45
43
  s.add_dependency 'highline', '>= 2.0.0'
46
44
  s.add_dependency 'i18n'
47
- s.add_dependency 'parser', '>= 2.2.3.0'
45
+ s.add_dependency 'parser', '>= 3.2.2.1'
48
46
  s.add_dependency 'rails-i18n'
49
47
  s.add_dependency 'rainbow', '>= 2.2.2', '< 4.0'
50
48
  s.add_dependency 'terminal-table', '>= 1.5.1'
@@ -52,7 +50,9 @@ Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
52
50
  s.add_development_dependency 'overcommit', '~> 0.58.0'
53
51
  s.add_development_dependency 'rake'
54
52
  s.add_development_dependency 'rspec', '~> 3.3'
55
- s.add_development_dependency 'rubocop', '~> 1.27.0'
53
+ s.add_development_dependency 'rubocop', '~> 1.50.1'
54
+ s.add_development_dependency 'rubocop-rake', '~> 0.6.0'
55
+ s.add_development_dependency 'rubocop-rspec', '~> 2.19.0'
56
56
  s.add_development_dependency 'simplecov'
57
57
  s.add_development_dependency 'yard'
58
58
 
@@ -35,14 +35,14 @@ class I18n::Tasks::CLI
35
35
 
36
36
  def run(argv)
37
37
  argv.each_with_index do |arg, i|
38
- if ['--config', '-c'].include?(arg)
39
- _, config_file = argv.slice!(i, 2)
40
- if File.exist?(config_file)
41
- @config_file = config_file
42
- break
43
- else
44
- error "Config file doesn't exist: #{config_file}", 128
45
- end
38
+ next unless ['--config', '-c'].include?(arg)
39
+
40
+ _, config_file = argv.slice!(i, 2)
41
+ if File.exist?(config_file)
42
+ @config_file = config_file
43
+ break
44
+ else
45
+ error "Config file doesn't exist: #{config_file}", 128
46
46
  end
47
47
  end
48
48
 
@@ -46,6 +46,20 @@ module I18n::Tasks
46
46
  terminal_report.mv_results results
47
47
  end
48
48
 
49
+ cmd :cp,
50
+ pos: 'FROM_KEY_PATTERN TO_KEY_PATTERN',
51
+ desc: t('i18n_tasks.cmd.desc.cp')
52
+ def cp(opt = {})
53
+ fail CommandError, 'requires FROM_KEY_PATTERN and TO_KEY_PATTERN' if opt[:arguments].size < 2
54
+
55
+ from_pattern = opt[:arguments].shift
56
+ to_pattern = opt[:arguments].shift
57
+ forest = i18n.data_forest
58
+ results = forest.mv_key!(compile_key_pattern(from_pattern), to_pattern, root: false, retain: true)
59
+ i18n.data.write forest
60
+ terminal_report.cp_results results
61
+ end
62
+
49
63
  cmd :rm,
50
64
  pos: 'KEY_PATTERN [KEY_PATTERN...]',
51
65
  desc: t('i18n_tasks.cmd.desc.rm')
@@ -43,7 +43,7 @@ module I18n::Tasks
43
43
  args: [:locales, :locale_to_translate_from, arg(:out_format).from(1), :translation_backend, :pattern]
44
44
 
45
45
  def translate_missing(opt = {})
46
- missing = i18n.missing_diff_forest opt[:locales], opt[:from]
46
+ missing = i18n.missing_diff_forest opt[:locales], opt[:from]
47
47
  if opt[:pattern]
48
48
  pattern_re = i18n.compile_key_pattern(opt[:pattern])
49
49
  missing.select_keys! { |full_key, _node| full_key =~ pattern_re }
@@ -61,7 +61,7 @@ module I18n::Tasks
61
61
  ['--nil-value', 'Set value to nil. Takes precedence over the value argument.']]
62
62
 
63
63
  # Merge base locale first, as this may affect the value for the other locales
64
- def add_missing(opt = {})
64
+ def add_missing(opt = {}) # rubocop:disable Metrics/AbcSize
65
65
  [
66
66
  [i18n.base_locale] & opt[:locales],
67
67
  opt[:locales] - [i18n.base_locale]
@@ -26,7 +26,7 @@ module I18n::Tasks
26
26
  arg :value,
27
27
  '-v',
28
28
  '--value VALUE',
29
- t('i18n_tasks.cmd.args.desc.value')
29
+ t('i18n_tasks.cmd.args.desc.value', dummy: 'value') # Dummy value is workaround for https://github.com/ruby-i18n/i18n/issues/689
30
30
 
31
31
  arg :config,
32
32
  '-c',
@@ -66,6 +66,8 @@ module I18n::Tasks::Configuration # rubocop:disable Metrics/ModuleLength
66
66
  conf[:deepl_api_key] = ENV['DEEPL_AUTH_KEY'] if ENV.key?('DEEPL_AUTH_KEY')
67
67
  conf[:deepl_host] = ENV['DEEPL_HOST'] if ENV.key?('DEEPL_HOST')
68
68
  conf[:deepl_version] = ENV['DEEPL_VERSION'] if ENV.key?('DEEPL_VERSION')
69
+ conf[:openai_api_key] = ENV['OPENAI_API_KEY'] if ENV.key?('OPENAI_API_KEY')
70
+ conf[:openai_model] = ENV['OPENAI_MODEL'] if ENV.key?('OPENAI_MODEL')
69
71
  conf[:yandex_api_key] = ENV['YANDEX_API_KEY'] if ENV.key?('YANDEX_API_KEY')
70
72
  conf
71
73
  end
@@ -87,7 +89,7 @@ module I18n::Tasks::Configuration # rubocop:disable Metrics/ModuleLength
87
89
  valid_locales = Dir[File.join(I18n::Tasks.gem_path, 'config', 'locales', '*.yml')]
88
90
  .map { |f| File.basename(f, '.yml') }
89
91
  unless valid_locales.include?(internal_locale)
90
- log_warn "invalid internal_locale #{internal_locale.inspect}. "\
92
+ log_warn "invalid internal_locale #{internal_locale.inspect}. " \
91
93
  "Available internal locales: #{valid_locales * ', '}."
92
94
  internal_locale = DEFAULTS[:internal_locale].to_s
93
95
  end
@@ -6,6 +6,7 @@ module I18n::Tasks
6
6
  module Adapter
7
7
  module YamlAdapter
8
8
  EMOJI_REGEX = /\\u[\da-f]{8}/i.freeze
9
+ TRAILING_SPACE_REGEX = / $/.freeze
9
10
 
10
11
  class << self
11
12
  # @return [Hash] locale tree
@@ -20,13 +21,18 @@ module I18n::Tasks
20
21
 
21
22
  # @return [String]
22
23
  def dump(tree, options)
23
- restore_emojis(tree.to_yaml(options || {}))
24
+ strip_trailing_spaces(restore_emojis(tree.to_yaml(options || {})))
24
25
  end
25
26
 
26
27
  # @return [String]
27
28
  def restore_emojis(yaml)
28
29
  yaml.gsub(EMOJI_REGEX) { |m| [m[-8..].to_i(16)].pack('U') }
29
30
  end
31
+
32
+ # @return [String]
33
+ def strip_trailing_spaces(yaml)
34
+ yaml.gsub(TRAILING_SPACE_REGEX, '')
35
+ end
30
36
  end
31
37
  end
32
38
  end
@@ -3,6 +3,7 @@
3
3
  require 'i18n/tasks/data/tree/node'
4
4
  require 'i18n/tasks/data/router/pattern_router'
5
5
  require 'i18n/tasks/data/router/conservative_router'
6
+ require 'i18n/tasks/data/router/isolating_router'
6
7
  require 'i18n/tasks/data/file_formats'
7
8
  require 'i18n/tasks/key_pattern_matching'
8
9
 
@@ -151,6 +152,7 @@ module I18n::Tasks
151
152
 
152
153
  ROUTER_NAME_ALIASES = {
153
154
  'conservative_router' => 'I18n::Tasks::Data::Router::ConservativeRouter',
155
+ 'isolating_router' => 'I18n::Tasks::Data::Router::IsolatingRouter',
154
156
  'pattern_router' => 'I18n::Tasks::Data::Router::PatternRouter'
155
157
  }.freeze
156
158
  def router
@@ -170,6 +172,9 @@ module I18n::Tasks
170
172
  end.map do |path|
171
173
  [path.freeze, load_file(path) || {}]
172
174
  end.map do |path, data|
175
+ if router.is_a?(I18n::Tasks::Data::Router::IsolatingRouter)
176
+ data.transform_values! { |tree| { "<#{router.alternate_path_for(path, base_locale)}>" => tree } }
177
+ end
173
178
  filter_nil_keys! path, data
174
179
  Data::Tree::Siblings.from_nested_hash(data).tap do |s|
175
180
  s.leaves { |x| x.data.update(path: path, locale: locale) }
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/key_pattern_matching'
4
+ require 'i18n/tasks/data/tree/node'
5
+
6
+ module I18n::Tasks
7
+ module Data::Router
8
+ # Route based on source file path
9
+ class IsolatingRouter
10
+ include ::I18n::Tasks::KeyPatternMatching
11
+
12
+ attr_reader :config_read_patterns, :base_locale
13
+
14
+ def initialize(_adapter, data_config)
15
+ @base_locale = data_config[:base_locale]
16
+ @config_read_patterns = Array.wrap(data_config[:read])
17
+ end
18
+
19
+ # Route keys to destinations
20
+ # @param forest [I18n::Tasks::Data::Tree::Siblings] forest roots are locales.
21
+ # @yieldparam [String] dest_path
22
+ # @yieldparam [I18n::Tasks::Data::Tree::Siblings] tree_slice
23
+ # @return [Hash] mapping of destination => [ [key, value], ... ]
24
+ def route(locale, forest, &block)
25
+ return to_enum(:route, locale, forest) unless block
26
+
27
+ locale = locale.to_s
28
+ out = {}
29
+
30
+ forest.keys do |key_namespaced_with_source_path, _node|
31
+ source_path, key = key_namespaced_with_source_path.match(/\A<([^>]*)>\.(.*)/).captures
32
+ target_path = alternate_path_for(source_path, locale)
33
+ next unless source_path && key && target_path
34
+
35
+ (out[target_path] ||= Set.new) << "#{locale}.#{key}"
36
+ end
37
+
38
+ out.each do |target_path, keys|
39
+ file_namespace_subtree = I18n::Tasks::Data::Tree::Siblings.new(
40
+ nodes: forest.get("#{locale}.<#{alternate_path_for(target_path, base_locale)}>")
41
+ )
42
+ file_namespace_subtree.set_root_key!(locale)
43
+
44
+ block.yield(
45
+ target_path,
46
+ file_namespace_subtree.select_keys { |key, _| keys.include?(key) }
47
+ )
48
+ end
49
+ end
50
+
51
+ def alternate_path_for(source_path, locale)
52
+ source_path = source_path.dup
53
+
54
+ config_read_patterns.each do |pattern|
55
+ regexp = Glob.new(format(pattern, locale: '(*)')).to_regexp
56
+ next unless source_path.match?(regexp)
57
+
58
+ source_path.match(regexp) do |match_data|
59
+ (1..match_data.size - 1).reverse_each do |capture_index|
60
+ capture_begin, capture_end = match_data.offset(capture_index)
61
+ source_path.slice!(Range.new(capture_begin, capture_end, true))
62
+ source_path.insert(capture_begin, locale.to_s)
63
+ end
64
+ end
65
+
66
+ return source_path
67
+ end
68
+
69
+ nil
70
+ end
71
+
72
+ # based on https://github.com/alexch/rerun/blob/36f2d237985b670752abbe4a7f6814893cdde96f/lib/rerun/glob.rb
73
+ class Glob
74
+ NO_LEADING_DOT = '(?=[^\.])'
75
+ START_OF_FILENAME = '(?:\A|\/)'
76
+ END_OF_STRING = '\z'
77
+
78
+ def initialize(pattern)
79
+ @pattern = pattern
80
+ end
81
+
82
+ def to_regexp_string # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
83
+ chars = smoosh(@pattern.chars)
84
+
85
+ curlies = 0
86
+ escaping = false
87
+
88
+ string = chars.map do |char|
89
+ if escaping
90
+ escaping = false
91
+ next char
92
+ end
93
+
94
+ case char
95
+ when '**' then '(?:[^/]+/)*'
96
+ when '*' then '.*'
97
+ when '?' then '.'
98
+ when '.' then '\.'
99
+ when '{'
100
+ curlies += 1
101
+ '('
102
+ when '}'
103
+ if curlies.positive?
104
+ curlies -= 1
105
+ ')'
106
+ else
107
+ char
108
+ end
109
+ when ','
110
+ if curlies.positive?
111
+ '|'
112
+ else
113
+ char
114
+ end
115
+ when '\\'
116
+ escaping = true
117
+ '\\'
118
+ else char
119
+ end
120
+ end.join
121
+
122
+ START_OF_FILENAME + string + END_OF_STRING
123
+ end
124
+
125
+ def to_regexp
126
+ Regexp.new(to_regexp_string)
127
+ end
128
+
129
+ def smoosh(chars)
130
+ out = []
131
+ until chars.empty?
132
+ char = chars.shift
133
+ if char == '*' && chars.first == '*'
134
+ chars.shift
135
+ chars.shift if chars.first == '/'
136
+ out.push('**')
137
+ else
138
+ out.push(char)
139
+ end
140
+ end
141
+ out
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -36,7 +36,7 @@ module I18n::Tasks::Data::Tree
36
36
  # @param to_pattern [Regexp]
37
37
  # @param root [Boolean]
38
38
  # @return {old key => new key}
39
- def mv_key!(from_pattern, to_pattern, root: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
39
+ def mv_key!(from_pattern, to_pattern, root: false, retain: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
40
40
  moved_forest = Siblings.new
41
41
  moved_nodes = []
42
42
  old_key_to_new_key = {}
@@ -69,7 +69,7 @@ module I18n::Tasks::Data::Tree
69
69
  node.value = new_target.to_sym
70
70
  end
71
71
  end
72
- remove_nodes_and_emptied_ancestors! moved_nodes
72
+ remove_nodes_and_emptied_ancestors!(moved_nodes) unless retain
73
73
  merge! moved_forest
74
74
  old_key_to_new_key
75
75
  end
@@ -161,12 +161,12 @@ module I18n::Tasks
161
161
  end
162
162
 
163
163
  def grep_keys(match, opts = {})
164
- select_keys(opts) do |full_key, _node|
164
+ select_keys(**opts) do |full_key, _node|
165
165
  match === full_key # rubocop:disable Style/CaseEquality
166
166
  end
167
167
  end
168
168
 
169
- def set_each_value!(val_pattern, key_pattern = nil, &value_proc)
169
+ def set_each_value!(val_pattern, key_pattern = nil, &value_proc) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
170
170
  value_proc ||= proc do |node|
171
171
  node_value = node.value
172
172
  next node_value if node.reference?
@@ -5,7 +5,7 @@ module I18n::Tasks
5
5
  class << self
6
6
  attr_accessor :variable_regex
7
7
  end
8
- @variable_regex = /%{[^}]+}/.freeze
8
+ @variable_regex = /(?<!%)%{[^}]+}/.freeze
9
9
 
10
10
  def inconsistent_interpolations(locales: nil, base_locale: nil) # rubocop:disable Metrics/AbcSize
11
11
  locales ||= self.locales
@@ -21,6 +21,7 @@ module I18n::Tasks::KeyPatternMatching
21
21
  # In patterns:
22
22
  # * is like .* in regexs
23
23
  # : matches a single key
24
+ # *: matches part of a single key, equivalent to `[^.]+?` regex
24
25
  # { a, b.c } match any in set, can use : and *, match is captured
25
26
  def compile_key_pattern(key_pattern)
26
27
  return key_pattern if key_pattern.is_a?(Regexp)
@@ -30,9 +31,10 @@ module I18n::Tasks::KeyPatternMatching
30
31
 
31
32
  def key_pattern_re_body(key_pattern)
32
33
  key_pattern
33
- .gsub(/\./, '\.')
34
- .gsub(/\*/, '.*')
35
- .gsub(/:/, '(?<=^|\.)[^.]+?(?=\.|$)')
34
+ .gsub('.', '\.')
35
+ .gsub('*:', '[^.]+?')
36
+ .gsub('*', '.*')
37
+ .gsub(':', '(?<=^|\.)[^.]+?(?=\.|$)')
36
38
  .gsub(/\{(.*?)}/) { "(#{Regexp.last_match(1).strip.gsub(/\s*,\s*/, '|')})" }
37
39
  end
38
40
  end