Ruby on Railsで簡単にlocaleファイルを自動生成するコマンドを作ってみたら便利だったので紹介する

Ruby on Railsで以下のように、モデルの日本語ファイルを用意する機会があると思います。

```yml
ja:
  activerecord:
    models:
      article:  記事
    attributes:
      article:
        title: 題名
        content: 本文
        created_at: 作成日時
        updated_at: 更新日時
```
このファイルを用意しておくと、エラーメッセージなどでも日本語で表示してくれますね。

しかし、このファイルを毎回手動で作っていくのは非常に大変です。
そこで、自動でこのファイルを作れるようにしてみようっていうのが今回の記事の内容です。


## 完成イメージ

```bash
❯ bundle exec "rails tools:locale_generate"
Created: config/locales/ja/models/post.yml
config/locales/ja/models/user.yml already exists. Do you want to overwrite? (y/n/force/skip): skip
Created: config/locales/ja/models/post_user.yml
Created: config/locales/ja/models/company.yml
Created: config/locales/ja/models/user_profile.yml
```

1. `bundle exec "rails tools:locale_generate"`というコマンドを実行すると、ファイルを作成していきます。
1. すでにファイルがある場合は、上書きするか、スキップするか選べます

実行している様子。
![スクリーンショット 2023-09-28 23.34.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/d31dda49-595e-55ed-aaf2-ca279b805d58.png)


日本語は、以下のようなテーブルのコメント名やカラムのコメント名を参照します
![スクリーンショット 2023-09-28 2.44.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/6e8e6dd4-1fb9-31e7-75c8-87d1303bc546.png)

上記をもとに、作成されるのは以下のようなファイルです。
![スクリーンショット 2023-09-28 2.44.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/9ec37d92-90f0-bcb8-22f6-bf89f3cce5e5.png)

### 前提
テーブルに記載されているコメント、カラムに記載されているコメントを参照します。
自分は基本的にテーブルやカラムにコメントを書いているので、同じようにしている方は同じコードで動かせると思います。

またconfig/application.rbのi18に関する設定は以下のようにしています。
```ruby
# i18n
    config.i18n.default_locale = :ja
    config.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.{rb,yml}")]
```


## まずはRakeタスクを作ろう

自分は普段使うようなRakeタスクは、 `lib/tasks/tools`フォルダを作ってそこに入れるようにしています。
ファイル作成
```bash
mkdir -p lib/tasks/tools
touch lib/tasks/tools/locale_generate.rake
```

