i18n-tasks 1.0.14 → 1.0.15

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -38
  3. data/config/locales/en.yml +11 -1
  4. data/config/locales/ru.yml +11 -1
  5. data/i18n-tasks.gemspec +4 -1
  6. data/lib/i18n/tasks/command/commands/missing.rb +3 -1
  7. data/lib/i18n/tasks/command/option_parsers/enum.rb +4 -3
  8. data/lib/i18n/tasks/command/options/locales.rb +12 -3
  9. data/lib/i18n/tasks/configuration.rb +7 -2
  10. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +1 -1
  11. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +2 -2
  12. data/lib/i18n/tasks/scanners/pattern_scanner.rb +1 -1
  13. data/lib/i18n/tasks/scanners/prism_scanner.rb +83 -0
  14. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +41 -0
  15. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +334 -0
  16. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +273 -0
  17. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +3 -3
  18. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +1 -1
  19. data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
  20. data/lib/i18n/tasks/translation.rb +4 -1
  21. data/lib/i18n/tasks/translators/base_translator.rb +5 -1
  22. data/lib/i18n/tasks/translators/deepl_translator.rb +5 -0
  23. data/lib/i18n/tasks/translators/google_translator.rb +12 -4
  24. data/lib/i18n/tasks/translators/openai_translator.rb +34 -20
  25. data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
  26. data/lib/i18n/tasks/translators/yandex_translator.rb +5 -1
  27. data/lib/i18n/tasks/used_keys.rb +3 -2
  28. data/lib/i18n/tasks/version.rb +1 -1
  29. data/lib/i18n/tasks.rb +1 -0
  30. data/templates/config/i18n-tasks.yml +2 -2
  31. metadata +31 -5
@@ -35,6 +35,8 @@ module I18n::Tasks::Translators
35
35
  else
36
36
  results += res.map(&:text)
37
37
  end
38
+
39
+ @progress_bar.progress += parts.size
38
40
  end
39
41
  results
40
42
  end
@@ -83,6 +85,9 @@ module I18n::Tasks::Translators
83
85
 
84
86
  # Convert 'es-ES' to 'ES' but warn about locales requiring a specific variant
85
87
  def to_deepl_target_locale(locale)
88
+ locale_aliases = @i18n_tasks.translation_config[:deepl_locale_aliases]
89
+ locale = locale_aliases[locale.to_s.downcase] || locale if locale_aliases.is_a?(Hash)
90
+
86
91
  loc, sub = locale.to_s.split('-')
87
92
  if SPECIFIC_TARGETS.include?(loc)
88
93
  # Must see how the deepl api evolves, so this could be an error in the future
@@ -17,14 +17,18 @@ module I18n::Tasks::Translators
17
17
  protected
18
18
 
19
19
  def translate_values(list, **options)
