deepl-rb 3.8.0 → 3.9.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.gitlab-ci.yml +1 -1
  3. data/CHANGELOG.md +23 -1
  4. data/README.md +213 -4
  5. data/VERSION +1 -1
  6. data/deepl-rb.gemspec +34 -6
  7. data/lib/deepl/document_api.rb +11 -1
  8. data/lib/deepl/requests/base.rb +55 -3
  9. data/lib/deepl/requests/document/upload.rb +50 -1
  10. data/lib/deepl/requests/translate.rb +4 -1
  11. data/lib/deepl/requests/translation_memory/base.rb +47 -0
  12. data/lib/deepl/requests/translation_memory/create_export.rb +41 -0
  13. data/lib/deepl/requests/translation_memory/create_import.rb +53 -0
  14. data/lib/deepl/requests/translation_memory/destroy.rb +37 -0
  15. data/lib/deepl/requests/translation_memory/download_export.rb +35 -0
  16. data/lib/deepl/requests/translation_memory/find.rb +38 -0
  17. data/lib/deepl/requests/translation_memory/find_job.rb +38 -0
  18. data/lib/deepl/requests/translation_memory/list.rb +5 -18
  19. data/lib/deepl/requests/translation_memory/segments.rb +46 -0
  20. data/lib/deepl/requests/translation_memory/storage_base.rb +47 -0
  21. data/lib/deepl/requests/translation_memory/upload_file.rb +47 -0
  22. data/lib/deepl/resources/translation_memory.rb +4 -1
  23. data/lib/deepl/resources/translation_memory_export.rb +37 -0
  24. data/lib/deepl/resources/translation_memory_import.rb +29 -0
  25. data/lib/deepl/resources/translation_memory_job.rb +142 -0
  26. data/lib/deepl/resources/translation_memory_segments.rb +78 -0
  27. data/lib/deepl/translation_memory_api.rb +293 -1
  28. data/lib/deepl/utils/time_parser.rb +26 -0
  29. data/lib/deepl.rb +31 -0
  30. data/lib/version.rb +1 -1
  31. data/spec/integration_tests/document_api_spec.rb +47 -0
  32. data/spec/integration_tests/integration_test_utils.rb +8 -0
  33. data/spec/integration_tests/translate_api_spec.rb +19 -0
  34. data/spec/integration_tests/translation_memory_api_spec.rb +192 -2
  35. data/spec/integration_tests/translation_memory_error_paths_spec.rb +68 -0
  36. data/spec/requests/document/upload_spec.rb +130 -0
  37. data/spec/requests/translate_spec.rb +55 -0
  38. data/spec/requests/translation_memory/create_export_spec.rb +37 -0
  39. data/spec/requests/translation_memory/create_import_spec.rb +61 -0
  40. data/spec/requests/translation_memory/destroy_spec.rb +36 -0
  41. data/spec/requests/translation_memory/download_export_spec.rb +54 -0
  42. data/spec/requests/translation_memory/find_job_spec.rb +37 -0
  43. data/spec/requests/translation_memory/find_spec.rb +36 -0
  44. data/spec/requests/translation_memory/segments_spec.rb +58 -0
  45. data/spec/requests/translation_memory/upload_file_spec.rb +55 -0
  46. data/spec/resources/translation_memory_export_spec.rb +38 -0
  47. data/spec/resources/translation_memory_import_spec.rb +30 -0
  48. data/spec/resources/translation_memory_job_spec.rb +109 -0
  49. data/spec/resources/translation_memory_segments_spec.rb +78 -0
  50. data/spec/resources/translation_memory_spec.rb +18 -1
  51. data/spec/support/managed_glossary.rb +48 -0
  52. data/spec/support/managed_translation_memory.rb +48 -0
  53. metadata +34 -3
