translation_diff 1.0.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/.github/workflows/ci.yml +84 -0
- data/.github/workflows/release.yml +28 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +39 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +723 -0
- data/Gemfile +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +41 -0
- data/data/languages/azure.json +285 -0
- data/data/languages/deepl.json +220 -0
- data/data/languages/google.json +399 -0
- data/data/languages/modernmt.json +413 -0
- data/docs/caching.md +185 -0
- data/docs/configuration.md +205 -0
- data/docs/contracts.md +187 -0
- data/docs/development.md +34 -0
- data/docs/errors.md +92 -0
- data/docs/how-it-works.md +145 -0
- data/docs/instrumentation.md +96 -0
- data/docs/languages.md +94 -0
- data/docs/providers.md +379 -0
- data/docs/sql-cache.md +366 -0
- data/lib/generators/translation_diff/install_generator.rb +15 -0
- data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
- data/lib/translation_diff/active_record/support.rb +34 -0
- data/lib/translation_diff/active_record.rb +3 -0
- data/lib/translation_diff/batch.rb +98 -0
- data/lib/translation_diff/call_preparation.rb +47 -0
- data/lib/translation_diff/capabilities.rb +9 -0
- data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
- data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
- data/lib/translation_diff/configuration/option_table.rb +38 -0
- data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
- data/lib/translation_diff/configuration.rb +135 -0
- data/lib/translation_diff/context.rb +23 -0
- data/lib/translation_diff/dispatcher.rb +57 -0
- data/lib/translation_diff/document.rb +23 -0
- data/lib/translation_diff/errors.rb +44 -0
- data/lib/translation_diff/fragment.rb +33 -0
- data/lib/translation_diff/http_provider.rb +128 -0
- data/lib/translation_diff/instrumentation.rb +25 -0
- data/lib/translation_diff/languages/refresh.rb +70 -0
- data/lib/translation_diff/languages/set.rb +53 -0
- data/lib/translation_diff/languages.rb +30 -0
- data/lib/translation_diff/leaves.rb +22 -0
- data/lib/translation_diff/markup.rb +85 -0
- data/lib/translation_diff/passage.rb +149 -0
- data/lib/translation_diff/preview.rb +3 -0
- data/lib/translation_diff/previewer.rb +78 -0
- data/lib/translation_diff/provider.rb +91 -0
- data/lib/translation_diff/providers/amazon.rb +126 -0
- data/lib/translation_diff/providers/azure.rb +78 -0
- data/lib/translation_diff/providers/deepl.rb +88 -0
- data/lib/translation_diff/providers/google.rb +64 -0
- data/lib/translation_diff/providers/libretranslate.rb +65 -0
- data/lib/translation_diff/providers/modernmt.rb +74 -0
- data/lib/translation_diff/providers/null.rb +20 -0
- data/lib/translation_diff/providers.rb +69 -0
- data/lib/translation_diff/railtie.rb +12 -0
- data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
- data/lib/translation_diff/rate_limiters/redis.rb +59 -0
- data/lib/translation_diff/rate_limiters.rb +9 -0
- data/lib/translation_diff/redaction.rb +45 -0
- data/lib/translation_diff/registry.rb +31 -0
- data/lib/translation_diff/segment.rb +32 -0
- data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
- data/lib/translation_diff/segmenters/simple.rb +122 -0
- data/lib/translation_diff/segmenters.rb +4 -0
- data/lib/translation_diff/sentence_cache.rb +76 -0
- data/lib/translation_diff/stores/active_record.rb +106 -0
- data/lib/translation_diff/stores/memory.rb +34 -0
- data/lib/translation_diff/stores/redis.rb +49 -0
- data/lib/translation_diff/stores.rb +9 -0
- data/lib/translation_diff/tasks/translation_diff.rake +21 -0
- data/lib/translation_diff/translation/request.rb +8 -0
- data/lib/translation_diff/translation/response.rb +35 -0
- data/lib/translation_diff/translation/usage.rb +8 -0
- data/lib/translation_diff/translator.rb +103 -0
- data/lib/translation_diff/version.rb +3 -0
- data/lib/translation_diff.rb +93 -0
- data/translation_diff.gemspec +56 -0
- metadata +243 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# How it works
|
|
2
|
+
|
|
3
|
+
A call to `TranslationDiff.translate` walks a value, cuts the prose in it
|
|
4
|
+
into sentences, translates only the sentences no cache already holds, and
|
|
5
|
+
puts the value back together in the shape it arrived in.
|
|
6
|
+
|
|
7
|
+
`TranslationDiff::Translator` coordinates document assembly, provider
|
|
8
|
+
resolution, settling the source language, language validation and the
|
|
9
|
+
sentence cache. `TranslationDiff::Dispatcher` takes over once there are cache
|
|
10
|
+
misses to send: it packs them into batches, throttles each one, makes the
|
|
11
|
+
wire call, and fires the `request`, `rate_limit` and `usage` events.
|
|
12
|
+
Everything below is a collaborator one of the two drives.
|
|
13
|
+
|
|
14
|
+
## The steps
|
|
15
|
+
|
|
16
|
+
1. **The caller's structure is walked, not flattened.**
|
|
17
|
+
`TranslationDiff::Document` visits every leaf `String` of a String, Array
|
|
18
|
+
or deep Hash and can rebuild the same shape from new leaves.
|
|
19
|
+
`TranslationDiff::Leaves` holds the two promises the structure itself does
|
|
20
|
+
not: a nested `nil` comes back as `""`, and the `values` count in an event
|
|
21
|
+
payload is every leaf the caller wrote, translatable or not.
|
|
22
|
+
|
|
23
|
+
2. **Each leaf becomes a passage of markup and prose.**
|
|
24
|
+
`TranslationDiff::Passage` parses the string with `ox` and records where
|
|
25
|
+
every construct begins, so each run of the source is either markup --
|
|
26
|
+
tags, comments, CDATA, doctypes, processing instructions, the bodies of
|
|
27
|
+
`config.opaque_elements` (`script`, `style`, `pre` and `code` by
|
|
28
|
+
default, matched case-insensitively), and anything inside
|
|
29
|
+
`class="notranslate"` -- or prose.
|
|
30
|
+
Each run becomes a `TranslationDiff::Fragment`, and a fragment is always a
|
|
31
|
+
slice of the source, never a rebuilt string.
|
|
32
|
+
|
|
33
|
+
3. **Prose is cut into sentences.**
|
|
34
|
+
A prose fragment is split by `config.segmenter` (see [The segmenter
|
|
35
|
+
contract](contracts.md#the-segmenter-contract)) into
|
|
36
|
+
`TranslationDiff::Segment`s. A segment keeps the whitespace it was found
|
|
37
|
+
in: its `#core` is the text a provider sees, with entity references
|
|
38
|
+
decoded to the characters they mean, and its `#render` is markup again.
|
|
39
|
+
|
|
40
|
+
4. **The cache is consulted once, for every sentence at once.**
|
|
41
|
+
`TranslationDiff::SentenceCache` builds one key per sentence and reads
|
|
42
|
+
them all in a single `read_multi`. Sentences it answers for are already
|
|
43
|
+
done; the rest are misses. See [Caching](caching.md).
|
|
44
|
+
|
|
45
|
+
5. **The misses are handed to `TranslationDiff::Dispatcher`.**
|
|
46
|
+
`TranslationDiff::Batch.pack` groups them into batches that fit inside the
|
|
47
|
+
provider's declared `max_batch_size` and `max_request_size` -- its
|
|
48
|
+
`TranslationDiff::Capabilities` (see [Providers](providers.md)).
|
|
49
|
+
`Dispatcher` throttles each batch through `config.rate_limiter_instance`
|
|
50
|
+
when one is configured, sends it to the provider, and applies the reply
|
|
51
|
+
back onto the very segments that produced it -- no step ever correlates a
|
|
52
|
+
translation to a sentence by position after the fact.
|
|
53
|
+
|
|
54
|
+
6. **What came back is written home, and the value is rebuilt.**
|
|
55
|
+
Only sentences that actually got a translation are cached, through
|
|
56
|
+
`TranslationDiff::SentenceCache#store` -- see [`write_multi` is
|
|
57
|
+
optional](caching.md#write_multi-is-optional) for what happens when the
|
|
58
|
+
store's write fails partway through a batch. Then each passage renders
|
|
59
|
+
itself -- markup fragments byte-exact, translated sentences re-encoded as
|
|
60
|
+
HTML text -- and `Document` puts the renders back into the caller's
|
|
61
|
+
shape.
|
|
62
|
+
|
|
63
|
+
`TranslationDiff::Markup` is the small module underneath steps 2, 3 and 6: it
|
|
64
|
+
decodes entity references on the way to a provider and, in
|
|
65
|
+
`TranslationDiff::Translation::Response.build`, on the way back too, for
|
|
66
|
+
every provider -- Google and DeepL both return HTML-escaped text, and
|
|
67
|
+
without the second decode a vendor's own `&` was escaped a second time, so
|
|
68
|
+
`didn't` came back as `didn't`. Named entities, and both the decimal
|
|
69
|
+
(`'`) and hex (`'`) numeric forms, are decoded; an entity neither
|
|
70
|
+
decoder recognizes, or one that would decode to invalid UTF-8, is left
|
|
71
|
+
exactly as it arrived.
|
|
72
|
+
|
|
73
|
+
Decoding a reply raw would make `<` a bare `<`, and `ox` reads a bare `<`
|
|
74
|
+
in front of a letter as an opening tag -- a provider's own `<b attack`
|
|
75
|
+
would become a real `<b attack>` element. So a reply is escaped the same way
|
|
76
|
+
a source document's own bare angles already are, before it is decoded, and
|
|
77
|
+
`Segment#render` re-encodes a translated sentence with
|
|
78
|
+
`Markup.encode_translation`: `&` is always escaped, and so is a `<` that is
|
|
79
|
+
not shaped like a tag -- a source document's own bare `<` is untouched by
|
|
80
|
+
this. **This is a behaviour change:** `if a < b then stop.` used to come
|
|
81
|
+
back with the bare `<` exactly as written; it now comes back
|
|
82
|
+
`if a < b then stop.`, the correct HTML encoding of that character,
|
|
83
|
+
rendering identically in a browser but visible to anything comparing output
|
|
84
|
+
byte-for-byte against an earlier release. `>` is left alone -- a stray `>`
|
|
85
|
+
never opens anything a parser would honour, so there is nothing to protect
|
|
86
|
+
it from.
|
|
87
|
+
|
|
88
|
+
*NOTE:* if `:from` is not specified or equal to nil, then the provider's `#detect` will be called once with a sample of text up to 100 characters long to determine the language, and `#translate` will be called separately with the entire text.
|
|
89
|
+
Try to specify `:from` explicitly to save the extra call -- it also improves segmentation, since the segmenter only sees a language when `:from` is given (see [The segmenter contract](contracts.md#the-segmenter-contract)).
|
|
90
|
+
|
|
91
|
+
## Input
|
|
92
|
+
|
|
93
|
+
`TranslationDiff.translate` can receive string, array or deep hash and will return the same, but translated.
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
TranslationDiff.translate("test", from: "en", to: "es")
|
|
97
|
+
TranslationDiff.translate(%w[test language], from: "en", to: "es")
|
|
98
|
+
TranslationDiff.translate(
|
|
99
|
+
{ title: "test", values: { type: "frequent" } }, from: "en", to: "es"
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A leaf that is not a `String` -- a number, a symbol, `true` -- is handed back
|
|
104
|
+
untouched. A nested `nil` comes back as `""`. See
|
|
105
|
+
`TranslationDiff::Document` and `TranslationDiff::Leaves` for details.
|
|
106
|
+
|
|
107
|
+
`to:` is not optional. It defaults to `nil` in the signature and a `nil`
|
|
108
|
+
target raises `ArgumentError` naming the keyword, rather than silently
|
|
109
|
+
handing your values back untranslated.
|
|
110
|
+
|
|
111
|
+
## HTML
|
|
112
|
+
|
|
113
|
+
You can pass HTML as like as plain text:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
TranslationDiff.translate("<b>Black</b>", from: "en", to: "es")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A `<pre>` or `<code>` block is not prose to this gem, so it is left alone.
|
|
120
|
+
`config.opaque_elements` names the set treated this way -- `script`,
|
|
121
|
+
`style`, `pre` and `code` by default -- matched case-insensitively, so
|
|
122
|
+
`<PRE><CODE>` and `<STYLE>` (the shape Word, Outlook and older CMSes emit)
|
|
123
|
+
are opaque too. An application can widen or narrow the set. Measured
|
|
124
|
+
against the live Google API:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
TranslationDiff.translate(
|
|
128
|
+
"<pre><code>curl -s https://example.com/level | jq '.meters'</code></pre>",
|
|
129
|
+
from: "en", to: "es"
|
|
130
|
+
)
|
|
131
|
+
# => "<pre><code>curl -s https://example.com/level | jq '.meters'</code></pre>"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Before `pre` and `code` joined the opaque set, nothing told the pipeline
|
|
135
|
+
that code holds language, not prose: the segmenter cut the block above at
|
|
136
|
+
the quote and handed `meters'` to the provider as a sentence of its own,
|
|
137
|
+
and Google translated it -- `jq '.meters'` came back as `jq '.metros'`,
|
|
138
|
+
inside the quoted filter. Wrap a block you don't want touched in
|
|
139
|
+
`class="notranslate"` instead, for protection finer than an element, or for
|
|
140
|
+
an element outside `config.opaque_elements`; the providers that honour it
|
|
141
|
+
(see [Providers](providers.md)) leave it exactly as written.
|
|
142
|
+
|
|
143
|
+
**Upgrading:** widening what counts as markup changes what gets sent to the
|
|
144
|
+
provider, so it changes cache keys for any document containing a `pre` or
|
|
145
|
+
`code` element -- see [Caching](caching.md#what-a-cache-key-is-made-of).
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Instrumentation and logging
|
|
2
|
+
|
|
3
|
+
`config.instrumenter` accepts anything satisfying
|
|
4
|
+
`ActiveSupport::Notifications`' interface -- `#instrument(name, payload) { }` --
|
|
5
|
+
and `config.logger` accepts a standard `Logger`. Neither is required: with
|
|
6
|
+
both unset, `TranslationDiff.translate` runs exactly the same, at no extra
|
|
7
|
+
cost.
|
|
8
|
+
|
|
9
|
+
A translation emits up to six events, each named `<name>.translation_diff`:
|
|
10
|
+
|
|
11
|
+
| Event | Fired | Payload |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `translate` | Once per `translate` call that has something to translate, wrapping the whole thing -- including a call served entirely from cache, which never reaches the provider. A call whose source and target languages are the same, or whose values hold no translatable text at all, returns early and emits no events. | `call_id`, `from`, `to`, `provider`, `values` (number of texts), `characters` (total considered by this call) |
|
|
14
|
+
| `cache` | Once per such call, after checking the cache for every sentence at once -- whether or not anything is left to send the provider. | `call_id`, `provider`, `hits`, `misses` |
|
|
15
|
+
| `request` | Once per batch actually sent to the provider (skipped entirely on a full cache hit). | `call_id`, `provider`, `batch` (values sent), `characters` (sent by this batch) |
|
|
16
|
+
| `rate_limit` | Once per batch sent to the provider, only when a rate limiter is configured. | `call_id`, `provider`, `characters` |
|
|
17
|
+
| `usage` | Once per batch actually sent to the provider, right after `request`. | `call_id`, `provider`, `characters`, `billed_characters`, `reported`, `model` |
|
|
18
|
+
| `cache_error` | Only when writing the translation back to the cache fails -- after the provider has already answered. Never fires on a successful write, so it is not part of every call the way the other five are. | `call_id`, `provider`, `error` (the failed write's error class, as a string) |
|
|
19
|
+
|
|
20
|
+
**Every event above carries `call_id`.** It is generated once per
|
|
21
|
+
`translate` call, opaque, and never derived from the text. Before it, a
|
|
22
|
+
subscriber receiving `cache`, `request`, `rate_limit`, `usage` or
|
|
23
|
+
`cache_error` events had no way to tell which `translate` call any of them
|
|
24
|
+
belonged to, short of tagging `Thread.current` itself -- a workaround that
|
|
25
|
+
breaks the moment two translations share a thread.
|
|
26
|
+
|
|
27
|
+
**`translate`'s `characters` and `request`'s `characters` measure different
|
|
28
|
+
things.** `translate`'s is the total this call considered -- every
|
|
29
|
+
non-blank sentence, hit or miss, whether or not any of it was sent to the
|
|
30
|
+
provider -- so a call served entirely from cache still reports a number
|
|
31
|
+
instead of nothing, even though no `request` event fires for it at all.
|
|
32
|
+
`request`'s keeps its narrower meaning: what this one batch actually sent.
|
|
33
|
+
`usage`'s `characters` carries that same narrower meaning too, per batch,
|
|
34
|
+
like `request`'s. Same name, three events, two meanings -- a subscriber
|
|
35
|
+
summing the wrong one gets a wrong bill.
|
|
36
|
+
|
|
37
|
+
`cache_error` is what a failing cache write looks like from the outside:
|
|
38
|
+
the write itself is rescued, not the translation, which still reaches the
|
|
39
|
+
caller -- see
|
|
40
|
+
[The three write paths fail differently](caching.md#the-three-write-paths-fail-differently).
|
|
41
|
+
`error` is the exception's class name, never its message, which could echo
|
|
42
|
+
the row it failed to write. Which class you see depends on the store: a
|
|
43
|
+
store that redacts its own failures reports that redaction, so
|
|
44
|
+
`Stores::ActiveRecord` always gives `"TranslationDiff::Error"` -- the
|
|
45
|
+
adapter's own class is named inside that error's (content-free) message,
|
|
46
|
+
not in this payload. `Stores::Redis` does not wrap, so it gives the
|
|
47
|
+
driver's class, `"Redis::CannotConnectError"` and the like. Alert on the
|
|
48
|
+
event, not on a particular class name.
|
|
49
|
+
|
|
50
|
+
The same failure is logged at **warn**, not debug: an application whose
|
|
51
|
+
cache has quietly stopped accepting writes pays the provider for every
|
|
52
|
+
sentence, every time, and a signal only visible at debug level is one
|
|
53
|
+
nobody sees in production.
|
|
54
|
+
|
|
55
|
+
`usage`'s `characters` is what this library sent, counted locally -- the same
|
|
56
|
+
number `request` carries. `billed_characters` is what the provider said it
|
|
57
|
+
charged for, or `nil` when it said nothing. **`reported` means the provider
|
|
58
|
+
reports billing at all -- not that this particular response was billed.**
|
|
59
|
+
`billed_characters: nil` alone cannot tell "this provider never says" apart
|
|
60
|
+
from "this response omitted it"; `reported` is what makes the `nil` honest.
|
|
61
|
+
Summing `billed_characters` across providers without checking `reported`
|
|
62
|
+
first produces a total that is quietly too low, since only three of the six
|
|
63
|
+
built-in providers (DeepL, Azure, ModernMT) report billing at all -- the
|
|
64
|
+
other three always answer `nil`. `model` is the model the provider used,
|
|
65
|
+
when it names one, and `nil` otherwise.
|
|
66
|
+
|
|
67
|
+
**`cache` fires once per call as of translation_diff 1.0.0, not once per chunk.** The cache is
|
|
68
|
+
now consulted for every sentence in one `read_multi` before anything is
|
|
69
|
+
batched, so there is one event where there used to be one per chunk. `hits`
|
|
70
|
+
and `misses` still sum to the same totals over a call, so a counter that adds
|
|
71
|
+
them up is unaffected; a counter of *events*, or a histogram of per-chunk hit
|
|
72
|
+
ratios, will see the cardinality drop.
|
|
73
|
+
|
|
74
|
+
**Instrumentation payloads never contain the text being translated, its
|
|
75
|
+
translation, or a credential.** This is a guarantee, not an implementation
|
|
76
|
+
detail: this library handles other people's content, and an instrumenter
|
|
77
|
+
usually writes somewhere that content must not go. Only counts, language
|
|
78
|
+
codes and provider names cross that boundary.
|
|
79
|
+
|
|
80
|
+
`config.logger` receives one `debug` line per provider resolution, naming
|
|
81
|
+
the provider class in use -- nothing about the content being translated. The
|
|
82
|
+
same guarantee applies to it as to instrumentation payloads: no log line this
|
|
83
|
+
library writes carries the text being translated, its translation, or a
|
|
84
|
+
credential.
|
|
85
|
+
|
|
86
|
+
**No HTTP-backed provider ever receives `config.logger`, and there is no way
|
|
87
|
+
to opt one in.** `TranslationDiff::HTTPProvider` installs no logging
|
|
88
|
+
middleware on its Faraday connection and never passes a logger to it -- this
|
|
89
|
+
is enforced by `test/support/http_provider_contract.rb`, not merely
|
|
90
|
+
documented. Earlier versions wrapped `deepl-rb`, which logged a
|
|
91
|
+
`Request details:` line at DEBUG holding the full
|
|
92
|
+
`Authorization: DeepL-Auth-Key ...` header and the request payload -- your
|
|
93
|
+
API key and the text being translated -- if you gave it a logger of its own.
|
|
94
|
+
Owning the transport directly closed that door rather than working around
|
|
95
|
+
it: nothing this library builds writes source text, a translation, or a
|
|
96
|
+
credential anywhere, and no configuration option reopens that.
|
data/docs/languages.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Languages
|
|
2
|
+
|
|
3
|
+
`TranslationDiff::Languages` answers, without making a request, whether a
|
|
4
|
+
provider translates a given source into a given target. `Translator` calls it
|
|
5
|
+
before every translation and refuses a pair it says no to.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
TranslationDiff::Languages.supports?(:deepl, from: "en", to: "ru") # => true
|
|
9
|
+
TranslationDiff::Languages.supports?(:amazon, from: "en", to: "ru") # => nil
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Three answers, not two: `true`, `false`, and `nil` for "no opinion". `nil` is
|
|
13
|
+
not a refusal -- a provider this registry knows nothing about is never
|
|
14
|
+
blocked from translating anything.
|
|
15
|
+
|
|
16
|
+
## What ships
|
|
17
|
+
|
|
18
|
+
One JSON file per provider under `data/languages/`, each naming the date it
|
|
19
|
+
was captured and the endpoint it came from:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"provider": "deepl",
|
|
24
|
+
"captured_at": "2026-09-10",
|
|
25
|
+
"endpoint": "https://api.deepl.com/v2/languages",
|
|
26
|
+
"source": ["ar", "bg", "cs", "..."],
|
|
27
|
+
"target": ["ar", "bg", "cs", "en-gb", "en-us", "..."]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`captured_at` is not decoration: it is the difference between "these are the
|
|
32
|
+
languages" and "these were the languages on 10 September 2026", and only the
|
|
33
|
+
second is true.
|
|
34
|
+
|
|
35
|
+
Data ships for **DeepL, Google, Azure and ModernMT** only. **Amazon** ships
|
|
36
|
+
none -- its language list needs signed credentials, so nothing can be fetched
|
|
37
|
+
without them. **LibreTranslate** ships none either, for a different reason:
|
|
38
|
+
it is self-hosted, so the language set belongs to whichever instance you
|
|
39
|
+
point this gem at, not to a vendor this gem can capture once and ship.
|
|
40
|
+
|
|
41
|
+
A provider with no shipped data -- Amazon, LibreTranslate, or any provider of
|
|
42
|
+
your own -- refuses nothing. `Languages.supports?` returns `nil` for it,
|
|
43
|
+
every time, and `Translator` treats `nil` the same as `true`.
|
|
44
|
+
|
|
45
|
+
## Matching a code
|
|
46
|
+
|
|
47
|
+
A code is matched downcased, on its primary subtag, so `en-GB` and `en`
|
|
48
|
+
match each other in both directions.
|
|
49
|
+
|
|
50
|
+
This is deliberately permissive. The registry exists to catch a wrong
|
|
51
|
+
language -- `to: "klingon"`, a swapped pair, a typo -- not to adjudicate a
|
|
52
|
+
wrong regional variant. `Languages.supports?(:azure, from: "en", to: "zh")`
|
|
53
|
+
answers `true`, even though Azure itself wants `zh-Hans` or `zh-Hant` as a
|
|
54
|
+
target and would reject a bare `zh`. The rule can over-allow; it must never
|
|
55
|
+
wrongly refuse a pair the provider would actually have accepted.
|
|
56
|
+
|
|
57
|
+
One more rule, for a mismatch the vendors themselves create. ModernMT
|
|
58
|
+
publishes ISO 639-3 individual codes where applications write the ISO 639-1
|
|
59
|
+
macrolanguage: it lists `pes`, not `fa`, and `uzn`, not `uz`. A vendor that
|
|
60
|
+
translates the individual language translates the macrolanguage it belongs
|
|
61
|
+
to, so `to: "fa"` is accepted against a list carrying `pes`.
|
|
62
|
+
|
|
63
|
+
That holds in one direction only. A vendor publishing `zh` has not promised
|
|
64
|
+
to accept `cmn`, so `Languages.supports?(:deepl, from: "en", to: "cmn")`
|
|
65
|
+
answers `false` -- DeepL would reject the code it was sent.
|
|
66
|
+
|
|
67
|
+
## The two escapes
|
|
68
|
+
|
|
69
|
+
Shipped data goes stale, and a vendor adding a language must not make this
|
|
70
|
+
gem refuse work that would now succeed. Two escapes exist for that:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Per call
|
|
74
|
+
TranslationDiff.translate(text, from: "en", to: "yue", assume_supported: true)
|
|
75
|
+
|
|
76
|
+
# Globally
|
|
77
|
+
TranslationDiff.configure { |config| config.validate_languages = false }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`assume_supported:` is a reserved keyword, like `provider:` and `config:` --
|
|
81
|
+
it is read by `Translator` and never forwarded to the provider itself.
|
|
82
|
+
|
|
83
|
+
## `rake languages:refresh`
|
|
84
|
+
|
|
85
|
+
A maintainer's tool, not something an application runs: it re-fetches every
|
|
86
|
+
provider's language lists from its vendor and rewrites the shipped files,
|
|
87
|
+
which is why it needs each vendor's own credentials configured to do
|
|
88
|
+
anything.
|
|
89
|
+
|
|
90
|
+
A provider whose fetch fails keeps its previous file -- an emptied list from
|
|
91
|
+
a timed-out request would be worse than a stale one -- and the task reports
|
|
92
|
+
which providers failed and why, rather than failing silently. Its output is
|
|
93
|
+
meant to be reviewed as a diff, the way any change to shipped data should be,
|
|
94
|
+
before it is committed.
|