embedded_localization 1.3.1 → 1.4.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/Gemfile +14 -3
- data/README.md +133 -5
- data/Rakefile +4 -14
- data/embedded_localization.gemspec +16 -13
- data/lib/embedded_localization/active_record/act_macro.rb +29 -67
- data/lib/embedded_localization/active_record/class_methods.rb +14 -0
- data/lib/embedded_localization/active_record/instance_methods.rb +52 -32
- data/lib/embedded_localization/storage/hstore.rb +61 -0
- data/lib/embedded_localization/storage/json.rb +25 -0
- data/lib/embedded_localization/version.rb +1 -1
- data/lib/embedded_localization.rb +6 -3
- metadata +11 -28
- data/.github/dependabot.yml +0 -8
- data/.github/workflows/codeql-analysis.yml +0 -72
- data/.github/workflows/ruby.yml +0 -64
- data/.gitignore +0 -7
- data/.rspec +0 -1
- data/lib/extensions/hash.rb +0 -7
- data/spec/embedded_localization/native_column_spec.rb +0 -191
- data/spec/embedded_localization/simple_model_spec.rb +0 -190
- data/spec/models.rb +0 -7
- data/spec/schema.rb +0 -18
- data/spec/spec.opts +0 -2
- data/spec/spec_helper.rb +0 -36
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6957e8085bbd37d3849c1b6f53b58f5d43188277db18acb757da7072d72ca6bc
|
|
4
|
+
data.tar.gz: 2f1ad574f456f63b92ab8f569e30da8a6d929ca70b941c4dfa1e5685694e0ce9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 940207de0ca5710093871a3abada7fc47ff1eef2993f0038f3bc624a94c1b7af9971fc817eaefadd0db73a1e88ac28f650e55237c1f2f04a72a49516883b6db6
|
|
7
|
+
data.tar.gz: dc86f831f57c6ec45e8192a8f1ae1dda75c2ae7f0107ff36b4a48ab68840c657e39031b115431d3967825ab2866b39183414e00a4c6825e0265038f55a4f26d7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# EmbeddedLocalization CHANGE LOG
|
|
2
2
|
|
|
3
|
+
## 1.4.0 (2026-08-24)
|
|
4
|
+
* new `storage:` option for `translates`: store the translations in a `json` / `jsonb` column (`storage: :json`) or in a PostgreSQL `hstore` column (`storage: :hstore`) instead of the YAML `text` column; the translated values are then queryable in SQL (see README, Example 3)
|
|
5
|
+
* fallbacks now follow the chain configured in `I18n.fallbacks` (e.g. `config.i18n.fallbacks = { 'de-AT' => 'de' }`: `:"de-AT"` → `:de` → `I18n.default_locale`); `I18n.default_locale` is always the last fallback, as before
|
|
6
|
+
* bug fix: fallbacks did not work for the current `I18n.locale` of a record loaded from the database, nor for a locale that had translations for other attributes; a `nil` translation now always falls back (as the README documented)
|
|
7
|
+
* bug fix: with `fallbacks: true`, reading an attribute that has no translation in `I18n.default_locale` returned a Hash of nils on unsaved records instead of nil
|
|
8
|
+
* bug fix: assigning a translation equal to the current value marked the record as changed on models without a DB column for that attribute (the fix for issue #4 only covered models with such a column)
|
|
9
|
+
* bug fix: `set_localized_attribute(attr, locale, value)` compared against `I18n.locale` instead of `locale`; when the current locale already held the same value, the translation for `locale` was not stored
|
|
10
|
+
* removed the `Hash.zip` monkey patch (`lib/extensions/hash.rb`); the gem no longer adds methods to core classes
|
|
11
|
+
* CI: ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested against SQLite, PostgreSQL and MySQL; Ruby 2.5 through 4.0, `head` and TruffleRuby; coverage upload through `codecov/codecov-action` instead of the unmaintained `codecov` gem
|
|
12
|
+
|
|
3
13
|
## 1.3.1 (2024-11-26)
|
|
4
14
|
* [Issue 14](https://github.com/tilo/embedded_localization/pull/14) Fix active support proxy object deprecation (thanks to [Romain Morlevat](https://github.com/RomainMorlevat))
|
|
5
15
|
|
data/Gemfile
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source 'https://rubygems.org'
|
|
2
4
|
|
|
3
5
|
# Specify your gem's dependencies in embedded_localization.gemspec
|
|
4
6
|
gemspec
|
|
@@ -7,7 +9,16 @@ gem 'rake'
|
|
|
7
9
|
|
|
8
10
|
group :test do
|
|
9
11
|
gem 'rspec'
|
|
10
|
-
gem '
|
|
12
|
+
gem 'simplecov'
|
|
11
13
|
gem 'i18n'
|
|
12
|
-
gem '
|
|
14
|
+
gem 'pg' if ENV['DB'] == 'postgresql' # DB=postgresql bundle exec rake (jsonb and hstore columns)
|
|
15
|
+
gem 'mysql2' if ENV['DB'] == 'mysql' # DB=mysql bundle exec rake
|
|
16
|
+
|
|
17
|
+
if ENV['RAILS_VERSION'] # RAILS_VERSION=7.2 bundle exec rake
|
|
18
|
+
gem 'activerecord', "~> #{ENV['RAILS_VERSION']}.0"
|
|
19
|
+
gem 'sqlite3', Gem::Version.new(ENV['RAILS_VERSION']) < Gem::Version.new('7.1') ? '~> 1.4' : '>= 1.4' # the sqlite adapter of Rails < 7.1 requires sqlite3 ~> 1.4
|
|
20
|
+
else
|
|
21
|
+
gem 'activerecord'
|
|
22
|
+
gem 'sqlite3'
|
|
23
|
+
end
|
|
13
24
|
end
|
data/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Embedded Localization
|
|
2
2
|
|
|
3
|
-
[](https://codecov.io/gh/tilo/embedded_localization)
|
|
3
|
+
 [](https://codecov.io/gh/tilo/embedded_localization) <!-- [](https://rubygems.org/gems/embedded_localization) --> [](https://rubygems.org/gems/embedded_localization) [](https://www.ruby-toolbox.com/projects/embedded_localization)
|
|
4
4
|
|
|
5
5
|
`embedded_localization` allows you to store your translations directly insight each record.
|
|
6
6
|
|
|
7
|
-
`embedded_localization` is compatible with Rails 6.
|
|
7
|
+
`embedded_localization` is compatible with Rails 6.1, 7.x and 8.x, and adds model translations to ActiveRecord, and is compatible with and builds on the [I18n API in Ruby on Rails](http://guides.rubyonrails.org/i18n.html)
|
|
8
8
|
|
|
9
9
|
`embedded_localization` is very lightweight, and allows you to transparently store multiple translations of attributes right inside each record — no extra database tables needed to store the localization data! Make sure that your database default encoding is UTF-8 or UFT-16.
|
|
10
10
|
|
|
@@ -26,7 +26,8 @@ If your requirements are different, this approach might not work for you. In tha
|
|
|
26
26
|
|
|
27
27
|
## Requirements
|
|
28
28
|
|
|
29
|
-
*
|
|
29
|
+
* Ruby >= 2.5 # Ruby 2.5 through 4.0, `head` and TruffleRuby are tested in CI
|
|
30
|
+
* ActiveRecord >= 6 # ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested in CI, against SQLite, PostgreSQL and MySQL
|
|
30
31
|
* [I18n](http://guides.rubyonrails.org/i18n.html)
|
|
31
32
|
|
|
32
33
|
## Installation
|
|
@@ -40,6 +41,8 @@ To install Embedded_Localization, use:
|
|
|
40
41
|
|
|
41
42
|
Adding localization to a table is very simple. Just add a text field named `i18n` to the table, and you are ready to go! This allows you to add translated fields via the helper method `translates` in the model.
|
|
42
43
|
|
|
44
|
+
Instead of a text field, the `i18n` column can also be a `json` / `jsonb` column, or a PostgreSQL `hstore` column, which makes the translated values queryable in SQL — see [Example 3](#example-3).
|
|
45
|
+
|
|
43
46
|
Optionally, you can also keep a DB field with the same name as the translated field, which will store the values for the `I18n.default_locale`.
|
|
44
47
|
|
|
45
48
|
Model translations allow you to translate your models’ attribute values. The attribute type needs to be string or text.
|
|
@@ -91,6 +94,68 @@ class CreateGenres < ActiveRecord::Migration
|
|
|
91
94
|
end
|
|
92
95
|
```
|
|
93
96
|
|
|
97
|
+
### Example 3
|
|
98
|
+
|
|
99
|
+
Instead of the YAML text column, the translations can be stored in a `json` / `jsonb` column, or in a PostgreSQL `hstore` column. Tell `translates` which one you use with the `storage:` option:
|
|
100
|
+
|
|
101
|
+
| `translates ... storage:` | `i18n` column type | Stored as | SQL example: find `name` translated to `:de` |
|
|
102
|
+
|---------------------------|------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
103
|
+
| `:yaml` (default) | `text` | YAML: `{en: {name: "..."}, de: {name: "..."}}` | not possible |
|
|
104
|
+
| `:json` or `:jsonb` | `json` (SQLite, MySQL, PostgreSQL) or `jsonb` (PostgreSQL) | JSON: `{"en": {"name": "..."}, "de": {"name": "..."}}` | PostgreSQL: `i18n -> 'de' ->> 'name' = ?` ; SQLite: `json_extract(i18n, '$.de.name') = ?` ; MySQL: `JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.de.name')) = ?` |
|
|
105
|
+
| `:hstore` | `hstore` (PostgreSQL) | flat keys: `"en.name" => "...", "de.name" => "..."` | `i18n -> 'de.name' = ?` |
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class CreateGenres < ActiveRecord::Migration[7.1]
|
|
109
|
+
def change
|
|
110
|
+
create_table :genres do |t|
|
|
111
|
+
t.jsonb :i18n # stores ALL the translated attributes; persisted as JSON (`t.json :i18n` on SQLite / MySQL, or on PostgreSQL if you prefer json over jsonb)
|
|
112
|
+
|
|
113
|
+
t.timestamps
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class Genre < ActiveRecord::Base
|
|
119
|
+
translates :name, :description, storage: :json # :json for json and jsonb columns (:jsonb is accepted as well), :hstore for an hstore column
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Genre.where("i18n -> 'de' ->> 'name' = ?", "Science-Fiction") # PostgreSQL json / jsonb
|
|
123
|
+
Genre.where("json_extract(i18n, '$.de.name') = ?", "Science-Fiction") # SQLite json
|
|
124
|
+
Genre.where("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.de.name')) = ?", "Science-Fiction") # MySQL json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For an `hstore` column, the migration needs `enable_extension 'hstore'`, and each translation is stored under the key `"<locale>.<attribute>"`, because `hstore` cannot nest:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class CreateGenres < ActiveRecord::Migration[7.1]
|
|
131
|
+
def change
|
|
132
|
+
enable_extension 'hstore'
|
|
133
|
+
|
|
134
|
+
create_table :genres do |t|
|
|
135
|
+
t.hstore :i18n # stores ALL the translated attributes; persisted as "en.name" => "...", "de.name" => "...", ...
|
|
136
|
+
|
|
137
|
+
t.timestamps
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class Genre < ActiveRecord::Base
|
|
143
|
+
translates :name, :description, storage: :hstore
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
Genre.where("i18n -> 'de.name' = ?", "Science-Fiction")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The extra DB column for the default locale from Example 2 (`t.string :name`) works the same way with all three storages.
|
|
150
|
+
|
|
151
|
+
Notes:
|
|
152
|
+
|
|
153
|
+
* In Ruby, the `i18n` Hash always has Symbol keys for locales and attributes, whatever the storage: `genre.i18n # => {en: {name: "Science Fiction"}, de: {name: "Science-Fiction"}}`.
|
|
154
|
+
* Existing tables keep working unchanged: the default is still the YAML `text` column. Changing the column type of an existing table means converting the stored YAML to JSON or hstore in a data migration; the gem does not do that for you.
|
|
155
|
+
* `json` columns work on SQLite, MySQL and PostgreSQL; `jsonb` and `hstore` columns are PostgreSQL only. The test suite runs against all three databases (`DB=postgresql` / `DB=mysql`, default SQLite).
|
|
156
|
+
* `Genre.translation_storage # => :json` reports the storage of a model.
|
|
157
|
+
|
|
158
|
+
|
|
94
159
|
# Usage
|
|
95
160
|
|
|
96
161
|
In your code you can modify the values of your translated attributes in two ways.
|
|
@@ -164,7 +229,20 @@ g.set_localized_attribute( :name, :jp, "サイエンスフィクション" )
|
|
|
164
229
|
scifi = Genre.where(:name => "science fiction").first
|
|
165
230
|
```
|
|
166
231
|
|
|
167
|
-
Limitation:
|
|
232
|
+
Limitation: with the YAML text column (the default), you can not search for the translated strings other than for your default locale.
|
|
233
|
+
|
|
234
|
+
With a `json` / `jsonb` / `hstore` column (see Example 3) every locale can be searched, and the extra DB column for the default locale is still possible on top of that:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class Genre < ActiveRecord::Base
|
|
238
|
+
translates :name, :description, storage: :json # or :hstore
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
Genre.where("i18n -> 'jp' ->> 'name' = ?", "サイエンスフィクション") # PostgreSQL json / jsonb
|
|
242
|
+
Genre.where("json_extract(i18n, '$.jp.name') = ?", "サイエンスフィクション") # SQLite json
|
|
243
|
+
Genre.where("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.jp.name')) = ?", "サイエンスフィクション") # MySQL json
|
|
244
|
+
Genre.where("i18n -> 'jp.name' = ?", "サイエンスフィクション") # PostgreSQL hstore (storage: :hstore)
|
|
245
|
+
```
|
|
168
246
|
|
|
169
247
|
|
|
170
248
|
## Data Migration
|
|
@@ -181,9 +259,41 @@ end
|
|
|
181
259
|
Genre.record_timestamps = true
|
|
182
260
|
```
|
|
183
261
|
|
|
262
|
+
### Converting the `i18n` column from YAML text to json / jsonb / hstore
|
|
263
|
+
|
|
264
|
+
The gem does not convert stored data. This migration does it for a table that was created as in Example 1 or 2: it renames the old column, adds the new one, copies every record's translations, and drops the old column. The model inside the migration reads the old column with `serialize` (the way the gem wrote it) and writes the new column through `translates ... storage:`, so the conversion is the same for `:json`, `:jsonb` and `:hstore`.
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
class ConvertGenresI18nToJsonb < ActiveRecord::Migration[7.1]
|
|
268
|
+
# a model for this migration only
|
|
269
|
+
class Genre < ActiveRecord::Base
|
|
270
|
+
serialize :i18n_yaml, coder: YAML, type: Hash # the old column
|
|
271
|
+
translates :name, :description, storage: :jsonb # the new column; :json or :hstore work the same way
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def up
|
|
275
|
+
rename_column :genres, :i18n, :i18n_yaml
|
|
276
|
+
add_column :genres, :i18n, :jsonb # or :json / :hstore (hstore also needs enable_extension 'hstore')
|
|
277
|
+
Genre.reset_column_information
|
|
278
|
+
|
|
279
|
+
Genre.find_each do |genre|
|
|
280
|
+
genre.update_column(:i18n, genre.i18n_yaml) # update_column: no validations, no callbacks, timestamps untouched
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
remove_column :genres, :i18n_yaml
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def down
|
|
287
|
+
raise ActiveRecord::IrreversibleMigration
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Afterwards, change the model to `translates :name, :description, storage: :jsonb` (or `:json` / `:hstore`). This migration is run by the test suite against SQLite (json), MySQL (json) and PostgreSQL (json, jsonb, hstore), see `spec/embedded_localization/storage_conversion_spec.rb`.
|
|
293
|
+
|
|
184
294
|
## I18n fallbacks for empty translations
|
|
185
295
|
|
|
186
|
-
It is possible to enable fallbacks for empty translations. It will depend on the configuration setting you have set for I18n translations in your Rails config, or you can enable fallback when you define the translation fields.
|
|
296
|
+
It is possible to enable fallbacks for empty translations. It will depend on the configuration setting you have set for I18n translations in your Rails config, or you can enable fallback when you define the translation fields. The fallback locales are the chain configured in `I18n.fallbacks` (e.g. `config.i18n.fallbacks = { 'de-AT' => 'de' }` makes `:"de-AT"` fall back to `:de`), followed by `I18n.default_locale`, which is always the last fallback; without a configured chain, the fallback is `I18n.default_locale` alone.
|
|
187
297
|
|
|
188
298
|
You can enable them by adding the next line to `config/application.rb` (or only `config/environments/production.rb` if you only want them in production)
|
|
189
299
|
|
|
@@ -213,6 +323,22 @@ I18n.locale = :de
|
|
|
213
323
|
g.name # => 'science fiction'
|
|
214
324
|
```
|
|
215
325
|
|
|
326
|
+
With a fallback chain (Rails: `config.i18n.fallbacks = { 'de-AT' => 'de' }`), the locales are tried in this order: the requested locale, its chain, then `I18n.default_locale`:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
class Genre < ActiveRecord::Base
|
|
330
|
+
translates :name, :description, :fallbacks => true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
g = Genre.first
|
|
334
|
+
g.name(:en) # => 'science fiction'
|
|
335
|
+
g.name(:de) # => 'Science-Fiction'
|
|
336
|
+
g.name(:"de-AT") # => 'Science-Fiction' (no :"de-AT" translation, so :de is used)
|
|
337
|
+
g.name(:fr) # => 'science fiction' (no chain for :fr, so I18n.default_locale is used)
|
|
338
|
+
|
|
339
|
+
Genre.fallback_locales(:"de-AT") # => [:de, :en] the locales tried after :"de-AT"
|
|
340
|
+
```
|
|
341
|
+
|
|
216
342
|
## Want some Candy?
|
|
217
343
|
|
|
218
344
|
It's nice to have the values of attributes be set or read with the current locale, but `embedded_localization` offers you a couple of additional features, which can come in handy.
|
|
@@ -224,6 +350,7 @@ Each class which uses `embedded_localization` will have these additional methods
|
|
|
224
350
|
* Klass.translated_attributes
|
|
225
351
|
* Klass.translated?
|
|
226
352
|
* Klass.fallbacks?
|
|
353
|
+
* Klass.translation_storage
|
|
227
354
|
|
|
228
355
|
e.g.:
|
|
229
356
|
|
|
@@ -231,6 +358,7 @@ e.g.:
|
|
|
231
358
|
Genre.translated_attributes # => [:name,:description]
|
|
232
359
|
Genre.translated? # => true
|
|
233
360
|
Genre.fallbacks? # => false
|
|
361
|
+
Genre.translation_storage # => :yaml (the `storage:` option given to `translates`: :yaml, :json, :jsonb or :hstore)
|
|
234
362
|
```
|
|
235
363
|
|
|
236
364
|
### Instance Methods
|
data/Rakefile
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require 'rubygems'
|
|
4
|
-
require 'rake'
|
|
1
|
+
# frozen_string_literal: true
|
|
5
2
|
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
6
4
|
require 'rspec/core/rake_task'
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
RSpec::Core::RakeTask.new do |t|
|
|
10
|
-
t.verbose = false
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
desc "Run specs for all test cases"
|
|
14
|
-
task :spec_all do
|
|
15
|
-
system "rake spec"
|
|
16
|
-
end
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
17
7
|
|
|
18
|
-
task :
|
|
8
|
+
task default: :spec
|
|
19
9
|
|
|
20
10
|
desc 'Run spec with coverage'
|
|
21
11
|
task :coverage do
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require File.expand_path("lib/embedded_localization/version", __dir__)
|
|
4
5
|
|
|
5
6
|
Gem::Specification.new do |spec|
|
|
6
7
|
spec.name = "embedded_localization"
|
|
@@ -9,25 +10,27 @@ Gem::Specification.new do |spec|
|
|
|
9
10
|
spec.email = ["tilo.sloboda@gmail.com"]
|
|
10
11
|
spec.homepage = "https://github.com/tilo/embedded_localization"
|
|
11
12
|
spec.summary = %q{Rails I18n: library for embedded ActiveRecord model/data translation}
|
|
12
|
-
spec.description = %q{Rails I18n: a very lightweight tool to allow you to transparently store multiple translations of attributes directly inside each DB record -- no extra database tables needed to store the localization data!}
|
|
13
|
+
spec.description = %q{Rails I18n: a very lightweight tool to allow you to transparently store multiple translations of attributes directly inside each DB record -- no extra database tables needed to store the localization data! All translations of a record live in one column: a YAML text column, a json/jsonb column, or a PostgreSQL hstore column.}
|
|
14
|
+
spec.license = "MIT"
|
|
13
15
|
|
|
14
|
-
# spec.platform = Gem::Platform::RUBY
|
|
15
16
|
spec.required_ruby_version = ">= 2.5.0"
|
|
16
17
|
|
|
17
|
-
spec.metadata["homepage_uri"]
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
19
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
-
spec.metadata["changelog_uri"]
|
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/tilo/embedded_localization/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/tilo/embedded_localization/issues"
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
spec.
|
|
23
|
-
|
|
23
|
+
# Files shipped in the gem: everything tracked by git except the specs and the CI / git configuration.
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
26
|
+
f.match(%r{\A(?:spec/|\.(?:git|github|rspec|circleci))})
|
|
27
|
+
end
|
|
28
|
+
end
|
|
24
29
|
spec.require_paths = ["lib"]
|
|
25
|
-
|
|
26
|
-
# specify any dependencies here; for example:
|
|
27
|
-
spec.add_development_dependency "codecov"
|
|
30
|
+
|
|
28
31
|
spec.add_development_dependency "rspec"
|
|
32
|
+
spec.add_development_dependency "simplecov"
|
|
29
33
|
spec.add_development_dependency "activerecord", ">= 6"
|
|
30
34
|
spec.add_development_dependency "i18n"
|
|
31
35
|
spec.add_development_dependency "sqlite3"
|
|
32
|
-
# spec.add_runtime_dependency "rest-client"
|
|
33
36
|
end
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
module EmbeddedLocalization
|
|
2
2
|
module ActiveRecord
|
|
3
3
|
module ActMacro
|
|
4
|
+
STORAGES = [:yaml, :json, :jsonb, :hstore].freeze
|
|
5
|
+
|
|
6
|
+
# translates :name, :description # `i18n` is a text column, stored as YAML (default)
|
|
7
|
+
# translates :name, :description, storage: :json # `i18n` is a json or jsonb column
|
|
8
|
+
# translates :name, :description, storage: :hstore # `i18n` is a PostgreSQL hstore column
|
|
9
|
+
# translates :name, :description, fallbacks: true # a locale without translations reads the I18n.default_locale value
|
|
10
|
+
#
|
|
11
|
+
# for details about I18n fallbacks, check the source:
|
|
12
|
+
# i18n-0.9.0/lib/i18n/backend/fallbacks.rb
|
|
13
|
+
# i18n-0.9.0/lib/i18n/locale/fallbacks.rb
|
|
4
14
|
def translates(*attr_names)
|
|
5
15
|
return if translates? # cludge to make sure we don't set this up twice..
|
|
6
16
|
|
|
7
|
-
# for details about I18n fallbacks, check the source:
|
|
8
|
-
# i18n-0.9.0/lib/i18n/backend/fallbacks.rb
|
|
9
|
-
# i18n-0.9.0/lib/i18n/locale/fallbacks.rb
|
|
10
|
-
|
|
11
|
-
# options[:fallbacks] => true or false # not used at this time
|
|
12
17
|
options = attr_names.extract_options!
|
|
18
|
+
storage = (options[:storage] || :yaml).to_sym
|
|
19
|
+
unless STORAGES.include?(storage)
|
|
20
|
+
raise ArgumentError, "unknown storage: #{storage.inspect} -- use one of #{STORAGES.inspect}"
|
|
21
|
+
end
|
|
13
22
|
|
|
14
23
|
class_attribute :translated_attribute_names, :translation_options
|
|
15
24
|
self.translated_attribute_names = attr_names.map(&:to_sym).sort.uniq
|
|
@@ -18,73 +27,27 @@ module EmbeddedLocalization
|
|
|
18
27
|
include InstanceMethods
|
|
19
28
|
extend ClassMethods
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# field :i18n, type: Hash
|
|
33
|
-
# but on the other hand, Mongoid now supports "localized fields" -- so we don't need to re-implement this.
|
|
34
|
-
# Yay! Durran Jordan is awesome! :-) See: http://mongoid.org/docs/documents/localized.html
|
|
35
|
-
#
|
|
36
|
-
# NOTE: I like how Durran implemented the localization in Mongoid.. too bad I didn't see that before.
|
|
37
|
-
# I'm thinking of re-writing this gem to store the localization hash per attribute... hmm... hmm... thinking...
|
|
38
|
-
# there would be a couple of advantages to store the I18n-hash per attribute:
|
|
39
|
-
# - drop-in internationalization for existing String type attributes
|
|
40
|
-
# - works well with rails scaffolding and with protection of attributes (attr_protected / attr_accessible)
|
|
41
|
-
# - we can easily hide the internal hash by re-defining the attr-accessors for doing the I18n
|
|
42
|
-
# - we can better add the per-attribute versioning, which is planned
|
|
43
|
-
# -
|
|
44
|
-
#+
|
|
30
|
+
# the `i18n` column holds all translations of the record as {locale => {attribute => value}}
|
|
31
|
+
# we should also protect it from direct assignment by the user
|
|
32
|
+
case storage
|
|
33
|
+
when :yaml
|
|
34
|
+
serialize :i18n, coder: YAML, type: Hash
|
|
35
|
+
when :json, :jsonb
|
|
36
|
+
attribute :i18n, EmbeddedLocalization::Storage::Json.new
|
|
37
|
+
when :hstore
|
|
38
|
+
attribute :i18n, EmbeddedLocalization::Storage::Hstore.new
|
|
39
|
+
end
|
|
45
40
|
|
|
46
41
|
after_initialize :initialize_i18n_hashes
|
|
47
42
|
|
|
48
43
|
# dynamically define the accessors for the translated attributes:
|
|
49
|
-
|
|
50
44
|
translated_attribute_names.each do |attr_name|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
define_method(attr_name) do |locale = I18n.locale|
|
|
55
|
-
if self.i18n.has_key?(locale)
|
|
56
|
-
if self.i18n[locale].keys.include?(attr_name)
|
|
57
|
-
self.i18n[ locale ][attr_name]
|
|
58
|
-
else
|
|
59
|
-
nil
|
|
60
|
-
end
|
|
61
|
-
else
|
|
62
|
-
# fallback to the I18n.default_locale if we do fallbacks:
|
|
63
|
-
if self.class.fallbacks? && self.i18n[ I18n.default_locale ]
|
|
64
|
-
return self.i18n[ I18n.default_locale ][attr_name]
|
|
65
|
-
else
|
|
66
|
-
return nil
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# define the setter method
|
|
72
|
-
#
|
|
73
|
-
define_method(attr_name.to_s+ '=') do |new_translation|
|
|
74
|
-
# first check if nothing changed - then we can just return, so that timestamps and other records don't get touched
|
|
75
|
-
if self.i18n.class == Hash && (self.i18n[I18n.locale]) && (self.i18n[I18n.locale][attr_name.to_sym] == new_translation)
|
|
76
|
-
return if (I18n.locale != I18n.default_locale)
|
|
77
|
-
return if (I18n.locale == I18n.default_locale) && (read_attribute(attr_name) == new_translation) # both i18n and attr_name need to be equal to new_translation
|
|
78
|
-
end
|
|
45
|
+
define_method(attr_name) do |locale = I18n.locale|
|
|
46
|
+
get_localized_attribute(attr_name, locale)
|
|
47
|
+
end
|
|
79
48
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
write_attribute(attr_name , new_translation) if I18n.locale == I18n.default_locale
|
|
83
|
-
end
|
|
84
|
-
self.i18n ||= Hash.new
|
|
85
|
-
self.i18n[I18n.locale] ||= Hash.new
|
|
86
|
-
self.i18n[I18n.locale][attr_name.to_sym] = new_translation
|
|
87
|
-
end
|
|
49
|
+
define_method("#{attr_name}=") do |new_translation|
|
|
50
|
+
set_localized_attribute(attr_name, I18n.locale, new_translation)
|
|
88
51
|
end
|
|
89
52
|
end
|
|
90
53
|
end
|
|
@@ -95,4 +58,3 @@ module EmbeddedLocalization
|
|
|
95
58
|
end
|
|
96
59
|
end
|
|
97
60
|
end
|
|
98
|
-
|
|
@@ -16,12 +16,26 @@ module EmbeddedLocalization
|
|
|
16
16
|
translated_attribute_names.include?(name.to_sym)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# How the `i18n` column is stored: :yaml (text column, the default), :json, :jsonb or :hstore
|
|
20
|
+
def translation_storage
|
|
21
|
+
(translation_options[:storage] || :yaml).to_sym
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
# # determine if we are using fallbacks
|
|
20
25
|
def fallbacks?
|
|
21
26
|
i18n_fallbacks = I18n.backend.class.included_modules.map(&:to_s).include?('I18n::Backend::Fallbacks') # will be true if config.i18n.fallbacks => true in config
|
|
22
27
|
i18n_fallbacks || translation_options[:fallbacks] == true
|
|
23
28
|
end
|
|
24
29
|
|
|
30
|
+
# The locales to look at, in order, when `locale` has no translation for an attribute:
|
|
31
|
+
# the chain configured in I18n.fallbacks (e.g. config.i18n.fallbacks = { 'de-AT' => 'de' } gives :de for :"de-AT";
|
|
32
|
+
# I18n.fallbacks exists once i18n/backend/fallbacks is loaded, which Rails does for config.i18n.fallbacks),
|
|
33
|
+
# followed by I18n.default_locale. `locale` itself is not part of the result.
|
|
34
|
+
def fallback_locales(locale)
|
|
35
|
+
chain = I18n.respond_to?(:fallbacks) ? I18n.fallbacks[locale] : []
|
|
36
|
+
(chain + [I18n.default_locale]).uniq - [locale]
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
#-
|
|
26
40
|
# # fetch the fallbacks from the i18n backend
|
|
27
41
|
# def fallbacks
|
|
@@ -2,44 +2,42 @@ module EmbeddedLocalization
|
|
|
2
2
|
module ActiveRecord
|
|
3
3
|
module InstanceMethods
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# Returns the translation of attr_name for the given locale; nil if there is none.
|
|
6
6
|
# - will convert given locale to symbol, e.g. "en","En" to :en
|
|
7
|
-
|
|
7
|
+
# - with fallbacks (see ClassMethods#fallbacks?), a nil translation is looked up in the fallback locales instead:
|
|
8
|
+
# the chain configured in I18n.fallbacks, then I18n.default_locale (see ClassMethods#fallback_locales)
|
|
8
9
|
def get_localized_attribute(attr_name, locale)
|
|
9
|
-
|
|
10
|
+
attr_name = attr_name.to_sym
|
|
11
|
+
locale = normalize_locale(locale)
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
else
|
|
18
|
-
if self.class.fallbacks? && self.i18n[ I18n.default_locale ]
|
|
19
|
-
return self.i18n[ I18n.default_locale ][attr_name]
|
|
20
|
-
else
|
|
21
|
-
return nil
|
|
22
|
-
end
|
|
13
|
+
translation = translation_for(attr_name, locale)
|
|
14
|
+
return translation unless translation.nil? && self.class.fallbacks?
|
|
15
|
+
|
|
16
|
+
self.class.fallback_locales(locale).each do |fallback_locale|
|
|
17
|
+
translation = translation_for(attr_name, fallback_locale)
|
|
18
|
+
return translation unless translation.nil?
|
|
23
19
|
end
|
|
20
|
+
nil
|
|
24
21
|
end
|
|
25
22
|
|
|
23
|
+
# Sets the translation of attr_name for the given locale.
|
|
26
24
|
# - will convert given locale to symbol, e.g. "en","En" to :en
|
|
27
|
-
|
|
25
|
+
# - for I18n.default_locale, a DB column with the attribute's name (if the user defined one) is written too,
|
|
26
|
+
# so that the default locale values can be used in SQL queries
|
|
27
|
+
# - does nothing when the value is unchanged, so the record stays clean and its timestamps are not touched
|
|
28
28
|
def set_localized_attribute(attr_name, locale, new_translation)
|
|
29
|
-
|
|
29
|
+
attr_name = attr_name.to_sym
|
|
30
|
+
locale = normalize_locale(locale)
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
if self.i18n.class == Hash && (self.i18n[I18n.locale]) && (self.i18n[I18n.locale][attr_name.to_sym] == new_translation)
|
|
33
|
-
return if (I18n.locale != I18n.default_locale)
|
|
34
|
-
return if (I18n.locale == I18n.default_locale) && (read_attribute(attr_name) == new_translation) # both i18n and attr_name need to be equal to new_translation
|
|
35
|
-
end
|
|
32
|
+
return if translation_unchanged?(attr_name, locale, new_translation)
|
|
36
33
|
|
|
37
34
|
self.i18n_will_change! # for ActiveModel Dirty tracking
|
|
38
|
-
if
|
|
39
|
-
write_attribute(attr_name
|
|
35
|
+
if native_column?(attr_name) && locale == I18n.default_locale
|
|
36
|
+
write_attribute(attr_name, new_translation)
|
|
40
37
|
end
|
|
38
|
+
self.i18n ||= Hash.new
|
|
41
39
|
self.i18n[locale] ||= Hash.new
|
|
42
|
-
self.i18n[locale][attr_name
|
|
40
|
+
self.i18n[locale][attr_name] = new_translation
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
# Returns all locales used for translation of all documents of this class.
|
|
@@ -63,7 +61,6 @@ module EmbeddedLocalization
|
|
|
63
61
|
#
|
|
64
62
|
def translated?(name)
|
|
65
63
|
self.class.translated?(name)
|
|
66
|
-
# self.class.instance_variable_get(translated_attribute_names).include?(name.to_sym)
|
|
67
64
|
end
|
|
68
65
|
|
|
69
66
|
# Purpose: to see the translation coverage
|
|
@@ -114,14 +111,37 @@ module EmbeddedLocalization
|
|
|
114
111
|
end
|
|
115
112
|
|
|
116
113
|
private
|
|
117
|
-
|
|
118
|
-
#
|
|
114
|
+
|
|
115
|
+
# initializes the `i18n` attribute with an empty Hash for I18n.locale and for I18n.default_locale
|
|
119
116
|
def initialize_i18n_hashes
|
|
120
117
|
self.i18n ||= Hash.new
|
|
121
|
-
self.i18n[ I18n.locale ]
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
self.i18n[ I18n.locale ] ||= Hash.new
|
|
119
|
+
self.i18n[ I18n.default_locale ] ||= Hash.new
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def normalize_locale(locale)
|
|
123
|
+
locale.is_a?(String) ? locale.downcase.to_sym : locale # ensure that locale is always a symbol
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# the stored translation of attr_name in locale; nil when the locale or the attribute is not there
|
|
127
|
+
def translation_for(attr_name, locale)
|
|
128
|
+
translations = self.i18n[locale]
|
|
129
|
+
translations[attr_name] if translations
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# did the user define a DB column with the name of the translated attribute?
|
|
133
|
+
def native_column?(attr_name)
|
|
134
|
+
has_attribute?(attr_name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# true when the record already holds exactly this translation -- and, for I18n.default_locale on a model
|
|
138
|
+
# with a native column for the attribute, the column holds it too
|
|
139
|
+
def translation_unchanged?(attr_name, locale, new_translation)
|
|
140
|
+
return false unless self.i18n.is_a?(Hash) && self.i18n[locale]
|
|
141
|
+
return false unless self.i18n[locale][attr_name] == new_translation
|
|
142
|
+
return true unless locale == I18n.default_locale && native_column?(attr_name)
|
|
143
|
+
|
|
144
|
+
read_attribute(attr_name) == new_translation
|
|
125
145
|
end
|
|
126
146
|
|
|
127
147
|
end
|