@@ -0,0 +1,37 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Requests::TranslationMemory::FindJob do
9
+ subject(:translation_memory_find_job) { described_class.new(api, job_id, options) }
10
+
11
+ around do |tests|
12
+ tmp_env = replace_env_preserving_deepl_vars_except_mock_server
13
+ tests.call
14
+ ENV.replace(tmp_env)
15
+ end
16
+
17
+ let(:api) { build_deepl_api }
18
+ let(:job_id) { '5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d' }
19
+ let(:options) { {} }
20
+
21
+ describe '#initialize' do
22
+ context 'when building a request' do
23
+ it 'creates a request object' do
24
+ expect(translation_memory_find_job).to be_a(described_class)
25
+ end
26
+ end
27
+ end
28
+
29
+ describe '#to_s' do
30
+ context 'when building a request' do
31
+ it 'requests the job below the v3 prefix' do
32
+ expect(translation_memory_find_job.to_s)
33
+ .to eq("GET /v3/translation_memories/jobs/#{job_id}")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Requests::TranslationMemory::Find do
9
+ subject(:translation_memory_find) { described_class.new(api, id, options) }
10
+
11
+ around do |tests|
12
+ tmp_env = replace_env_preserving_deepl_vars_except_mock_server
13
+ tests.call
14
+ ENV.replace(tmp_env)
15
+ end
16
+
17
+ let(:api) { build_deepl_api }
18
+ let(:id) { 'a74d88fb-ed2a-4943-a664-a4512398b994' }
19
+ let(:options) { {} }
20
+
21
+ describe '#initialize' do
22
+ context 'when building a request' do
23
+ it 'creates a request object' do
24
+ expect(translation_memory_find).to be_a(described_class)
25
+ end
26
+ end
27
+ end
28
+
29
+ describe '#to_s' do
30
+ context 'when building a request' do
31
+ it 'requests the translation memory below the v3 prefix' do
32
+ expect(translation_memory_find.to_s).to eq("GET /v3/translation_memories/#{id}")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,58 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Requests::TranslationMemory::Segments do
9
+ subject(:translation_memory_segments) { described_class.new(api, id, options) }
10
+
11
+ around do |tests|
12
+ tmp_env = replace_env_preserving_deepl_vars_except_mock_server
13
+ tests.call
14
+ ENV.replace(tmp_env)
15
+ end
16
+
17
+ let(:api) { build_deepl_api }
18
+ let(:id) { 'a74d88fb-ed2a-4943-a664-a4512398b994' }
19
+ let(:options) { {} }
20
+
21
+ describe '#initialize' do
22
+ context 'when building a request' do
23
+ it 'creates a request object' do
24
+ expect(translation_memory_segments).to be_a(described_class)
25
+ end
26
+ end
27
+ end
28
+
29
+ describe '#to_s' do
30
+ context 'when no options are given' do
31
+ it 'requests the segments below the v3 prefix without a query string' do
32
+ expect(translation_memory_segments.to_s)
33
+ .to eq("GET /v3/translation_memories/#{id}/segments")
34
+ end
35
+ end
36
+
37
+ context 'when the supported options are given' do
38
+ let(:options) { { page_size: 10, page_cursor: 'NQ', filter_case_sensitive: true } }
39
+
40
+ it 'adds the options to the query string' do
41
+ expect(translation_memory_segments.to_s)
42
+ .to eq("GET /v3/translation_memories/#{id}/segments" \
43
+ '?page_size=10&page_cursor=NQ&filter_case_sensitive=true')
44
+ end
45
+ end
46
+
47
+ context 'when the text filter contains characters that must be escaped' do
48
+ let(:options) { { filter_text: 'a b+c' } }
49
+
50
+ # A space must be percent-encoded in a URI query string; `+` only means a space in a form
51
+ # body, and a literal `+` has to survive as %2B.
52
+ it 'percent-encodes the spaces instead of writing them as a plus' do
53
+ expect(translation_memory_segments.to_s)
54
+ .to eq("GET /v3/translation_memories/#{id}/segments?filter_text=a%20b%2Bc")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Requests::TranslationMemory::UploadFile do
9
+ subject(:upload_file) { described_class.new(api, upload_url, file_content) }
10
+
11
+ around do |tests|
12
+ tmp_env = replace_env_preserving_deepl_vars_except_mock_server
13
+ tests.call
14
+ ENV.replace(tmp_env)
15
+ end
16
+
17
+ let(:api) { build_deepl_api }
18
+ let(:upload_url) { 'https://storage.example.com/upload/tm?signature=abc' }
19
+ let(:file_content) { '<tmx version="1.4"/>' }
20
+
21
+ describe '#initialize' do
22
+ context 'when building a request' do
23
+ it 'creates a request object' do
24
+ expect(upload_file).to be_a(described_class)
25
+ expect(upload_file.content_type).to eq(described_class::DEFAULT_CONTENT_TYPE)
26
+ end
27
+ end
28
+
29
+ context 'when no upload URL is given' do
30
+ let(:upload_url) { '' }
31
+
32
+ it 'raises an error' do
33
+ expect { upload_file }.to raise_error(DeepL::Exceptions::Error, /must not be empty/)
34
+ end
35
+ end
36
+ end
37
+
38
+ describe '#to_s' do
39
+ context 'when building a request' do
40
+ it 'puts to the pre-signed storage URL' do
41
+ expect(upload_file.to_s).to eq('PUT /upload/tm?signature=abc')
42
+ end
43
+ end
44
+ end
45
+
46
+ describe '#details' do
47
+ context 'when building a request' do
48
+ # The upload URL points outside of the DeepL API, so the auth key must not be sent there.
49
+ it 'does not send the DeepL authorization header' do
50
+ expect(upload_file.details).not_to include('Authorization')
51
+ expect(upload_file.details).to include('User-Agent')
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,38 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Resources::TranslationMemoryExport do
9
+ subject(:translation_memory_export) { described_class.new(response, false, nil, nil) }
10
+
11
+ let(:response) do
12
+ {
13
+ 'job_id' => '5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d',
14
+ 'parameters' => { 'translation_memory_id' => 'a74d88fb-ed2a-4943-a664-a4512398b994' }
15
+ }
16
+ end
17
+
18
+ describe '#initialize' do
19
+ it 'creates a resource' do
20
+ expect(translation_memory_export).to be_a(described_class)
21
+ end
22
+
23
+ it 'assigns the attributes of a newly created export' do
24
+ expect(translation_memory_export.job_id).to eq('5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d')
25
+ expect(translation_memory_export.translation_memory_id)
26
+ .to eq('a74d88fb-ed2a-4943-a664-a4512398b994')
27
+ expect(translation_memory_export.reused_existing?).to be(false)
28
+ end
29
+
30
+ context 'when the API reused a previously completed export' do
31
+ subject(:translation_memory_export) { described_class.new(response, true, nil, nil) }
32
+
33
+ it 'reports the export as reused' do
34
+ expect(translation_memory_export.reused_existing?).to be(true)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,30 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Resources::TranslationMemoryImport do
9
+ subject(:translation_memory_import) do
10
+ described_class.new({
11
+ 'job_id' => '5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d',
12
+ 'upload_url' => 'https://storage.example.com/upload',
13
+ 'expires_at' => '2026-01-01T00:00:00Z'
14
+ }, nil, nil)
15
+ end
16
+
17
+ describe '#initialize' do
18
+ it 'creates a resource' do
19
+ expect(translation_memory_import).to be_a(described_class)
20
+ end
21
+
22
+ it 'assigns the attributes' do
23
+ expect(translation_memory_import.job_id).to eq('5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d')
24
+ expect(translation_memory_import.upload_url).to eq('https://storage.example.com/upload')
25
+ expect(translation_memory_import.expires_at).to eq(Time.parse('2026-01-01T00:00:00Z'))
26
+ expect(translation_memory_import.to_s)
27
+ .to eq('TranslationMemoryImport: ID: 5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,109 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Resources::TranslationMemoryJob do
9
+ subject(:job) { described_class.new(response, nil, nil) }
10
+
11
+ let(:response) do
12
+ {
13
+ 'job_id' => '5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d',
14
+ 'product' => 'translation_memory',
15
+ 'operation' => 'import',
16
+ 'creation_time' => '2026-01-01T00:00:00Z',
17
+ 'updated_time' => '2026-01-02T00:00:00Z',
18
+ 'source_file' => { 'content_type' => 'application/xml', 'content_length' => 128 },
19
+ 'parameters' => { 'display_name' => 'Legal' },
20
+ 'results' => [
21
+ { 'status' => 'completed', 'translation_memory_id' => 'tm-1',
22
+ 'skipped_segment_count' => 2 }
23
+ ]
24
+ }
25
+ end
26
+
27
+ describe '#initialize' do
28
+ it 'creates a resource' do
29
+ expect(job).to be_a(described_class)
30
+ end
31
+
32
+ it 'assigns the job level attributes' do
33
+ expect(job.job_id).to eq('5b1a0a52-1f3b-4b7f-9f6b-9d4d3f5b6c7d')
34
+ expect(job.product).to eq('translation_memory')
35
+ expect(job.operation).to eq('import')
36
+ expect(job.creation_time).to eq(Time.parse('2026-01-01T00:00:00Z'))
37
+ expect(job.updated_time).to eq(Time.parse('2026-01-02T00:00:00Z'))
38
+ expect(job.display_name).to eq('Legal')
39
+ expect(job.source_content_type).to eq('application/xml')
40
+ expect(job.source_content_length).to eq(128)
41
+ end
42
+
43
+ it 'maps the single result and delegates the status helpers to it' do
44
+ expect(job.result).to be_a(DeepL::Resources::TranslationMemoryJobResult)
45
+ expect(job.status).to eq('completed')
46
+ expect(job.finished?).to be(true)
47
+ expect(job.awaiting_input?).to be(false)
48
+ expect(job.error?).to be(false)
49
+ expect(job.result.translation_memory_id).to eq('tm-1')
50
+ expect(job.result.skipped_segment_count).to eq(2)
51
+ end
52
+ end
53
+
54
+ context 'when the job is awaiting its file upload' do
55
+ let(:response) do
56
+ { 'job_id' => 'job-1', 'operation' => 'import',
57
+ 'results' => [{ 'status' => 'awaiting_input',
58
+ 'status_metadata' => { 'required_action' => 'Waiting for upload' } }] }
59
+ end
60
+
61
+ it 'exposes the required action and does not report the job as finished' do
62
+ expect(job.awaiting_input?).to be(true)
63
+ expect(job.finished?).to be(false)
64
+ expect(job.result.required_action).to eq('Waiting for upload')
65
+ end
66
+ end
67
+
68
+ context 'when the export job completed' do
69
+ let(:response) do
70
+ { 'job_id' => 'job-2', 'operation' => 'export',
71
+ 'parameters' => { 'translation_memory_id' => 'tm-1' },
72
+ 'results' => [{ 'status' => 'completed', 'download_url' => 'https://example.com/tmx',
73
+ 'expires_at' => '2026-01-03T00:00:00Z' }] }
74
+ end
75
+
76
+ it 'exposes the download URL and its expiry' do
77
+ expect(job.translation_memory_id).to eq('tm-1')
78
+ expect(job.result.download_url).to eq('https://example.com/tmx')
79
+ expect(job.result.expires_at).to eq(Time.parse('2026-01-03T00:00:00Z'))
80
+ expect(job.to_s).to eq('TranslationMemoryJob: export - ID: job-2 - Status: completed')
81
+ end
82
+ end
83
+
84
+ context 'when the job failed' do
85
+ let(:response) do
86
+ { 'job_id' => 'job-3', 'operation' => 'import',
87
+ 'results' => [{ 'status' => 'failed', 'error' => { 'message' => 'Invalid TMX' } }] }
88
+ end
89
+
90
+ it 'reports the error and treats the job as finished' do
91
+ expect(job.error?).to be(true)
92
+ expect(job.finished?).to be(true)
93
+ expect(job.error_message).to eq('Invalid TMX')
94
+ end
95
+ end
96
+
97
+ context 'when the API returned no result' do
98
+ let(:response) { { 'job_id' => 'job-4', 'operation' => 'import' } }
99
+
100
+ it 'reports neither a status nor an error' do
101
+ expect(job.result).to be_nil
102
+ expect(job.status).to be_nil
103
+ expect(job.finished?).to be(false)
104
+ expect(job.awaiting_input?).to be(false)
105
+ expect(job.error?).to be(false)
106
+ expect(job.error_message).to be_nil
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,78 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'spec_helper'
7
+
8
+ describe DeepL::Resources::TranslationMemorySegments do
9
+ subject(:segments_page) { described_class.new(response, nil, nil) }
10
+
11
+ let(:response) do
12
+ {
13
+ 'segments' => [
14
+ {
15
+ 'source_segment_id' => 'tm-1-source-0',
16
+ 'source_text' => 'Quelltext Nummer 0',
17
+ 'creation_time' => '2026-01-01T00:00:00Z',
18
+ 'updated_time' => '2026-01-02T00:00:00Z',
19
+ 'last_used_time' => '2026-01-03T00:00:00Z',
20
+ 'targets' => [
21
+ {
22
+ 'target_segment_id' => 'tm-1-target-en-0',
23
+ 'target_language' => 'en',
24
+ 'target_text' => 'Source text number 0',
25
+ 'creation_time' => '2026-01-01T00:00:00Z',
26
+ 'updated_time' => '2026-01-02T00:00:00Z',
27
+ 'last_used_time' => '2026-01-03T00:00:00Z'
28
+ }
29
+ ]
30
+ }
31
+ ],
32
+ 'segment_count' => 12,
33
+ 'next_page_cursor' => 'NQ'
34
+ }
35
+ end
36
+
37
+ describe '#initialize' do
38
+ it 'creates a resource' do
39
+ expect(segments_page).to be_a(described_class)
40
+ end
41
+
42
+ it 'assigns the page level attributes' do
43
+ expect(segments_page.segment_count).to eq(12)
44
+ expect(segments_page.next_page_cursor).to eq('NQ')
45
+ expect(segments_page.next_page?).to be(true)
46
+ expect(segments_page.to_s).to eq('1 of 12 segment(s)')
47
+ end
48
+
49
+ it 'maps the segments into resources' do
50
+ segment = segments_page.segments.first
51
+ expect(segment).to be_a(DeepL::Resources::TranslationMemorySegment)
52
+ expect(segment.source_segment_id).to eq('tm-1-source-0')
53
+ expect(segment.source_text).to eq('Quelltext Nummer 0')
54
+ expect(segment.creation_time).to eq(Time.parse('2026-01-01T00:00:00Z'))
55
+ expect(segment.updated_time).to eq(Time.parse('2026-01-02T00:00:00Z'))
56
+ expect(segment.last_used_time).to eq(Time.parse('2026-01-03T00:00:00Z'))
57
+ end
58
+
59
+ it 'maps the target segments into resources' do
60
+ target = segments_page.segments.first.targets.first
61
+ expect(target).to be_a(DeepL::Resources::TranslationMemoryTargetSegment)
62
+ expect(target.target_segment_id).to eq('tm-1-target-en-0')
63
+ expect(target.target_language).to eq('en')
64
+ expect(target.target_text).to eq('Source text number 0')
65
+ expect(target.to_s).to eq('en: Source text number 0')
66
+ end
67
+ end
68
+
69
+ context 'when the response is the last page' do
70
+ let(:response) { { 'segments' => [], 'segment_count' => 12 } }
71
+
72
+ it 'reports no further page' do
73
+ expect(segments_page.segments).to eq([])
74
+ expect(segments_page.next_page_cursor).to be_nil
75
+ expect(segments_page.next_page?).to be(false)
76
+ end
77
+ end
78
+ end
@@ -12,7 +12,9 @@ describe DeepL::Resources::TranslationMemory do
12
12
  'name' => 'Legal',
13
13
  'source_language' => 'en',
14
14
  'target_languages' => %w[es de],
15
- 'segment_count' => 3542
15
+ 'segment_count' => 3542,
16
+ 'creation_time' => '2026-01-01T00:00:00Z',
17
+ 'updated_time' => '2026-01-02T00:00:00Z'
16
18
  }, nil, nil)
