lingodotdev 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 +7 -0
- data/.env.example +1 -0
- data/.github/workflows/publish.yml +63 -0
- data/README.md +485 -0
- data/Rakefile +4 -0
- data/lib/lingodotdev/version.rb +8 -0
- data/lib/lingodotdev.rb +938 -0
- data/sig/sdk/ruby.rbs +6 -0
- data/spec/lingo_dot_dev/configuration_spec.rb +146 -0
- data/spec/lingo_dot_dev/engine_spec.rb +688 -0
- data/spec/spec_helper.rb +36 -0
- metadata +112 -0
data/lib/lingodotdev.rb
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lingodotdev/version"
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'openssl'
|
|
9
|
+
require 'nokogiri'
|
|
10
|
+
|
|
11
|
+
# Configure SSL context globally at module load time to work around CRL verification issues
|
|
12
|
+
# This is a production-safe workaround for OpenSSL 3.6+ that disables CRL checking
|
|
13
|
+
# while maintaining certificate validation. See: https://github.com/ruby/openssl/issues/949
|
|
14
|
+
#
|
|
15
|
+
# The issue occurs in environments where CRL (Certificate Revocation List) distribution
|
|
16
|
+
# points are unreachable, causing SSL handshakes to fail with "certificate verify failed
|
|
17
|
+
# (unable to get certificate CRL)". We disable CRL checking via verify_callback while
|
|
18
|
+
# keeping peer certificate validation enabled (VERIFY_PEER).
|
|
19
|
+
#
|
|
20
|
+
# This is safe because:
|
|
21
|
+
# 1. VERIFY_PEER is still enabled (validates certificate chain)
|
|
22
|
+
# 2. Certificate expiration is still checked
|
|
23
|
+
# 3. Certificate hostname matching is still performed
|
|
24
|
+
# 4. Only CRL revocation checking is disabled (which fails in many environments without CRL access)
|
|
25
|
+
begin
|
|
26
|
+
OpenSSL::SSL::SSLContext.class_eval do
|
|
27
|
+
unless const_defined?(:LingoDotDev_SSL_INITIALIZED)
|
|
28
|
+
original_new = method(:new)
|
|
29
|
+
|
|
30
|
+
define_singleton_method(:new) do |*args, &block|
|
|
31
|
+
ctx = original_new.call(*args, &block)
|
|
32
|
+
# Set verify_callback to skip CRL checks while keeping other validations
|
|
33
|
+
ctx.verify_callback = proc do |is_ok, x509_store_ctx|
|
|
34
|
+
# Return true to continue (skip CRL errors), but let other errors bubble up
|
|
35
|
+
# When is_ok is true, the certificate is valid (no CRL needed)
|
|
36
|
+
# When is_ok is false, we could check the error code, but we accept it anyway
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
ctx
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
const_set(:LingoDotDev_SSL_INITIALIZED, true)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
# If SSL context manipulation fails, continue without it
|
|
47
|
+
# This ensures backwards compatibility if OpenSSL behavior changes
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Ruby SDK for Lingo.dev localization and translation API.
|
|
51
|
+
#
|
|
52
|
+
# This module provides a simple and powerful interface for localizing content
|
|
53
|
+
# in Ruby applications. It supports text, object (Hash), and chat message
|
|
54
|
+
# localization with batch operations, progress tracking, and concurrent processing.
|
|
55
|
+
#
|
|
56
|
+
# @example Basic usage
|
|
57
|
+
# engine = LingoDotDev::Engine.new(api_key: 'your-api-key')
|
|
58
|
+
# result = engine.localize_text('Hello world', target_locale: 'es')
|
|
59
|
+
# puts result # => "Hola mundo"
|
|
60
|
+
#
|
|
61
|
+
# @see Engine
|
|
62
|
+
module LingoDotDev
|
|
63
|
+
# Base error class for all SDK errors.
|
|
64
|
+
class Error < StandardError; end
|
|
65
|
+
|
|
66
|
+
# Error raised for invalid arguments.
|
|
67
|
+
class ArgumentError < Error; end
|
|
68
|
+
|
|
69
|
+
# Error raised for API request failures.
|
|
70
|
+
class APIError < Error; end
|
|
71
|
+
|
|
72
|
+
# Error raised for server-side errors (5xx responses).
|
|
73
|
+
class ServerError < APIError; end
|
|
74
|
+
|
|
75
|
+
# Error raised for authentication failures.
|
|
76
|
+
class AuthenticationError < APIError; end
|
|
77
|
+
|
|
78
|
+
# Error raised for validation failures (invalid input or configuration).
|
|
79
|
+
class ValidationError < ArgumentError; end
|
|
80
|
+
|
|
81
|
+
# Configuration for the Lingo.dev Engine.
|
|
82
|
+
#
|
|
83
|
+
# Holds API credentials and batch processing settings.
|
|
84
|
+
class Configuration
|
|
85
|
+
# @return [String] the Lingo.dev API key
|
|
86
|
+
attr_accessor :api_key
|
|
87
|
+
|
|
88
|
+
# @return [String] the API endpoint URL
|
|
89
|
+
attr_accessor :api_url
|
|
90
|
+
|
|
91
|
+
# @return [Integer] maximum number of items per batch (1-250)
|
|
92
|
+
attr_accessor :batch_size
|
|
93
|
+
|
|
94
|
+
# @return [Integer] target word count per batch item (1-2500)
|
|
95
|
+
attr_accessor :ideal_batch_item_size
|
|
96
|
+
|
|
97
|
+
# Creates a new Configuration instance.
|
|
98
|
+
#
|
|
99
|
+
# @param api_key [String] your Lingo.dev API key (required)
|
|
100
|
+
# @param api_url [String] the API endpoint URL (default: 'https://engine.lingo.dev')
|
|
101
|
+
# @param batch_size [Integer] maximum items per batch, 1-250 (default: 25)
|
|
102
|
+
# @param ideal_batch_item_size [Integer] target word count per batch item, 1-2500 (default: 250)
|
|
103
|
+
#
|
|
104
|
+
# @raise [ValidationError] if any parameter is invalid
|
|
105
|
+
def initialize(api_key:, api_url: 'https://engine.lingo.dev', batch_size: 25, ideal_batch_item_size: 250)
|
|
106
|
+
@api_key = api_key
|
|
107
|
+
@api_url = api_url
|
|
108
|
+
@batch_size = batch_size
|
|
109
|
+
@ideal_batch_item_size = ideal_batch_item_size
|
|
110
|
+
validate!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def validate!
|
|
116
|
+
raise ValidationError, 'API key is required' if api_key.nil? || api_key.empty?
|
|
117
|
+
raise ValidationError, 'API URL must be a valid HTTP/HTTPS URL' unless api_url =~ /\Ahttps?:\/\/.+/
|
|
118
|
+
raise ValidationError, 'Batch size must be between 1 and 250' unless batch_size.is_a?(Integer) && batch_size.between?(1, 250)
|
|
119
|
+
raise ValidationError, 'Ideal batch item size must be between 1 and 2500' unless ideal_batch_item_size.is_a?(Integer) && ideal_batch_item_size.between?(1, 2500)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Main engine for localizing content via the Lingo.dev API.
|
|
124
|
+
#
|
|
125
|
+
# The Engine class provides methods for text, object, and chat localization
|
|
126
|
+
# with support for batch operations, progress tracking, and concurrent processing.
|
|
127
|
+
#
|
|
128
|
+
# @example Basic text localization
|
|
129
|
+
# engine = LingoDotDev::Engine.new(api_key: 'your-api-key')
|
|
130
|
+
# result = engine.localize_text('Hello', target_locale: 'es')
|
|
131
|
+
# # => "Hola"
|
|
132
|
+
#
|
|
133
|
+
# @example Object localization
|
|
134
|
+
# data = { greeting: 'Hello', farewell: 'Goodbye' }
|
|
135
|
+
# result = engine.localize_object(data, target_locale: 'fr')
|
|
136
|
+
# # => { greeting: "Bonjour", farewell: "Au revoir" }
|
|
137
|
+
#
|
|
138
|
+
# @example Batch localization
|
|
139
|
+
# results = engine.batch_localize_text('Hello', target_locales: ['es', 'fr', 'de'])
|
|
140
|
+
# # => ["Hola", "Bonjour", "Hallo"]
|
|
141
|
+
class Engine
|
|
142
|
+
# @return [Configuration] the engine's configuration
|
|
143
|
+
attr_reader :config
|
|
144
|
+
|
|
145
|
+
# Creates a new Engine instance.
|
|
146
|
+
#
|
|
147
|
+
# @param api_key [String] your Lingo.dev API key (required)
|
|
148
|
+
# @param api_url [String] the API endpoint URL (default: 'https://engine.lingo.dev')
|
|
149
|
+
# @param batch_size [Integer] maximum items per batch, 1-250 (default: 25)
|
|
150
|
+
# @param ideal_batch_item_size [Integer] target word count per batch item, 1-2500 (default: 250)
|
|
151
|
+
#
|
|
152
|
+
# @yield [config] optional block for additional configuration
|
|
153
|
+
# @yieldparam config [Configuration] the configuration instance
|
|
154
|
+
#
|
|
155
|
+
# @raise [ValidationError] if any parameter is invalid
|
|
156
|
+
#
|
|
157
|
+
# @example Basic initialization
|
|
158
|
+
# engine = LingoDotDev::Engine.new(api_key: 'your-api-key')
|
|
159
|
+
#
|
|
160
|
+
# @example With custom configuration
|
|
161
|
+
# engine = LingoDotDev::Engine.new(api_key: 'your-api-key', batch_size: 50)
|
|
162
|
+
#
|
|
163
|
+
# @example With block configuration
|
|
164
|
+
# engine = LingoDotDev::Engine.new(api_key: 'your-api-key') do |config|
|
|
165
|
+
# config.batch_size = 50
|
|
166
|
+
# config.ideal_batch_item_size = 500
|
|
167
|
+
# end
|
|
168
|
+
def initialize(api_key:, api_url: 'https://engine.lingo.dev', batch_size: 25, ideal_batch_item_size: 250)
|
|
169
|
+
@config = Configuration.new(
|
|
170
|
+
api_key: api_key,
|
|
171
|
+
api_url: api_url,
|
|
172
|
+
batch_size: batch_size,
|
|
173
|
+
ideal_batch_item_size: ideal_batch_item_size
|
|
174
|
+
)
|
|
175
|
+
yield @config if block_given?
|
|
176
|
+
@config.send(:validate!)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Localizes a string to the target locale.
|
|
180
|
+
#
|
|
181
|
+
# @param text [String] the text to localize
|
|
182
|
+
# @param target_locale [String] the target locale code (e.g., 'es', 'fr', 'ja')
|
|
183
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
184
|
+
# @param fast [Boolean, nil] enable fast mode for quicker results (optional)
|
|
185
|
+
# @param reference [Hash, nil] additional context for translation (optional)
|
|
186
|
+
# @param on_progress [Proc, nil] callback for progress updates (optional)
|
|
187
|
+
# @param concurrent [Boolean] enable concurrent processing (default: false)
|
|
188
|
+
#
|
|
189
|
+
# @yield [progress] optional block for progress tracking
|
|
190
|
+
# @yieldparam progress [Integer] completion percentage (0-100)
|
|
191
|
+
#
|
|
192
|
+
# @return [String] the localized text
|
|
193
|
+
#
|
|
194
|
+
# @raise [ValidationError] if target_locale is missing or text is nil
|
|
195
|
+
# @raise [APIError] if the API request fails
|
|
196
|
+
#
|
|
197
|
+
# @example Basic usage
|
|
198
|
+
# result = engine.localize_text('Hello', target_locale: 'es')
|
|
199
|
+
# # => "Hola"
|
|
200
|
+
#
|
|
201
|
+
# @example With source locale
|
|
202
|
+
# result = engine.localize_text('Hello', target_locale: 'fr', source_locale: 'en')
|
|
203
|
+
# # => "Bonjour"
|
|
204
|
+
#
|
|
205
|
+
# @example With progress tracking
|
|
206
|
+
# result = engine.localize_text('Hello', target_locale: 'de') do |progress|
|
|
207
|
+
# puts "Progress: #{progress}%"
|
|
208
|
+
# end
|
|
209
|
+
def localize_text(text, target_locale:, source_locale: nil, fast: nil, reference: nil, on_progress: nil, concurrent: false, &block)
|
|
210
|
+
raise ValidationError, 'Target locale is required' if target_locale.nil? || target_locale.empty?
|
|
211
|
+
raise ValidationError, 'Text cannot be nil' if text.nil?
|
|
212
|
+
|
|
213
|
+
callback = block || on_progress
|
|
214
|
+
|
|
215
|
+
response = localize_raw(
|
|
216
|
+
{ text: text },
|
|
217
|
+
target_locale: target_locale,
|
|
218
|
+
source_locale: source_locale,
|
|
219
|
+
fast: fast,
|
|
220
|
+
reference: reference,
|
|
221
|
+
concurrent: concurrent
|
|
222
|
+
) do |progress, chunk, processed_chunk|
|
|
223
|
+
callback&.call(progress)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
raise APIError, 'API did not return localized text' unless response.key?('text')
|
|
227
|
+
response['text']
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Localizes all string values in a Hash.
|
|
231
|
+
#
|
|
232
|
+
# @param obj [Hash] the Hash object to localize
|
|
233
|
+
# @param target_locale [String] the target locale code (e.g., 'es', 'fr', 'ja')
|
|
234
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
235
|
+
# @param fast [Boolean, nil] enable fast mode for quicker results (optional)
|
|
236
|
+
# @param reference [Hash, nil] additional context for translation (optional)
|
|
237
|
+
# @param on_progress [Proc, nil] callback for progress updates (optional)
|
|
238
|
+
# @param concurrent [Boolean] enable concurrent processing (default: false)
|
|
239
|
+
#
|
|
240
|
+
# @yield [progress] optional block for progress tracking
|
|
241
|
+
# @yieldparam progress [Integer] completion percentage (0-100)
|
|
242
|
+
#
|
|
243
|
+
# @return [Hash] a new Hash with localized string values
|
|
244
|
+
#
|
|
245
|
+
# @raise [ValidationError] if target_locale is missing, obj is nil, or obj is not a Hash
|
|
246
|
+
# @raise [APIError] if the API request fails
|
|
247
|
+
#
|
|
248
|
+
# @example Basic usage
|
|
249
|
+
# data = { greeting: 'Hello', farewell: 'Goodbye' }
|
|
250
|
+
# result = engine.localize_object(data, target_locale: 'es')
|
|
251
|
+
# # => { greeting: "Hola", farewell: "Adiós" }
|
|
252
|
+
def localize_object(obj, target_locale:, source_locale: nil, fast: nil, reference: nil, on_progress: nil, concurrent: false, &block)
|
|
253
|
+
raise ValidationError, 'Target locale is required' if target_locale.nil? || target_locale.empty?
|
|
254
|
+
raise ValidationError, 'Object cannot be nil' if obj.nil?
|
|
255
|
+
raise ValidationError, 'Object must be a Hash' unless obj.is_a?(Hash)
|
|
256
|
+
|
|
257
|
+
callback = block || on_progress
|
|
258
|
+
|
|
259
|
+
response = localize_raw(
|
|
260
|
+
obj,
|
|
261
|
+
target_locale: target_locale,
|
|
262
|
+
source_locale: source_locale,
|
|
263
|
+
fast: fast,
|
|
264
|
+
reference: reference,
|
|
265
|
+
concurrent: concurrent,
|
|
266
|
+
&callback
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
raise APIError, 'API returned empty localization response' if response.empty?
|
|
270
|
+
response
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Localizes chat messages while preserving structure.
|
|
274
|
+
#
|
|
275
|
+
# Each message must have :name and :text keys. The structure of messages
|
|
276
|
+
# is preserved while all text content is localized.
|
|
277
|
+
#
|
|
278
|
+
# @param chat [Array<Hash>] array of chat messages, each with :name and :text keys
|
|
279
|
+
# @param target_locale [String] the target locale code (e.g., 'es', 'fr', 'ja')
|
|
280
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
281
|
+
# @param fast [Boolean, nil] enable fast mode for quicker results (optional)
|
|
282
|
+
# @param reference [Hash, nil] additional context for translation (optional)
|
|
283
|
+
# @param on_progress [Proc, nil] callback for progress updates (optional)
|
|
284
|
+
# @param concurrent [Boolean] enable concurrent processing (default: false)
|
|
285
|
+
#
|
|
286
|
+
# @yield [progress] optional block for progress tracking
|
|
287
|
+
# @yieldparam progress [Integer] completion percentage (0-100)
|
|
288
|
+
#
|
|
289
|
+
# @return [Array<Hash>] array of localized chat messages
|
|
290
|
+
#
|
|
291
|
+
# @raise [ValidationError] if target_locale is missing, chat is nil, not an Array, or messages are invalid
|
|
292
|
+
# @raise [APIError] if the API request fails
|
|
293
|
+
#
|
|
294
|
+
# @example Basic usage
|
|
295
|
+
# chat = [
|
|
296
|
+
# { name: 'user', text: 'Hello!' },
|
|
297
|
+
# { name: 'assistant', text: 'Hi there!' }
|
|
298
|
+
# ]
|
|
299
|
+
# result = engine.localize_chat(chat, target_locale: 'ja')
|
|
300
|
+
# # => [
|
|
301
|
+
# # { name: 'user', text: 'こんにちは!' },
|
|
302
|
+
# # { name: 'assistant', text: 'こんにちは!' }
|
|
303
|
+
# # ]
|
|
304
|
+
def localize_chat(chat, target_locale:, source_locale: nil, fast: nil, reference: nil, on_progress: nil, concurrent: false, &block)
|
|
305
|
+
raise ValidationError, 'Target locale is required' if target_locale.nil? || target_locale.empty?
|
|
306
|
+
raise ValidationError, 'Chat cannot be nil' if chat.nil?
|
|
307
|
+
raise ValidationError, 'Chat must be an Array' unless chat.is_a?(Array)
|
|
308
|
+
|
|
309
|
+
chat.each do |message|
|
|
310
|
+
unless message.is_a?(Hash) && message[:name] && message[:text]
|
|
311
|
+
raise ValidationError, 'Each chat message must have :name and :text keys'
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
callback = block || on_progress
|
|
316
|
+
|
|
317
|
+
response = localize_raw(
|
|
318
|
+
{ chat: chat },
|
|
319
|
+
target_locale: target_locale,
|
|
320
|
+
source_locale: source_locale,
|
|
321
|
+
fast: fast,
|
|
322
|
+
reference: reference,
|
|
323
|
+
concurrent: concurrent
|
|
324
|
+
) do |progress, chunk, processed_chunk|
|
|
325
|
+
callback&.call(progress)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
raise APIError, 'API did not return localized chat' unless response.key?('chat')
|
|
329
|
+
response['chat']
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Localizes an HTML document while preserving structure and formatting.
|
|
333
|
+
#
|
|
334
|
+
# Handles both text content and localizable attributes (alt, title, placeholder, meta content).
|
|
335
|
+
#
|
|
336
|
+
# @param html [String] the HTML document string to be localized
|
|
337
|
+
# @param target_locale [String] the target locale code (e.g., 'es', 'fr', 'ja')
|
|
338
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
339
|
+
# @param fast [Boolean, nil] enable fast mode for quicker results (optional)
|
|
340
|
+
# @param reference [Hash, nil] additional context for translation (optional)
|
|
341
|
+
# @param on_progress [Proc, nil] callback for progress updates (optional)
|
|
342
|
+
# @param concurrent [Boolean] enable concurrent processing (default: false)
|
|
343
|
+
#
|
|
344
|
+
# @yield [progress] optional block for progress tracking
|
|
345
|
+
# @yieldparam progress [Integer] completion percentage (0-100)
|
|
346
|
+
#
|
|
347
|
+
# @return [String] the localized HTML document as a string, with updated lang attribute
|
|
348
|
+
#
|
|
349
|
+
# @raise [ValidationError] if target_locale is missing or html is nil
|
|
350
|
+
# @raise [APIError] if the API request fails
|
|
351
|
+
#
|
|
352
|
+
# @example Basic usage
|
|
353
|
+
# html = '<html><head><title>Hello</title></head><body><p>World</p></body></html>'
|
|
354
|
+
# result = engine.localize_html(html, target_locale: 'es')
|
|
355
|
+
# # => "<html lang=\"es\">..."
|
|
356
|
+
def localize_html(html, target_locale:, source_locale: nil, fast: nil, reference: nil, on_progress: nil, concurrent: false, &block)
|
|
357
|
+
raise ValidationError, 'Target locale is required' if target_locale.nil? || target_locale.empty?
|
|
358
|
+
raise ValidationError, 'HTML cannot be nil' if html.nil?
|
|
359
|
+
|
|
360
|
+
callback = block || on_progress
|
|
361
|
+
|
|
362
|
+
doc = Nokogiri::HTML::Document.parse(html)
|
|
363
|
+
|
|
364
|
+
localizable_attributes = {
|
|
365
|
+
'meta' => ['content'],
|
|
366
|
+
'img' => ['alt'],
|
|
367
|
+
'input' => ['placeholder'],
|
|
368
|
+
'a' => ['title']
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
unlocalizable_tags = ['script', 'style']
|
|
372
|
+
|
|
373
|
+
extracted_content = {}
|
|
374
|
+
|
|
375
|
+
get_path = lambda do |node, attribute = nil|
|
|
376
|
+
indices = []
|
|
377
|
+
current = node
|
|
378
|
+
root_parent = nil
|
|
379
|
+
|
|
380
|
+
while current
|
|
381
|
+
parent = current.parent
|
|
382
|
+
break unless parent
|
|
383
|
+
|
|
384
|
+
if parent == doc.root
|
|
385
|
+
root_parent = current.name.downcase if current.element?
|
|
386
|
+
break
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
siblings = parent.children.select do |n|
|
|
390
|
+
(n.element? || (n.text? && n.text.strip != ''))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
index = siblings.index(current)
|
|
394
|
+
if index
|
|
395
|
+
indices.unshift(index)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
current = parent
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
base_path = root_parent ? "#{root_parent}/#{indices.join('/')}" : indices.join('/')
|
|
402
|
+
attribute ? "#{base_path}##{attribute}" : base_path
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
process_node = lambda do |node|
|
|
406
|
+
parent = node.parent
|
|
407
|
+
while parent && !parent.is_a?(Nokogiri::XML::Document)
|
|
408
|
+
if parent.element? && unlocalizable_tags.include?(parent.name.downcase)
|
|
409
|
+
return
|
|
410
|
+
end
|
|
411
|
+
parent = parent.parent
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
if node.text?
|
|
415
|
+
text = node.text.strip
|
|
416
|
+
if text != ''
|
|
417
|
+
extracted_content[get_path.call(node)] = text
|
|
418
|
+
end
|
|
419
|
+
elsif node.element?
|
|
420
|
+
element = node
|
|
421
|
+
tag_name = element.name.downcase
|
|
422
|
+
attributes = localizable_attributes[tag_name] || []
|
|
423
|
+
attributes.each do |attr|
|
|
424
|
+
value = element[attr]
|
|
425
|
+
if value && value.strip != ''
|
|
426
|
+
extracted_content[get_path.call(element, attr)] = value
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
element.children.each do |child|
|
|
431
|
+
process_node.call(child)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
head = doc.at_css('head')
|
|
437
|
+
if head
|
|
438
|
+
head.children.select do |n|
|
|
439
|
+
n.element? || (n.text? && n.text.strip != '')
|
|
440
|
+
end.each do |child|
|
|
441
|
+
process_node.call(child)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
body = doc.at_css('body')
|
|
446
|
+
if body
|
|
447
|
+
body.children.select do |n|
|
|
448
|
+
n.element? || (n.text? && n.text.strip != '')
|
|
449
|
+
end.each do |child|
|
|
450
|
+
process_node.call(child)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
localized_content = localize_raw(
|
|
455
|
+
extracted_content,
|
|
456
|
+
target_locale: target_locale,
|
|
457
|
+
source_locale: source_locale,
|
|
458
|
+
fast: fast,
|
|
459
|
+
reference: reference,
|
|
460
|
+
concurrent: concurrent
|
|
461
|
+
) do |progress, chunk, processed_chunk|
|
|
462
|
+
callback&.call(progress)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
doc.root['lang'] = target_locale if doc.root
|
|
466
|
+
|
|
467
|
+
localized_content.each do |path, value|
|
|
468
|
+
node_path, attribute = path.split('#')
|
|
469
|
+
parts = node_path.split('/')
|
|
470
|
+
root_tag = parts[0]
|
|
471
|
+
indices = parts[1..-1]
|
|
472
|
+
|
|
473
|
+
parent = root_tag == 'head' ? doc.at_css('head') : doc.at_css('body')
|
|
474
|
+
next unless parent
|
|
475
|
+
current = parent
|
|
476
|
+
|
|
477
|
+
indices.each do |index_str|
|
|
478
|
+
index = index_str.to_i
|
|
479
|
+
siblings = parent.children.select do |n|
|
|
480
|
+
(n.element? || (n.text? && n.text.strip != ''))
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
current = siblings[index]
|
|
484
|
+
break unless current
|
|
485
|
+
|
|
486
|
+
if current.element?
|
|
487
|
+
parent = current
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
if current
|
|
492
|
+
if attribute
|
|
493
|
+
if current.element?
|
|
494
|
+
current[attribute] = value
|
|
495
|
+
end
|
|
496
|
+
else
|
|
497
|
+
if current.text?
|
|
498
|
+
current.content = value
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
doc.to_html
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Localizes text to multiple target locales.
|
|
508
|
+
#
|
|
509
|
+
# @param text [String] the text to localize
|
|
510
|
+
# @param target_locales [Array<String>] array of target locale codes
|
|
511
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
512
|
+
# @param fast [Boolean, nil] enable fast mode for quicker results (optional)
|
|
513
|
+
# @param reference [Hash, nil] additional context for translation (optional)
|
|
514
|
+
# @param concurrent [Boolean] enable concurrent processing (default: false)
|
|
515
|
+
#
|
|
516
|
+
# @return [Array<String>] array of localized strings in the same order as target_locales
|
|
517
|
+
#
|
|
518
|
+
# @raise [ValidationError] if text is nil, target_locales is not an Array, or target_locales is empty
|
|
519
|
+
# @raise [APIError] if any API request fails
|
|
520
|
+
#
|
|
521
|
+
# @example Basic usage
|
|
522
|
+
# results = engine.batch_localize_text('Hello', target_locales: ['es', 'fr', 'de'])
|
|
523
|
+
# # => ["Hola", "Bonjour", "Hallo"]
|
|
524
|
+
#
|
|
525
|
+
# @example With concurrent processing
|
|
526
|
+
# results = engine.batch_localize_text('Hello', target_locales: ['es', 'fr', 'de', 'ja'], concurrent: true)
|
|
527
|
+
def batch_localize_text(text, target_locales:, source_locale: nil, fast: nil, reference: nil, concurrent: false)
|
|
528
|
+
raise ValidationError, 'Text cannot be nil' if text.nil?
|
|
529
|
+
raise ValidationError, 'Target locales must be an Array' unless target_locales.is_a?(Array)
|
|
530
|
+
raise ValidationError, 'Target locales cannot be empty' if target_locales.empty?
|
|
531
|
+
|
|
532
|
+
if concurrent
|
|
533
|
+
threads = target_locales.map do |target_locale|
|
|
534
|
+
Thread.new do
|
|
535
|
+
localize_text(
|
|
536
|
+
text,
|
|
537
|
+
target_locale: target_locale,
|
|
538
|
+
source_locale: source_locale,
|
|
539
|
+
fast: fast,
|
|
540
|
+
reference: reference
|
|
541
|
+
)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
threads.map(&:value)
|
|
545
|
+
else
|
|
546
|
+
target_locales.map do |target_locale|
|
|
547
|
+
localize_text(
|
|
548
|
+
text,
|
|
549
|
+
target_locale: target_locale,
|
|
550
|
+
source_locale: source_locale,
|
|
551
|
+
fast: fast,
|
|
552
|
+
reference: reference
|
|
553
|
+
)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Localizes multiple objects to the same target locale.
|
|
559
|
+
#
|
|
560
|
+
# @param objects [Array<Hash>] array of Hash objects to localize
|
|
561
|
+
# @param target_locale [String] the target locale code (e.g., 'es', 'fr', 'ja')
|
|
562
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
563
|
+
# @param fast [Boolean, nil] enable fast mode for quicker results (optional)
|
|
564
|
+
# @param reference [Hash, nil] additional context for translation (optional)
|
|
565
|
+
# @param concurrent [Boolean] enable concurrent processing (default: false)
|
|
566
|
+
#
|
|
567
|
+
# @return [Array<Hash>] array of localized Hash objects in the same order as input
|
|
568
|
+
#
|
|
569
|
+
# @raise [ValidationError] if objects is not an Array, objects is empty, target_locale is missing, or any object is not a Hash
|
|
570
|
+
# @raise [APIError] if any API request fails
|
|
571
|
+
#
|
|
572
|
+
# @example Basic usage
|
|
573
|
+
# objects = [
|
|
574
|
+
# { title: 'Welcome', body: 'Hello there' },
|
|
575
|
+
# { title: 'About', body: 'Learn more' }
|
|
576
|
+
# ]
|
|
577
|
+
# results = engine.batch_localize_objects(objects, target_locale: 'es')
|
|
578
|
+
# # => [
|
|
579
|
+
# # { title: "Bienvenido", body: "Hola" },
|
|
580
|
+
# # { title: "Acerca de", body: "Aprende más" }
|
|
581
|
+
# # ]
|
|
582
|
+
def batch_localize_objects(objects, target_locale:, source_locale: nil, fast: nil, reference: nil, concurrent: false)
|
|
583
|
+
raise ValidationError, 'Objects must be an Array' unless objects.is_a?(Array)
|
|
584
|
+
raise ValidationError, 'Objects cannot be empty' if objects.empty?
|
|
585
|
+
raise ValidationError, 'Target locale is required' if target_locale.nil? || target_locale.empty?
|
|
586
|
+
|
|
587
|
+
objects.each do |obj|
|
|
588
|
+
raise ValidationError, 'Each object must be a Hash' unless obj.is_a?(Hash)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if concurrent
|
|
592
|
+
threads = objects.map do |obj|
|
|
593
|
+
Thread.new do
|
|
594
|
+
localize_object(
|
|
595
|
+
obj,
|
|
596
|
+
target_locale: target_locale,
|
|
597
|
+
source_locale: source_locale,
|
|
598
|
+
fast: fast,
|
|
599
|
+
reference: reference,
|
|
600
|
+
concurrent: true
|
|
601
|
+
)
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
threads.map(&:value)
|
|
605
|
+
else
|
|
606
|
+
objects.map do |obj|
|
|
607
|
+
localize_object(
|
|
608
|
+
obj,
|
|
609
|
+
target_locale: target_locale,
|
|
610
|
+
source_locale: source_locale,
|
|
611
|
+
fast: fast,
|
|
612
|
+
reference: reference,
|
|
613
|
+
concurrent: concurrent
|
|
614
|
+
)
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Detects the locale of the given text.
|
|
620
|
+
#
|
|
621
|
+
# @param text [String] the text to analyze
|
|
622
|
+
#
|
|
623
|
+
# @return [String] the detected locale code (e.g., 'en', 'es', 'ja')
|
|
624
|
+
#
|
|
625
|
+
# @raise [ValidationError] if text is nil or empty
|
|
626
|
+
# @raise [APIError] if the API request fails
|
|
627
|
+
#
|
|
628
|
+
# @example Basic usage
|
|
629
|
+
# locale = engine.recognize_locale('Bonjour le monde')
|
|
630
|
+
# # => "fr"
|
|
631
|
+
#
|
|
632
|
+
# @example Japanese text
|
|
633
|
+
# locale = engine.recognize_locale('こんにちは世界')
|
|
634
|
+
# # => "ja"
|
|
635
|
+
def recognize_locale(text)
|
|
636
|
+
raise ValidationError, 'Text cannot be empty' if text.nil? || text.strip.empty?
|
|
637
|
+
|
|
638
|
+
begin
|
|
639
|
+
response = make_request(
|
|
640
|
+
"#{config.api_url}/recognize",
|
|
641
|
+
json: { text: text }
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
handle_response(response)
|
|
645
|
+
data = JSON.parse(response.body, symbolize_names: true)
|
|
646
|
+
data[:locale] || ''
|
|
647
|
+
rescue StandardError => e
|
|
648
|
+
raise APIError, "Request failed: #{e.message}"
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Returns information about the authenticated user.
|
|
653
|
+
#
|
|
654
|
+
# @return [Hash, nil] a Hash with :email and :id keys if authenticated, nil otherwise
|
|
655
|
+
#
|
|
656
|
+
# @example Basic usage
|
|
657
|
+
# user = engine.whoami
|
|
658
|
+
# # => { email: "user@example.com", id: "user-id" }
|
|
659
|
+
def whoami
|
|
660
|
+
begin
|
|
661
|
+
response = make_request("#{config.api_url}/whoami")
|
|
662
|
+
|
|
663
|
+
status_code = response.code.to_i
|
|
664
|
+
return nil unless status_code >= 200 && status_code < 300
|
|
665
|
+
|
|
666
|
+
data = JSON.parse(response.body, symbolize_names: true)
|
|
667
|
+
return nil unless data[:email]
|
|
668
|
+
|
|
669
|
+
{ email: data[:email], id: data[:id] }
|
|
670
|
+
rescue StandardError => e
|
|
671
|
+
raise APIError, "Request failed: #{e.message}" if e.message.include?('Server error')
|
|
672
|
+
nil
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# One-off translation without managing engine lifecycle.
|
|
677
|
+
#
|
|
678
|
+
# Creates a temporary engine instance, performs the translation, and returns the result.
|
|
679
|
+
# Suitable for single translations where engine configuration is not needed.
|
|
680
|
+
#
|
|
681
|
+
# @param content [String, Hash] the content to translate (String for text, Hash for object)
|
|
682
|
+
# @param api_key [String] your Lingo.dev API key
|
|
683
|
+
# @param target_locale [String] the target locale code (e.g., 'es', 'fr', 'ja')
|
|
684
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
685
|
+
# @param fast [Boolean] enable fast mode for quicker results (default: true)
|
|
686
|
+
# @param api_url [String] the API endpoint URL (default: 'https://engine.lingo.dev')
|
|
687
|
+
#
|
|
688
|
+
# @return [String, Hash] localized content (String if input was String, Hash if input was Hash)
|
|
689
|
+
#
|
|
690
|
+
# @raise [ValidationError] if content is not a String or Hash, or other validation fails
|
|
691
|
+
# @raise [APIError] if the API request fails
|
|
692
|
+
#
|
|
693
|
+
# @example Translate text
|
|
694
|
+
# result = LingoDotDev::Engine.quick_translate('Hello', api_key: 'your-api-key', target_locale: 'es')
|
|
695
|
+
# # => "Hola"
|
|
696
|
+
#
|
|
697
|
+
# @example Translate object
|
|
698
|
+
# result = LingoDotDev::Engine.quick_translate(
|
|
699
|
+
# { greeting: 'Hello', farewell: 'Goodbye' },
|
|
700
|
+
# api_key: 'your-api-key',
|
|
701
|
+
# target_locale: 'fr'
|
|
702
|
+
# )
|
|
703
|
+
# # => { greeting: "Bonjour", farewell: "Au revoir" }
|
|
704
|
+
def self.quick_translate(content, api_key:, target_locale:, source_locale: nil, fast: true, api_url: 'https://engine.lingo.dev')
|
|
705
|
+
engine = new(api_key: api_key, api_url: api_url)
|
|
706
|
+
case content
|
|
707
|
+
when String
|
|
708
|
+
engine.localize_text(
|
|
709
|
+
content,
|
|
710
|
+
target_locale: target_locale,
|
|
711
|
+
source_locale: source_locale,
|
|
712
|
+
fast: fast
|
|
713
|
+
)
|
|
714
|
+
when Hash
|
|
715
|
+
engine.localize_object(
|
|
716
|
+
content,
|
|
717
|
+
target_locale: target_locale,
|
|
718
|
+
source_locale: source_locale,
|
|
719
|
+
fast: fast,
|
|
720
|
+
concurrent: true
|
|
721
|
+
)
|
|
722
|
+
else
|
|
723
|
+
raise ValidationError, 'Content must be a String or Hash'
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# One-off batch translation to multiple locales without managing engine lifecycle.
|
|
728
|
+
#
|
|
729
|
+
# Creates a temporary engine instance, performs batch translations, and returns the results.
|
|
730
|
+
# Suitable for single batch translations where engine configuration is not needed.
|
|
731
|
+
#
|
|
732
|
+
# @param content [String, Hash] the content to translate (String for text, Hash for object)
|
|
733
|
+
# @param api_key [String] your Lingo.dev API key
|
|
734
|
+
# @param target_locales [Array<String>] array of target locale codes
|
|
735
|
+
# @param source_locale [String, nil] the source locale code (optional, auto-detected if not provided)
|
|
736
|
+
# @param fast [Boolean] enable fast mode for quicker results (default: true)
|
|
737
|
+
# @param api_url [String] the API endpoint URL (default: 'https://engine.lingo.dev')
|
|
738
|
+
#
|
|
739
|
+
# @return [Array<String>, Array<Hash>] array of localized results (Strings if input was String, Hashes if input was Hash)
|
|
740
|
+
#
|
|
741
|
+
# @raise [ValidationError] if content is not a String or Hash, or other validation fails
|
|
742
|
+
# @raise [APIError] if any API request fails
|
|
743
|
+
#
|
|
744
|
+
# @example Batch translate text
|
|
745
|
+
# results = LingoDotDev::Engine.quick_batch_translate(
|
|
746
|
+
# 'Hello',
|
|
747
|
+
# api_key: 'your-api-key',
|
|
748
|
+
# target_locales: ['es', 'fr', 'de']
|
|
749
|
+
# )
|
|
750
|
+
# # => ["Hola", "Bonjour", "Hallo"]
|
|
751
|
+
#
|
|
752
|
+
# @example Batch translate object
|
|
753
|
+
# results = LingoDotDev::Engine.quick_batch_translate(
|
|
754
|
+
# { greeting: 'Hello' },
|
|
755
|
+
# api_key: 'your-api-key',
|
|
756
|
+
# target_locales: ['es', 'fr']
|
|
757
|
+
# )
|
|
758
|
+
# # => [{ greeting: "Hola" }, { greeting: "Bonjour" }]
|
|
759
|
+
def self.quick_batch_translate(content, api_key:, target_locales:, source_locale: nil, fast: true, api_url: 'https://engine.lingo.dev')
|
|
760
|
+
engine = new(api_key: api_key, api_url: api_url)
|
|
761
|
+
case content
|
|
762
|
+
when String
|
|
763
|
+
engine.batch_localize_text(
|
|
764
|
+
content,
|
|
765
|
+
target_locales: target_locales,
|
|
766
|
+
source_locale: source_locale,
|
|
767
|
+
fast: fast,
|
|
768
|
+
concurrent: true
|
|
769
|
+
)
|
|
770
|
+
when Hash
|
|
771
|
+
target_locales.map do |target_locale|
|
|
772
|
+
engine.localize_object(
|
|
773
|
+
content,
|
|
774
|
+
target_locale: target_locale,
|
|
775
|
+
source_locale: source_locale,
|
|
776
|
+
fast: fast,
|
|
777
|
+
concurrent: true
|
|
778
|
+
)
|
|
779
|
+
end
|
|
780
|
+
else
|
|
781
|
+
raise ValidationError, 'Content must be a String or Hash'
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
private
|
|
786
|
+
|
|
787
|
+
def make_request(url, json: nil)
|
|
788
|
+
uri = URI(url)
|
|
789
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
790
|
+
http.use_ssl = true
|
|
791
|
+
http.read_timeout = 60
|
|
792
|
+
http.open_timeout = 60
|
|
793
|
+
|
|
794
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
795
|
+
request['Authorization'] = "Bearer #{config.api_key}"
|
|
796
|
+
request['Content-Type'] = 'application/json; charset=utf-8'
|
|
797
|
+
request.body = JSON.generate(json) if json
|
|
798
|
+
|
|
799
|
+
http.request(request)
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def localize_raw(payload, target_locale:, source_locale: nil, fast: nil, reference: nil, concurrent: false, &progress_callback)
|
|
803
|
+
chunked_payload = extract_payload_chunks(payload)
|
|
804
|
+
workflow_id = SecureRandom.hex(8)
|
|
805
|
+
|
|
806
|
+
processed_chunks = if concurrent && !progress_callback
|
|
807
|
+
threads = chunked_payload.map do |chunk|
|
|
808
|
+
Thread.new do
|
|
809
|
+
localize_chunk(
|
|
810
|
+
chunk,
|
|
811
|
+
target_locale: target_locale,
|
|
812
|
+
source_locale: source_locale,
|
|
813
|
+
fast: fast,
|
|
814
|
+
reference: reference,
|
|
815
|
+
workflow_id: workflow_id
|
|
816
|
+
)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
threads.map(&:value)
|
|
820
|
+
else
|
|
821
|
+
chunked_payload.each_with_index.map do |chunk, index|
|
|
822
|
+
percentage_completed = (((index + 1).to_f / chunked_payload.length) * 100).round
|
|
823
|
+
|
|
824
|
+
processed_chunk = localize_chunk(
|
|
825
|
+
chunk,
|
|
826
|
+
target_locale: target_locale,
|
|
827
|
+
source_locale: source_locale,
|
|
828
|
+
fast: fast,
|
|
829
|
+
reference: reference,
|
|
830
|
+
workflow_id: workflow_id
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
progress_callback&.call(percentage_completed, chunk, processed_chunk)
|
|
834
|
+
|
|
835
|
+
processed_chunk
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
result = {}
|
|
840
|
+
processed_chunks.each { |chunk| result.merge!(chunk) }
|
|
841
|
+
result
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def localize_chunk(chunk, target_locale:, source_locale:, fast:, reference:, workflow_id:)
|
|
845
|
+
request_body = {
|
|
846
|
+
params: {
|
|
847
|
+
workflowId: workflow_id,
|
|
848
|
+
fast: fast || false
|
|
849
|
+
},
|
|
850
|
+
locale: {
|
|
851
|
+
source: source_locale,
|
|
852
|
+
target: target_locale
|
|
853
|
+
},
|
|
854
|
+
data: chunk
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if reference && !reference.empty?
|
|
858
|
+
raise ValidationError, 'Reference must be a Hash' unless reference.is_a?(Hash)
|
|
859
|
+
request_body[:reference] = reference
|
|
860
|
+
else
|
|
861
|
+
request_body[:reference] = {}
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
begin
|
|
865
|
+
response = make_request(
|
|
866
|
+
"#{config.api_url}/i18n",
|
|
867
|
+
json: request_body
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
handle_response(response)
|
|
871
|
+
|
|
872
|
+
data = JSON.parse(response.body, symbolize_names: true)
|
|
873
|
+
|
|
874
|
+
if !data[:data] && data[:error]
|
|
875
|
+
raise APIError, data[:error]
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# Normalize all keys to strings for consistent access throughout the SDK
|
|
879
|
+
(data[:data] || {}).transform_keys(&:to_s)
|
|
880
|
+
rescue StandardError => e
|
|
881
|
+
raise APIError, "Request failed: #{e.message}"
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def extract_payload_chunks(payload)
|
|
886
|
+
result = []
|
|
887
|
+
current_chunk = {}
|
|
888
|
+
current_chunk_item_count = 0
|
|
889
|
+
|
|
890
|
+
payload.each do |key, value|
|
|
891
|
+
current_chunk[key] = value
|
|
892
|
+
current_chunk_item_count += 1
|
|
893
|
+
|
|
894
|
+
current_chunk_size = count_words_in_record(current_chunk)
|
|
895
|
+
|
|
896
|
+
if current_chunk_size > config.ideal_batch_item_size ||
|
|
897
|
+
current_chunk_item_count >= config.batch_size ||
|
|
898
|
+
key == payload.keys.last
|
|
899
|
+
|
|
900
|
+
result << current_chunk
|
|
901
|
+
current_chunk = {}
|
|
902
|
+
current_chunk_item_count = 0
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
result
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def count_words_in_record(payload)
|
|
910
|
+
case payload
|
|
911
|
+
when Array
|
|
912
|
+
payload.sum { |item| count_words_in_record(item) }
|
|
913
|
+
when Hash
|
|
914
|
+
payload.values.sum { |item| count_words_in_record(item) }
|
|
915
|
+
when String
|
|
916
|
+
payload.strip.split.reject(&:empty?).length
|
|
917
|
+
else
|
|
918
|
+
0
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def handle_response(response)
|
|
923
|
+
status_code = response.code.to_i
|
|
924
|
+
return if status_code >= 200 && status_code < 300
|
|
925
|
+
|
|
926
|
+
if status_code >= 500
|
|
927
|
+
raise ServerError, "Server error (#{status_code}): #{response.message}. #{response.body}. This may be due to temporary service issues."
|
|
928
|
+
elsif status_code == 400
|
|
929
|
+
raise ValidationError, "Invalid request (#{status_code}): #{response.message}"
|
|
930
|
+
elsif status_code == 401
|
|
931
|
+
raise AuthenticationError, "Authentication failed (#{status_code}): #{response.message}"
|
|
932
|
+
else
|
|
933
|
+
raise APIError, response.body
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|