このファイルの中身はひとまず全部以下に載せてしまいます。(GitHub Gistは[こちら](https://gist.github.com/hatsu38/9bb38e3abdfa5b26d4e7fd7cb4b92e1f))

<details><summary>ファイルの中身</summary>

<div>

```ruby
# frozen_string_literal: true

require "fileutils"
require "yaml"
require "pathname"

COLUMN_COMMENTS = {
  ja: { "created_at" => "作成日時", "updated_at" => "更新日時" },
  en: { "created_at" => "Created At", "updated_at" => "Updated At" }
}.freeze

EXCLUDED_TABLES = %w[schema_migrations ar_internal_metadata].freeze
DEFAULT_COLUMNS = %w[id created_at updated_at].freeze

module LocaleTasks
  class << self
    attr_accessor :overwrite_mode

    def write_locale_file(args, table_name, locale_data)
      locale_dir = Rails.root.join("config", "locales", args[:locale], "models")
      create_directory(locale_dir)
      locale_file_path = construct_file_path(locale_dir, table_name)
      return unless file_writable?(locale_file_path)

      write_to_file(locale_file_path, locale_data)
    end

    def generate_locale_data(args, table_name)
      model_class = classify_constantize(table_name)
      locale_data_initial(args, table_name).tap do |locale_data|
        process_columns(args, model_class, table_name, locale_data)
      end
    rescue StandardError => e
      handle_error(e, table_name)
    end

    private

    def classify_constantize(table_name)
      table_name.classify.constantize
    end

    def locale_data_initial(args, table_name)
      table_comment = ActiveRecord::Base.connection.table_comment(table_name)
      { args[:locale].to_sym => { activerecord: { models: { table_name => table_comment }, attributes: { table_name => {} } } } }
    end

    def process_columns(args, model_class, table_name, locale_data)
      ActiveRecord::Base.connection.columns(table_name).each do |column|
        next if column.name == "id"

        column_comment = fetch_column_comment(args, model_class, column)
        locale_data[args[:locale].to_sym][:activerecord][:attributes][table_name][column.name] = column.comment || column_comment
      end
    end

    def fetch_column_comment(args, model_class, column)
      relation_name = find_relation_name(model_class, column.name)
      if relation_name
        reflection = model_class.reflections[relation_name]
        if reflection.options[:polymorphic]
          nil
        else
          related_class = reflection.klass
          if related_class < ActiveRecord::Base
            ActiveRecord::Base.connection.table_comment(related_class.table_name)
          end
        end
      elsif DEFAULT_COLUMNS.include?(column.name)
        COLUMN_COMMENTS[args[:locale].to_sym][column.name]
      end
    end

    def find_relation_name(model_class, column_name)
      model_class.reflections.find { |_k, v| v.foreign_key == column_name }&.first
    end

    def handle_error(error, table_name = nil)
      message = table_name ? "Error with tables #{table_name}: #{error}" : error
      puts "\e[31m#{message}\e[0m"
    end

    def create_directory(dir)
      FileUtils.mkdir_p(dir) unless File.directory?(dir)
    end

    def construct_file_path(dir, table_name)
      dir.join("#{table_name.classify.underscore}.yml")
    end

    def file_writable?(file_path)
      return true unless File.exist?(file_path)

      relative_path = relative_file_path(file_path)
      overwrite_file?(relative_path)
    end

    def relative_file_path(file_path)
      Pathname.new(file_path).relative_path_from(Pathname.new(Dir.pwd))
    end

    def overwrite_file?(relative_path)
      case overwrite_mode
      when "force"
        true
      when "skip"
        false
      else
        print "#{relative_path} already exists. Do you want to overwrite? (y/n/force/skip): "
        answer = $stdin.gets.chomp
        self.overwrite_mode = answer.downcase
        answer.casecmp("y").zero? || answer.casecmp("force").zero?
      end
    end

    def write_to_file(file_path, data)
      yaml_data = YAML.dump(stringify_keys(data))
      File.write(file_path, yaml_data)
      puts "\e[32mCreated: #{relative_file_path(file_path)}\e[0m"
    end

    def stringify_keys(hash)
      case hash
      when Hash
        hash.to_h { |k, v| [k.to_s, stringify_keys(v)] }
      when Enumerable
        hash.map { |v| stringify_keys(v) }
      else
        hash
      end
    end
  end
end

# コマンド: bundle exec "rails tools:locale_generate"
# コマンド: bundle exec "rails tools:locale_generate[ja,version]"
namespace :tools do
  desc "Generate locale files from database comments"
  task :locale_generate, [:locale, :model_name] => :environment do |_t, args|
    args.with_defaults(locale: Rails.application.config.i18n.default_locale.to_s)

    if args[:model_name].present?
      table_name = args[:model_name].underscore.pluralize
      locale_data = LocaleTasks.generate_locale_data(args, table_name)
      LocaleTasks.write_locale_file(args, table_name, locale_data)
    else
      ActiveRecord::Base.connection.tables.each do |table_name|
        next if EXCLUDED_TABLES.include?(table_name)

        locale_data = LocaleTasks.generate_locale_data(args, table_name)
        LocaleTasks.write_locale_file(args, table_name, locale_data)
      end
    end
  end
end
```
</div>
</details>


## 気にしたポイント1: 特定のモデルのみの作成に対応

Rakeタスクの引数、なくても構わないのですが、2つ取ることができるようになっています。

```ruby
# コマンド: bundle exec "rails tools:locale_generate"
# コマンド: bundle exec "rails tools:locale_generate[ja,version]"
namespace :tools do
  desc "Generate locale files from database comments"
  task :locale_generate, [:locale, :model_name] => :environment do |_t, args|
    args.with_defaults(locale: Rails.application.config.i18n.default_locale.to_s)

    if args[:model_name].present?
      table_name = args[:model_name].underscore.pluralize
      locale_data = LocaleTasks.generate_locale_data(args, table_name)
      LocaleTasks.write_locale_file(args, table_name, locale_data)
    else
      ActiveRecord::Base.connection.tables.each do |table_name|
        next if EXCLUDED_TABLES.include?(table_name)

        locale_data = LocaleTasks.generate_locale_data(args, table_name)
        LocaleTasks.write_locale_file(args, table_name, locale_data)
      end
    end
  end
end
```

1つ目の引数には、日本語か英語かなど、どの言語であるかを指定できます。
この引数がない場合は、今Applicationで設定されている言語として作成します。

今設定されている言語は、これで取得します。
```ruby
Rails.application.config.i18n.default_locale.to_s
```

2つ目の引数には、モデルを指定できます。
特定のモデルのみ作成する場合などに利用します。
つまり、下記のようなコマンドの場合には、言語はjaで、モデルはversionで作成することになります。
```bash
❯ bundle exec "rails tools:locale_generate[ja,version]"
Created: config/locales/ja/models/version.yml
```

実行結果はこんな感じです。
![スクリーンショット 2023-09-28 23.53.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/38e2f3f4-8396-40d1-06cd-650bdb80f202.png)


## 気にしたポイント2: ファイルがすでに存在する場合は、上書きするかどうかを選べる

ファイルがすでに存在する場合には、上書きしたいこともあれば、上書きしたくないこともあると思います。
そこで、`y`か`n`かで選択できるようにしています。

```ruby
print "#{relative_path} already exists. Do you want to overwrite? (y/n/force/skip): "
answer = $stdin.gets.chomp
self.overwrite_mode = answer.downcase
answer.casecmp("y").zero? || answer.casecmp("force").zero?
```

- `y`が入力されたら、そのファイルを上書きします。
- `force`が入力されたら、そのファイル以外もすでに存在するファイルは全て上書きする設定です。
- `n`が入力されたら、そのファイルを上書きしません。
- `skip`が入力されたら、そのファイル以外もすでに存在するファイルは全て上書きしない設定です。

上書き対象になるファイルが非常に多くなる可能性があったので、skipやforceのオプションも追加しています。

![スクリーンショット 2023-09-29 0.23.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/f6bfecac-5576-6b9f-b12e-a07bdb3c3b0f.png)


## 気にしたポイント3: belongs_toになっているカラムには、そのテーブルのコメントを参照する

例えば以下のように、referencesで作ったカラムに、コメントがない場合があるかもしれません。
```ruby
class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts, id: :uuid, comment: "投稿" do |t|
      t.references :company, foreign_key: true, null: false, comment: "会社"
      t.references :user, foreign_key: true, null: false
      t.string :title, null: false, collation: "ja-x-icu", limit: 255, comment: "タイトル"
      t.text :content, null: false, comment: "投稿内容"

      t.timestamps
    end
  end
end
```

この時、コメント(`comment: 〇〇`)があればコメントを参照します。
が、コメントがない上記では`user_id`カラムのような時には、`users`テーブルのテーブルに書かれているコメント名を参照してファイルを作成するようにしています。

```yml
---
ja:
  activerecord:
    models:
      posts: "投稿"
    attributes:
      posts:
        company_id: "会社"
　　　　　　　　　　　　　　　　user_id: "ユーザー" # ←カラムにはコメントないが、usersテーブルのコメント名が反映される
        title: "タイトル"
        content: "投稿内容"
        created_at: "作成日時"
        updated_at: "更新日時"
```


## 終わりに

こちら実際に作ってみて結構便利だったので、紹介の記事でした。
他の人が各モデルの日本語化をどのようにやっているかも気になっています。ぜひ良い方法あれば教えてください。
ぜひ同じような手順やってみて、localesの作成が便利になったとかあったら嬉しいです！