17
19
  end
18
20
 
@@ -29,6 +31,21 @@ describe DeepL::Resources::TranslationMemory do
29
31
  expect(translation_memory.source_language).to eq('en')
30
32
  expect(translation_memory.target_languages).to eq(%w[es de])
31
33
  expect(translation_memory.segment_count).to eq(3542)
34
+ expect(translation_memory.creation_time).to eq(Time.parse('2026-01-01T00:00:00Z'))
35
+ expect(translation_memory.updated_time).to eq(Time.parse('2026-01-02T00:00:00Z'))
36
+ end
37
+ end
38
+
39
+ context 'when the optional timestamps are missing' do
40
+ subject(:translation_memory) do
41
+ described_class.new({ 'translation_memory_id' => 'tm-1', 'name' => 'Legal' }, nil, nil)
42
+ end
43
+
44
+ it 'leaves the timestamps nil and defaults the collections' do
45
+ expect(translation_memory.creation_time).to be_nil
46
+ expect(translation_memory.updated_time).to be_nil
47
+ expect(translation_memory.target_languages).to eq([])
48
+ expect(translation_memory.segment_count).to eq(0)
32
49
  end
33
50
  end
34
51
  end
@@ -4,6 +4,11 @@
4
4
  # frozen_string_literal: true
5
5
 
