i18n-tasks 1.0.12 → 1.0.14

Sign up to get free protection for your applications and to get access to all the features.
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