20
- restore_newlines(
20
+ result = restore_newlines(
21
21
  EasyTranslate.translate(
22
22
  replace_newlines_with_placeholder(list, options[:html]),
23
23
  options,
24
- format: :text
24
+ format: options[:html] ? :html : :text
25
25
  ),
26
26
  options[:html]
27
27
  )
28
+
29
+ @progress_bar.progress += result.size
30
+
31
+ result
28
32
  end
29
33
 
30
34
  def options_for_translate_values(from:, to:, **options)
@@ -69,7 +73,9 @@ module I18n::Tasks::Translators
69
73
  return list unless html
70
74
 
71
75
  list.map do |value|
72
- value.gsub("\n", NEWLINE_PLACEHOLDER)
76
+ value.gsub(/\n(\s*)/) do
77
+ "<Z__#{::Regexp.last_match(1)&.length || 0}>"
78
+ end
73
79
  end
74
80
  end
75
81
 
@@ -77,7 +83,9 @@ module I18n::Tasks::Translators
77
83
  return translations unless html
78
84
 
79
85
  translations.map do |translation|
80
- translation.gsub("#{NEWLINE_PLACEHOLDER} ", "\n")
86
+ translation.gsub(/<Z__(\d+)>/) do
87
+ "\n#{' ' * ::Regexp.last_match(1).to_i}"
88
+ end
81
89
  end
82
90
  end
83
91
  end
@@ -18,6 +18,10 @@ module I18n::Tasks::Translators
18
18
  Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
19
19
 
20
20
  Keep in mind the context of all the strings for a more accurate translation.
21
+ It is CRITICAL you output only the result, without any additional information, code block syntax or comments.
22
+ PROMPT
23
+ JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT = <<~PROMPT.squish
24
+ Return the translations as a JSON object with a 'translations' array containing the translated strings.
21
25
  PROMPT
22
26
 
23
27
  def initialize(*)
@@ -51,7 +55,7 @@ module I18n::Tasks::Translators
51
55
  private
52
56
 
53
57
  def translator
54
- @translator ||= OpenAI::Client.new(access_token: api_key)
58
+ @translator ||= OpenAI::Client.new(access_token: api_key, log_errors: true)
55
59
  end
56
60
 
57
61
  def api_key
@@ -64,11 +68,14 @@ module I18n::Tasks::Translators
64
68
  end
65
69
 
66
70
  def model
67
- @model ||= @i18n_tasks.translation_config[:openai_model].presence || 'gpt-3.5-turbo'
71
+ @model ||= @i18n_tasks.translation_config[:openai_model].presence || 'gpt-4o-mini'
68
72
  end
69
73
 
70
74
  def system_prompt
71
- @system_prompt ||= @i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
75
+ @system_prompt ||=
76
+ (@i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT)
77
+ .concat("\n#{JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT}")
78
+ @system_prompt
72
79
  end
73
80
 
74
81
  def translate_values(list, from:, to:)
@@ -76,15 +83,37 @@ module I18n::Tasks::Translators
76
83
 
77
84
  list.each_slice(BATCH_SIZE) do |batch|
78
85
  translations = translate(batch, from, to)
86
+ result = JSON.parse(translations)
87
+ results << result
79
88
 
80
- results << JSON.parse(translations)
89
+ @progress_bar.progress += result.size
81
90
  end
82
91
 
83
92
  results.flatten
84
93
  end
85
94
 
86
95
  def translate(values, from, to)
87
- messages = [
96
+ response = translator.chat(
97
+ parameters: {
98
+ model: model,
99
+ messages: build_messages(values, from, to),
100
+ temperature: 0.0,
101
+ response_format: { type: 'json_object' }
102
+ }
103
+ )
104
+
105
+ translations = response.dig('choices', 0, 'message', 'content')
106
+ error = response['error']
107
+
108
+ fail "AI error: #{error}" if error.present?
109
+
110
+ # Extract the array from the JSON object response
111
+ result = JSON.parse(translations)
112
+ result['translations'].to_json
113
+ end
114
+
115
+ def build_messages(values, from, to)
116
+ [
88
117
  {
89
118
  role: 'system',
90
119
  content: format(system_prompt, from: from, to: to)
@@ -98,21 +127,6 @@ module I18n::Tasks::Translators
98
127
  content: values.to_json
99
128
  }
100
129
  ]
101
-
102
- response = translator.chat(
103
- parameters: {
104
- model: model,
105
- messages: messages,
106
- temperature: 0.0
107
- }
108
- )
109
-
110
- translations = response.dig('choices', 0, 'message', 'content')
111
- error = response['error']
112
-
113
- fail "AI error: #{error}" if error.present?
114
-
115
- translations
116
130
  end
117
131
  end
118
132
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/translators/base_translator'
4
+ require 'active_support/core_ext/string/filters'
5
+
6
+ module I18n::Tasks::Translators
7
+ class WatsonxTranslator < BaseTranslator
8
+ # max allowed texts per request
9
+ BATCH_SIZE = 50
10
+ DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
11
+ You are a helpful assistant that translates content from the %{from} locale
12
+ to the %{to} locale in an i18n locale array.
13
+ You always preserve the structure and formatting exactly as it is.
14
+
15
+ The array has a structured format and contains multiple strings. Your task is to translate
16
+ each of these strings and create a new array with the translated strings.
17
+
18
+ Reminder:
19
+ - Translate only the text, preserving the structure and formatting.
20
+ - Do not translate any URLs.
21
+ - Do not translate HTML tags like `<details>` and `<summary>`.
22
+ - HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
23
+ - Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
24
+ - Output only the result, without any additional information or comments.
25
+ PROMPT
26
+
27
+ def options_for_translate_values(from:, to:, **options)
28
+ options.merge(
29
+ from: from,
30
+ to: to
31
+ )
32
+ end
33
+
34
+ def options_for_html
35
+ {}
36
+ end
37
+
38
+ def options_for_plain
39
+ {}
40
+ end
41
+
42
+ def no_results_error_message
43
+ I18n.t('i18n_tasks.watsonx_translate.errors.no_results')
44
+ end
45
+
46
+ private
47
+
48
+ def translator
49
+ @translator ||= WatsonxClient.new(key: api_key)
50
+ end
51
+
52
+ def api_key
53
+ @api_key ||= begin
54
+ key = @i18n_tasks.translation_config[:watsonx_api_key]
55
+ fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.watsonx_translate.errors.no_api_key') if key.blank?
56
+
57
+ key
58
+ end
59
+ end
60
+
61
+ def project_id
62
+ @project_id ||= begin
63
+ project_id = @i18n_tasks.translation_config[:watsonx_project_id]
64
+ if project_id.blank?
65
+ fail ::I18n::Tasks::CommandError,
66
+ I18n.t('i18n_tasks.watsonx_translate.errors.no_project_id')
67
+ end
68
+
69
+ project_id
70
+ end
71
+ end
72
+
73
+ def model
74
+ @model ||= @i18n_tasks.translation_config[:watsonx_model].presence || 'meta-llama/llama-3-2-90b-vision-instruct'
75
+ end
76
+
77
+ def system_prompt
78
+ @system_prompt ||= @i18n_tasks.translation_config[:watsonx_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
79
+ end
80
+
81
+ def translate_values(list, from:, to:)
82
+ results = []
83
+
84
+ list.each_slice(BATCH_SIZE) do |batch|
85
+ translations = translate(batch, from, to)
86
+ result = JSON.parse(translations)
87
+ results << result
88
+
89
+ @progress_bar.progress += results.size
90
+ end
91
+
92
+ results.flatten
93
+ end
94
+
95
+ def translate(values, from, to)
96
+ prompt = [
97
+ '<|eot_id|><|start_header_id|>system<|end_header_id|>',
98
+ format(system_prompt, from: from, to: to),
99
+ '<|eot_id|><|start_header_id|>user<|end_header_id|>Translate this array:',
100
+ "<|eot_id|><|start_header_id|>user<|end_header_id|>#{values.to_json}",
101
+ '<|eot_id|><|start_header_id|>assistant<|end_header_id|>'
102
+ ].join
103
+
104
+ response = translator.generate_text(
105
+ model_id: model,
106
+ project_id: project_id,
107
+ input: prompt,
108
+ parameters: {
109
+ decoding_method: :greedy,
110
+ max_new_tokens: 2048,
111
+ repetition_penalty: 1
112
+ }
113
+ )
114
+ response.dig('results', 0, 'generated_text')
115
+ end
116
+ end
117
+ end
118
+
119
+ class WatsonxClient
120
+ WATSONX_BASE_URL = 'https://us-south.ml.cloud.ibm.com/ml/'
121
+ IBM_CLOUD_IAM_URL = 'https://iam.cloud.ibm.com/identity/token'
122
+
123
+ def initialize(key:)
124
+ begin
125
+ require 'faraday'
126
+ rescue LoadError
127
+ raise ::I18n::Tasks::CommandError, "Add gem 'faraday' to your Gemfile to use this command"
128
+ end
129
+
130
+ @http = Faraday.new(url: WATSONX_BASE_URL) do |conn|
131
+ conn.use Faraday::Response::RaiseError
132
+ conn.request :json
133
+ conn.response :json
134
+ conn.options.timeout = 600
135
+ conn.request :authorization, :Bearer, token(key)
136
+ end
137
+ end
138
+
139
+ def generate_text(**opts)
140
+ @http.post('v1/text/generation?version=2024-05-20', **opts).body
141
+ end
142
+
143
+ private
144
+
145
+ def token(key)
146
+ Faraday.new(url: IBM_CLOUD_IAM_URL) do |conn|
147
+ conn.use Faraday::Response::RaiseError
148
+ conn.response :json
149
+ conn.params = {
150
+ grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
151
+ apikey: key
152
+ }
153
+ end.post.body['access_token']
154
+ end
155
+ end
@@ -16,7 +16,11 @@ module I18n::Tasks::Translators
16
16
  protected
17
17
 
18
18
  def translate_values(list, **options)
19
- list.map { |item| translator.translate(item, options) }
19
+ result = list.map { |item| translator.translate(item, options) }
20
+
21
+ @progress_bar.progress += result.size
22
+
23
+ result
20
24
  end
21
25
 
22
26
  def options_for_translate_values(from:, to:, **options)
@@ -4,6 +4,7 @@ require 'find'
4
4
  require 'i18n/tasks/scanners/pattern_with_scope_scanner'
5
5
  require 'i18n/tasks/scanners/ruby_ast_scanner'
6
6
  require 'i18n/tasks/scanners/erb_ast_scanner'
7
+ require 'i18n/tasks/scanners/prism_scanner'
7
8
  require 'i18n/tasks/scanners/scanner_multiplexer'
8
9
  require 'i18n/tasks/scanners/files/caching_file_finder_provider'
9
10
  require 'i18n/tasks/scanners/files/caching_file_reader'
@@ -21,8 +22,8 @@ module I18n::Tasks
21
22
  relative_roots: %w[app/controllers app/helpers app/mailers app/presenters app/views].freeze,
22
23
  scanners: [
23
24
  ['::I18n::Tasks::Scanners::RubyAstScanner', { only: %w[*.rb] }],
24
- ['::I18n::Tasks::Scanners::ErbAstScanner', { only: %w[*.html.erb] }],
25
- ['::I18n::Tasks::Scanners::PatternWithScopeScanner', { exclude: %w[*.html.erb *.rb] }]
25
+ ['::I18n::Tasks::Scanners::ErbAstScanner', { only: %w[*.erb] }],
26
+ ['::I18n::Tasks::Scanners::PatternWithScopeScanner', { exclude: %w[*.erb *.rb] }]
26
27
  ],
27
28
  ast_matchers: [],
28
29
  strict: true
@@ -2,6 +2,6 @@
2
2
 
3
3
  module I18n
4
4
  module Tasks
5
- VERSION = '1.0.14'
5
+ VERSION = '1.0.15'
6
6
  end
7
7
  end
data/lib/i18n/tasks.rb CHANGED
@@ -66,6 +66,7 @@ require 'active_support/core_ext/module/delegation'
66
66
  require 'active_support/core_ext/object/blank'
67
67
  require 'active_support/core_ext/object/try'
68
68
 
69
+ require 'ruby-progressbar'
69
70
  require 'rainbow'
70
71
  require 'erubi'
71
72
 
@@ -34,7 +34,7 @@ data:
34
34
  ## Example (replace %#= with %=):
35
35
  # - "<%#= %x[bundle info vagrant --path].chomp %>/templates/locales/%{locale}.yml"
36
36
 
37
- ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class.
37
+ ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, isolating_router, or a custom class.
38
38
  # router: conservative_router
39
39
 
40
40
  yaml:
@@ -122,7 +122,7 @@ search:
122
122
  # formality: prefer_less
123
123
  # # OpenAI
124
124
  # openai_api_key: "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
125
- # # openai_model: "gpt-3.5-turbo" # see https://platform.openai.com/docs/models
125
+ # # openai_model: "gpt-4o-mini" # see https://platform.openai.com/docs/models
126
126
  # # may contain `%{from}` and `%{to}`, which will be replaced by source and target locale codes, respectively (using `Kernel.format`)
127
127
  # # openai_system_prompt: >-
128
128
  # # You are a professional translator that translates content from the %{from} locale
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.14
4
+ version: 1.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - glebm
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-05-10 00:00:00.000000000 Z
10
+ date: 2025-03-07 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -128,6 +127,26 @@ dependencies:
128
127
  - - "<"
129
128
  - !ruby/object:Gem::Version
130
129
  version: '4.0'
130
+ - !ruby/object:Gem::Dependency
131
+ name: ruby-progressbar
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '1.8'
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: 1.8.1
140
+ type: :runtime
141
+ prerelease: false
142
+ version_requirements: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - "~>"
145
+ - !ruby/object:Gem::Version
146
+ version: '1.8'
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: 1.8.1
131
150
  - !ruby/object:Gem::Dependency
132
151
  name: terminal-table
133
152
  requirement: !ruby/object:Gem::Requirement
@@ -402,12 +421,17 @@ files:
402
421
  - lib/i18n/tasks/scanners/pattern_mapper.rb
403
422
  - lib/i18n/tasks/scanners/pattern_scanner.rb
404
423
  - lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb
424
+ - lib/i18n/tasks/scanners/prism_scanner.rb
425
+ - lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb
426
+ - lib/i18n/tasks/scanners/prism_scanners/nodes.rb
427
+ - lib/i18n/tasks/scanners/prism_scanners/visitor.rb
405
428
  - lib/i18n/tasks/scanners/relative_keys.rb
406
429
  - lib/i18n/tasks/scanners/results/key_occurrences.rb
407
430
  - lib/i18n/tasks/scanners/results/occurrence.rb
408
431
  - lib/i18n/tasks/scanners/ruby_ast_call_finder.rb
409
432
  - lib/i18n/tasks/scanners/ruby_ast_scanner.rb
410
433
  - lib/i18n/tasks/scanners/ruby_key_literals.rb
434
+ - lib/i18n/tasks/scanners/ruby_parser_factory.rb
411
435
  - lib/i18n/tasks/scanners/scanner.rb
412
436
  - lib/i18n/tasks/scanners/scanner_multiplexer.rb
413
437
  - lib/i18n/tasks/split_key.rb
@@ -418,6 +442,7 @@ files:
418
442
  - lib/i18n/tasks/translators/deepl_translator.rb
419
443
  - lib/i18n/tasks/translators/google_translator.rb
420
444
  - lib/i18n/tasks/translators/openai_translator.rb
445
+ - lib/i18n/tasks/translators/watsonx_translator.rb
421
446
  - lib/i18n/tasks/translators/yandex_translator.rb
422
447
  - lib/i18n/tasks/unused_keys.rb
423
448
  - lib/i18n/tasks/used_keys.rb
@@ -429,8 +454,10 @@ homepage: https://github.com/glebm/i18n-tasks
429
454
  licenses:
430
455
  - MIT
431
456
  metadata:
457
+ changelog_uri: https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md
432
458
  issue_tracker: https://github.com/glebm/i18n-tasks
433
459
  rubygems_mfa_required: 'true'
460
+ source_code_uri: https://github.com/glebm/i18n-tasks
434
461
  post_install_message: |
435
462
  # Install default configuration:
436
463
  cp $(bundle exec i18n-tasks gem-path)/templates/config/i18n-tasks.yml config/
@@ -455,8 +482,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
455
482
  - !ruby/object:Gem::Version
456
483
  version: '0'
457
484
  requirements: []
458
- rubygems_version: 3.5.3
459
- signing_key:
485
+ rubygems_version: 3.6.3
460
486
  specification_version: 4
461
487
  summary: Manage localization and translation with the awesome power of static analysis
462
488
  test_files: []