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`.
This commit is contained in:
Sojan Jose
2026-04-27 15:40:00 +05:30
committed by GitHub
parent 06467057be
commit 0920a01e66
20 changed files with 14 additions and 40 deletions
+1
View File
@@ -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
+4
View File
@@ -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)
+1
View File
@@ -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')
@@ -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
-1
View File
@@ -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:"
-1
View File
@@ -57,5 +57,4 @@ ja:
not_found: "見つかりませんでした"
not_locked: "はロックされていません"
not_saved:
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
-1
View File
@@ -57,5 +57,4 @@ ko:
not_found: "찾을 수 없습니다"
not_locked: "잠겨 있지 않습니다"
not_saved:
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
-1
View File
@@ -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:"
-1
View File
@@ -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:"
-1
View File
@@ -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}:"
-1
View File
@@ -57,5 +57,4 @@ zh_CN:
not_found: "找不到"
not_locked: "未锁定"
not_saved:
one: "%{count} 个错误禁止保存 %{resource}"
other: "%{count} 个错误禁止保存 %{resource}"
-1
View File
@@ -57,5 +57,4 @@ zh_TW:
not_found: "找不到。"
not_locked: "並未被鎖定。"
not_saved:
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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: '기본 정책'
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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'
-4
View File
@@ -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: '預設策略'