6
6
  module ManagedGlossary
7
+ # Bounded retry budget for tolerating a freshly-created glossary that is not
8
+ # yet resolvable on the real API (eventual consistency): up to ~10 seconds.
9
+ GLOSSARY_RETRY_ATTEMPTS = 20
10
+ GLOSSARY_RETRY_DELAY = 0.5
11
+
7
12
  def with_managed_glossary(name:, source_lang:, target_lang:, entries:, options: {})
8
13
  glossary = DeepL.glossaries.create(name, source_lang, target_lang, entries, options)
9
14
  yield glossary
@@ -14,4 +19,47 @@ module ManagedGlossary
14
19
  nil
15
20
  end
16
21
  end
22
+
23
+ # A just-created glossary is not always immediately resolvable by id on the
24
+ # real API (eventual consistency). Poll #find until each glossary is
25
+ # retrievable so subsequent calls (e.g. translate with glossary_ids) do not
26
+ # fail with a transient "Glossary <uuid> not found" error.
27
+ #
28
+ # Note: no keyword arguments here — Ruby 2.7 would fold a trailing options
29
+ # hash from callers into keywords, breaking positional-hash APIs.
30
+ def wait_until_glossaries_ready(*glossaries)
31
+ glossaries.each do |glossary|
32
+ remaining = GLOSSARY_RETRY_ATTEMPTS
33
+ begin
34
+ DeepL.glossaries.find(glossary.id)
35
+ rescue DeepL::Exceptions::RequestError
36
+ raise if (remaining -= 1) <= 0
37
+
38
+ sleep(GLOSSARY_RETRY_DELAY)
39
+ retry
40
+ end
41
+ end
42
+ end
43
+
44
+ # Translate with a bounded retry that tolerates a transient
45
+ # "Glossary <uuid> not found" error caused by eventual consistency on a
46
+ # freshly created glossary. Depending on timing the real API returns this as
47
+ # either a 400 or a 404, so match on the message rather than the status.
48
+ # Any other error is re-raised immediately.
49
+ #
50
+ # `options` is an explicit positional hash (matching DeepL.translate) rather
51
+ # than keyword arguments, so passing `{ glossary_ids: [...] }` works on Ruby
52
+ # 2.7 as well as 3.x.
53
+ def translate_retrying_missing_glossary(text, source_lang, target_lang, options = {})
54
+ remaining = GLOSSARY_RETRY_ATTEMPTS
55
+ begin
56
+ DeepL.translate(text, source_lang, target_lang, options)
57
+ rescue DeepL::Exceptions::RequestError => e
58
+ raise unless e.to_s.match?(/glossary .* not found/i)
59
+ raise if (remaining -= 1) <= 0
60
+
61
+ sleep(GLOSSARY_RETRY_DELAY)
62
+ retry
63
+ end
64
+ end
17
65
  end
