active_translation 0.7.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1234e553819683f3b7d5f77a491a8a39e1c1aef7f5fdef19190e2f046c7debfd
4
+ data.tar.gz: 5823014a65f7a39d1f3cf625f30ab1021d8cc6def7b655aa2f8b893419f56a5a
5
+ SHA512:
6
+ metadata.gz: b5c18a5c9df15dddbd68088de1e7ed409d354bd9097430f1294559606abb6b527311515bf59fef1b60fb912496cc5d952dc0e0cbe38c5fd453c7dbfd156183f9
7
+ data.tar.gz: 37384c54c3d287811635d616cafac796200ebc86c856ee5d827a9c2fd3c9db137abf7dfb7724250c3870a4718b590ebd58945b11a68f3b1ff4f138819756c1e8
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Sean Hogge
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,365 @@
1
+ # WARNING KLAXON WARNING
2
+
3
+ 🚨 ActiveTranslation is pre-1.0 🚨
4
+
5
+ This means there may be unhandled edge cases and undiscovered bugs. Please don't use it in production without rigorous QA.
6
+
7
+ If you find a bug or problem, please report it. If you have an idea for a new feature, please suggest it.
8
+
9
+
10
+ # ActiveTranslation
11
+
12
+ ActiveTranslation is a Rails plugin that lets you easily translate ActiveRecord models. With a single line added to that model, you can declare which columns, which locales, and what constraints to allow or prevent translation.
13
+
14
+ ActiveTranslation was built at and is sponsored by [Talentronic](https://talentronic.com)
15
+
16
+
17
+ ## How does this differ from internationalization (`I18n`)?
18
+
19
+ I18n is a great solution for elements of your app that are static, nearly static, aren't database-driven, or otherwise can be controlled or constrained in some fashion.
20
+
21
+ It can't help you if you have something that's user-supplied. It can't help you if you need translations available without a deploy, on demand.
22
+
23
+ Even in instances where it can help you, there are times when having hundreds of lines of YAML can become tedious or difficult to maintain.
24
+
25
+ Consider something like product categories: you might allow product managers to create them as needed. If you want them translated, you now need to communicate this to someone who can update the translation files. It's not difficult or tedious at first.
26
+
27
+ Instead, what if you just add a single line to your `Category` model. Now product managers can create new categories with whimsical abandon, and your international customers don't have to wait for engineering or a third party to copy/paste into a `.yml` file.
28
+
29
+
30
+ ## Installation
31
+
32
+ Add the gem to your gemfile:
33
+
34
+ ```ruby
35
+ gem "active_translation", git: "https://github.com/seanhogge/active_translation"
36
+ ```
37
+
38
+ And then bundle:
39
+
40
+ ```bash
41
+ bundle
42
+ ```
43
+
44
+ Run the installer to add a migration and initializer:
45
+
46
+ ```ruby
47
+ rails generate active_translation:install
48
+ ```
49
+
50
+ Migrate your primary database:
51
+
52
+ ```ruby
53
+ rails db:migrate
54
+ ```
55
+
56
+ You will need to restart your rails server and your ActiveJob adapter process (if separate) if it was running when you installed and migrated.
57
+
58
+
59
+ ## Configuration
60
+
61
+ The first step after installation is to configure your Google credentials. ActiveTranslation uses the Google Translate API in the background for translation. This is a bit more than just an API key.
62
+
63
+ The general idea is:
64
+
65
+ 1. Create a project at https://console.cloud.google.com
66
+ 1. In “APIs & Services” > “Library” look for “Cloud Translation API”
67
+ 1. Create a Service Account and download the JSON key file
68
+ 1. Ensure billing is enabled, and all the other prerequisites that Google requires
69
+ 1. Extract the necessary data from that JSON file and plug those values into `config/initializers/active_translation.rb` by setting the appropriate environment variables
70
+
71
+ Feel free to change the names of the environment variables, or to alter that initializer to assign those keys however you like. At Talentronic, we have an `APIConnection` model we use for stuff like that so we grab the credentials from there and assign them.
72
+
73
+ You could also use something like `dotenv-rails` and create a `.env` file in your various environments.
74
+
75
+ Or you could use the OS to define them, such as in an `/etc/environment` file.
76
+
77
+ If you're using Kamal, you probably already have a way to manage secrets and add them to env variables - that works, too.
78
+
79
+ Obvious reminder: whatever method you use, just make sure it's not committed to any repository, even once. If you do, make sure you get new credentials and expire/delete the credentials that got committed.
80
+
81
+ That's the hard part!
82
+
83
+
84
+ ## Usage
85
+
86
+ To any ActiveRecord model, add `translates` with a list of columns that should be translated, a list of locales and any constraints.
87
+
88
+ Simplest form:
89
+
90
+ ```ruby
91
+ translates :content, into: %i[es fr de]
92
+ ```
93
+
94
+ ### Into
95
+
96
+ The `into` argument can be an array of locales, a symbol that matches a method that returns an array of locales, or a Proc that returns an array of locales.
97
+
98
+ So you could do:
99
+
100
+ ```ruby
101
+ translates :content, into: :method_that_returns_locales
102
+ ```
103
+
104
+ or
105
+
106
+ ```ruby
107
+ translates :content, into: -> { I18n.available_locales - [ I18n.default_locale ] }
108
+ ```
109
+
110
+ > `it` is a recent Ruby syntactical grain of sugar. It's the same as `_1` which lets you skip the `{ |arg| arg == :stuff }` repetition
111
+
112
+ #### Into All
113
+
114
+ Because translating a model into all the locales your app may define is so common, you can pass `:all` to the `into` argument to achieve the same result as passing `-> { I18n.available_locales - [ I18n.default_locale ] }`.
115
+
116
+ This means you cannot pass in your own method called "all" as a symbol, of course.
117
+
118
+ ### If Constraints
119
+
120
+ An `if` constraint will prevent translating if it returns `false`.
121
+
122
+ If you have a boolean column like `published`, you might do:
123
+
124
+ ```ruby
125
+ translates :content, into: %i[es fr de], if: :published?
126
+ ```
127
+
128
+ Or you can define your own method that returns a boolean:
129
+
130
+ ```ruby
131
+ translates :content, into: %i[es fr de], if: :record_should_be_translated?
132
+ ```
133
+
134
+ Or you can use a Proc:
135
+
136
+ ```ruby
137
+ translates :content, into: %i[es fr de], if: -> { content.length > 10 }
138
+ ```
139
+
140
+ ### Unless Constraints
141
+
142
+ These work exactly the same as the `if` constraint, but the logic is flipped. If the constraint returns `true` then no translating will take place.
143
+
144
+ ### Constraint Compliance
145
+
146
+ If your record is updated such that either an `if` or `unless` constraint is toggled, this will trigger the addition _or removal_ of translation data. The idea here is that the constraint controls whether a translation should _exist_, not whether a translation should be performed.
147
+
148
+ This means if you use a constraint that frequently changes value, you will be paying for half of all change events.
149
+
150
+ This is intentional. Translations are regenerated any time one of the translated attributes changes. But what about something like a `Post` that shouldn't be translated until it's published? There's no sense in translating it dozens of times as it's edited, but clicking the “publish” button doesn't update the translatable attributes.
151
+
152
+ So ActiveTranslation watches for the constraint to change so that when the `Post` is published, the translation is performed with no extra effort.
153
+
154
+ Likewise, if the constraint changes the other way, translations are removed since ActiveTranslation will no longer be keeping those translations up-to-date. Better to have no translation than a completely wrong one.
155
+
156
+ ### Manual Attributes
157
+
158
+ Sometimes you want to translate an attribute, but it's not something Google Translate or an LLM can handle on their own. For instance, at Talentronic, we have names of businesses that operate in airports. These names have trademarked names that might look like common words, but aren't. These names also have the airport included which can confuse the LLM or API when it's mixed in with the business name.
159
+
160
+ So we need manual translation attributes:
161
+
162
+ ```ruby
163
+ translates :content, manual: :name, into: %i[es fr]
164
+ ```
165
+
166
+ Manual attributes have a special setter in the form of `#{locale}_#{attribute_name}`. So in this example, we get `fr_name=` and `es_name=`.
167
+
168
+ These attributes never trigger retranslation, and are never checked against the original text - it's entirely up to you to maintain them. However, it does get stored alongside all the other translations, keeping your database tidy and your translation code consistent.
169
+
170
+ ### The Show
171
+
172
+ Once you have added the `translates` directive with your columns, locales, and constraints and your models have been translated to at least one locale, it's time to actually use them.
173
+
174
+ If you set:
175
+
176
+ ```ruby
177
+ translates :content, manual: :name, into: %i[es fr]
178
+ ```
179
+
180
+ on a `Post` model, then you can simply call `.content` and it will use the current locale from Rails `I18n`.
181
+
182
+ If you need the value for a locale other than the current, you can specify the locale explicitly:
183
+
184
+ ```ruby
185
+ @post.content(locale: :fr)
186
+ ```
187
+
188
+ If the post has an `fr_translation`, then that will be shown. If no `fr_translation` exists, it will show the post's untranslated `content`.
189
+
190
+ In this way, you'll never have missing values, but you will have the default language version instead of the translated version.
191
+
192
+ The same goes for manual translations:
193
+
194
+ ```ruby
195
+ @post.name
196
+ ```
197
+
198
+ returns the translated name for the current locale if it exists, or the untranslated `name` if it does.
199
+
200
+ ```ruby
201
+ @post.name(locale: :es)
202
+ ```
203
+
204
+ Regardless of the current locale, it will return the `:es` translated value for the `name` attribute, or the untranslated `name` if the `es_translation` doesn't exist.
205
+
206
+
207
+ ### Extras
208
+
209
+ There are a few niceties provided to make ActiveTranslation as flexible as possible.
210
+
211
+ Ideal world: you won't need them.
212
+ Real world: you might need them.
213
+
214
+ #### Translate on Demand
215
+
216
+ There may be times when things get hosed. You might need or want to translate the automatic columns manually. You can do this in three ways:
217
+
218
+ ##### translate_if_needed
219
+
220
+ By calling `translate_if_needed`, you can run the same checks that would occur on update. This is similar to calling `touch`, but it doesn't update the `updated_at` timestamp
221
+
222
+ This will schedule the translation as a background job
223
+
224
+ ##### translate!
225
+
226
+ By calling `translate!`, you skip all checks for whether a translation is outdated or missing and generate a new translation even if it's already extant and accurate.
227
+
228
+ This will schedule the translation as a background job.
229
+
230
+ ##### translate_now!(locales)
231
+
232
+ By calling `translate_now!` and optionally passing 1 or more locales, you skip all checks for whether a translation is outdated or missing and generate a new translation for the passed locales even if they're already extant and accurate.
233
+
234
+ The default value for locales is `translatable_locales`, so if you don't pass anything, it will translate into all locales defined for that model.
235
+
236
+ This method does **not** schedule the job, and instead immediately performs the translation, blocking until the translations for all locales are complete.
237
+
238
+ #### Introspection
239
+
240
+ The goal of ActiveTranslation is to make translations as automatic and invisible as possible. However, there may be times when you need to know the state of translations on a model instance.
241
+
242
+ ##### translation_checksum
243
+
244
+ By calling `translation_checksum`, you can return the checksum used on a record to determine whether translations are outdated.
245
+
246
+ ##### translations_outdated?
247
+
248
+ By calling `translations_outdated?`, you can get `true` if any translation has a checksum that no longer matches the source (otherwise you get `false`).
249
+
250
+ This has limited value, but is provided in case you need to handle situations in which models change without triggering callbacks.
251
+
252
+ > NOTE: `translations_outdated?` will _always_ return `false` if the conditions you passed (`if` & `unless`) are not met
253
+
254
+ ##### outdated_translations
255
+
256
+ By calling `outdated_translations`, you can get an array of all `translations` that are outdated.
257
+
258
+ This has limited value, but is provided in case you need to handle situations in which models change without triggering callbacks.
259
+
260
+ ##### translations_missing?
261
+
262
+ By calling `translations_missing?`, you can get `true` if any translations are missing. This is a complex question, and is `false` unless:
263
+
264
+ - any automatic translation attributes are not blank
265
+ - any automatic translation attributes are missing an entry for any locale (in addition to not being blank)
266
+
267
+ So if you have `translates :title, manual: :name, into: :all` and your app supports `:fr` and `:es`, you will get `true` if:
268
+
269
+ - the `title` has been translated into `:es`, but not `:fr`
270
+ - no translations exist at all
271
+ - the `name` has been translated into both `:es` and `:fr` but `title` hasn't been translated
272
+ - the `name` has been translated into both `:es` and `:fr` but `title` has been translated into only one locale
273
+
274
+ and you will get `false` if:
275
+
276
+ - translations conditions are not met, regardless of the presence or absence of any translations
277
+ - the `title` column is blank (`nil` or empty string)
278
+ - the `title` column has been fully translated but the `name` column has not been (manual attributes are ignored)
279
+ - the `title` column has been fully translated, but the `title` column has changed since the translation in a way that doesn't trigger callbacks
280
+
281
+ This has limited value, but is provided in case you need to handle situations in which models change without triggering callbacks.
282
+
283
+ ##### fully_translated?(auto_or_manual_or_all)
284
+
285
+ By calling `fully_translated?` with no arguments, you can get `true` if all attributes are translated. This ignores manual attributes by default.
286
+
287
+ There are some special symbols you can pass to change the scope of "fully." If you pass `:all` or `:include_manual`, then you will get `true` only if all automatic _and_ manual attributes have a translation.
288
+
289
+ If you pass `:manual` or `:manual_only`, then you will get `true` only if all manual attributes have a translation, disregarding automatic attributes.
290
+
291
+ Passing `:auto` or `:auto_only` is the same as passing no argument.
292
+
293
+ Passing an invalid argument raises an error.
294
+
295
+ > NOTE: `fully_translated?` will _always_ return `true` if the conditions you passed (`if` & `unless`) are not met
296
+
297
+ ##### translatable_locales
298
+
299
+ By calling `translatable_locales`, you will get an array of locales for which the object will be translated. This has no bearing on whether any translations exist, or any conditions for translations to be performed.
300
+
301
+ > NOTES: This is only defined on instances of a model, not the model itself, since the `into` argument allows more than just an Array literal.
302
+
303
+ ##### translation_config
304
+
305
+ You can call `translation_config` on a model or instance to see what you've set up for translations. You'll see something like:
306
+
307
+ ```ruby
308
+ > Page.translation_config
309
+ => {attributes: [:title, :heading, :subhead, :content], manual_attributes: [], locales: :all, unless: nil, if: :published?}
310
+
311
+ > Category.translation_config
312
+ => {attributes: [:name, :short_name],
313
+ manual_attributes: [],
314
+ locales: #<Proc:0x000000012231a2b8 /path/to/projects/active_translation/app/models/category.rb:67 (lambda)>,
315
+ unless: nil,
316
+ if: nil}
317
+
318
+ > Widget.translation_config
319
+ => {attributes: [:title, :headline, :ad_html],
320
+ manual_attributes: [],
321
+ locales: [:es, :fr],
322
+ unless: #<Proc:0x00000001228fea58 /path/to/projects/active_translation/app/models/widget.rb:42 (lambda)>,
323
+ if: nil}
324
+
325
+ > Widget.last.translation_config
326
+ => {attributes: [:title, :headline, :ad_html],
327
+ manual_attributes: [],
328
+ locales: [:es, :fr],
329
+ unless: #<Proc:0x00000001228fea58 /path/to/projects/active_translation/app/models/widget.rb:42 (lambda)>,
330
+ if: nil}
331
+
332
+ > Account.translation_config
333
+ => {attributes: [:profile_html], manual_attributes: ["name"], locales: :method_that_returns_locales, unless: nil, if: nil}
334
+ ```
335
+
336
+ #### Disclaimer
337
+
338
+ ActiveTranslation doesn't check the accuracy of translations in any way. It assumes that the response from Google is always perfect. If you are translating sensitive content where accuracy is critical in a legal or existential sense, you must handle translation auditing separately.
339
+
340
+ So if you use the for an EULA, make it a manual attribute or don't use ActiveTranslation for it at all.
341
+
342
+ ActiveTranslation doesn't redact any content. It assumes you would never send PII or financial data for translation. So... please don't.
343
+
344
+ ## Testing
345
+
346
+ Ideally, you do not need to write any tests for translations or how they behave, since ActiveTranslation tests itself.
347
+
348
+ However, perhaps you bolt on additional functionality to tests, or translations are critical to your application, or you simply want to ensure you're expecting the correct results from using the gem. In that case, ActiveTranslation provides some simple testing features.
349
+
350
+ All translations skip the call to Google in the test environment, and return a simple modification of that content. For example, translating into the `:fr` locale in a test environment would behave as so:
351
+
352
+ - Input: "auto translated content from ActiveRecord object"
353
+ - Output: "[fr] auto translated content from ActiveRecord object"
354
+
355
+
356
+ ## Contributing
357
+
358
+ Fork the repo, make your changes, make a pull request.
359
+
360
+ Or simply report issues on the [GitHub repository](https://github.com/seanhogge/active_translation)
361
+
362
+
363
+ ## License
364
+
365
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,4 @@
1
+ module ActiveTranslation
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveTranslation
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveTranslation
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,52 @@
1
+ # USAGE: GoogleTranslate.translate(target_language_code: "es-MX", text: "Hello world!")
2
+ # USAGE: GoogleTranslate.translate(target_language_code: "es-MX", text: Job.posted_status.last.title, obj: Job.posted_status.last)
3
+ # @see https://cloud.google.com/translate/docs/basic/translating-text
4
+
5
+ module ActiveTranslation
6
+ class GoogleTranslate
7
+ class << self
8
+ def translate(target_language_code:, text:, source: "en-US", obj: nil)
9
+ conn = Faraday.new(url: "https://translation.googleapis.com/") do |faraday|
10
+ faraday.request :json
11
+ faraday.response :json
12
+ faraday.request :authorization, "Bearer", -> { token }
13
+ end
14
+
15
+ response =
16
+ conn.post(
17
+ "language/translate/v2",
18
+ {
19
+ q: text,
20
+ target: target_language_code,
21
+ source: source,
22
+ }
23
+ )
24
+
25
+ return nil unless response.success?
26
+
27
+ parse_response(response)
28
+ end
29
+
30
+ private
31
+
32
+ def parse_response(response)
33
+ response.body.dig("data", "translations", 0, "translatedText")
34
+ # CGI.unescapeHTML(translation) if translation.is_a?(String)
35
+ end
36
+
37
+ def token
38
+ return "fake_access_token" if Rails.env.test?
39
+
40
+ Rails.cache.fetch("google_access_token", expires_in: 55.minutes) do
41
+ google_oauth_credentials = ActiveTranslation.configuration.to_json
42
+ authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
43
+ json_key_io: StringIO.new(google_oauth_credentials),
44
+ scope: [ "https://www.googleapis.com/auth/cloud-platform" ]
45
+ )
46
+ authorizer.fetch_access_token!
47
+ authorizer.access_token
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,6 @@
1
+ module ActiveTranslation
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveTranslation
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ module ActiveTranslation
2
+ class Translation < ApplicationRecord
3
+ belongs_to :translatable, polymorphic: true
4
+
5
+ validates :locale, presence: true, uniqueness: { scope: [ :translatable_type, :translatable_id ] }
6
+
7
+ serialize :translated_attributes, coder: JSON
8
+
9
+ def outdated?
10
+ source_checksum != translatable.translation_checksum
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Active translations</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "active_translation/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ ActiveTranslation::Engine.routes.draw do
2
+ end
@@ -0,0 +1,29 @@
1
+ module ActiveTranslation
2
+ class Configuration
3
+ attr_accessor :type,
4
+ :project_id,
5
+ :private_key_id,
6
+ :private_key,
7
+ :client_email,
8
+ :client_id,
9
+ :auth_uri,
10
+ :token_uri,
11
+ :auth_provider_x509_cert_url,
12
+ :client_x509_cert_url,
13
+ :universe_domain
14
+
15
+ def initialize
16
+ @type = nil
17
+ @project_id = nil
18
+ @private_key_id = nil
19
+ @private_key = nil
20
+ @client_email = nil
21
+ @client_id = nil
22
+ @auth_uri = "https://accounts.google.com/o/oauth2/auth"
23
+ @token_uri = "https://oauth2.googleapis.com/token"
24
+ @auth_provider_x509_cert_url = "https://www.googleapis.com/oauth2/v1/certs"
25
+ @client_x509_cert_url = nil
26
+ @universe_domain = "googleapis.com"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,11 @@
1
+ module ActiveTranslation
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ActiveTranslation
4
+
5
+ initializer "active_translation.model" do
6
+ ActiveSupport.on_load(:active_record) do
7
+ include ActiveTranslation::Translatable
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,230 @@
1
+ module ActiveTranslation
2
+ module Translatable
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def translates(*attributes, manual: [], into:, unless: nil, if: nil)
7
+ @translation_config ||= {}
8
+ @translation_config[:attributes] = Array(attributes).map(&:to_s)
9
+ @translation_config[:manual_attributes] = Array(manual).map(&:to_s)
10
+ @translation_config[:locales] = into
11
+ @translation_config[:unless] = binding.local_variable_get(:unless)
12
+ @translation_config[:if] = binding.local_variable_get(:if)
13
+
14
+ has_many :translations, class_name: "ActiveTranslation::Translation", as: :translatable, dependent: :destroy
15
+
16
+ delegate :translation_config, to: :class
17
+ delegate :translatable_attribute_names, to: :class
18
+
19
+ after_commit :translate_if_needed, on: [ :create, :update ]
20
+
21
+ # Respond to calls for manual attribute retrieval (model.fr_attribute)
22
+ # Respond to calls for manual attribute assignment (model.fr_attribute = "Bonjour")
23
+ # Respond to calls such as fr_translation or de_translation
24
+ # Respond to calls such as model.name(locale: :fr)
25
+ define_method(:method_missing) do |method_name, *args, &block|
26
+ super() unless method_name.to_s.split("_").size == 2
27
+
28
+ locale = method_name.to_s.split("_").first
29
+ attribute = method_name.to_s.split("_").last
30
+
31
+ if translation_config[:manual_attributes].include? attribute
32
+ translation = translations.find_by(locale: locale)
33
+ return read_attribute(attribute) unless translation
34
+
35
+ translation.translated_attributes[attribute].presence || read_attribute(attribute)
36
+ elsif attribute.last == "=" && translation_config[:manual_attributes].include?(attribute.delete("="))
37
+ attribute.delete!("=")
38
+ translation = translations.find_or_initialize_by(locale: locale.to_s)
39
+ attrs = translation.translated_attributes ? translation.translated_attributes : {}
40
+ attrs[attribute] = args.first
41
+ translation.translated_attributes = attrs
42
+ translation.save!
43
+ elsif attribute == "translation" || translation_config[:attributes].include?(attribute)
44
+ translations.find_by(locale: locale)
45
+ end
46
+ end
47
+
48
+ # Override attribute methods so that they accept a locale argument (defaulting to current I18n locale)
49
+ attributes.each do |attr|
50
+ define_method(attr) do |locale: I18n.locale|
51
+ if locale && translation = translations.find_by(locale: locale.to_s)
52
+ translation.translated_attributes&.dig attr.to_s || super()
53
+ else
54
+ super()
55
+ end
56
+ end
57
+ end
58
+
59
+ # Override manual attribute methods so that they accept a locale argument (defaulting to current I18n locale)
60
+ Array(manual).each do |attr|
61
+ define_method("#{attr}") do |locale: I18n.locale|
62
+ if locale && translation = translations.find_by(locale: locale.to_s)
63
+ translation.translated_attributes[attr.to_s].presence || read_attribute(attr)
64
+ else
65
+ read_attribute(attr)
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ def translatable_attribute_names
72
+ translation_config[:attributes]
73
+ end
74
+
75
+ def translation_config
76
+ @translation_config
77
+ end
78
+ end
79
+
80
+ def fully_translated?(attribute_types = :auto)
81
+ case attribute_types
82
+ when :auto, :auto_only
83
+ !translations_missing?
84
+ when :manual, :manual_only
85
+ !manual_translations_missing?
86
+ when :all, :include_manual
87
+ !translations_missing? && !manual_translations_missing?
88
+ else
89
+ raise ArgumentError, "acceptable arguments are [:auto, :auto_only, :manual, :manual_only, :all, :include_manual]"
90
+ end
91
+ end
92
+
93
+ def manual_translations_missing?
94
+ return false unless conditions_met?
95
+
96
+ translatable_locales.each do |locale|
97
+ translation_config[:manual_attributes].each do |attribute|
98
+ next if read_attribute(attribute).blank?
99
+
100
+ return true unless translation = translations.find_by(locale: locale)
101
+ return true unless translation.translated_attributes.keys.include?(attribute)
102
+ end
103
+ end
104
+
105
+ false
106
+ end
107
+
108
+ def outdated_translations
109
+ translations.select { _1.outdated? }
110
+ end
111
+
112
+ def translatable_locales
113
+ case translation_config[:locales]
114
+ when Symbol
115
+ if translation_config[:locales] == :all
116
+ I18n.available_locales - [ I18n.default_locale ]
117
+ else
118
+ send(translation_config[:locales])
119
+ end
120
+ when Proc
121
+ instance_exec(&translation_config[:locales])
122
+ when Array
123
+ translation_config[:locales]
124
+ end
125
+ end
126
+
127
+ def translate_if_needed
128
+ translations.delete_all and return unless conditions_met?
129
+
130
+ return unless translatable_attributes_changed? || condition_checks_changed? || translations_outdated? || translations_missing?
131
+
132
+ translatable_locales.each do |locale|
133
+ translation = translations.find_or_create_by(locale: locale.to_s)
134
+
135
+ if translation.new_record? || translation.outdated?
136
+ TranslationJob.perform_later(self, locale.to_s, translation_checksum)
137
+ end
138
+ end
139
+ end
140
+
141
+ def translate!
142
+ translatable_locales.each do |locale|
143
+ TranslationJob.perform_later(self, locale.to_s, translation_checksum)
144
+ end
145
+ end
146
+
147
+ def translate_now!(locales = translatable_locales)
148
+ Array(locales).each do |locale|
149
+ TranslationJob.perform_now(self, locale.to_s, translation_checksum)
150
+ end
151
+ end
152
+
153
+ def translation_checksum
154
+ values = translatable_attribute_names.map { |attr| read_attribute(attr).to_s }
155
+ Digest::MD5.hexdigest(values.join)
156
+ end
157
+
158
+ # translations are "missing" if they are not manual, the translatable attribute isn't blank
159
+ # and there's no translation for that attribute for all locales
160
+ def translations_missing?
161
+ return false unless conditions_met?
162
+
163
+ translatable_locales.each do |locale|
164
+ translatable_attribute_names.each do |attribute|
165
+ next if read_attribute(attribute).blank?
166
+
167
+ return true unless translation = translations.find_by(locale: locale)
168
+ return true unless translation.translated_attributes.keys.include?(attribute)
169
+ end
170
+ end
171
+
172
+ false
173
+ end
174
+
175
+ def translations_outdated?
176
+ return false unless conditions_met?
177
+ return true if translations.map(&:outdated?).any?
178
+
179
+ false
180
+ end
181
+
182
+ private
183
+
184
+ def condition_checks_changed?
185
+ saved_changes.any? && conditions_exist? && conditions_met?
186
+ end
187
+
188
+ def conditions_exist?
189
+ return true if translation_config[:if] || translation_config[:unless]
190
+
191
+ false
192
+ end
193
+
194
+ # returns true if all conditions are met, or if there are no conditions
195
+ def conditions_met?
196
+ if_condition_met? && unless_condition_met?
197
+ end
198
+
199
+ def evaluate_condition(condition)
200
+ case condition
201
+ when Symbol
202
+ send(condition)
203
+ when Proc
204
+ instance_exec(&condition)
205
+ when nil
206
+ true
207
+ else
208
+ false
209
+ end
210
+ end
211
+
212
+ # returns true if condition is met or there is no condition
213
+ def if_condition_met?
214
+ return true unless translation_config[:if]
215
+
216
+ evaluate_condition(translation_config[:if])
217
+ end
218
+
219
+ def translatable_attributes_changed?
220
+ saved_changes.any? && saved_changes.keys.intersect?(translatable_attribute_names)
221
+ end
222
+
223
+ # returns true if condition is met or there is no condition
224
+ def unless_condition_met?
225
+ return true unless translation_config[:unless]
226
+
227
+ !evaluate_condition(translation_config[:unless])
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,36 @@
1
+ module ActiveTranslation
2
+ class TranslationJob < ActiveJob::Base
3
+ queue_as :default
4
+
5
+ def perform(object, locale, checksum)
6
+ translated_data = {}
7
+
8
+ object.translatable_attribute_names.each do |attribute|
9
+ source_text = object.read_attribute(attribute)
10
+ translated_data[attribute.to_s] = translate_text(source_text, locale)
11
+ end
12
+
13
+ translation = object.translations
14
+ .find_or_initialize_by(
15
+ locale: locale,
16
+ )
17
+
18
+ existing_data = translation.translated_attributes.present? ? translation.translated_attributes : {}
19
+
20
+ merged_attributes = existing_data.merge(translated_data)
21
+
22
+ translation.update!(
23
+ translated_attributes: merged_attributes,
24
+ source_checksum: checksum
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def translate_text(text, target_locale)
31
+ return "[#{target_locale}] #{text}" if Rails.env.test?
32
+
33
+ ActiveTranslation::GoogleTranslate.translate(target_language_code: target_locale, text: text)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveTranslation
2
+ VERSION = "0.7.1"
3
+ end
@@ -0,0 +1,21 @@
1
+ require "active_translation/version"
2
+ require "active_translation/engine"
3
+ require "active_translation/configuration"
4
+ require "active_translation/translatable"
5
+ require "active_translation/translation_job"
6
+ require "faraday"
7
+ require "googleauth"
8
+
9
+ module ActiveTranslation
10
+ class << self
11
+ attr_writer :configuration
12
+
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ module ActiveTranslation
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def self.next_migration_number(dirname)
9
+ next_migration_number = current_migration_number(dirname) + 1
10
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
11
+ end
12
+
13
+ def copy_migration
14
+ migration_template "create_translations.rb", "db/migrate/create_translations.rb"
15
+ end
16
+
17
+ def copy_initializer
18
+ template "active_translation.rb", "config/initializers/active_translation.rb"
19
+ end
20
+
21
+ def show_readme
22
+ readme "README" if behavior == :invoke
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ ===============================================================================
2
+
3
+ Active Translations has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Run migrations:
8
+ rails db:migrate
9
+
10
+ 2. Configure your Google Translation API key:
11
+ # config/initializers/active_translation.rb
12
+ ActiveTranslation.configure do |config|
13
+ config.google_api_key = ENV['GOOGLE_TRANSLATION_API_KEY']
14
+ end
15
+
16
+ 3. Add to your models:
17
+ translates :name, :description, into: [:es, :fr]
18
+
19
+ ===============================================================================
@@ -0,0 +1,14 @@
1
+ ActiveTranslation.configure do |config|
2
+ config.project_id = ENV.fetch("GOOGLE_TRANSLATION_PROJECT_ID", :missing_google_translation_project_id)
3
+ config.private_key_id = ENV.fetch("GOOGLE_TRANSLATION_PRIVATE_KEY_ID", :missing_google_translation_private_key_id)
4
+ config.private_key = ENV.fetch("GOOGLE_TRANSLATION_PRIVATE_KEY", :missing_google_translation_private_key)
5
+ config.client_email = ENV.fetch("GOOGLE_TRANSLATION_CLIENT_EMAIL", :missing_google_translation_client_email)
6
+ config.client_id = ENV.fetch("GOOGLE_TRANSLATION_CLIENT_ID", :missing_google_translation_client_id)
7
+ config.client_x509_cert_url = ENV.fetch("GOOGLE_TRANSLATION_CLIENT_CERT_URL", :missing_google_translation_client_cert_url)
8
+
9
+ config.type = ENV.fetch("GOOGLE_TRANSLATION_TYPE", "service_account")
10
+ config.auth_uri = ENV.fetch("GOOGLE_TRANSLATION_AUTH_URI", "https://accounts.google.com/o/oauth2/auth")
11
+ config.token_uri = ENV.fetch("GOOGLE_TRANSLATION_TOKEN_URI", "https://oauth2.googleapis.com/token")
12
+ config.auth_provider_x509_cert_url = ENV.fetch("GOOGLE_TRANSLATION_AUTH_PROVIDER_CERT_URL", "https://www.googleapis.com/oauth2/v1/certs")
13
+ config.universe_domain = ENV.fetch("GOOGLE_TRANSLATION_UNIVERSE_DOMAIN", "googleapis.com")
14
+ end
@@ -0,0 +1,14 @@
1
+ class CreateTranslations < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :active_translation_translations do |t|
4
+ t.references :translatable, polymorphic: true, null: false
5
+ t.string :locale, null: false
6
+ t.text :translated_attributes
7
+ t.string :source_checksum
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :active_translation_translations, [ :translatable_type, :translatable_id, :locale ],
12
+ unique: true, name: "index_translations_on_translatable_and_locale"
13
+ end
14
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :active_translation do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_translation
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.7.1
5
+ platform: ruby
6
+ authors:
7
+ - Sean Hogge
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: googleauth
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ description: Easily translate specific attributes of any ActiveRecord model
69
+ email:
70
+ - sean@seanhogge.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - MIT-LICENSE
76
+ - README.md
77
+ - Rakefile
78
+ - app/assets/stylesheets/active_translation/application.css
79
+ - app/controllers/active_translation/application_controller.rb
80
+ - app/helpers/active_translation/application_helper.rb
81
+ - app/jobs/active_translation/application_job.rb
82
+ - app/lib/active_translation/google_translate.rb
83
+ - app/mailers/active_translation/application_mailer.rb
84
+ - app/models/active_translation/application_record.rb
85
+ - app/models/active_translation/translation.rb
86
+ - app/views/layouts/active_translation/application.html.erb
87
+ - config/routes.rb
88
+ - lib/active_translation.rb
89
+ - lib/active_translation/configuration.rb
90
+ - lib/active_translation/engine.rb
91
+ - lib/active_translation/translatable.rb
92
+ - lib/active_translation/translation_job.rb
93
+ - lib/active_translation/version.rb
94
+ - lib/generators/active_translation/install_generator.rb
95
+ - lib/generators/active_translation/templates/README
96
+ - lib/generators/active_translation/templates/active_translation.rb
97
+ - lib/generators/active_translation/templates/create_translations.rb
98
+ - lib/tasks/active_translation_tasks.rake
99
+ homepage: https://github.com/seanhogge/active_translation
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ homepage_uri: https://github.com/seanhogge/active_translation
104
+ source_code_uri: https://github.com/seanhogge/active_translation
105
+ changelog_uri: https://github.com/seanhogge/active_translation
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.3'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.6.8
121
+ specification_version: 4
122
+ summary: Easily translate specific attributes of any ActiveRecord model
123
+ test_files: []