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.
@@ -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
+