actiontext_translate 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0de2c393d8b1ce42d10f5f3de9da605d055f3eacfa71a0a38338d3a8ef588b40
4
+ data.tar.gz: bd701532905304c0e8d23fd93e77a42e77781d0f0225d19644807d1793bff420
5
+ SHA512:
6
+ metadata.gz: 2a32f00eeb6517cda823e6facb7cfcc8e21672b562ad79dc58bed7828501263e2b680bddf7efc77dde0652464e3bf2130d8eca4c82a9dace71fbe6be94c435bc
7
+ data.tar.gz: 8fda68dee1d2f4884f718f10f0a0ca24f8003b2da9375706dd0ed9d7cafd8ecfcd37456d9c5dc580d27ce10cb17c41ca104e993abecfcdd2f55a66a4868f76f5
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2024-12-18
9
+
10
+ ### Added
11
+ - Initial release
12
+ - ChatGPT (OpenAI) translator with HTML preservation
13
+ - Link protection to prevent URL corruption during translation
14
+ - ActionText rich text support
15
+ - HTML tag preservation
16
+ - Extensible translator interface for multiple providers
17
+ - Configuration system
18
+ - Comprehensive test suite
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Mykyta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,333 @@
1
+ # ActionText Translate
2
+
3
+ A Ruby gem for translating ActionText rich text content using ChatGPT (OpenAI) while perfectly preserving HTML structure, tags, and links.
4
+
5
+ ## Features
6
+
7
+ - **High-Quality Translation**: Uses ChatGPT for natural, contextual translations
8
+ - **HTML Preservation**: Maintains all HTML tags, attributes, and structure
9
+ - **Smart Link Protection**: Prevents URL corruption during translation
10
+ - **ActionText Support**: Seamless integration with Rails ActionText
11
+ - **Extensible Architecture**: Easy to add additional translation providers
12
+ - **Well Tested**: Comprehensive test suite with 33+ test cases
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'actiontext_translate'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ Or install it yourself as:
29
+
30
+ ```bash
31
+ gem install actiontext_translate
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ ### Basic Configuration
37
+
38
+ ```ruby
39
+ ActiontextTranslate.configure do |config|
40
+ config.provider = :chatgpt
41
+ config.api_key = ENV['OPENAI_API_KEY']
42
+ config.model = 'gpt-4o-mini' # Fast and cost-effective
43
+ config.temperature = 0.3 # Lower temperature for consistent translations
44
+ config.timeout = 30 # API timeout in seconds
45
+ end
46
+ ```
47
+
48
+ ### Rails Configuration
49
+
50
+ Create an initializer `config/initializers/actiontext_translate.rb`:
51
+
52
+ ```ruby
53
+ # config/initializers/actiontext_translate.rb
54
+ ActiontextTranslate.configure do |config|
55
+ config.provider = :chatgpt
56
+ config.api_key = ENV['OPENAI_API_KEY']
57
+ config.model = 'gpt-4o-mini'
58
+ config.temperature = 0.3
59
+ end
60
+ ```
61
+
62
+ ### Get Your OpenAI API Key
63
+
64
+ 1. Visit https://platform.openai.com/api-keys
65
+ 2. Create a new API key
66
+ 3. Add it to your environment variables
67
+
68
+ ## Usage
69
+
70
+ ### Simple Text Translation
71
+
72
+ ```ruby
73
+ # Basic translation
74
+ ActiontextTranslate.translate("Привіт Світ", from: "uk", to: "en")
75
+ # => "Hello World"
76
+
77
+ # With custom translator instance
78
+ translator = ActiontextTranslate::Translator.new
79
+ translator.translate("Bonjour le monde", from: "fr", to: "en")
80
+ # => "Hello world"
81
+ ```
82
+
83
+ ### HTML Translation
84
+
85
+ ```ruby
86
+ html = '<p>Check <a href="https://example.com">this link</a> for more info.</p>'
87
+
88
+ result = ActiontextTranslate.translate_html(html, from: "uk", to: "en")
89
+ # => '<p>Check <a href="https://example.com">this link</a> for more info.</p>'
90
+ # All HTML structure and link attributes are perfectly preserved!
91
+ ```
92
+
93
+ ### ActionText Translation
94
+
95
+ ```ruby
96
+ # Translate ActionText rich text content
97
+ article = Article.find(1)
98
+ translated_html = ActiontextTranslate.translate_action_text(
99
+ article.body,
100
+ from: "uk",
101
+ to: "en"
102
+ )
103
+
104
+ # Set the translated content
105
+ article.body_en = translated_html
106
+ article.save
107
+ ```
108
+
109
+ ## Rails Integration
110
+
111
+ ### Background Job Example
112
+
113
+ ```ruby
114
+ # app/jobs/translate_content_job.rb
115
+ class TranslateContentJob < ApplicationJob
116
+ queue_as :default
117
+
118
+ def perform(model_class, model_id, from_locale, to_locale)
119
+ model = model_class.constantize.find_by(id: model_id)
120
+ return unless model
121
+
122
+ translator = ActiontextTranslate::Translator.new
123
+
124
+ # Translate ActionText body field
125
+ if model.respond_to?(:body) && model.body.present?
126
+ translated_html = translator.translate_action_text(
127
+ model.body,
128
+ from: from_locale,
129
+ to: to_locale
130
+ )
131
+ model.public_send("body_#{to_locale}=", translated_html)
132
+ end
133
+
134
+ # Translate regular text fields
135
+ if model.respond_to?(:title) && model.title.present?
136
+ translated_title = translator.translate(
137
+ model.title,
138
+ from: from_locale,
139
+ to: to_locale
140
+ )
141
+ model.public_send("title_#{to_locale}=", translated_title)
142
+ end
143
+
144
+ model.save!
145
+ rescue ActiontextTranslate::Translators::TranslationError => e
146
+ Rails.logger.error("Translation failed: #{e.message}")
147
+ raise
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### Rake Task Example
153
+
154
+ ```ruby
155
+ # lib/tasks/translations.rake
156
+ namespace :translations do
157
+ desc 'Translate content from Ukrainian to English'
158
+ task translate_articles: :environment do
159
+ Article.where(translated: false).find_each do |article|
160
+ puts "Translating Article ##{article.id}..."
161
+ TranslateContentJob.perform_later('Article', article.id, 'uk', 'en')
162
+ end
163
+ end
164
+ end
165
+ ```
166
+
167
+ ### Model Integration
168
+
169
+ ```ruby
170
+ # app/models/article.rb
171
+ class Article < ApplicationRecord
172
+ has_rich_text :body
173
+ has_rich_text :body_en
174
+
175
+ after_create :enqueue_translation
176
+
177
+ private
178
+
179
+ def enqueue_translation
180
+ TranslateContentJob.perform_later(self.class.name, id, 'uk', 'en')
181
+ end
182
+ end
183
+ ```
184
+
185
+ ## Advanced Features
186
+
187
+ ### Link Protection
188
+
189
+ The gem automatically protects links during translation:
190
+
191
+ ```ruby
192
+ html = '<p>Visit <a href="https://example.com">our website</a> or <a href="https://docs.com">https://docs.com</a></p>'
193
+
194
+ result = ActiontextTranslate.translate_html(html, from: "uk", to: "en")
195
+ # - Links with descriptive text: text gets translated, URL preserved
196
+ # - Links where text = URL: kept unchanged
197
+ # - All attributes (href, rel, target, class) are preserved
198
+ ```
199
+
200
+ ### HTML Structure Preservation
201
+
202
+ ```ruby
203
+ html = <<~HTML
204
+ <div class="content">
205
+ <h1>Title</h1>
206
+ <p>Paragraph with <strong>bold</strong> and <em>italic</em> text.</p>
207
+ <ul>
208
+ <li>Item 1</li>
209
+ <li>Item 2</li>
210
+ </ul>
211
+ </div>
212
+ HTML
213
+
214
+ result = ActiontextTranslate.translate_html(html, from: "uk", to: "en")
215
+ # All tags, classes, and structure are perfectly preserved
216
+ ```
217
+
218
+ ### Error Handling
219
+
220
+ ```ruby
221
+ begin
222
+ translator = ActiontextTranslate::Translator.new
223
+ result = translator.translate("Text", from: "uk", to: "en")
224
+ rescue ActiontextTranslate::Translators::TranslationError => e
225
+ Rails.logger.error("Translation failed: #{e.message}")
226
+ # Handle error (retry, fallback, notify, etc.)
227
+ end
228
+ ```
229
+
230
+ ## Supported Languages
231
+
232
+ The gem supports **all languages** supported by OpenAI's GPT models. Simply use the appropriate language code:
233
+
234
+ ```ruby
235
+ # Ukrainian to English
236
+ ActiontextTranslate.translate("Привіт", from: "uk", to: "en")
237
+
238
+ # French to German
239
+ ActiontextTranslate.translate("Bonjour", from: "fr", to: "de")
240
+
241
+ # Any language combination
242
+ ActiontextTranslate.translate(text, from: "source_lang", to: "target_lang")
243
+ ```
244
+
245
+ ## Cost Considerations
246
+
247
+ Using ChatGPT (GPT-4o-mini) is very cost-effective:
248
+
249
+ - **Input**: ~$0.15 per 1M tokens
250
+ - **Output**: ~$0.60 per 1M tokens
251
+ - **Average translation**: A typical blog post (1000 words) costs < $0.01
252
+
253
+ Example monthly costs for 1000 translations:
254
+ - ~$5-10/month for typical usage
255
+ - Pay only for what you use
256
+
257
+ ## Configuration Options
258
+
259
+ | Option | Default | Description |
260
+ |--------|---------|-------------|
261
+ | `provider` | `:chatgpt` | Translation provider |
262
+ | `api_key` | `nil` | OpenAI API key (required) |
263
+ | `model` | `'gpt-4o-mini'` | OpenAI model to use |
264
+ | `temperature` | `0.3` | Response randomness (0.0-2.0) |
265
+ | `timeout` | `30` | API request timeout in seconds |
266
+
267
+ ## Development
268
+
269
+ After checking out the repo, run:
270
+
271
+ ```bash
272
+ bundle install
273
+ ```
274
+
275
+ Run tests:
276
+
277
+ ```bash
278
+ bundle exec rspec
279
+ ```
280
+
281
+ Check code coverage (requires 90% minimum):
282
+
283
+ ```bash
284
+ bundle exec rspec
285
+ open coverage/index.html
286
+ ```
287
+
288
+ Run RuboCop:
289
+
290
+ ```bash
291
+ bundle exec rubocop
292
+ ```
293
+
294
+ ### Code Coverage
295
+
296
+ The gem maintains **96%+ code coverage** with comprehensive tests:
297
+ - 48 test cases covering all major features
298
+ - Integration tests with realistic Trix/ActionText content
299
+ - Edge case coverage for HTML preservation
300
+ - Minimum coverage threshold: 90%
301
+
302
+ View coverage report after running tests:
303
+ ```bash
304
+ open coverage/index.html
305
+ ```
306
+
307
+ ## Contributing
308
+
309
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mykbren/actiontext_translate.
310
+
311
+ ## License
312
+
313
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
314
+
315
+ ## Roadmap
316
+
317
+ Future enhancements planned:
318
+
319
+ - [ ] Additional translation providers (Google Translate, DeepL, Azure)
320
+ - [ ] Batch translation support for improved performance
321
+ - [ ] Translation memory/caching to reduce costs
322
+ - [ ] Automatic language detection
323
+ - [ ] Rails generators for easy setup
324
+ - [ ] Translation quality scoring
325
+ - [ ] Custom prompt templates
326
+ - [ ] Streaming support for large documents
327
+
328
+ ## Support
329
+
330
+ For questions, issues, or feature requests:
331
+ - Open an issue on GitHub
332
+ - Review the test suite for examples
333
+ - Check the documentation
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiontextTranslate
4
+ class Configuration
5
+ attr_accessor :provider, :api_key, :timeout, :model, :temperature
6
+
7
+ def initialize
8
+ @provider = :chatgpt
9
+ @api_key = nil
10
+ @timeout = 30
11
+ @model = 'gpt-4o-mini' # Fast and cost-effective for translations
12
+ @temperature = 0.3 # Lower temperature for more consistent translations
13
+ end
14
+ end
15
+
16
+ class << self
17
+ attr_writer :configuration
18
+
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = Configuration.new
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'uri'
5
+ require 'cgi'
6
+
7
+ module ActiontextTranslate
8
+ # Handles HTML processing for translation, including link protection
9
+ class HtmlProcessor
10
+ # Protect links by replacing them with simple placeholders
11
+ # @param html [String] The HTML content to process
12
+ # @return [Array<String, Hash>] Protected HTML and link map
13
+ def self.protect_links(html)
14
+ doc = Nokogiri::HTML.fragment(html)
15
+ link_map = {}
16
+ counter = 0
17
+
18
+ doc.css('a').each do |link|
19
+ counter += 1
20
+ start_placeholder = "XLINKSTARTX#{counter}X"
21
+ end_placeholder = "XLINKENDX#{counter}X"
22
+
23
+ text = link.text.strip
24
+ href = link['href'].to_s
25
+
26
+ # Determine if link is translatable
27
+ # Links are not translatable if:
28
+ # 1. Text exactly matches URL
29
+ # 2. Text contains any URL (to avoid translator breaking URLs)
30
+ uri_regex = URI::DEFAULT_PARSER.make_regexp(%w[http https])
31
+ translatable = !(text == href || text.match?(uri_regex))
32
+
33
+ link_map[start_placeholder] = {
34
+ end_placeholder: end_placeholder,
35
+ attributes: link.attributes.transform_values(&:value),
36
+ text: text,
37
+ translatable: translatable
38
+ }
39
+
40
+ # Replace link with placeholders
41
+ protected_text = translatable ? text : ''
42
+ link.replace("#{start_placeholder}#{protected_text}#{end_placeholder}")
43
+ end
44
+
45
+ [doc.to_html, link_map]
46
+ end
47
+
48
+ # Restore links after translation
49
+ # @param html [String] The translated HTML with placeholders
50
+ # @param link_map [Hash] The link map from protect_links
51
+ # @return [String] HTML with restored links
52
+ def self.restore_links(html, link_map)
53
+ html = html.dup
54
+
55
+ link_map.each do |start_placeholder, data|
56
+ end_placeholder = data[:end_placeholder]
57
+
58
+ # Extract the inner text
59
+ inner_text = html[/#{Regexp.escape(start_placeholder)}(.*?)#{Regexp.escape(end_placeholder)}/m, 1].to_s.strip
60
+
61
+ # Use original text for non-translatable links
62
+ inner_text = data[:text] unless data[:translatable]
63
+
64
+ # Rebuild <a> tag with exact attributes
65
+ attrs = data[:attributes].map { |k, v| %(#{k}="#{CGI.escapeHTML(v)}") }.join(' ')
66
+ link_html = "<a #{attrs}>#{inner_text}</a>"
67
+
68
+ html.gsub!(/#{Regexp.escape(start_placeholder)}.*?#{Regexp.escape(end_placeholder)}/m, link_html)
69
+ end
70
+
71
+ html
72
+ end
73
+
74
+ # Protect ActionText attachments (images with captions) by replacing with placeholders
75
+ # @param html [String] The HTML content to process
76
+ # @return [Array<String, Hash>] Protected HTML and attachment map
77
+ def self.protect_attachments(html)
78
+ doc = Nokogiri::HTML.fragment(html)
79
+ attachment_map = {}
80
+ counter = 0
81
+
82
+ doc.css('action-text-attachment').each do |attachment|
83
+ counter += 1
84
+ start_placeholder = "XATTACHSTARTX#{counter}X"
85
+ end_placeholder = "XATTACHENDX#{counter}X"
86
+
87
+ # Extract all attributes
88
+ attributes = attachment.attributes.transform_values(&:value)
89
+
90
+ # Get inner HTML
91
+ inner_html = attachment.inner_html
92
+
93
+ # Check if there's translatable content (figcaption, img alt)
94
+ inner_doc = Nokogiri::HTML.fragment(inner_html)
95
+ figcaption = inner_doc.at_css('figcaption')
96
+ img = inner_doc.at_css('img')
97
+
98
+ caption_text = figcaption&.text&.strip
99
+ has_translatable_content = caption_text&.length.to_i.positive?
100
+ translatable_alt = img&.[]('alt')&.strip
101
+
102
+ # Keep alt text as-is in inner_html for translation
103
+ # We'll extract the translated alt text after translation
104
+
105
+ # Store attachment data
106
+ attachment_map[start_placeholder] = {
107
+ end_placeholder: end_placeholder,
108
+ tag_name: 'action-text-attachment',
109
+ attributes: attributes,
110
+ inner_html: inner_html,
111
+ translatable_alt: translatable_alt,
112
+ has_translatable_content: has_translatable_content
113
+ }
114
+
115
+ # Replace attachment with placeholder + inner content (so captions get translated)
116
+ attachment.replace("#{start_placeholder}#{inner_html}#{end_placeholder}")
117
+ end
118
+
119
+ [doc.to_html, attachment_map]
120
+ end
121
+
122
+ # Restore ActionText attachments after translation
123
+ # @param html [String] The translated HTML with placeholders
124
+ # @param attachment_map [Hash] The attachment map from protect_attachments
125
+ # @return [String] HTML with restored attachments
126
+ def self.restore_attachments(html, attachment_map)
127
+ html = html.dup
128
+
129
+ attachment_map.each do |start_placeholder, data|
130
+ end_placeholder = data[:end_placeholder]
131
+
132
+ # Extract the translated inner HTML
133
+ inner_html = html[/#{Regexp.escape(start_placeholder)}(.*?)#{Regexp.escape(end_placeholder)}/m, 1].to_s
134
+
135
+ # Alt text was kept in the HTML and should have been translated naturally
136
+ # No additional processing needed for alt text
137
+
138
+ # Rebuild action-text-attachment tag
139
+ attrs = data[:attributes].map { |k, v| %(#{k}="#{CGI.escapeHTML(v)}") }.join(' ')
140
+ attachment_html = "<#{data[:tag_name]} #{attrs}>#{inner_html}</#{data[:tag_name]}>"
141
+
142
+ # Replace placeholder with full attachment
143
+ html.gsub!(/#{Regexp.escape(start_placeholder)}.*?#{Regexp.escape(end_placeholder)}/m, attachment_html)
144
+ end
145
+
146
+ html
147
+ end
148
+
149
+ # Sanitize HTML by removing potentially problematic elements
150
+ # @param html [String] The HTML content
151
+ # @return [String] Sanitized HTML
152
+ def self.sanitize_for_translation(html)
153
+ # Remove script and style tags
154
+ doc = Nokogiri::HTML.fragment(html)
155
+ doc.css('script, style').remove
156
+ doc.to_html
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiontextTranslate
4
+ # Main translator class that delegates to provider-specific translators
5
+ class Translator
6
+ attr_reader :provider
7
+
8
+ def initialize(provider = nil)
9
+ @provider = create_provider(provider || ActiontextTranslate.configuration.provider)
10
+ end
11
+
12
+ # Translate text
13
+ # @param text [String] Text to translate
14
+ # @param from [String] Source language code
15
+ # @param to [String] Target language code
16
+ # @param html_mode [Boolean] Whether to preserve HTML tags
17
+ # @return [String] Translated text
18
+ def translate(text, from:, to:, html_mode: false)
19
+ provider.translate(text, from: from, to: to, html_mode: html_mode)
20
+ end
21
+
22
+ # Translate HTML content with full link and tag preservation
23
+ # @param html [String] HTML content to translate
24
+ # @param from [String] Source language code
25
+ # @param to [String] Target language code
26
+ # @return [String] Translated HTML
27
+ def translate_html(html, from:, to:)
28
+ provider.translate_html(html, from: from, to: to)
29
+ end
30
+
31
+ # Translate ActionText rich text content
32
+ # @param rich_text [ActionText::RichText, String] Rich text to translate
33
+ # @param from [String] Source language code
34
+ # @param to [String] Target language code
35
+ # @return [String] Translated HTML suitable for ActionText
36
+ def translate_action_text(rich_text, from:, to:)
37
+ html = extract_html_from_rich_text(rich_text)
38
+ translate_html(html, from: from, to: to)
39
+ end
40
+
41
+ private
42
+
43
+ def create_provider(provider_name)
44
+ case provider_name.to_sym
45
+ when :chatgpt, :openai
46
+ require_relative 'translators/chatgpt'
47
+ Translators::Chatgpt.new
48
+ else
49
+ raise ArgumentError, "Unknown translation provider: #{provider_name}"
50
+ end
51
+ end
52
+
53
+ def extract_html_from_rich_text(rich_text)
54
+ if rich_text.respond_to?(:body)
55
+ rich_text.body.to_s
56
+ else
57
+ rich_text.to_s
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiontextTranslate
4
+ module Translators
5
+ # Base translator interface
6
+ class Base
7
+ attr_reader :config
8
+
9
+ def initialize(config = nil)
10
+ @config = config || ActiontextTranslate.configuration
11
+ end
12
+
13
+ # Translate text from source language to target language
14
+ # @param text [String] The text to translate
15
+ # @param from [String] Source language code (e.g., 'uk', 'en')
16
+ # @param to [String] Target language code (e.g., 'en', 'uk')
17
+ # @param html_mode [Boolean] Whether to preserve HTML tags
18
+ # @return [String] Translated text
19
+ def translate(text, from:, to:, html_mode: false)
20
+ raise NotImplementedError, "#{self.class} must implement #translate"
21
+ end
22
+
23
+ # Translate with HTML preservation using the HTML processor
24
+ # @param html [String] The HTML content to translate
25
+ # @param from [String] Source language code
26
+ # @param to [String] Target language code
27
+ # @return [String] Translated HTML with preserved structure
28
+ def translate_html(html, from:, to:)
29
+ return html if blank?(html)
30
+
31
+ # Protect ActionText attachments first (images with captions)
32
+ protected_html, attachment_map = HtmlProcessor.protect_attachments(html)
33
+
34
+ # Protect links (in the exposed inner HTML of attachments)
35
+ protected_html, link_map = HtmlProcessor.protect_links(protected_html)
36
+
37
+ # Translate
38
+ translated_html = translate(protected_html, from: from, to: to, html_mode: true)
39
+
40
+ # Restore in reverse order: links first, then attachments
41
+ translated_html = HtmlProcessor.restore_links(translated_html, link_map)
42
+ HtmlProcessor.restore_attachments(translated_html, attachment_map)
43
+ end
44
+
45
+ # Check if value is blank (nil, empty, or whitespace)
46
+ # @param value [Object] Value to check
47
+ # @return [Boolean]
48
+ def blank?(value)
49
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
50
+ end
51
+
52
+ protected
53
+
54
+ # Normalize language code to a standard format
55
+ # @param code [String, Symbol] Language code
56
+ # @return [String] Normalized language code
57
+ def normalize_language_code(code)
58
+ code.to_s.downcase.split(/[-_]/).first
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module ActiontextTranslate
8
+ module Translators
9
+ # ChatGPT (OpenAI) translator implementation
10
+ class Chatgpt < Base
11
+ API_ENDPOINT = 'https://api.openai.com/v1/chat/completions'
12
+
13
+ # Language code to name mapping for clearer ChatGPT prompts
14
+ # Only includes languages where the code doesn't obviously map to the name
15
+ # For unlisted languages, the code is capitalized (e.g., 'fr' -> 'Fr')
16
+ LANGUAGE_NAMES = {
17
+ 'en' => 'English',
18
+ 'uk' => 'Ukrainian',
19
+ 'de' => 'German',
20
+ 'fr' => 'French',
21
+ 'es' => 'Spanish',
22
+ 'it' => 'Italian',
23
+ 'pl' => 'Polish',
24
+ 'pt' => 'Portuguese'
25
+ }.freeze
26
+
27
+ def initialize(config = nil)
28
+ super
29
+ @api_key = config&.api_key || ActiontextTranslate.configuration.api_key
30
+ raise ArgumentError, 'API key is required for ChatGPT translator' if @api_key.nil?
31
+ end
32
+
33
+ # Translate text using ChatGPT
34
+ def translate(text, from:, to:, html_mode: false)
35
+ return text if blank?(text)
36
+
37
+ source_lang = language_name(from)
38
+ target_lang = language_name(to)
39
+
40
+ prompt = build_prompt(text, source_lang, target_lang, html_mode)
41
+
42
+ response = call_openai_api(prompt)
43
+ extract_translation(response)
44
+ end
45
+
46
+ private
47
+
48
+ def build_prompt(text, source_lang, target_lang, html_mode)
49
+ if html_mode
50
+ <<~PROMPT
51
+ Translate the following HTML content from #{source_lang} to #{target_lang}.
52
+
53
+ CRITICAL RULES:
54
+ 1. Preserve ALL HTML tags, attributes, and structure EXACTLY as they are
55
+ 2. Only translate the text content between tags
56
+ 3. Do NOT translate URLs, link hrefs, or placeholder text like "XLINKSTARTX1X"
57
+ 4. Do NOT add or remove any HTML tags
58
+ 5. Maintain proper spacing and formatting
59
+ 6. Return ONLY the translated HTML, no explanations
60
+
61
+ Content to translate:
62
+ #{text}
63
+ PROMPT
64
+ else
65
+ <<~PROMPT
66
+ Translate the following text from #{source_lang} to #{target_lang}.
67
+ Return ONLY the translation, no explanations or additional text.
68
+
69
+ Text to translate:
70
+ #{text}
71
+ PROMPT
72
+ end
73
+ end
74
+
75
+ def call_openai_api(prompt)
76
+ uri = URI(API_ENDPOINT)
77
+ http = Net::HTTP.new(uri.host, uri.port)
78
+ http.use_ssl = true
79
+ http.read_timeout = config.timeout
80
+
81
+ request = Net::HTTP::Post.new(uri.path)
82
+ request['Authorization'] = "Bearer #{@api_key}"
83
+ request['Content-Type'] = 'application/json'
84
+ request.body = {
85
+ model: config.model,
86
+ messages: [
87
+ {
88
+ role: 'system',
89
+ content: 'You are a professional translator. Translate accurately and naturally while ' \
90
+ 'preserving any HTML structure.'
91
+ },
92
+ {
93
+ role: 'user',
94
+ content: prompt
95
+ }
96
+ ],
97
+ temperature: config.temperature
98
+ }.to_json
99
+
100
+ response = http.request(request)
101
+
102
+ unless response.is_a?(Net::HTTPSuccess)
103
+ raise TranslationError, "OpenAI API error: #{response.code} - #{response.body}"
104
+ end
105
+
106
+ JSON.parse(response.body)
107
+ rescue JSON::ParserError => e
108
+ raise TranslationError, "Failed to parse OpenAI response: #{e.message}"
109
+ rescue StandardError => e
110
+ raise TranslationError, "OpenAI API request failed: #{e.message}"
111
+ end
112
+
113
+ def extract_translation(response)
114
+ response.dig('choices', 0, 'message', 'content')&.strip || ''
115
+ rescue StandardError => e
116
+ raise TranslationError, "Failed to extract translation from response: #{e.message}"
117
+ end
118
+
119
+ def language_name(code)
120
+ normalized = normalize_language_code(code)
121
+ LANGUAGE_NAMES[normalized] || normalized.capitalize
122
+ end
123
+ end
124
+
125
+ # Custom error class for translation errors
126
+ class TranslationError < StandardError; end
127
+ end
128
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiontextTranslate
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'actiontext_translate/version'
4
+ require_relative 'actiontext_translate/configuration'
5
+ require_relative 'actiontext_translate/html_processor'
6
+ require_relative 'actiontext_translate/translators/base'
7
+ require_relative 'actiontext_translate/translator'
8
+
9
+ module ActiontextTranslate
10
+ class Error < StandardError; end
11
+
12
+ # Quick access method for translation
13
+ # @param text [String] Text to translate
14
+ # @param from [String] Source language code
15
+ # @param to [String] Target language code
16
+ # @param html_mode [Boolean] Whether to preserve HTML
17
+ # @return [String] Translated text
18
+ def self.translate(text, from:, to:, html_mode: false)
19
+ translator = Translator.new
20
+ translator.translate(text, from: from, to: to, html_mode: html_mode)
21
+ end
22
+
23
+ # Quick access method for HTML translation
24
+ # @param html [String] HTML to translate
25
+ # @param from [String] Source language code
26
+ # @param to [String] Target language code
27
+ # @return [String] Translated HTML
28
+ def self.translate_html(html, from:, to:)
29
+ translator = Translator.new
30
+ translator.translate_html(html, from: from, to: to)
31
+ end
32
+
33
+ # Quick access method for ActionText translation
34
+ # @param rich_text [ActionText::RichText, String] Rich text to translate
35
+ # @param from [String] Source language code
36
+ # @param to [String] Target language code
37
+ # @return [String] Translated HTML
38
+ def self.translate_action_text(rich_text, from:, to:)
39
+ translator = Translator.new
40
+ translator.translate_action_text(rich_text, from: from, to: to)
41
+ end
42
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: actiontext_translate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mykbren
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-12-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.13'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.21'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.21'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.22'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.22'
83
+ - !ruby/object:Gem::Dependency
84
+ name: webmock
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.18'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.18'
97
+ description: A Ruby gem for translating ActionText rich text content using ChatGPT
98
+ while perfectly preserving HTML structure, tags, and links. Extensible architecture
99
+ allows adding additional translation providers.
100
+ email:
101
+ - myk.bren@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - CHANGELOG.md
107
+ - LICENSE.txt
108
+ - README.md
109
+ - lib/actiontext_translate.rb
110
+ - lib/actiontext_translate/configuration.rb
111
+ - lib/actiontext_translate/html_processor.rb
112
+ - lib/actiontext_translate/translator.rb
113
+ - lib/actiontext_translate/translators/base.rb
114
+ - lib/actiontext_translate/translators/chatgpt.rb
115
+ - lib/actiontext_translate/version.rb
116
+ homepage: https://github.com/mykbren/actiontext_translate
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ homepage_uri: https://github.com/mykbren/actiontext_translate
121
+ source_code_uri: https://github.com/mykbren/actiontext_translate
122
+ changelog_uri: https://github.com/mykbren/actiontext_translate/blob/main/CHANGELOG.md
123
+ rubygems_mfa_required: 'true'
124
+ post_install_message:
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: 2.7.0
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubygems_version: 3.4.6
140
+ signing_key:
141
+ specification_version: 4
142
+ summary: Translate ActionText content while preserving HTML tags using ChatGPT
143
+ test_files: []