Dev

How I use OpenAI to translate my Rails application into multiple languages

Profil Picture

Guillaume Briday

5 minutes

I recently decided to add multiple languages to my time tracking application, Timecop-app.com, to expand its market reach.

The application is a monolith built with Ruby on Rails. Thanks to AI, you can easily automate the translation of all your keys in a flash, for almost no cost. I paid about 2 cents per new language.

We'll use OpenAI in this example, as it provided the best results.

Here’s how it looks on Timecop-app.com.

1. Before starting

If it’s not already the case, you’ll need to add all the translation keys required in your app instead of using hard-coded strings.

For instance:

 <h1>
- Project
+ <%= Import.model_name.human %>
 </h1>
 
 <button>
- Save
+ <%= t('actions.save') %> 
 </button>

And create the corresponding translation files, config/locales/models/projects.en.yml:

---
en:
  activerecord:
    models:
      project: Project

and config/locales/actions.en.yml:

---
en:
  actions:
    save: Save

There shouldn't be any visible difference in the UI, but now the title and the button will change according to the current locale.

For more details, you can refer to the official Rails documentation about i18n.

2. A bit of Rails I18n configuration

The gem i18n is already installed with Rails, but we need to create a new initializer to configure some settings.

# config/initializers/i18n.rb

Rails.application.configure do
  config.i18n.default_locale = :en
  config.i18n.available_locales = %i[en fr it es] # Needed to ensure we can switch to these locales.
end

Let’s update the ApplicationController to change the current locale dynamically.

We will check multiple parameters to determine the locale to apply. If none match, we will fallback to the default locale.

You can use an around_action callback and anonymous blocks (available in Ruby versions greater than 3.1):

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  around_action :switch_locale
  
  private

  def switch_locale(&)
    locale = params[:locale] ||
             current_user.try(:preferred_locale) ||
             extract_locale_from_accept_language_header.presence ||
             I18n.default_locale

    locale = I18n.default_locale if I18n.available_locales.exclude?(locale.to_sym)

    I18n.with_locale(locale, &)
  end
  
  # Find the browser's default language
  def extract_locale_from_accept_language_header
    request.env['HTTP_ACCEPT_LANGUAGE'].to_s.scan(/^[a-z]{2}/).first
  end
  
  # Automatically include the locale in the query string
  def default_url_options
    { locale: I18n.locale }
  end  
end

To determine the locale, we follow this priority order:

  1. Locale in params: If the locale is specified in the URL, such as /en/pricing or /fr/pricing.
  2. User preference: If the user has a preferred locale saved in their profile.
  3. Browser default: Based on the browser's default language.
  4. Fallback: Default to the default locale if none of the above match.

Additionally, during development, you can add a CSS snippet to easily identify missing translations. Rails automatically applies the class translation_missing to elements with missing translations:

.translation_missing {
  background: rgb(239 68 68 / 1);
  border-radius: 0.25rem;
  padding: 0.25rem;
}

2.1 Save the preferred locale (optional)

You can add a preferred_locale column to the users table. Ensure that the column allows null values so that users can remain in "automatic" mode if they do not select a preferred locale.

class AddDefaultLocaleToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :preferred_locale, :string, null: true
  end
end

You can add a validator to the model to ensure the preferred_locale column contains a valid locale value:

class User < ApplicationRecord
  validates :preferred_locale,
            inclusion: { in: I18n.available_locales.map(&:to_s) },
            allow_blank: true
end

And add the select input in your user profile form:

<%= f.select :preferred_locale,
             options_for_select(
               I18n.available_locales.map { |locale| [t("locales.#{locale}"), locale] }, 
               current_user.preferred_locale
             ),
             { include_blank: "System Settings" } %>

If you use Devise, don't forget to add the parameter to the permitted_parameters:

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:account_update) do |user|
    user.permit(
      # ...
      :preferred_locale,
      # ...
    )
  end
end

See: https://github.com/heartcombo/devise/blob/main/lib/devise/parameter_sanitizer.rb

2.2 Configure your routes

Instead of using a query parameter like /time_entries?locale=fr, you can create a cleaner URL structure such as /fr/time_entries.

To achieve this, wrap the routes you want to translate in a scope with an optional locale parameter:

scope '/(:locale)', locale: /#{I18n.available_locales.join('|')}/ do
  resources :time_entries
end

This setup allows you to include the locale in the URL path, making it more user-friendly and SEO-friendly.

3. Install the i18n-tasks gem

The gem i18n-tasks will be our sidekick here.

Add these gems in your Gemfile:

group :development, :test do
  gem 'i18n-tasks'
  gem 'ruby-openai' # Required for automating translations
end

It offers many useful features for managing translations.

For example, you can normalize (i.e., sort) all your translation files:

$ bundle exec i18n-tasks normalize

You can find unused keys:

$ bundle exec i18n-tasks unused

Most importantly, it can identify missing keys, which is crucial for automatically translating them:

$ bundle exec i18n-tasks missing

4. Configure i18n-tasks

You need to create a YAML configuration file. Here’s an example of mine; you can adapt it to suit your needs:

# config/i18n-tasks.yml

base_locale: en
locales: [en, fr, it, es]

data:
  read:
     - config/locales/%{locale}.yml
     - config/locales/**/*.%{locale}.yml # Important if you want to split your translation keys across multiple files.

  yaml:
    write:
      # do not wrap lines at 80 characters
      line_width: -1

Before adding automatic translations, make sure all your translation keys are properly set up in your YAML files.

5. Create OpenAI API Key

Open https://platform.openai.com/api-keys and create a new secret key.

Create new secret key
Create new secret key

Copy the newly generated secret key to your clipboard. You'll need to add credit to your account in order to use the API.

6. Run the translate-missing task

I prefer to run the normalize task before translating the missing keys to ensure that my files are properly formatted, but it's up to you.

$ bundle exec i18n-tasks normalize
$ OPENAI_API_KEY=your-secret-api-key-here bundle exec i18n-tasks translate-missing --backend=openai

You can run this command as often as needed whenever you add new English keys to your application.

And voilà!

7. Enjoy!

Additionally, thanks to Turbo, you can implement a seamless dropdown to switch locales without needing to reload the page!

module ApplicationHelper
  def locale_flag(locale = I18n.locale)
    {
      fr: '🇫🇷',
      es: '🇪🇸',
      it: '🇮🇹',
      en: '🇺🇸'
    }[locale]
  end
end

And in your view:

<div class="dropdown">
  <a class="btn btn-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
    <%= locale_flag %>

    <%= I18n.locale %>
  </a>

  <ul class="dropdown-menu">
    <% I18n.available_locales.each do |locale| %>
      <li>
        <%= link_to url_for(params.to_unsafe_h.merge(locale: locale)), class: 'dropdown-item' do %>
          <%= locale_flag(locale) %>

          <%= t("locales.#{locale}") %>
        <% end %>
      </li>
    <% end %>
  </ul>
</div>

8. Bonus

You can also explore these two useful projects: rails-i18n and devise-i18n.

Thank you!

Simplify your time tracking with Timecop

Timecop is a time tracking app that brings simplicity in your day to day life.

Timecop projects