@@ -4,4 +4,52 @@
4
4
  # frozen_string_literal: true
5
5
 
6
6
  module ManagedTranslationMemory
7
+ # A minimal, valid TMX document to import in the translation memory specs.
8
+ EXAMPLE_TMX = <<~TMX
9
+ <?xml version="1.0" encoding="UTF-8"?>
10
+ <tmx version="1.4">
11
+ <header creationtool="deepl-rb-spec" srclang="de" segtype="sentence"
12
+ adminlang="en" datatype="plaintext" o-tmf="TMX"/>
13
+ <body>
14
+ <tu>
15
+ <tuv xml:lang="de"><seg>Quelltext Nummer 0</seg></tuv>
16
+ <tuv xml:lang="en"><seg>Source text number 0</seg></tuv>
17
+ </tu>
18
+ </body>
19
+ </tmx>
20
+ TMX
21
+
22
+ # Imports the TMX file at +input_file_path+ and yields the finished import job, deleting the
23
+ # resulting translation memory afterwards.
24
+ def with_managed_translation_memory(input_file_path, display_name: nil)
25
+ job = DeepL.translation_memories.import_from_filepath(input_file_path,
26
+ display_name: display_name)
27
+ yield job
28
+ ensure
29
+ begin
30
+ DeepL.translation_memories.destroy(job.result.translation_memory_id) if job
31
+ rescue StandardError
32
+ nil
33
+ end
34
+ end
35
+
36
+ # Creates an import job for the TMX file at +input_file_path+ through +translation_memories+
37
+ # and uploads the file, then returns the created import. The mock server keeps the job in the
38
+ # `awaiting_input` status for +processing_polls+ status queries before completing it, standing
39
+ # in for the live API detecting the upload asynchronously.
40
+ def create_uploaded_import(translation_memories, input_file_path, processing_polls:)
41
+ file_content = File.binread(input_file_path)
42
+ created = translation_memories.create_import(
43
+ File.basename(input_file_path), file_content.bytesize,
44
+ additional_headers: tm_job_processing_polls_header(processing_polls)
45
+ )
46
+ translation_memories.upload_file(created, file_content)
47
+ created
48
+ end
49
+
50
+ # Writes the example TMX document to +path+ and returns the path.
51
+ def write_example_tmx(path)
52
+ File.write(path, EXAMPLE_TMX)
53
+ path
54
+ end
7
55
  end