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
@@ -0,0 +1,118 @@
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 OpenAiTranslator < BaseTranslator
8
+ # max allowed texts per request
9
+ BATCH_SIZE = 50
10
+ DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
11
+ You are a professional translator that translates content from the %{from} locale
12
+ to the %{to} locale in an i18n locale array.
13
+
14
+ The array has a structured format and contains multiple strings. Your task is to translate
15
+ each of these strings and create a new array with the translated strings.
16
+
17
+ HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
18
+ Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
19
+
20
+ Keep in mind the context of all the strings for a more accurate translation.
21
+ PROMPT
22
+
23
+ def initialize(*)
24
+ begin
25
+ require 'openai'
26
+ rescue LoadError
27
+ raise ::I18n::Tasks::CommandError, "Add gem 'ruby-openai' to your Gemfile to use this command"
28
+ end
29
+ super
30
+ end
31
+
32
+ def options_for_translate_values(from:, to:, **options)
33
+ options.merge(
34
+ from: from,
35
+ to: to
36
+ )
37
+ end
38
+
39
+ def options_for_html
40
+ {}
41
+ end
42
+
43
+ def options_for_plain
44
+ {}
45
+ end
46
+
47
+ def no_results_error_message
48
+ I18n.t('i18n_tasks.openai_translate.errors.no_results')
49
+ end
50
+
51
+ private
52
+
53
+ def translator
54
+ @translator ||= OpenAI::Client.new(access_token: api_key)
55
+ end
56
+
57
+ def api_key
58
+ @api_key ||= begin
59
+ key = @i18n_tasks.translation_config[:openai_api_key]
60
+ fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.openai_translate.errors.no_api_key') if key.blank?
61
+
62
+ key
63
+ end
64
+ end
65
+
66
+ def model
67
+ @model ||= @i18n_tasks.translation_config[:openai_model].presence || 'gpt-3.5-turbo'
68
+ end
69
+
70
+ def system_prompt
71
+ @system_prompt ||= @i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
72
+ end
73
+
74
+ def translate_values(list, from:, to:)
75
+ results = []
76
+
77
+ list.each_slice(BATCH_SIZE) do |batch|
78
+ translations = translate(batch, from, to)
79
+
80
+ results << JSON.parse(translations)
81
+ end
82
+
83
+ results.flatten
84
+ end
85
+
86
+ def translate(values, from, to)
87
+ messages = [
88
+ {
89
+ role: 'system',
90
+ content: format(system_prompt, from: from, to: to)
91
+ },
92
+ {
93
+ role: 'user',
94
+ content: "Translate this array: \n\n\n"
95
+ },
96
+ {
97
+ role: 'user',
98
+ content: values.to_json
99
+ }
100
+ ]
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
+ end
117
+ end
118
+ end
@@ -21,15 +21,15 @@ module I18n::Tasks
21
21
  relative_roots: %w[app/controllers app/helpers app/mailers app/presenters app/views].freeze,
22
22
  scanners: [
23
23
  ['::I18n::Tasks::Scanners::RubyAstScanner', { only: %w[*.rb] }],
24
- ['::I18n::Tasks::Scanners::ErbAstScanner', { only: %w[*.erb] }],
25
- ['::I18n::Tasks::Scanners::PatternWithScopeScanner', { exclude: %w[*.erb *.rb] }]
24
+ ['::I18n::Tasks::Scanners::ErbAstScanner', { only: %w[*.html.erb] }],
25
+ ['::I18n::Tasks::Scanners::PatternWithScopeScanner', { exclude: %w[*.html.erb *.rb] }]
26
26
  ],
27
27
  ast_matchers: [],
28
28
  strict: true
29
29
  }.freeze
30
30
 
