From 0920a01e662163c13714fd6bd393b292df89151d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 27 Apr 2026 15:40:00 +0530 Subject: [PATCH] fix(i18n): align pluralization with locale rules (#14266) Loads Rails locale-specific pluralization rules so languages with an `other`-only plural model can safely use Crowdin exports without maintaining duplicate `one` keys. ## Closes None ## Why Crowdin exports Rails YAML pluralized strings using each target language's plural categories. These categories come from Unicode CLDR and represent grammatical forms, not a literal "number is 1" bucket. Some languages need separate forms such as `one` and `other`, but languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and Chinese use the same form for `1`, `2`, `5`, and larger counts in these strings. For those locales, CLDR correctly models the plural category as `other` only. Before this change, Chatwoot still relied on Rails' default English-style plural behavior for these locales. That meant a valid Crowdin export containing only `other` could fail at runtime when Rails received `count: 1` and looked for a missing `one` branch. Keeping duplicate `one` keys would only fight Crowdin on every translation sync. The runtime should instead follow the locale's plural rules. ## What changed - Added `rails-i18n` and enabled only its pluralization module. - Added explicit `other`-only plural rules for Chatwoot's underscore Chinese locale aliases, `zh_CN` and `zh_TW`. - Removed redundant `one` keys from the affected Devise and `time_units` translations. ## Validation - Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`, `zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and `time_units.days` resolve with only `other` for `count: 1`. - Ran YAML parse validation for all edited locale files. - Ran `bundle exec rubocop Gemfile config/application.rb config/initializers/i18n_pluralization.rb`. --- Gemfile | 1 + Gemfile.lock | 4 ++++ config/application.rb | 1 + config/initializers/i18n_pluralization.rb | 8 ++++++++ config/locales/devise.id.yml | 1 - config/locales/devise.ja.yml | 1 - config/locales/devise.ko.yml | 1 - config/locales/devise.ms.yml | 1 - config/locales/devise.th.yml | 1 - config/locales/devise.vi.yml | 1 - config/locales/devise.zh_CN.yml | 1 - config/locales/devise.zh_TW.yml | 1 - config/locales/id.yml | 4 ---- config/locales/ja.yml | 4 ---- config/locales/ko.yml | 4 ---- config/locales/ms.yml | 4 ---- config/locales/th.yml | 4 ---- config/locales/vi.yml | 4 ---- config/locales/zh_CN.yml | 4 ---- config/locales/zh_TW.yml | 4 ---- 20 files changed, 14 insertions(+), 40 deletions(-) create mode 100644 config/initializers/i18n_pluralization.rb diff --git a/Gemfile b/Gemfile index a5068e765..c4989c538 100644 --- a/Gemfile +++ b/Gemfile @@ -84,6 +84,7 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f..7d29e0b02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -727,6 +727,9 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) railties (7.1.5.2) actionpack (= 7.1.5.2) activesupport (= 7.1.5.2) @@ -1125,6 +1128,7 @@ DEPENDENCIES rack-mini-profiler (>= 3.2.0) rack-timeout rails (~> 7.1) + rails-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) diff --git a/config/application.rb b/config/application.rb index aa150794a..08f0451c1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,6 +37,7 @@ module Chatwoot class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 + config.rails_i18n.enabled_modules = [:pluralization] config.eager_load_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('enterprise/lib') diff --git a/config/initializers/i18n_pluralization.rb b/config/initializers/i18n_pluralization.rb new file mode 100644 index 000000000..c4fc3fb4b --- /dev/null +++ b/config/initializers/i18n_pluralization.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +other_plural_rule = ->(_count) { :other } + +Rails.application.config.after_initialize do + I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } }) + I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } }) +end diff --git a/config/locales/devise.id.yml b/config/locales/devise.id.yml index 71b9f46fb..fc6bfb26a 100644 --- a/config/locales/devise.id.yml +++ b/config/locales/devise.id.yml @@ -57,5 +57,4 @@ id: not_found: "tidak ditemukan" not_locked: "tidak terkunci" not_saved: - one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:" other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:" diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml index 043cd8351..a5840c6fc 100644 --- a/config/locales/devise.ja.yml +++ b/config/locales/devise.ja.yml @@ -57,5 +57,4 @@ ja: not_found: "見つかりませんでした" not_locked: "はロックされていません" not_saved: - one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:" other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:" diff --git a/config/locales/devise.ko.yml b/config/locales/devise.ko.yml index 846664ec9..5afb6c1c2 100644 --- a/config/locales/devise.ko.yml +++ b/config/locales/devise.ko.yml @@ -57,5 +57,4 @@ ko: not_found: "찾을 수 없습니다" not_locked: "잠겨 있지 않습니다" not_saved: - one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:" other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:" diff --git a/config/locales/devise.ms.yml b/config/locales/devise.ms.yml index ebcfe89e3..cecd08588 100644 --- a/config/locales/devise.ms.yml +++ b/config/locales/devise.ms.yml @@ -57,5 +57,4 @@ ms: not_found: "not found" not_locked: "was not locked" not_saved: - one: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/devise.th.yml b/config/locales/devise.th.yml index c9f52018d..18e1572bb 100644 --- a/config/locales/devise.th.yml +++ b/config/locales/devise.th.yml @@ -57,5 +57,4 @@ th: not_found: "not found" not_locked: "was not locked" not_saved: - one: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/devise.vi.yml b/config/locales/devise.vi.yml index 947e756f3..15dca044a 100644 --- a/config/locales/devise.vi.yml +++ b/config/locales/devise.vi.yml @@ -57,5 +57,4 @@ vi: not_found: "không tìm thấy" not_locked: "không được khoá" not_saved: - one: "Có %{count} lỗi được tìm thấy từ %{resource}:" other: "Có %{count} lỗi được tìm thấy từ %{resource}:" diff --git a/config/locales/devise.zh_CN.yml b/config/locales/devise.zh_CN.yml index 00f239948..2bf2831a8 100644 --- a/config/locales/devise.zh_CN.yml +++ b/config/locales/devise.zh_CN.yml @@ -57,5 +57,4 @@ zh_CN: not_found: "找不到" not_locked: "未锁定" not_saved: - one: "%{count} 个错误禁止保存 %{resource}:" other: "%{count} 个错误禁止保存 %{resource}:" diff --git a/config/locales/devise.zh_TW.yml b/config/locales/devise.zh_TW.yml index c5bd49450..f892bf796 100644 --- a/config/locales/devise.zh_TW.yml +++ b/config/locales/devise.zh_TW.yml @@ -57,5 +57,4 @@ zh_TW: not_found: "找不到。" not_locked: "並未被鎖定。" not_saved: - one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:" other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:" diff --git a/config/locales/id.yml b/config/locales/id.yml index aefcff3fe..b097de83d 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -435,16 +435,12 @@ id: button: Buka percakapan time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 5e3412378..deca85ebf 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -435,16 +435,12 @@ ja: button: 会話を開く time_units: days: - one: '%{count} 日' other: '%{count} 日' hours: - one: '%{count} 時間' other: '%{count} 時間' minutes: - one: '%{count} 分' other: '%{count} 分' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/ko.yml b/config/locales/ko.yml index c010ee2ac..153f39928 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -435,16 +435,12 @@ ko: button: 대화 열기 time_units: days: - one: '%{count}일' other: '%{count}일' hours: - one: '%{count}시간' other: '%{count}시간' minutes: - one: '%{count}분' other: '%{count}분' seconds: - one: '%{count}초' other: '%{count}초' auto_assignment: default_policy_name: '기본 정책' diff --git a/config/locales/ms.yml b/config/locales/ms.yml index 617056d7e..e1ee39aee 100644 --- a/config/locales/ms.yml +++ b/config/locales/ms.yml @@ -435,16 +435,12 @@ ms: button: Open conversation time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/th.yml b/config/locales/th.yml index b278ec442..aeef51e95 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -435,16 +435,12 @@ th: button: เปิดดูการสนทนา time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/vi.yml b/config/locales/vi.yml index c53f8de4c..1facb248f 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -435,16 +435,12 @@ vi: button: Mở cuộc trò chuyện time_units: days: - one: '%{count} days' other: '%{count} days' hours: - one: '%{count} hours' other: '%{count} hours' minutes: - one: '%{count} minutes' other: '%{count} minutes' seconds: - one: '%{count} seconds' other: '%{count} seconds' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml index 0f6cd069f..ab8005c5e 100644 --- a/config/locales/zh_CN.yml +++ b/config/locales/zh_CN.yml @@ -435,16 +435,12 @@ zh_CN: button: 重新打开会话 time_units: days: - one: '%{count} 天' other: '%{count} 天' hours: - one: '%{count} 小时' other: '%{count} 小时' minutes: - one: '%{count} 分钟' other: '%{count} 分钟' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: 'Default Policy' diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml index 775bcf000..d7dd33efa 100644 --- a/config/locales/zh_TW.yml +++ b/config/locales/zh_TW.yml @@ -435,16 +435,12 @@ zh_TW: button: '開啟對話' time_units: days: - one: '%{count} 天' other: '%{count} 天' hours: - one: '%{count} 小時' other: '%{count} 小時' minutes: - one: '%{count} 分鐘' other: '%{count} 分鐘' seconds: - one: '%{count} 秒' other: '%{count} 秒' auto_assignment: default_policy_name: '預設策略'