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
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] - 2026-09-24
|
|
8
|
+
|
|
9
|
+
First release under the name **translation_diff**. This gem was published as
|
|
10
|
+
`deepl_diff` through 2.2.0; everything here is relative to `deepl_diff` 2.2.0.
|
|
11
|
+
|
|
12
|
+
### Breaking
|
|
13
|
+
|
|
14
|
+
- **One `TranslationDiff::RateLimitExceeded`, whichever limiter noticed it.**
|
|
15
|
+
The two limiters used to raise two same-named classes under their own
|
|
16
|
+
namespaces, so an application that rescued one and then switched
|
|
17
|
+
`rate_limiter` from `:redis` to `:active_record` quietly stopped catching
|
|
18
|
+
it. See [Errors](docs/errors.md).
|
|
19
|
+
|
|
20
|
+
- **Language validation is on by default.** `TranslationDiff.translate` now
|
|
21
|
+
refuses, before making a request, any source/target pair the shipped data
|
|
22
|
+
doesn't list for that provider -- raising
|
|
23
|
+
`TranslationDiff::UnsupportedLanguageError`. Data ships for DeepL, Google,
|
|
24
|
+
Azure and ModernMT only; Amazon and LibreTranslate ship none, and a
|
|
25
|
+
provider with no shipped data refuses nothing. Two escapes: pass
|
|
26
|
+
`assume_supported: true` for one call, or set
|
|
27
|
+
`config.validate_languages = false` globally. See
|
|
28
|
+
[Languages](docs/languages.md).
|
|
29
|
+
|
|
30
|
+
- **Cache keys change for any document containing a `pre` or `code`
|
|
31
|
+
element.** `pre` and `code` are now opaque (see Added, below), so what
|
|
32
|
+
gets sent to the provider changed, and what gets keyed changed with it;
|
|
33
|
+
an entry cached under the old behaviour keeps serving what the old
|
|
34
|
+
behaviour produced. Give the configuration a new `cache_namespace`, or
|
|
35
|
+
let `cache_ttl` lapse, to get every such document retranslated. See
|
|
36
|
+
[Caching](docs/caching.md#what-a-cache-key-is-made-of).
|
|
37
|
+
|
|
38
|
+
- **A runtime `cache_namespace` change now moves the rate limiter too.**
|
|
39
|
+
`cache_namespace` names the limiter's own bookkeeping namespace as well
|
|
40
|
+
as the cache store's; it used to move only the store, leaving the
|
|
41
|
+
limiter counting silently under the old namespace. `active_record_base`
|
|
42
|
+
behaves the same way and for the same reason -- the SQL-backed limiter
|
|
43
|
+
builds its model from that class just as the store does. See
|
|
44
|
+
[Configuration](docs/configuration.md#changing-configuration-at-runtime).
|
|
45
|
+
|
|
46
|
+
- **A provider with a blank `cache_key` now raises
|
|
47
|
+
`TranslationDiff::InvalidProviderError`, not
|
|
48
|
+
`TranslationDiff::Translator::Error`.** The two are siblings under
|
|
49
|
+
`TranslationDiff::Error`, not parent and child, so an application
|
|
50
|
+
rescuing the old class specifically stops catching this failure.
|
|
51
|
+
Rescue `TranslationDiff::Error` to catch both. See
|
|
52
|
+
[Errors](docs/errors.md).
|
|
53
|
+
|
|
54
|
+
- **The cache stores, rate limiters and ActiveRecord plumbing moved under
|
|
55
|
+
their registry's own namespace; nothing was left behind at the old
|
|
56
|
+
name.** `TranslationDiff::MemoryCacheStore`, `RedisCacheStore` and
|
|
57
|
+
`ActiveRecordCacheStore` are now `TranslationDiff::Stores::Memory`,
|
|
58
|
+
`Stores::Redis` and `Stores::ActiveRecord`; `RedisRateLimiter` and
|
|
59
|
+
`ActiveRecordRateLimiter` are now `TranslationDiff::RateLimiters::Redis`
|
|
60
|
+
and `RateLimiters::ActiveRecord`; `TranslationDiff::ActiveRecordSupport`
|
|
61
|
+
is now `TranslationDiff::ActiveRecord::Support`. `config.cache = :redis`
|
|
62
|
+
and the rest of the symbol-keyed configuration are unaffected -- only
|
|
63
|
+
the constant a name resolves to changed. See
|
|
64
|
+
[Caching](docs/caching.md) and [SQL cache](docs/sql-cache.md).
|
|
65
|
+
|
|
66
|
+
- Renamed the gem to `translation_diff` and the module to `TranslationDiff`.
|
|
67
|
+
- The provider (`config.provider`; see Removed below for what replaced the
|
|
68
|
+
old `TranslationDiff.api` accessor) must satisfy the five-method contract
|
|
69
|
+
(`translate`, optional `detect`, `max_request_size`, `max_batch_size`,
|
|
70
|
+
`cache_key`) instead of being a raw client such as `DeepL`.
|
|
71
|
+
- `translate` takes keyword arguments -- `translate(values, from:, to:, **options)`
|
|
72
|
+
-- and no longer accepts a positional options hash.
|
|
73
|
+
- Request-size and batch-size limits moved out of `Chunker` and into the
|
|
74
|
+
provider (`#max_request_size`, `#max_batch_size`); they are no longer
|
|
75
|
+
hard-coded to DeepL's numbers.
|
|
76
|
+
- **Every cache key changes.** The key now includes the provider's
|
|
77
|
+
`cache_key`, a digest of the provider options, and lowercased language
|
|
78
|
+
codes. Nothing cached by `deepl_diff` -- or by an earlier `translation_diff`
|
|
79
|
+
prerelease -- is reused. The next translation of every sentence is a cache
|
|
80
|
+
miss, once, everywhere.
|
|
81
|
+
- Dropped `punkt-segmenter` and, with it, its `unicode_utils` dependency.
|
|
82
|
+
Sentence boundaries are now produced by `config.segmenter`,
|
|
83
|
+
defaulting to `TranslationDiff::Segmenters::Pragmatic`, backed by the
|
|
84
|
+
[`pragmatic_segmenter`](https://github.com/diasks2/pragmatic_segmenter) gem
|
|
85
|
+
(MIT, zero dependencies of its own) -- so `ox` and `pragmatic_segmenter` are
|
|
86
|
+
now the gem's only two runtime dependencies. Measured against the Golden
|
|
87
|
+
Rules corpus -- the `context "Golden Rules" do` block of each of the 10
|
|
88
|
+
per-language spec files on `diasks2/pragmatic_segmenter`, 80 exemplars in
|
|
89
|
+
total; a sample of the same corpus is in
|
|
90
|
+
`test/translation_diff/golden_rules_test.rb` -- the default now scores
|
|
91
|
+
76/80 against punkt's 38/80 and the old in-house segmenter's 47/80; the
|
|
92
|
+
gap is largest on languages with no letter case at all -- Arabic, Hindi,
|
|
93
|
+
Armenian, Greek -- which the in-house segmenter cannot reason about by
|
|
94
|
+
design.
|
|
95
|
+
- `config.segmenter_instance.split_offsets` now takes a second, optional
|
|
96
|
+
`language:` keyword argument. `pragmatic_segmenter` picks its rule set by
|
|
97
|
+
language and falls back to English rules without one, which can
|
|
98
|
+
mis-segment other languages (Russian abbreviations, for one); `from:` is
|
|
99
|
+
the only way a caller supplies it, and only when segmentation happens
|
|
100
|
+
before language detection would need to run. `Segmenters::Pragmatic`
|
|
101
|
+
normalises the code first -- downcased, region subtag dropped -- and falls
|
|
102
|
+
back to English for anything `pragmatic_segmenter` does not recognise
|
|
103
|
+
afterward. Without this, DeepL's own codes (`"RU"`, `"EN-GB"`) missed their
|
|
104
|
+
rule set entirely: `pragmatic_segmenter`'s lookup is case-sensitive and
|
|
105
|
+
region-blind, so this gem's own flagship provider was hitting the broken
|
|
106
|
+
path on every call.
|
|
107
|
+
- **`config.rate_limit` now actually enforces the threshold you configure.**
|
|
108
|
+
`TranslationDiff::RedisRateLimiter` called `Ratelimit#add(size)`, but that
|
|
109
|
+
gem's signature is `add(subject, count = 1)` -- so it recorded the hit
|
|
110
|
+
under a subject *named after the character count*, while `exceeded?`
|
|
111
|
+
checked a subject nothing ever incremented. The limiter never limited
|
|
112
|
+
anything, in every release back to `v1.0.2` (tagged 2023-02-16, roughly
|
|
113
|
+
three years ago). If you have `rate_limit` configured, your traffic has
|
|
114
|
+
never actually been throttled; on upgrading to 3.1.0 it will be, for the
|
|
115
|
+
first time, against a threshold you set once and have never seen fire. You
|
|
116
|
+
changed no configuration, but your throttling behaviour changes on
|
|
117
|
+
upgrade. Re-validate `rate_limit` and `rate_interval` before upgrading --
|
|
118
|
+
see "Rate limiting" in the README.
|
|
119
|
+
- `rate_interval` is silently clamped by the `ratelimit` gem's fixed bucket
|
|
120
|
+
span to roughly **5-600 seconds** (measured: `3600` becomes `600`, `1`
|
|
121
|
+
becomes `5`). Combined with a limiter that now actually fires, an interval
|
|
122
|
+
configured above 600 seconds is enforced over 600 seconds instead -- up to
|
|
123
|
+
six times more eager than the configuration reads. Keep `rate_interval`
|
|
124
|
+
within that range, or expect a tighter effective window than configured.
|
|
125
|
+
- Providers must inherit `TranslationDiff::Provider`. A duck-typed object is
|
|
126
|
+
no longer accepted: the base class supplies the transport, the
|
|
127
|
+
configuration check and the capability defaults, and a provider without
|
|
128
|
+
them is a provider that fails in the ways this library has already been
|
|
129
|
+
bitten by twice.
|
|
130
|
+
- `provider.translate(texts, from:, to:, **options)` is now
|
|
131
|
+
`provider.translate(request)`, taking a `Translation::Request` and
|
|
132
|
+
returning a `Translation::Response`. The response carries the detected
|
|
133
|
+
source language and, where the provider reports it, the characters billed.
|
|
134
|
+
- `max_request_size` and `max_batch_size` move from provider methods to
|
|
135
|
+
`Capabilities`.
|
|
136
|
+
- `TranslationDiff::Providers::Naming` is gone; the registry stamps
|
|
137
|
+
`cache_key` and `Provider` implements it.
|
|
138
|
+
- Every provider normalises language codes to the casing its own vendor
|
|
139
|
+
documents and accepts either casing from the caller: DeepL upper-cases, the
|
|
140
|
+
other five lower-case. A code carrying a script or region subtag
|
|
141
|
+
(`"zh-Hans"`, `"pt-BR"`) is passed through untouched. `Provider#language`
|
|
142
|
+
is the shared rule and `self.language_case` selects the casing, so a
|
|
143
|
+
provider of your own gets it by inheriting. Previously only Google and
|
|
144
|
+
DeepL normalised at all -- Amazon Translate rejected `"EN"`/`"RU"` and
|
|
145
|
+
LibreTranslate answered 400, on every call, for anyone who followed the
|
|
146
|
+
README's "switch provider by changing `config.provider`" with DeepL-style
|
|
147
|
+
codes -- and DeepL upper-cased subtags too, corrupting `"zh-Hans"`.
|
|
148
|
+
- `deepl-rb` and `google-cloud-translate-v2` are no longer used at all.
|
|
149
|
+
`faraday` and `faraday-retry` become runtime dependencies; `aws-sigv4` is
|
|
150
|
+
required lazily by the Amazon provider only.
|
|
151
|
+
- `config.deepl_host` is renamed `config.deepl_api_base`, matching the
|
|
152
|
+
`<provider>_api_base` name every other provider uses. There is no alias: a
|
|
153
|
+
configuration still setting `deepl_host` raises `NoMethodError` on
|
|
154
|
+
`TranslationDiff.configure`. Rename it.
|
|
155
|
+
- A provider returning the wrong number of translations now raises
|
|
156
|
+
`TranslationDiff::ResponseError`, not the error that used to live on
|
|
157
|
+
`Request`; a `rescue` written to catch a short response that way no longer
|
|
158
|
+
catches one. Both are `TranslationDiff::Error`, so a rescue of the base
|
|
159
|
+
class is unaffected.
|
|
160
|
+
- A provider returning a well-formed response that carries no translation for
|
|
161
|
+
one input -- Azure answers 200 for a batch where a single string failed --
|
|
162
|
+
also raises `TranslationDiff::ResponseError`, naming the position. It
|
|
163
|
+
previously reached the spacing step and died there as `NoMethodError`.
|
|
164
|
+
- **The translation pipeline is new code.** `TranslationDiff::Linearizer`,
|
|
165
|
+
`Spacing`, `Chunker`, `Tokenizer`, `Cache` and `Request` are gone as public
|
|
166
|
+
constants. What replaces them: `Document` and `Leaves` (walking the
|
|
167
|
+
caller's structure), `Passage`, `Fragment` and `Segment` (markup and prose,
|
|
168
|
+
cut into sentences), `Markup` (entity references and a `<` that opens no
|
|
169
|
+
tag), `Batch` (packing sentences into provider requests), `SentenceCache`
|
|
170
|
+
(the cache key, read and write) and `Translator` (the coordinator
|
|
171
|
+
`TranslationDiff.translate` and `Context#translate` now build). See
|
|
172
|
+
[How it works](docs/how-it-works.md). If you referenced any of the six by
|
|
173
|
+
name, that reference is now a `NameError`.
|
|
174
|
+
- `TranslationDiff::Request::Error` is now `TranslationDiff::Translator::Error`
|
|
175
|
+
and `TranslationDiff::Cache::Error` is now
|
|
176
|
+
`TranslationDiff::SentenceCache::Error`. There is no alias for either: this
|
|
177
|
+
gem has never been published under the name `translation_diff` with those
|
|
178
|
+
constants in it. `TranslationDiff::Chunker::Error` is gone with no
|
|
179
|
+
replacement -- a single sentence too large to send now raises
|
|
180
|
+
`TranslationDiff::Batch::Error`. All three remain
|
|
181
|
+
`TranslationDiff::Error`, so a rescue of the base class is unaffected.
|
|
182
|
+
- **`TranslationDiff.translate` and `Context#translate` raise `ArgumentError`
|
|
183
|
+
when `to:` is missing or `nil`.** The keyword still defaults to `nil` in the
|
|
184
|
+
signature, and the message names it. Previously a `nil` target compared
|
|
185
|
+
equal to a `nil` source, the call short-circuited as "same language" and
|
|
186
|
+
your values came back untranslated, silently. If you have a caller reading
|
|
187
|
+
`to:` out of a configuration that can be blank, it has been a no-op and will
|
|
188
|
+
now raise.
|
|
189
|
+
- **The `cache` instrumentation event fires once per `translate` call, not
|
|
190
|
+
once per chunk.** The cache is now consulted for every sentence in one
|
|
191
|
+
`read_multi` before anything is batched. `hits` and `misses` still sum to
|
|
192
|
+
the same totals over a call, so a counter that adds them up is unaffected;
|
|
193
|
+
a counter of *events*, or a histogram of per-chunk hit ratios, will see the
|
|
194
|
+
cardinality drop. `request` and `rate_limit` still fire once per batch sent.
|
|
195
|
+
- **Two more cache keys move, beyond the entity and `<` fixes below.** A
|
|
196
|
+
sentence padded with Unicode whitespace -- a non-breaking space, say -- now
|
|
197
|
+
keys as the bare sentence: the pipeline uses one Unicode-aware definition
|
|
198
|
+
of padding everywhere, where the key used to be built with ASCII `strip`,
|
|
199
|
+
which leaves a `U+00A0` in place. And the whole document key format is
|
|
200
|
+
otherwise unmoved: it is pinned by test against recorded values, and every
|
|
201
|
+
other input in the corpus this rewrite was judged against produces the same
|
|
202
|
+
key it did before.
|
|
203
|
+
|
|
204
|
+
### Removed
|
|
205
|
+
|
|
206
|
+
- The four module-level accessors (`TranslationDiff.api`, `.cache_store`,
|
|
207
|
+
`.segmenter`, `.rate_limiter`) and `TranslationDiff::CACHE_NAMESPACE`.
|
|
208
|
+
Every setting now lives on `TranslationDiff::Configuration`, reached
|
|
209
|
+
through `TranslationDiff.config` or `TranslationDiff.configure`.
|
|
210
|
+
|
|
211
|
+
### Added
|
|
212
|
+
|
|
213
|
+
- **Every event from one `translate` call now shares a `call_id`.** Generated
|
|
214
|
+
once per call, opaque, and never derived from the text, it lands in
|
|
215
|
+
`translate`, `cache`, `request`, `rate_limit`, `usage` and `cache_error`
|
|
216
|
+
alike. Before it, a subscriber receiving `cache` or `request` events had no
|
|
217
|
+
way to tell which `translate` call they belonged to, short of tagging
|
|
218
|
+
`Thread.current` itself -- a workaround that breaks the moment two
|
|
219
|
+
translations share a thread. See
|
|
220
|
+
[Instrumentation](docs/instrumentation.md).
|
|
221
|
+
|
|
222
|
+
- **`translate` now carries `characters`: the total this call considered,
|
|
223
|
+
hit or miss.** A call served entirely from cache never fires a `request`
|
|
224
|
+
event and used to report nothing about its size; it now reports a number
|
|
225
|
+
there instead. `request`'s own `characters` keeps its narrower meaning --
|
|
226
|
+
what one batch actually sent -- so the two fields share a name but not an
|
|
227
|
+
event: summing the wrong one produces a wrong bill. See
|
|
228
|
+
[Instrumentation](docs/instrumentation.md).
|
|
229
|
+
|
|
230
|
+
- **`TranslationDiff.preview` predicts a `translate` call without making
|
|
231
|
+
it.** It answers how many sentences a call would send, how many the cache
|
|
232
|
+
already has, and how many characters that is -- without calling a
|
|
233
|
+
provider and without writing anything. A preview never pays for language
|
|
234
|
+
detection, so `from:` is required wherever there is anything to preview;
|
|
235
|
+
leaving it unset raises `TranslationDiff::Previewer::Error`. Built for an
|
|
236
|
+
editor that wants to show "this edit will send 1 sentence" before the
|
|
237
|
+
author saves. See
|
|
238
|
+
[Caching](docs/caching.md#asking-what-a-call-would-do-without-doing-it).
|
|
239
|
+
|
|
240
|
+
- **`pre` and `code` are no longer sent for translation, and changing a
|
|
241
|
+
configuration option at runtime now rebuilds only what it actually
|
|
242
|
+
feeds.** `pre` and `code` join `script` and `style` in
|
|
243
|
+
`config.opaque_elements`, the set `TranslationDiff::Passage` never treats
|
|
244
|
+
as prose -- default `%i[script style pre code]`, widen or narrow it as
|
|
245
|
+
needed -- after a live `<pre><code>` block came back from Google with
|
|
246
|
+
`jq '.meters'` mangled into `jq '.metros'`, because nothing told the
|
|
247
|
+
pipeline that code holds language, not prose. Separately, `provider` and
|
|
248
|
+
the cache, pool, rate and segmenter options each rebuild only their own
|
|
249
|
+
collaborator now, instead of nothing: before this, switching `provider`
|
|
250
|
+
at runtime meant `TranslationDiff.reset!` and reconfiguring from scratch,
|
|
251
|
+
discarding a Redis pool that had no reason to go. See
|
|
252
|
+
[Configuration](docs/configuration.md#changing-configuration-at-runtime).
|
|
253
|
+
Two upgrade consequences of this are filed under Breaking, above:
|
|
254
|
+
cache keys changing for a document containing `pre` or `code`, and a
|
|
255
|
+
runtime `cache_namespace` change now moving the rate limiter too.
|
|
256
|
+
|
|
257
|
+
- A `usage` instrumentation event, firing once per provider request, beside
|
|
258
|
+
`translate`, `cache`, `request` and `rate_limit`. Its payload carries
|
|
259
|
+
`provider`, `characters` (what this library sent, counted locally),
|
|
260
|
+
`billed_characters` (what the provider said it charged, or `nil`),
|
|
261
|
+
`reported` (whether the provider reports billing **at all** -- not that
|
|
262
|
+
this response was billed) and `model`. Summing `billed_characters` across
|
|
263
|
+
providers without checking `reported` first produces a total that is
|
|
264
|
+
quietly too low: only three of the six built-in providers report billing
|
|
265
|
+
at all. See [Instrumentation](docs/instrumentation.md).
|
|
266
|
+
|
|
267
|
+
- **A SQL-backed cache store and rate limiter, for an application that runs
|
|
268
|
+
Postgres or MySQL and does not want Redis for this alone.**
|
|
269
|
+
`TranslationDiff::ActiveRecordCacheStore` (`config.cache =
|
|
270
|
+
:active_record`) and `TranslationDiff::ActiveRecordRateLimiter`
|
|
271
|
+
(`config.rate_limiter = :active_record`) cache translations and throttle
|
|
272
|
+
requests in the application's own database, exercised in CI against
|
|
273
|
+
Postgres, MySQL and SQLite. Nothing on this branch is breaking: both
|
|
274
|
+
are opt-in, the default resolution of `cache` and `rate_limiter` is
|
|
275
|
+
untouched, and an application with `redis_url` set keeps getting Redis
|
|
276
|
+
exactly as before. `rails generate translation_diff:install` writes the
|
|
277
|
+
migration for both tables, idempotently -- every `create_table` and
|
|
278
|
+
`add_index` in it carries `if_not_exists: true`; for anyone not on Rails,
|
|
279
|
+
its body is in [SQL cache](docs/sql-cache.md) verbatim -- **the gem
|
|
280
|
+
itself never runs DDL.** `rake translation_diff:prune` ships inside the
|
|
281
|
+
gem: a `Railtie` wires it into a Rails application's own rake tasks
|
|
282
|
+
automatically, enhanced with `:environment` so it prunes that
|
|
283
|
+
application's own configuration; a non-Rails application loads the task
|
|
284
|
+
file itself and
|
|
285
|
+
must configure `TranslationDiff` before running it, since the task gets
|
|
286
|
+
no `:environment`-equivalent there. ActiveRecord 7.1 or newer is
|
|
287
|
+
required when either is used, refused by name at build time rather than
|
|
288
|
+
failing inside a query, and `activerecord` is never a dependency of this
|
|
289
|
+
gem -- it is required lazily on first use, the same way `redis` already
|
|
290
|
+
is. Four new configuration options: `cache_table_name`,
|
|
291
|
+
`rate_limit_table_name`, `active_record_base` and
|
|
292
|
+
`cache_prune_probability`; the last, along with `cache_namespace`, is
|
|
293
|
+
now validated at `configure` time -- a `cache_prune_probability` that
|
|
294
|
+
will not coerce to a number, or a `cache_namespace` over 64 characters,
|
|
295
|
+
is refused before either reaches a query. See
|
|
296
|
+
[SQL cache](docs/sql-cache.md).
|
|
297
|
+
- **The SQL cache store's write joins the caller's transaction, and its
|
|
298
|
+
errors are redacted, not silent.** A rollback in the caller's transaction
|
|
299
|
+
discards translations `ActiveRecordCacheStore` already wrote -- the
|
|
300
|
+
largest behavioural difference from `RedisCacheStore`, which is never
|
|
301
|
+
inside anyone's transaction. A failed write itself runs in its own
|
|
302
|
+
savepoint, so it no longer aborts a transaction it does not own, and the
|
|
303
|
+
`TranslationDiff::Error` it raises carries the adapter's error class, not
|
|
304
|
+
the row. `upsert_all` inlines values rather than binding them, though, so
|
|
305
|
+
a written translation still appears verbatim in the host application's
|
|
306
|
+
own ActiveRecord log at `debug` -- unrelated to this gem's own `logger`
|
|
307
|
+
option, which never prints content. The redaction now covers every
|
|
308
|
+
`ActiveRecord::ActiveRecordError` the write path can raise, not only a
|
|
309
|
+
statement failure -- an application whose GET requests are routed to a
|
|
310
|
+
read replica by `ActiveRecord::Middleware::DatabaseSelector` gets
|
|
311
|
+
`ActiveRecord::ReadOnlyError` there instead, and it is redacted the same
|
|
312
|
+
way. Pointing `active_record_base` at a different database or a
|
|
313
|
+
writer-role class does not exempt this store from that routing decision;
|
|
314
|
+
see [Rails replica routing](docs/sql-cache.md#rails-replica-routing) for
|
|
315
|
+
what does. See
|
|
316
|
+
[Transactions](docs/sql-cache.md#transactions) and
|
|
317
|
+
[What ends up in your log](docs/sql-cache.md#what-ends-up-in-your-log).
|
|
318
|
+
- **A failing cache write no longer loses the translation it was caching --
|
|
319
|
+
for every store, not only the SQL one.** `Translator#fill` now rescues a
|
|
320
|
+
store failure, logs it, fires a new `cache_error` instrumentation event
|
|
321
|
+
(`provider` and the error's class, never the text), and returns the
|
|
322
|
+
translation regardless: the cache is an optimisation on top of a
|
|
323
|
+
translation already paid for at the provider, and losing the write should
|
|
324
|
+
never mean losing that. See
|
|
325
|
+
[The three write paths fail differently](docs/caching.md#the-three-write-paths-fail-differently)
|
|
326
|
+
and [Instrumentation](docs/instrumentation.md). The rate limiter refuses
|
|
327
|
+
rather than degrades under the same routing -- it runs before the provider
|
|
328
|
+
is called, so nothing has been paid for yet -- but it too now raises a
|
|
329
|
+
redacted `TranslationDiff::Error` rather than a raw `ActiveRecord` one.
|
|
330
|
+
See [Rails replica routing](docs/sql-cache.md#rails-replica-routing).
|
|
331
|
+
- **MySQL: the migration's `translation` column now carries
|
|
332
|
+
`limit: 16_777_215`, giving it `MEDIUMTEXT` instead of `TEXT`.** `TEXT`
|
|
333
|
+
caps at 65,535 bytes on MySQL; a single sentence over that size failed
|
|
334
|
+
the whole batch it rode in with. This is a no-op on Postgres and
|
|
335
|
+
SQLite -- neither has a length ceiling on `text` to begin with, and
|
|
336
|
+
nothing else about the schema changes for either. **An installation that
|
|
337
|
+
already ran this migration on MySQL needs one statement, once:**
|
|
338
|
+
`ALTER TABLE translation_diff_translations MODIFY translation MEDIUMTEXT NOT NULL;`
|
|
339
|
+
-- this gem never runs DDL, so nothing does this for you. See
|
|
340
|
+
[The migration](docs/sql-cache.md#the-migration).
|
|
341
|
+
- **`config.rate_limiter` no longer requires `config.rate_limit`.** Setting
|
|
342
|
+
the limiter alone used to pass `nil` as the threshold, overriding the
|
|
343
|
+
keyword default and crashing every check with
|
|
344
|
+
`ArgumentError: comparison of Integer with nil failed`. An unset
|
|
345
|
+
`rate_limit` now falls back to the limiter's own default -- 8,000
|
|
346
|
+
characters per `rate_interval`, the same for both shipped limiters. See
|
|
347
|
+
[Configuration options](docs/configuration.md#configuration-options).
|
|
348
|
+
- **A refused request now says what it hit.** `RateLimitExceeded`
|
|
349
|
+
carries a message naming the namespace, the threshold and the
|
|
350
|
+
interval (`"rate limit reached for translation-diff: 8000 characters per
|
|
351
|
+
60 seconds"`) -- never the content that tripped it. See
|
|
352
|
+
[Errors](docs/errors.md).
|
|
353
|
+
- **`cache_ttl` of `0` or less now means never expires, and `nil` is
|
|
354
|
+
reachable through `TranslationDiff.configure`.** Previously `nil` was
|
|
355
|
+
documented as meaningful but unreachable through the public
|
|
356
|
+
configuration path, and `0` wrote a row whose `expires_at` was already in
|
|
357
|
+
the past -- a cache entry that could never hit. Both now fold to the same
|
|
358
|
+
`nil` `expires_at`. See
|
|
359
|
+
[`cache_ttl` becomes `expires_at`](docs/sql-cache.md#cache_ttl-becomes-expires_at).
|
|
360
|
+
- **`ActiveRecordRateLimiter`'s sliding window is conservative, not
|
|
361
|
+
exact.** It sums the oldest bucket touching the trailing `rate_interval`
|
|
362
|
+
seconds in full, even though that bucket is only ever partially inside
|
|
363
|
+
the window, so the window actually enforced is `rate_interval` to
|
|
364
|
+
`rate_interval + rate_interval / 12` seconds -- slightly stricter than
|
|
365
|
+
configured, never looser. See
|
|
366
|
+
[The rate limiter](docs/sql-cache.md#the-rate-limiter).
|
|
367
|
+
- `write_multi(pairs)` joins the cache store contract, as an optional
|
|
368
|
+
method: a store that implements it gets one call carrying a whole batch
|
|
369
|
+
of sentences instead of one call per sentence; a store that does not is
|
|
370
|
+
still called once per sentence, exactly as before this method existed --
|
|
371
|
+
a custom cache store written against the older contract is unaffected.
|
|
372
|
+
All three shipped stores implement it now: `MemoryCacheStore` and
|
|
373
|
+
`RedisCacheStore` gain it here too, alongside `ActiveRecordCacheStore`.
|
|
374
|
+
The two batching paths fail differently from the per-key one and from
|
|
375
|
+
each other -- see
|
|
376
|
+
[The three write paths fail differently](docs/caching.md#the-three-write-paths-fail-differently).
|
|
377
|
+
|
|
378
|
+
- Five more providers, all on the same base class and the same transport:
|
|
379
|
+
`:google` (Cloud Translation v2), `:azure` (Azure AI Translator v3.0),
|
|
380
|
+
`:modernmt`, `:libretranslate` and `:amazon` (Amazon Translate). Each
|
|
381
|
+
declares its own options, its own limits, and what it can actually do --
|
|
382
|
+
see the provider table in the README, which is written from the same
|
|
383
|
+
capabilities the library reads at runtime.
|
|
384
|
+
- A Google provider: `config.provider = :google` translates through Cloud
|
|
385
|
+
Translation v2 (Basic) over HTTP directly, with no Google gem installed. It
|
|
386
|
+
declares `google_api_key`, `google_project_id` and `google_api_base`; an API
|
|
387
|
+
key alone is enough. With no key configured it reads `TRANSLATE_KEY` and
|
|
388
|
+
then `GOOGLE_CLOUD_KEY`, and `google_project_id` falls back to
|
|
389
|
+
`TRANSLATE_PROJECT` -- the variables `google-cloud-translate-v2` used to
|
|
390
|
+
read on your behalf. Application default credentials are **not** supported:
|
|
391
|
+
that path lived in the gem that is gone, and an application relying on ADC
|
|
392
|
+
must now configure an API key. The provider asks for `format: :html`, which
|
|
393
|
+
the tokenizer's output requires -- a `notranslate` span is handed over with
|
|
394
|
+
its tags -- and downcases bare language codes so a configuration written for
|
|
395
|
+
DeepL (`"EN"`) keeps working, leaving subtagged codes such as `"zh-Hans"`
|
|
396
|
+
alone.
|
|
397
|
+
- Environment-variable credential fallbacks, read by this library now that the
|
|
398
|
+
vendor SDKs that read them are gone: `DEEPL_AUTH_KEY` for `deepl_api_key`,
|
|
399
|
+
`TRANSLATE_KEY` then `GOOGLE_CLOUD_KEY` for `google_api_key`, and
|
|
400
|
+
`TRANSLATE_PROJECT` for `google_project_id`. Each is read on use rather than
|
|
401
|
+
at load, so setting one after requiring the gem still works, and an
|
|
402
|
+
explicitly configured value always wins. The Amazon provider deliberately
|
|
403
|
+
has no environment fallback: `aws-sigv4` is handed explicit credentials and
|
|
404
|
+
this library does not implement the AWS credential chain.
|
|
405
|
+
- A provider declares a default for one of its options by writing
|
|
406
|
+
`key => default` in `configuration_options` instead of a bare symbol; a
|
|
407
|
+
callable default is evaluated on every read.
|
|
408
|
+
- `TranslationDiff::Configuration`, a declarative settings object built
|
|
409
|
+
through the `option(key, default)` macro. Options fall back to their
|
|
410
|
+
default until assigned, treat a blank string as unset, and support a
|
|
411
|
+
callable default (evaluated on every read, not at load time). `#copy`
|
|
412
|
+
carries option values into an isolated configuration without carrying
|
|
413
|
+
already-built collaborators along with them.
|
|
414
|
+
- `TranslationDiff::Registry`, a small `name -> class` map backing every
|
|
415
|
+
pluggable extension point: a registered class need only answer
|
|
416
|
+
`build(config)`. Three registries exist: `TranslationDiff::Providers`,
|
|
417
|
+
`TranslationDiff::Stores`, and `TranslationDiff::Segmenters.registry`.
|
|
418
|
+
- `TranslationDiff::Context`, returned by `TranslationDiff.context`: an
|
|
419
|
+
isolated configuration scope with the same `#translate` entry point as the
|
|
420
|
+
`TranslationDiff` module, for multi-tenant and per-request configuration
|
|
421
|
+
that never touches the global configuration.
|
|
422
|
+
- Instrumentation and logging: `config.instrumenter` (anything satisfying
|
|
423
|
+
`ActiveSupport::Notifications`' interface) receives `translate`, `cache`,
|
|
424
|
+
`request` and `rate_limit` events, each named `<name>.translation_diff`
|
|
425
|
+
and carrying counts, language codes and provider names only -- never the
|
|
426
|
+
text being translated, its translation, or a credential. `config.logger`
|
|
427
|
+
receives a `debug` line per provider resolution, naming the provider
|
|
428
|
+
class, and is held to the same guarantee: it is never handed to
|
|
429
|
+
`deepl-rb`, which logs the whole request -- auth header and payload -- at
|
|
430
|
+
DEBUG when given a logger of its own. See "Instrumentation and logging" in
|
|
431
|
+
the README for how to opt into that logging deliberately. Both are no-ops,
|
|
432
|
+
at no extra cost, when left unset.
|
|
433
|
+
- `TranslationDiff::Providers.register` raises when a provider declares a
|
|
434
|
+
`configuration_options` name another provider already declared. The two
|
|
435
|
+
would otherwise share one accessor on `TranslationDiff::Configuration`, so
|
|
436
|
+
a credential set for one service would be sent to the other. The same
|
|
437
|
+
provider redeclaring its own options stays silent, so a double `require`
|
|
438
|
+
and Rails development reloading keep working.
|
|
439
|
+
- `TranslationDiff::MemoryCacheStore`, a bounded, in-process LRU, and the
|
|
440
|
+
new default cache store when `redis_url` is not configured -- so the
|
|
441
|
+
library works before any infrastructure does.
|
|
442
|
+
- `TranslationDiff::Segmenters::Pragmatic`, the default sentence segmenter,
|
|
443
|
+
wrapping `pragmatic_segmenter`'s per-language rule sets. Before
|
|
444
|
+
segmenting, it shadows every single newline (one with no adjoining
|
|
445
|
+
newline) to a space in a copy of the text -- `pragmatic_segmenter`
|
|
446
|
+
otherwise treats almost any single newline as a sentence boundary
|
|
447
|
+
candidate even with no punctuation at all, a false split that HTML text
|
|
448
|
+
nodes routinely trigger via incidental source-formatting newlines -- then
|
|
449
|
+
segments the shadow and recovers offsets against it, so the *original*
|
|
450
|
+
text, newline included, reaches the output untouched. Blank-line runs
|
|
451
|
+
(real paragraph breaks) are left alone. This costs one Golden Rules point
|
|
452
|
+
(77 -> 76: a bare list of items separated by single newlines, with no
|
|
453
|
+
punctuation, now segments as one unit instead of three) -- a deliberate
|
|
454
|
+
trade, since that shape does not arise in this gem's actual input. It
|
|
455
|
+
recovers offsets from the strings `pragmatic_segmenter` returns by locating
|
|
456
|
+
each one in the shadow, in order, and keeps every offset it locates; the
|
|
457
|
+
first sentence it cannot locate (`pragmatic_segmenter`'s cleaner also
|
|
458
|
+
collapses runs of three or more spaces and respaces abbreviations such as
|
|
459
|
+
`"Ph.D."`, among other things it rewrites) ends the search -- but the
|
|
460
|
+
boundary at the end of the last sentence it did locate is not discarded
|
|
461
|
+
with the rest, since it was matched character for character too; only the
|
|
462
|
+
genuinely unrecoverable remainder becomes one final unit. This is a
|
|
463
|
+
coarsening, not a failure -- every offset it ever emits has been verified,
|
|
464
|
+
so the cache unit is simply larger, never wrong. It never guesses an
|
|
465
|
+
offset it did not verify. `TranslationDiff::Segmenters::Pragmatic::Error`
|
|
466
|
+
exists for the one case that would still be silent corruption -- offsets
|
|
467
|
+
it computed itself violating their own postcondition (start at 0, strictly
|
|
468
|
+
increase, stay within the text) -- not for ordinary `pragmatic_segmenter`
|
|
469
|
+
rewriting.
|
|
470
|
+
- `TranslationDiff::Segmenters::Simple` (formerly `TranslationDiff::Segmenter`,
|
|
471
|
+
renamed and moved to its own namespace alongside `Pragmatic`), the
|
|
472
|
+
zero-dependency, in-house sentence segmenter this gem shipped with before
|
|
473
|
+
`pragmatic_segmenter` became the default. It is deliberately conservative:
|
|
474
|
+
it splits only on a handful of strong signals (a terminator followed by
|
|
475
|
+
whitespace and an uppercase or CJK next character, none of the guard
|
|
476
|
+
conditions -- a known abbreviation, an initial, digits on both sides, or a
|
|
477
|
+
URL/email -- matching) so that a missed sentence boundary, which only
|
|
478
|
+
costs a cache hit, is always preferred over a false one, which sends half
|
|
479
|
+
a sentence to the translation provider. Its central rule has no meaning in
|
|
480
|
+
scripts without letter case, which is why it is no longer the default; it
|
|
481
|
+
stays available for callers who want no extra dependency and translate
|
|
482
|
+
only from cased scripts. `config.segmenter` is swappable the same
|
|
483
|
+
way `config.provider` and `.cache` are.
|
|
484
|
+
- `TranslationDiff::Providers::DeepL` and `TranslationDiff::Providers::Null`.
|
|
485
|
+
- `test/support/provider_contract.rb`, the executable form of the provider
|
|
486
|
+
contract; any third-party provider can include it to verify it behaves as
|
|
487
|
+
documented.
|
|
488
|
+
- `detect` is now its own provider method. Previously
|
|
489
|
+
`api.translate(sample, nil, to)` returned an object of a different shape
|
|
490
|
+
than the same call with `from:` given -- one method, two return types,
|
|
491
|
+
told apart by an argument's value. A provider with no `detect` makes
|
|
492
|
+
`from:` required and raises a clear error when it is missing, instead of
|
|
493
|
+
`NoMethodError`.
|
|
494
|
+
- Four new providers: `TranslationDiff::Providers::Azure` (`:azure`),
|
|
495
|
+
`TranslationDiff::Providers::ModernMT` (`:modernmt`),
|
|
496
|
+
`TranslationDiff::Providers::LibreTranslate` (`:libretranslate`), and
|
|
497
|
+
`TranslationDiff::Providers::Amazon` (`:amazon`), Amazon Translate, signed
|
|
498
|
+
with `aws-sigv4` rather than headed. Every provider's limits, HTML
|
|
499
|
+
support, `notranslate` handling, detection and billing reporting are
|
|
500
|
+
declared through `Capabilities` and measured against the vendor rather
|
|
501
|
+
than assumed -- see the provider table in the README.
|
|
502
|
+
- An error hierarchy for everything a provider's transport can do wrong:
|
|
503
|
+
`TranslationDiff::ConfigurationError`, `TranslationDiff::ProviderError`
|
|
504
|
+
(and its `AuthenticationError`, `QuotaExceededError`,
|
|
505
|
+
`InvalidRequestError`, `ServiceError` and `RateLimitError` subclasses),
|
|
506
|
+
`TranslationDiff::TransportError`, `TranslationDiff::ResponseError` and
|
|
507
|
+
`TranslationDiff::InvalidProviderError`, all under `TranslationDiff::Error`.
|
|
508
|
+
- `config.open_timeout`, `config.timeout` and `config.max_retries`, read by
|
|
509
|
+
every HTTP provider's connection and retry policy.
|
|
510
|
+
|
|
511
|
+
### Changed
|
|
512
|
+
|
|
513
|
+
- `Adapters` renamed to `Providers`, and providers are now registered by
|
|
514
|
+
name through `TranslationDiff::Providers.register` rather than assigned
|
|
515
|
+
directly to a module accessor. A provider built through the registry is
|
|
516
|
+
stamped with its registered name and needs no `cache_key` of its own; an
|
|
517
|
+
object assigned straight to `config.provider`, bypassing the registry,
|
|
518
|
+
must define `cache_key` itself or every call through it raises -- it has
|
|
519
|
+
no registered name to fall back on.
|
|
520
|
+
- The cache key's options digest uses a canonical, version-stable encoding
|
|
521
|
+
instead of `Hash#inspect`, which renders symbol-keyed hashes differently
|
|
522
|
+
across Ruby 3.2-4.0.
|
|
523
|
+
|
|
524
|
+
### Fixed
|
|
525
|
+
|
|
526
|
+
- **A `notranslate` span nested inside an opaque element (`pre`, `code`,
|
|
527
|
+
`script` or `style`) no longer silences every sentence after it.**
|
|
528
|
+
Closing the protected span used to leave the scanner's own opacity depth
|
|
529
|
+
one too high, so nothing past it was ever handed to the segmenter again.
|
|
530
|
+
Found while adding the `pre`/`code` opaque elements above, and fixed the
|
|
531
|
+
same way for all four. See [How it works](docs/how-it-works.md#html).
|
|
532
|
+
- **Google and DeepL translations in HTML mode no longer come back
|
|
533
|
+
double-escaped.** Both vendors return entity-escaped text -- an
|
|
534
|
+
apostrophe as `'`, a quote as `"`, an ampersand as `&` -- and
|
|
535
|
+
the pipeline decoded entities on the way in but never on the way out, so
|
|
536
|
+
the renderer escaped the vendor's own `&` a second time and a reader saw
|
|
537
|
+
`didn't` on the page. English is full of apostrophes, so in practice
|
|
538
|
+
every Google or DeepL translation into English was affected somewhere.
|
|
539
|
+
`TranslationDiff::Translation::Response.build` now decodes a provider's
|
|
540
|
+
reply the same way it already decoded the source, symmetrically, for
|
|
541
|
+
every provider -- named entities, and both the decimal (`'`) and hex
|
|
542
|
+
(`'`) numeric forms, are decoded; an entity neither decoder
|
|
543
|
+
recognizes is left exactly as it arrived. See [How it
|
|
544
|
+
works](docs/how-it-works.md).
|
|
545
|
+
- **Behaviour change: a literal `<` in a source sentence now renders as
|
|
546
|
+
`<`.** Decoding the fix above exposed a second bug: a provider's own
|
|
547
|
+
`<` now decoded to a bare `<`, and a bare `<` in front of a letter
|
|
548
|
+
reads as an opening tag -- a provider could inject markup into the
|
|
549
|
+
rendered document. A translated `<` that is not shaped like a tag is now
|
|
550
|
+
escaped on render instead. `if a < b then stop.` used to come back with
|
|
551
|
+
the bare `<` exactly as written; it now comes back
|
|
552
|
+
`if a < b then stop.`, the correct HTML encoding of that character and
|
|
553
|
+
identical once a browser renders it -- but visible to anything comparing
|
|
554
|
+
output byte-for-byte against an earlier release. `>` is untouched: a
|
|
555
|
+
stray `>` never opens anything a parser would honour. See [How it
|
|
556
|
+
works](docs/how-it-works.md#html).
|
|
557
|
+
- **A warm cache keeps serving the corrupted text after you upgrade.** A
|
|
558
|
+
cache entry's key is derived from the source sentence, not from the value
|
|
559
|
+
stored under it, so an entry written before this fix is served exactly as
|
|
560
|
+
it was written until it expires -- upgrading alone does not clear it.
|
|
561
|
+
Give the configuration a new `cache_namespace`, or let `cache_ttl` lapse,
|
|
562
|
+
to force every sentence to be retranslated under the fix. See
|
|
563
|
+
[Caching](docs/caching.md).
|
|
564
|
+
|
|
565
|
+
- `notranslate` works with DeepL. The provider sent no `tag_handling`, and
|
|
566
|
+
per DeepL's documentation "tags are treated as regular text" without it,
|
|
567
|
+
so a span the tokenizer had marked as protected was translated anyway.
|
|
568
|
+
The provider now sends `tag_handling: :html` and
|
|
569
|
+
`tag_handling_version: "v2"`, both overridable per call. This has been
|
|
570
|
+
broken since the gem moved from Google to DeepL: `tag_handling` appears
|
|
571
|
+
nowhere in the repository's history before this change. It failed
|
|
572
|
+
quietly, because DeepL leaves the tags themselves alone either way and
|
|
573
|
+
only the protected content came back changed.
|
|
574
|
+
- Comments, doctypes and CDATA sections survive a translation. `Tokenizer`
|
|
575
|
+
declared no handler for those three Ox SAX events, so the bytes each one
|
|
576
|
+
covered belonged to no token: a leading `<!-- ... -->` or `<!DOCTYPE html>`
|
|
577
|
+
disappeared from the result outright, and a comment in the middle of a
|
|
578
|
+
sentence leaked its `<!` into the surrounding text and handed the comment's
|
|
579
|
+
own contents to the translation provider as prose -- both paying for the
|
|
580
|
+
characters and risking an internal note coming back translated in place of
|
|
581
|
+
the comment.
|
|
582
|
+
- `TranslationDiff::RedisRateLimiter` requires `ratelimit` lazily, on the
|
|
583
|
+
first check, and raises `TranslationDiff::Error` naming the gem to add
|
|
584
|
+
when it is missing. Previously the bare constant surfaced a raw
|
|
585
|
+
`NameError` instead of the message the Redis path already raises.
|
|
586
|
+
- DeepL's batch limit was declared as 300 sentences per request; DeepL
|
|
587
|
+
documents 50. The request-size limit (1,700 escaped characters) was
|
|
588
|
+
already correct and is unchanged.
|
|
589
|
+
- **An entity reference no longer reaches the provider raw.** `Salt &
|
|
590
|
+
pepper.` was sent to the provider as the six characters `&`, so the
|
|
591
|
+
provider translated the entity's spelling as if it were words -- and was
|
|
592
|
+
billed for it. It is now sent as `Salt & pepper.`, the text the document
|
|
593
|
+
actually says, and re-encoded on the way out. Named entities, `&` and
|
|
594
|
+
`&` alike are decoded; anything neither decoder knows is left as it
|
|
595
|
+
arrived.
|
|
596
|
+
- **A bare `<` no longer swallows the rest of the sentence.** `if a < b then
|
|
597
|
+
stop. Fine.` was parsed by `ox` as prose followed by an unclosed tag, so
|
|
598
|
+
only `if a` was ever sent for translation and everything after the `<` came
|
|
599
|
+
back untranslated. A `<` that no element name, closing name, declaration or
|
|
600
|
+
instruction follows is now escaped before parsing and restored after, so the
|
|
601
|
+
whole sentence is translated. `5 < 6 and 7 > 6. True.` was sent as three
|
|
602
|
+
fragments and is now sent as two sentences.
|
|
603
|
+
- **These two fixes move the cache key for the documents they affect.** A
|
|
604
|
+
document containing an entity reference, or a `<` that opens no tag, will
|
|
605
|
+
miss the cache once and be re-translated. That is the point: what was cached
|
|
606
|
+
for it was translated from the wrong text.
|
|
607
|
+
- **A known remaining limit: `<` still reaches a provider undecoded**, and
|
|
608
|
+
`a <b then stop. Fine.` still loses everything after the `<`. Both fall out
|
|
609
|
+
of escaping a bare `<` as `<` to work around `ox` rather than replacing
|
|
610
|
+
it with a lexer of this gem's own, which is out of scope here. `<b` cannot
|
|
611
|
+
be told apart from a tag without one. Every other entity is decoded.
|
|
612
|
+
|
|
613
|
+
### Security
|
|
614
|
+
|
|
615
|
+
- `Configuration#inspect` and `Provider#inspect` print `[FILTERED]` in place
|
|
616
|
+
of every credential option's value, instead of the credential itself. The
|
|
617
|
+
filtered set is derived, not hand-maintained: option names matching a
|
|
618
|
+
sensitive pattern, plus whatever each registered provider declares in
|
|
619
|
+
`sensitive_options`. A non-credential option -- a base URL, a region,
|
|
620
|
+
`cache_namespace` -- stays visible in full. A URL-valued option that
|
|
621
|
+
carries a credential in its userinfo, `redis_url` included, has just that
|
|
622
|
+
part redacted (`rediss://default:[FILTERED]@cache.example.upstash.io:6379`);
|
|
623
|
+
the scheme, host, port and path stay visible. See
|
|
624
|
+
[Providers](docs/providers.md).
|
|
625
|
+
|
|
626
|
+
## deepl_diff
|
|
627
|
+
|
|
628
|
+
The releases below were published under the gem's former name, `deepl_diff`.
|
|
629
|
+
|
|
630
|
+
## [2.2.0] - 2026-09-07
|
|
631
|
+
|
|
632
|
+
Message-only release under the `deepl_diff` name. No code changes.
|
|
633
|
+
|
|
634
|
+
### Added
|
|
635
|
+
|
|
636
|
+
- `spec.post_install_message`, pointing installers at `translation_diff`,
|
|
637
|
+
the gem's new name. `deepl_diff` 2.2.0 is the last release under that
|
|
638
|
+
name and keeps working for anyone pinned to it.
|
|
639
|
+
|
|
640
|
+
## [2.1.0] - 2026-09-07
|
|
641
|
+
|
|
642
|
+
Six bugs found while auditing the library, each reproduced against the real
|
|
643
|
+
behaviour and covered by a regression test.
|
|
644
|
+
|
|
645
|
+
### Added
|
|
646
|
+
|
|
647
|
+
- `DeepLDiff::Request::Error`, raised when the adapter returns fewer
|
|
648
|
+
translations than requested, instead of letting `nil` reach `Spacing` and
|
|
649
|
+
fail two layers away as `NoMethodError`.
|
|
650
|
+
|
|
651
|
+
### Fixed
|
|
652
|
+
|
|
653
|
+
- The options hash passed to `Request` was consumed with `Hash#delete`, so a
|
|
654
|
+
second call with the same hash -- or any frozen hash -- broke.
|
|
655
|
+
- The chunker compared raw `String#size` against a limit meant for the
|
|
656
|
+
escaped request, so Cyrillic and other non-Latin text could run several
|
|
657
|
+
times over the actual request-size limit.
|
|
658
|
+
- A detected source language (a `String`) was compared against `:to`
|
|
659
|
+
(usually a `Symbol`) with `==`, so the same-language short circuit never
|
|
660
|
+
fired and text was translated into its own language.
|
|
661
|
+
- Non-string scalars (`nil`, `Integer`, `Symbol`) raised instead of passing
|
|
662
|
+
through untouched.
|
|
663
|
+
- The chunk count limit was off by one.
|
|
664
|
+
|
|
665
|
+
### Upgrade note
|
|
666
|
+
|
|
667
|
+
The chunker fix changes how non-Latin text is split into requests: chunks
|
|
668
|
+
are smaller, so there are more requests for the same number of characters
|
|
669
|
+
billed. A `MAX_CHUNK_SIZE` tuned empirically against Cyrillic now produces a
|
|
670
|
+
very different payload than it did.
|
|
671
|
+
|
|
672
|
+
## [2.0.0] - 2026-09-07
|
|
673
|
+
|
|
674
|
+
### Breaking
|
|
675
|
+
|
|
676
|
+
- Six unused or duck-typed runtime dependencies were dropped:
|
|
677
|
+
`dry-initializer`, `connection_pool`, `redis`, `deepl-rb`,
|
|
678
|
+
`redis-namespace`, `ratelimit`. Applications that relied on this gem to
|
|
679
|
+
install them must now depend on them directly.
|
|
680
|
+
- `RedisRateLimiter` takes `threshold:` and `interval:` as keyword
|
|
681
|
+
arguments.
|
|
682
|
+
|
|
683
|
+
### Fixed
|
|
684
|
+
|
|
685
|
+
- `RedisRateLimiter` declared `threshold` and `interval` as positional
|
|
686
|
+
parameters, so the keyword call the README already documented silently
|
|
687
|
+
discarded them and fell back to the defaults (8000 / 60).
|
|
688
|
+
|
|
689
|
+
## [1.1.1] - 2026-09-07
|
|
690
|
+
|
|
691
|
+
No changes to `lib/`.
|
|
692
|
+
|
|
693
|
+
### Changed
|
|
694
|
+
|
|
695
|
+
- The test suite moved from RSpec to Minitest.
|
|
696
|
+
- The gem is published from CI through RubyGems trusted publishing instead
|
|
697
|
+
of a personal API key.
|
|
698
|
+
|
|
699
|
+
## [1.1.0] - 2026-09-07
|
|
700
|
+
|
|
701
|
+
### Changed
|
|
702
|
+
|
|
703
|
+
- Every runtime dependency is now explicitly versioned; the gem requires
|
|
704
|
+
Ruby >= 3.2.
|
|
705
|
+
- CI moved from Travis to GitHub Actions, running against Ruby 3.2, 3.3,
|
|
706
|
+
3.4 and 4.0, plus a RuboCop job.
|
|
707
|
+
|
|
708
|
+
### Fixed
|
|
709
|
+
|
|
710
|
+
- `cgi/escape`, `digest/md5`, `forwardable` and `stringio` are now required
|
|
711
|
+
explicitly instead of relying on them being pulled in transitively.
|
|
712
|
+
- DeepL options were passed via `**options` into a method that takes a
|
|
713
|
+
positional hash, so an empty options hash passed nothing.
|
|
714
|
+
- `Chunker::Chunk` members `values`/`size` were renamed (eventually to
|
|
715
|
+
`texts`/`escaped_size`) because they shadowed `Struct#values` and
|
|
716
|
+
`Struct#size`.
|
|
717
|
+
|
|
718
|
+
[1.0.0]: https://github.com/Halvanhelv/translation_diff/compare/v2.2.0...v1.0.0
|
|
719
|
+
[2.2.0]: https://github.com/Halvanhelv/deepl_diff/compare/v2.1.0...v2.2.0
|
|
720
|
+
[2.1.0]: https://github.com/Halvanhelv/deepl_diff/compare/v2.0.0...v2.1.0
|
|
721
|
+
[2.0.0]: https://github.com/Halvanhelv/deepl_diff/compare/v1.1.1...v2.0.0
|
|
722
|
+
[1.1.1]: https://github.com/Halvanhelv/deepl_diff/compare/v1.1.0...v1.1.1
|
|
723
|
+
[1.1.0]: https://github.com/Halvanhelv/deepl_diff/compare/v1.0.1...v1.1.0
|