31
- ALWAYS_EXCLUDE = %w[*.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less
32
- *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus
31
+ ALWAYS_EXCLUDE = %w[*.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss
32
+ *.less *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus
33
33
  *.webp *.map *.xlsx].freeze
34
34
 
35
35
  # Find all keys in the source and return a forest with the keys in absolute form and their occurrences.
@@ -142,7 +142,7 @@ module I18n::Tasks
142
142
 
143
143
  # keys in the source that end with a ., e.g. t("category.#{ cat.i18n_key }") or t("category." + category.key)
144
144
  # @param [String] replacement for interpolated values.
145
- def expr_key_re(replacement: ':')
145
+ def expr_key_re(replacement: '*:')
146
146
  @expr_key_re ||= begin
147
147
  # disallow patterns with no keys
148
148
  ignore_pattern_re = /\A[.#{replacement}]*\z/
@@ -2,6 +2,6 @@
2
2
 
3
3
  module I18n
4
4
  module Tasks
5
- VERSION = '1.0.12'
5
+ VERSION = '1.0.14'
6
6
  end
7
7
  end
@@ -13,7 +13,7 @@ data:
13
13
  ## Provide a custom adapter:
14
14
  # adapter: I18n::Tasks::Data::FileSystem
15
15
 
16
- # Locale files or `File.find` patterns where translations are read from:
16
+ # Locale files or `Dir.glob` patterns where translations are read from:
17
17
  read:
18
18
  ## Default:
19
19
  # - config/locales/%{locale}.yml
@@ -52,7 +52,7 @@ data:
52
52
 
53
53
  # Find translate calls
54
54
  search:
55
- ## Paths or `File.find` patterns to search in:
55
+ ## Paths or `Find.find` patterns to search in:
56
56
  # paths:
57
57
  # - app/
58
58
 
@@ -92,9 +92,13 @@ search:
92
92
  ## - RailsModelMatcher
93
93
  ## Matches ActiveRecord translations like
94
94
  ## User.human_attribute_name(:email) and User.model_name.human
95
+ ## - DefaultI18nSubjectMatcher
96
+ ## Matches ActionMailer's default_i18n_subject method
95
97
  ##
96
98
  ## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`.
97
- <%# I18n::Tasks.add_ast_matcher('I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher') %>
99
+ # ast_matchers:
100
+ # - 'I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher'
101
+ # - 'I18n::Tasks::Scanners::AstMatchers::DefaultI18nSubjectMatcher'
98
102
 
99
103
  ## Multiple scanners can be used. Their results are merged.
100
104
  ## The options specified above are passed down to each scanner. Per-scanner options can be specified as well.
@@ -110,6 +114,27 @@ search:
110
114
  # deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A"
111
115
  # # deepl_host: "https://api.deepl.com"
112
116
  # # deepl_version: "v2"
117
+ # # deepl_glossary_ids:
118
+ # # - f28106eb-0e06-489e-82c6-8215d6f95089
119
+ # # - 2c6415be-1852-4f54-9e1b-d800463496b4
120
+ # # add additional options to the DeepL.translate call: https://www.deepl.com/docs-api/translate-text/translate-text/
121
+ # deepl_options:
122
+ # formality: prefer_less
123
+ # # OpenAI
124
+ # openai_api_key: "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
125
+ # # openai_model: "gpt-3.5-turbo" # see https://platform.openai.com/docs/models
126
+ # # may contain `%{from}` and `%{to}`, which will be replaced by source and target locale codes, respectively (using `Kernel.format`)
127
+ # # openai_system_prompt: >-
128
+ # # You are a professional translator that translates content from the %{from} locale
129
+ # # to the %{to} locale in an i18n locale array.
130
+ # #
131
+ # # The array has a structured format and contains multiple strings. Your task is to translate
132
+ # # each of these strings and create a new array with the translated strings.
133
+ # #
134
+ # # HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
135
+ # # Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
136
+ # #
137
+ # # Keep in mind the context of all the strings for a more accurate translation.
113
138
 
114
139
  ## Do not consider these keys missing:
115
140
  # ignore_missing:
@@ -5,18 +5,18 @@ require 'i18n/tasks'
5
5
  class I18nTest < ActiveSupport::TestCase
6
6
  def setup
7
7
  @i18n = I18n::Tasks::BaseTask.new
8
- @missing_keys = @i18n.missing_keys
9
- @unused_keys = @i18n.unused_keys
10
8
  end
11
9
 
12
10
  def test_no_missing_keys
13
- assert_empty @missing_keys,
14
- "Missing #{@missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them"
11
+ missing_keys = @i18n.missing_keys
12
+ assert_empty missing_keys,
13
+ "Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them"
15
14
  end
16
15
 
17
16
  def test_no_unused_keys
18
- assert_empty @unused_keys,
19
- "#{@unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them"
17
+ unused_keys = @i18n.unused_keys
18
+ assert_empty unused_keys,
19
+ "#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them"
20
20
  end
21
21
 
22
22
  def test_files_are_normalized
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.12
4
+ version: 1.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - glebm
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-09-18 00:00:00.000000000 Z
11
+ date: 2024-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -38,26 +38,6 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: 2.1.0
41
- - !ruby/object:Gem::Dependency
42
- name: better_html
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '1.0'
48
- - - "<"
49
- - !ruby/object:Gem::Version
50
- version: '3.0'
51
- type: :runtime
52
- prerelease: false
53
- version_requirements: !ruby/object:Gem::Requirement
54
- requirements:
55
- - - ">="
56
- - !ruby/object:Gem::Version
57
- version: '1.0'
58
- - - "<"
59
- - !ruby/object:Gem::Version
60
- version: '3.0'
61
41
  - !ruby/object:Gem::Dependency
62
42
  name: erubi
63
43
  requirement: !ruby/object:Gem::Requirement
@@ -106,14 +86,14 @@ dependencies:
106
86
  requirements:
107
87
  - - ">="
108
88
  - !ruby/object:Gem::Version
109
- version: 2.2.3.0
89
+ version: 3.2.2.1
110
90
  type: :runtime
111
91
  prerelease: false
112
92
  version_requirements: !ruby/object:Gem::Requirement
113
93
  requirements:
114
94
  - - ">="
115
95
  - !ruby/object:Gem::Version
116
- version: 2.2.3.0
96
+ version: 3.2.2.1
117
97
  - !ruby/object:Gem::Dependency
118
98
  name: rails-i18n
119
99
  requirement: !ruby/object:Gem::Requirement
@@ -230,14 +210,42 @@ dependencies:
230
210
  requirements:
231
211
  - - "~>"
232
212
  - !ruby/object:Gem::Version
233
- version: 1.27.0
213
+ version: 1.50.1
214
+ type: :development
215
+ prerelease: false
216
+ version_requirements: !ruby/object:Gem::Requirement
217
+ requirements:
218
+ - - "~>"
219
+ - !ruby/object:Gem::Version
220
+ version: 1.50.1
221
+ - !ruby/object:Gem::Dependency
222
+ name: rubocop-rake
223
+ requirement: !ruby/object:Gem::Requirement
224
+ requirements:
225
+ - - "~>"
226
+ - !ruby/object:Gem::Version
227
+ version: 0.6.0
228
+ type: :development
229
+ prerelease: false
230
+ version_requirements: !ruby/object:Gem::Requirement
231
+ requirements:
232
+ - - "~>"
233
+ - !ruby/object:Gem::Version
234
+ version: 0.6.0
235
+ - !ruby/object:Gem::Dependency
236
+ name: rubocop-rspec
237
+ requirement: !ruby/object:Gem::Requirement
238
+ requirements:
239
+ - - "~>"
240
+ - !ruby/object:Gem::Version
241
+ version: 2.19.0
234
242
  type: :development
235
243
  prerelease: false
236
244
  version_requirements: !ruby/object:Gem::Requirement
237
245
  requirements:
238
246
  - - "~>"
239
247
  - !ruby/object:Gem::Version
240
- version: 1.27.0
248
+ version: 2.19.0
241
249
  - !ruby/object:Gem::Dependency
242
250
  name: simplecov
243
251
  requirement: !ruby/object:Gem::Requirement
@@ -360,6 +368,7 @@ files:
360
368
  - lib/i18n/tasks/data/file_system.rb
361
369
  - lib/i18n/tasks/data/file_system_base.rb
362
370
  - lib/i18n/tasks/data/router/conservative_router.rb
371
+ - lib/i18n/tasks/data/router/isolating_router.rb
363
372
  - lib/i18n/tasks/data/router/pattern_router.rb
364
373
  - lib/i18n/tasks/data/tree/node.rb
365
374
  - lib/i18n/tasks/data/tree/nodes.rb
@@ -378,9 +387,9 @@ files:
378
387
  - lib/i18n/tasks/reports/base.rb
379
388
  - lib/i18n/tasks/reports/terminal.rb
380
389
  - lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb
390
+ - lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb
381
391
  - lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb
382
392
  - lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb
383
- - lib/i18n/tasks/scanners/erb_ast_processor.rb
384
393
  - lib/i18n/tasks/scanners/erb_ast_scanner.rb
385
394
  - lib/i18n/tasks/scanners/file_scanner.rb
386
395
  - lib/i18n/tasks/scanners/files/caching_file_finder.rb
@@ -408,6 +417,7 @@ files:
408
417
  - lib/i18n/tasks/translators/base_translator.rb
409
418
  - lib/i18n/tasks/translators/deepl_translator.rb
410
419
  - lib/i18n/tasks/translators/google_translator.rb
420
+ - lib/i18n/tasks/translators/openai_translator.rb
411
421
  - lib/i18n/tasks/translators/yandex_translator.rb
412
422
  - lib/i18n/tasks/unused_keys.rb
413
423
  - lib/i18n/tasks/used_keys.rb
@@ -445,8 +455,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
445
455
  - !ruby/object:Gem::Version
446
456
  version: '0'
447
457
  requirements: []
448
- rubygems_version: 3.1.2
449
- signing_key:
458
+ rubygems_version: 3.5.3
459
+ signing_key:
450
460
  specification_version: 4
451
461
  summary: Manage localization and translation with the awesome power of static analysis
452
462
  test_files: []
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'ast'
4
- require 'set'
5
- require 'i18n/tasks/scanners/local_ruby_parser'
6
-
7
- module I18n::Tasks::Scanners
8
- class ErbAstProcessor
9
- include AST::Processor::Mixin
10
- def initialize
11
- super()
12
- @ruby_parser = LocalRubyParser.new(ignore_blocks: true)
13
- @comments = []
14
- end
15
-
16
- def process_and_extract_comments(ast)
17
- result = process(ast)
18
- [result, @comments]
19
- end
20
-
21
- def on_code(node)
22
- parsed, comments = @ruby_parser.parse(
23
- node.children[0],
24
- location: node.location
25
- )
26
- @comments.concat(comments)
27
-
28
- unless parsed.nil?
29
- parsed = parsed.updated(
30
- nil,
31
- parsed.children.map { |child| node?(child) ? process(child) : child }
32
- )
33
- node = node.updated(:send, parsed)
34
- end
35
- node
36
- end
37
-
38
- # @param node [::Parser::AST::Node]
39
- # @return [::Parser::AST::Node]
40
- def handler_missing(node)
41
- node = handle_comment(node)
42
- return if node.nil?
43
-
44
- node.updated(
45
- nil,
46
- node.children.map { |child| node?(child) ? process(child) : child }
47
- )
48
- end
49
-
50
- private
51
-
52
- # Convert ERB-comments to ::Parser::Source::Comment and skip processing node
53
- #
54
- # @param node Parser::AST::Node Potential comment node
55
- # @return Parser::AST::Node or nil
56
- def handle_comment(node)
57
- if node.type == :erb && node.children.size == 4 &&
58
- node.children[0]&.type == :indicator && node.children[0].children[0] == '#' &&
59
- node.children[2]&.type == :code
60
-
61
- # Do not continue parsing this node
62
- comment = node.children[2]
63
- @comments << ::Parser::Source::Comment.new(comment.location.expression)
64
- return
65
- end
66
-
67
- node
68
- end
69
-
70
- def node?(node)
71
- node.is_a?(::Parser::AST::Node)
72
- end
73
- end
74
- end