feat(i18n): add Russian UI language (#34717)

* feat(i18n): add Russian UI language

Register `ru` as a built-in UI language using the catalog from #33370,
reconciled against current en.json: keys pruned from English were dropped,
stale translations whose English source changed were removed, and the
catalog verifier now accepts CLDR few/many forms for an existing `_other`
family.

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>

* fix(i18n): drop Russian _one entries superseded by count placeholders

Main now interpolates the count in English _one strings (#34693), so the
literal-1 Russian forms no longer match their source.

* feat(i18n): complete Russian catalog and add terminology glossary

Translate the 650 remaining English keys and the few/many plural forms for
every count family, and document the Russian glossary and style conventions.

* fix(i18n): apply reviewed Russian translation corrections

Fix mistranslations and grammar flagged in a full-catalog review, and align
terminology with the glossary: рабочее дерево for worktree, рабочее
пространство for workspace, пул-реквест and файлы cookie.

* fix(i18n): resolve Russian plural forms in main-process strings and align ru terminology

translateMain now resolves literal _one/_other keys through the CLDR plural
family like translate(), so native dialogs pick Russian few/many forms. The
plural helpers move to src/shared for reuse. apply-translation-delta accepts
locale-only plural categories sourced from English _other. ru.json follows the
glossary for account/session/prompt and uses a single ellipsis character.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>
This commit is contained in:
OrcaWin
2026-10-27 23:18:15 -07:00
committed by GitHub
co-authored by OrcaWin Vyacheslav Scherbinin
parent a1522f94b3
commit 52a43ed99e
26 changed files with 20938 additions and 56 deletions
+106
View File
@@ -0,0 +1,106 @@
# Russian localization
Russian uses the built-in locale `ru`. System detection recognizes regional and POSIX tags such as `ru-RU`, `ru-KZ` and `ru_RU.UTF-8`. Renderer catalogs and native menu translations load lazily. The selected language also supplies the locale for the existing number, date, relative-time and weekday formatters. Paired web clients keep their language preference locally when a server is older or offline.
The nested i18next catalog lives at `src/renderer/src/i18n/locales/ru.json`. Missing translations use the current English fallback. Standalone React Native mobile screens have separate copy; the shared desktop renderer used by paired web clients uses this catalog. Provider transcripts, approval options, user content and third-party plugin content retain their original language.
## Translation workflow
Keep inline English defaults and `en.json` synchronized with the current source. Run `pnpm run sync:localization-catalog` to add missing literal defaults and `pnpm run sync:localization-runtime-catalog` to regenerate the English boot subset. Apply reviewed translations with `node config/scripts/apply-translation-delta.mjs <delta.json>`, which rejects empty values and changed placeholders.
Translate complete phrases in context. Preserve product names, command syntax, paths and interpolation variables. Reuse a translation only after checking both the English source and its UI meaning. Native language labels, technical identifiers and interpolation-only values may intentionally match English. Never exempt ordinary untranslated prose.
## Glossary
Use these renderings unless context clearly requires otherwise. Address the user as «вы» (lowercase mid-sentence). Buttons and commands use the infinitive («Создать», «Открыть»); instructions use the polite imperative («Выберите»). Use sentence case, «ёлочки» quotes, a single `…` character, and write «ё» consistently. Keep product names (Orca, Claude Code, Codex, GitHub, GitLab), commands, paths and placeholders in their original form.
| English | Russian | Notes |
| --------------- | ---------------------- | ---------------------------------------------------------------------------- |
| workspace | рабочее пространство | Сущность Orca, объединяющая рабочие инструменты |
| worktree | рабочее дерево | Git worktree; отличать от workspace. Мн. ч. «рабочие деревья» |
| agent | агент | В действиях — «запустить агента», «остановить агента» |
| repository | репозиторий | Также для сокращения repo |
| branch | ветка | Base branch — «базовая ветка», target branch — «целевая ветка» |
| commit | коммит | Кнопка — «Закоммитить»; в пояснениях — «создать коммит» |
| pull request | пул-реквест | Склоняется: «пул-реквеста» |
| merge request | запрос на слияние | Соответствует терминологии GitLab |
| issue | задача | Для сущности трекера GitHub, GitLab, Linear или Jira |
| terminal | терминал | «открыть терминал», «вкладка терминала» |
| tab | вкладка | Название клавиши Tab сохранять латиницей |
| pane | панель | Самостоятельная область внутри раскладки |
| split | разделение | Существительное — «разделение» |
| sidebar | боковая панель | При необходимости «левая боковая панель» или «правая боковая панель» |
| settings | настройки | Название раздела — «Настройки», отдельный параметр — «настройка» |
| host | сервер | SSH host — «SSH-сервер» |
| remote | удалённый | Прилагательное согласовывать с существительным |
| session | сессия | «сессия агента», «сессия терминала» |
| plugin | плагин | «установить плагин», мн. ч. «плагины» |
| skill | навык | «навык агента», «установить навык» |
| automation | автоматизация | Исчисляемая сущность: «создать автоматизацию», «запуски автоматизации» |
| review | проверка | Code review — «проверка кода» |
| diff | сравнение | Результат сравнения — «различия», показываемые правки — «изменения» |
| merge | слияние | Кнопка — «Слить», процесс — «слияние», состояние — «Выполняется слияние» |
| rebase | перебазирование | Кнопка — «Перебазировать», abort rebase — «Прервать перебазирование» |
| stash | stash | Не склонять: «убрать изменения в stash» |
| checkout | переключиться на ветку | Для действия с веткой, включая check out |
| push | отправить | Отправка коммитов в удалённый репозиторий |
| pull | получить и слить | При rebase — «Получить и перебазировать» |
| fetch | получить изменения | Получает данные из удалённого репозитория без слияния с текущей веткой |
| project | проект | Название сущности Orca и проекта во внешнем трекере |
| folder | папка | Основной термин для файлового интерфейса и выбора расположения |
| file | файл | Filename — «имя файла», file path — «путь к файлу» |
| quota | квота | «квота исчерпана», «остаток квоты» |
| usage | использование | Для количества израсходованных токенов естественно «расход токенов» |
| dictation | диктовка | «режим диктовки», «начать диктовку», «остановить диктовку» |
| browser | браузер | Built-in browser — «встроенный браузер» |
| notification | уведомление | Notification area — «область уведомлений» |
| shortcut | сочетание клавиш | Ярлык приложения — отдельное значение |
| command palette | палитра команд | Устоявшееся название в русской терминологии VS Code |
| editor | редактор | При необходимости «редактор кода», «редактор CSV» |
| source control | управление версиями | Название раздела и Git-функций |
| staged | в индексе | Staged changes — «изменения в индексе» |
| unstaged | не в индексе | Unstage — «Убрать из индекса» |
| conflict | конфликт | Merge conflict — «конфликт слияния», resolve conflict — «разрешить конфликт» |
| prompt | запрос | Shell prompt — «приглашение командной строки» |
| model | модель | Названия и идентификаторы моделей сохранять в официальном написании |
| provider | провайдер | При необходимости уточнять: «провайдер моделей» |
| chat | чат | «новый чат», «открыть чат», «название чата» |
| thread | обсуждение | Цепочка сообщений или комментариев |
| run | запуск | Кнопка — «Запустить»; отдельная команда — «Выполнить» |
| task | задача | Задание агенту или элемент списка задач |
| ticket | задача | Linked ticket — «связанная задача» |
| account | учётная запись | «добавить учётную запись», «войти в учётную запись» |
| sign in | войти | Существительное — «вход» |
| archive | архивировать | Процесс — «архивация», состояние — «В архиве» |
| pin | закрепить | Обратное действие — «Открепить» |
| theme | тема | «светлая тема», «тёмная тема» |
| preview | предпросмотр | «открыть предпросмотр», «предпросмотр темы» |
| permission | разрешение | Файловые permissions — «права доступа» |
| approve | одобрить | В прямом запросе доступа — «Разрешить» |
| comment | комментарий | Действие — «Добавить комментарий», inline comment — «комментарий к строке» |
| environment | окружение | Runtime environment — «среда выполнения»; переменная окружения |
| sandbox | песочница | В пояснении — «изолированная среда» |
| port | порт | «локальный порт», «удалённый порт», «перенаправление портов» |
| SSH | SSH | Общепринятая аббревиатура протокола: латиницей, прописными, без склонения |
| token | токен | «1 токен», «2 токена», «5 токенов» |
| Git | Git | Без склонения: «репозиторий Git» |
| PR | PR | Прописные латинские буквы, без окончаний: «этот PR», «3 PR» |
| MR | MR | Прописные латинские буквы, без окончаний: «этот MR», «3 MR» |
## Plurals and source grammar
Pass `count` for count-based messages. Russian uses `one`, `few`, `many` and `other`; test zero, singular, small plurals, teens, compound counts and fractions. The validator permits additional locale-specific forms only for an existing English `_other` family and valid CLDR categories, and checks their placeholders against that source.
Prefer a whole sentence over translated fragments or JavaScript singular/plural concatenation. Where changing legacy behavior is unnecessary, a reviewed count-neutral phrase can preserve grammar. Keep original provider content separate from application labels.
## Verification
Run these catalog checks after editing source copy or translations:
```sh
pnpm run verify:localization-catalogs
pnpm run verify:localization-extraction
pnpm run verify:localization-coverage
```
Completeness measures coverage of the current English catalog; it does not prove linguistic quality or rendered behavior.
+10 -4
View File
@@ -5,7 +5,7 @@ import process from 'node:process'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const SUPPORTED_LOCALES = new Set(['es', 'fr', 'ja', 'ko', 'zh'])
const SUPPORTED_LOCALES = new Set(['es', 'fr', 'ja', 'ko', 'ru', 'zh'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
function placeholders(value) {
@@ -28,8 +28,14 @@ function setEntry(catalog, key, value) {
cursor[parts.at(-1)] = value
}
function getEn(catalog, key) {
const value = getEntry(catalog, key)
function getEn(catalog, key, locale) {
// Why: locales with more CLDR plural categories than English (ru few/many) translate from `_other`.
const plural = /^(.*)_(zero|one|two|few|many|other)$/.exec(key)
const value =
getEntry(catalog, key) ??
(plural && new Intl.PluralRules(locale).resolvedOptions().pluralCategories.includes(plural[2])
? getEntry(catalog, `${plural[1]}_other`)
: undefined)
if (typeof value !== 'string') {
throw new Error(`en.json has no string entry for ${key}`)
}
@@ -57,7 +63,7 @@ async function main() {
skipped += 1
continue
}
const enValue = getEn(enCatalog, key)
const enValue = getEn(enCatalog, key, locale)
if (placeholders(enValue) !== placeholders(rawValue)) {
throw new Error(
`[${locale}] placeholder mismatch for ${key}: en=${placeholders(enValue)} vs locale=${placeholders(rawValue)}`
+28 -7
View File
@@ -284,19 +284,40 @@ function setCatalogEntry(catalog, key, value) {
cursor[parts.at(-1)] = value
}
function collectLocaleParityIssues(enCatalog, localeCatalog) {
// Locales with more CLDR plural categories than English (ru few/many) source those forms from `_other`.
function resolveLocaleSourceKey(enEntries, localeName, key) {
if (enEntries.has(key)) {
return key
}
const plural = /^(.*)_(zero|one|two|few|many|other)$/.exec(key)
if (!plural || !localeName || !enEntries.has(`${plural[1]}_other`)) {
return undefined
}
try {
return new Intl.PluralRules(localeName).resolvedOptions().pluralCategories.includes(plural[2])
? `${plural[1]}_other`
: undefined
} catch {
return undefined
}
}
function collectLocaleParityIssues(enCatalog, localeCatalog, localeName) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
const missingInLocale = [...enEntries.keys()].filter((key) => !localeEntries.has(key))
const extraInLocale = [...localeEntries.keys()].filter((key) => !enEntries.has(key))
const extraInLocale = [...localeEntries.keys()].filter(
(key) => !resolveLocaleSourceKey(enEntries, localeName, key)
)
const interpolationMismatches = []
for (const key of enEntries.keys()) {
if (!localeEntries.has(key)) {
for (const [key, localeValue] of localeEntries) {
const sourceKey = resolveLocaleSourceKey(enEntries, localeName, key)
if (!sourceKey) {
continue
}
const enVariables = collectInterpolationVariables(enEntries.get(key))
const localeVariables = collectInterpolationVariables(localeEntries.get(key))
const enVariables = collectInterpolationVariables(enEntries.get(sourceKey))
const localeVariables = collectInterpolationVariables(localeValue)
if (enVariables.join('|') !== localeVariables.join('|')) {
interpolationMismatches.push(key)
}
@@ -422,7 +443,7 @@ async function readCatalogFile(catalogPath) {
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
collectLocaleParityIssues(enCatalog, localeCatalog, localeName)
const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName)
// Feature PRs own English declarations; missing target translations use i18next fallback until localization supplies them.
@@ -163,6 +163,26 @@ describe('verify-localization-catalog', () => {
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
it('accepts Russian few/many forms only for an existing plural family', async () => {
const { root, localesDir } = makeProject({
sourceText: 'export {}\n',
enCatalog: { images_one: '{{count}} image', images_other: '{{count}} images' }
})
const russianPath = path.join(localesDir, 'ru.json')
writeJson(russianPath, {
images_few: '{{count}} изображения',
images_many: '{{count}} изображений'
})
await expect(verifyLocalizationCatalog(root)).resolves.toBe(0)
writeJson(russianPath, { unknown_many: '{{count}} изображений' })
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
writeJson(russianPath, { images_many: '{{wrongCount}} изображений' })
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
writeJson(russianPath, { images_two: '{{count}} изображения' })
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
})
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
const { root } = makeProject({
sourceText: 'export {}\n',
+1 -1
View File
@@ -28,7 +28,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then
- Status bar toggles, including **Resource Manager** (CPU/memory/sessions, daemon controls, workspace disk scans).
- **Usage percentages** — show provider limits as **% used** or **% remaining** on the status-bar roster.
- App Icon — cycle between Classic, Watercolor, and Blue for the icon shown in the Dock and window switcher.
- **Language** — primary Interface control. Switch between System (follows the OS), English, 中文(简体), 繁體中文(台灣), 한국어, 日本語, Español, Français, or Português (Brasil). Settings search also matches native words for “language” (语言 / 語言 / 언어 / 言語 / Idioma / Langue) so you can find Language while the UI is still in English.
- **Language** — primary Interface control. Switch between System (follows the OS), English, 中文(简体), 繁體中文(台灣), 한국어, 日本語, Español, Français, Português (Brasil), or Русский. Settings search also matches native words for “language” (语言 / 語言 / 언어 / 言語 / Idioma / Langue / Язык) so you can find Language while the UI is still in English.
## Git
@@ -15,6 +15,7 @@ import {
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH
} from '../../shared/ui-language'
import {
@@ -70,6 +71,25 @@ describe('main-i18n lazy locale loading', () => {
expect(translateMain('missing.main.feature', 'English fallback')).toBe('English fallback')
})
it('loads Russian before native menu registration reads it', async () => {
expect(await setMainUiLanguage(UI_LANGUAGE_RUSSIAN)).toBe('ru')
expect(translateMain('menu.file', 'File')).toBe('Файл')
expect(translateMain('menu.settings', 'Settings')).toBe('Настройки')
// Production callers pass the literal `_other` key; Russian must still pick few/many.
for (const [count, expected] of [
[3, '3 активных терминала переподключатся после перезапуска.'],
[5, '5 активных терминалов переподключатся после перезапуска.']
] as const) {
expect(
translateMain(
'localBuild.liveTerminals_other',
'{{count}} live terminals will reconnect after restart.',
{ count }
)
).toBe(expected)
}
})
it('returns to English from a lazily-loaded locale', async () => {
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
expect(translateMain('menu.file', 'File')).toBe('Archivo')
+14 -2
View File
@@ -6,6 +6,10 @@ import i18next, {
type TOptions
} from 'i18next'
import {
resolvePluralFamilyKey,
withPluralCategoriesFromOther
} from '../../shared/i18n-plural-family'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization'
import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale'
import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language'
@@ -28,6 +32,7 @@ const LAZY_LOCALE_LOADERS: Record<
ja: () => import('../../renderer/src/i18n/locales/ja.json'),
ko: () => import('../../renderer/src/i18n/locales/ko.json'),
pt: () => import('../../renderer/src/i18n/locales/pt.json'),
ru: () => import('../../renderer/src/i18n/locales/ru.json'),
zh: () => import('../../renderer/src/i18n/locales/zh.json'),
'zh-Hant': () => import('../../renderer/src/i18n/locales/zh-Hant.json')
}
@@ -43,7 +48,14 @@ const lazyLocaleBackend: BackendModule = {
return
}
loader().then(
(mod) => callback(null, mod.default),
(mod) =>
callback(
null,
withPluralCategoriesFromOther(
mod.default,
new Intl.PluralRules(language).resolvedOptions().pluralCategories
)
),
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
)
}
@@ -136,7 +148,7 @@ export function setMainPluginLanguagePacks(
export function translateMain(key: string, fallback: string, options?: TOptions): string {
// Menu registration can precede async initialization; use the English fallback then.
const raw = initialized
? mainI18n.t(key, { defaultValue: fallback, ...options })
? mainI18n.t(resolvePluralFamilyKey(key, options), { defaultValue: fallback, ...options })
: interpolateFallback(fallback, options)
const value = typeof raw === 'string' && raw.length > 0 ? raw : fallback
return isPseudoLocalizationLocale(mainI18n.language) ? pseudoLocalizeString(value) : value
@@ -321,7 +321,8 @@ describe('onboarding language request', () => {
expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe(
'checked'
)
expect(screen.getAllByRole('radio')).toHaveLength(8)
expect(screen.getAllByRole('radio')).toHaveLength(9)
expect(screen.getByRole('radio', { name: 'Русский' })).toBeTruthy()
expect(save).not.toHaveBeenCalled()
expect(useAppStore.getState().settings?.uiLanguage).toBe('system')
fireEvent.click(screen.getByRole('radio', { name: 'English' }))
@@ -459,7 +460,7 @@ describe('onboarding language request', () => {
})
installWebPreloadApi()
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
expect(screen.getAllByRole('radio')).toHaveLength(8)
expect(screen.getAllByRole('radio')).toHaveLength(9)
expect(screen.queryByRole('button', { name: 'Other language…' })).toBeNull()
expect(screen.queryByRole('textbox')).toBeNull()
} finally {
@@ -475,7 +476,7 @@ describe('onboarding language request', () => {
const save = vi.fn().mockResolvedValue(undefined)
useAppStore.setState({ updateSettingsOrThrow: save })
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
expect(screen.getAllByRole('radio')).toHaveLength(8)
expect(screen.getAllByRole('radio')).toHaveLength(9)
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
expect(save).not.toHaveBeenCalled()
})
@@ -56,6 +56,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry
...translateSearchKeyword('settings.appearance.language.spanish', 'Español'),
...translateSearchKeyword('settings.appearance.language.french', 'Français'),
...translateSearchKeyword('settings.appearance.language.portuguese', 'Português (Brasil)'),
...translateSearchKeyword('settings.appearance.language.russian', 'Русский'),
// Index native language words in every locale so users can find their language from an unfamiliar UI.
'语言',
'語言',
@@ -63,6 +64,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry
'言語',
'Idioma',
'Langue',
'Язык',
...translateSearchKeyword(
'auto.components.settings.appearance.search.language.locale',
'locale'
+8 -27
View File
@@ -12,6 +12,7 @@ import frCatalogUrl from './locales/fr.json?url'
import jaCatalogUrl from './locales/ja.json?url'
import koCatalogUrl from './locales/ko.json?url'
import ptCatalogUrl from './locales/pt.json?url'
import ruCatalogUrl from './locales/ru.json?url'
import zhCatalogUrl from './locales/zh.json?url'
import zhHantCatalogUrl from './locales/zh-Hant.json?url'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
@@ -19,11 +20,12 @@ import { advanceTranslationResourceVersion } from './translation-resource-versio
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
import type { SupportedUiLocale } from '../../../shared/ui-locale'
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
import {
isCatalogObject,
type PluginLanguagePackRegistration
} from '../../../shared/plugins/plugin-language-pack-artifact'
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import {
resolvePluralFamilyKey,
withPluralCategoriesFromOther
} from '../../../shared/i18n-plural-family'
export const i18n: I18nInstance = i18next.createInstance()
@@ -34,6 +36,7 @@ const NON_DEFAULT_LOCALE_URLS: Record<Exclude<SupportedUiLocale, 'en'>, string>
ja: jaCatalogUrl,
ko: koCatalogUrl,
pt: ptCatalogUrl,
ru: ruCatalogUrl,
zh: zhCatalogUrl,
'zh-Hant': zhHantCatalogUrl
}
@@ -103,12 +106,8 @@ const getPluralRule = pluralResolver.getRule.bind(pluralResolver)
pluralResolver.getRule = (code: string, options?: { ordinal?: boolean }) =>
getPluralRule(pluralLocaleByResourceLanguage.get(code) ?? code, options)
const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/
export function translate(key: string, fallback: string, options?: TOptions): string {
// Callers name a literal `_one`/`_other` key for extraction; with a count, let CLDR pick the form.
const resolvedKey = typeof options?.count === 'number' ? key.replace(PLURAL_SUFFIX_RE, '') : key
const value = i18n.t(resolvedKey, { defaultValue: fallback, ...options })
const value = i18n.t(resolvePluralFamilyKey(key, options), { defaultValue: fallback, ...options })
return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value
}
@@ -163,24 +162,6 @@ export function resolveRendererResourceLanguage(language: string): string {
return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language
}
// Catalogs translated from English often ship only _one/_other; reuse _other so few/many stay in-language.
function withPluralCategoriesFromOther(
catalog: Record<string, unknown>,
categories: readonly string[]
): Record<string, unknown> {
const filled: Record<string, unknown> = {}
for (const [key, value] of Object.entries(catalog)) {
filled[key] = isCatalogObject(value) ? withPluralCategoriesFromOther(value, categories) : value
if (typeof value === 'string' && key.endsWith('_other')) {
const base = key.slice(0, -'_other'.length)
for (const category of categories) {
filled[`${base}_${category}`] ??= value
}
}
}
return filled
}
export function setRendererPluginLanguagePacks(
packs: readonly PluginLanguagePackRegistration[]
): void {
+25
View File
@@ -5,6 +5,7 @@ import {
UI_LANGUAGE_CHINESE_TRADITIONAL,
UI_LANGUAGE_ENGLISH,
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH
} from '../../../shared/ui-language'
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from './i18n'
@@ -39,6 +40,30 @@ describe('renderer i18n lazy locale loading', () => {
expect(i18n.t('menu.file', { defaultValue: 'File' })).not.toBe('File')
})
it('loads Russian and selects its complete CLDR plural family', async () => {
await setRendererUiLanguage(UI_LANGUAGE_RUSSIAN)
expect(i18n.language).toBe('ru')
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Файл')
for (const [count, expected] of [
[0, '0 изображений'],
[1, '1 изображение'],
[2, '2 изображения'],
[5, '5 изображений'],
[11, '11 изображений'],
[21, '21 изображение'],
[22, '22 изображения'],
[25, '25 изображений'],
[1.5, '1.5 изображения']
] as const) {
expect(
i18n.t('components.native-chat.queuedMessages.imageCount', {
defaultValue: '{{count}} images',
count
})
).toBe(expected)
}
})
it('falls back from Traditional Chinese to English, never Simplified', async () => {
i18n.addResourceBundle('zh', 'translation', { zhOnlyKey: '简体' }, true, true)
await setRendererUiLanguage(UI_LANGUAGE_CHINESE_TRADITIONAL)
+2 -1
View File
@@ -357,7 +357,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "Show Claude token and cost usage for the active workspace.",
+2 -1
View File
@@ -115,7 +115,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "Muestra el consumo de tokens y el costo de Claude para el espacio de trabajo activo.",
+2 -1
View File
@@ -288,7 +288,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "Afficher l'utilisation des tokens et des coûts de Claude pour l'espace de travail actif.",
+2 -1
View File
@@ -115,7 +115,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "アクティブなワークスペースの Claude トークンとコストの使用状況を表示します。",
+2 -1
View File
@@ -115,7 +115,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "활성 워크스페이스에 대한 Claude 토큰 및 비용 사용량을 표시합니다.",
+2 -1
View File
@@ -732,7 +732,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "Mostra o uso de tokens e custo do Claude para o espaço de trabalho ativo.",
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -357,7 +357,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "顯示目前工作區的 Claude token 用量與費用。",
+2 -1
View File
@@ -115,7 +115,8 @@
"japanese": "日本語",
"spanish": "Español",
"french": "Français",
"portuguese": "Português (Brasil)"
"portuguese": "Português (Brasil)",
"russian": "Русский"
},
"statusBar": {
"claudeToggleDescription": "显示当前工作区的 Claude Token 与费用消耗情况。",
+5 -2
View File
@@ -7,6 +7,7 @@ import {
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_SYSTEM,
type BuiltInUiLanguage,
@@ -34,7 +35,8 @@ export const UI_LANGUAGE_CHOICES: UiLanguageChoice[] = [
{ value: UI_LANGUAGE_JAPANESE, labelKey: 'settings.appearance.language.japanese' },
{ value: UI_LANGUAGE_SPANISH, labelKey: 'settings.appearance.language.spanish' },
{ value: UI_LANGUAGE_FRENCH, labelKey: 'settings.appearance.language.french' },
{ value: UI_LANGUAGE_PORTUGUESE, labelKey: 'settings.appearance.language.portuguese' }
{ value: UI_LANGUAGE_PORTUGUESE, labelKey: 'settings.appearance.language.portuguese' },
{ value: UI_LANGUAGE_RUSSIAN, labelKey: 'settings.appearance.language.russian' }
]
const UI_LANGUAGE_CHOICE_FALLBACKS: Record<BuiltInUiLanguage, string> = {
@@ -46,7 +48,8 @@ const UI_LANGUAGE_CHOICE_FALLBACKS: Record<BuiltInUiLanguage, string> = {
[UI_LANGUAGE_JAPANESE]: '日本語',
[UI_LANGUAGE_SPANISH]: 'Español',
[UI_LANGUAGE_FRENCH]: 'Français',
[UI_LANGUAGE_PORTUGUESE]: 'Português (Brasil)'
[UI_LANGUAGE_PORTUGUESE]: 'Português (Brasil)',
[UI_LANGUAGE_RUSSIAN]: 'Русский'
}
export function getUiLanguageChoiceLabel(
+27
View File
@@ -0,0 +1,27 @@
import type { TOptions } from 'i18next'
import { isCatalogObject } from './plugins/plugin-language-pack-artifact'
const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/
// Callers name a literal `_one`/`_other` key for extraction; with a count, let CLDR pick the form.
export function resolvePluralFamilyKey(key: string, options?: TOptions): string {
return typeof options?.count === 'number' ? key.replace(PLURAL_SUFFIX_RE, '') : key
}
// Catalogs translated from English often ship only _one/_other; reuse _other so few/many stay in-language.
export function withPluralCategoriesFromOther(
catalog: Record<string, unknown>,
categories: readonly string[]
): Record<string, unknown> {
const filled: Record<string, unknown> = {}
for (const [key, value] of Object.entries(catalog)) {
filled[key] = isCatalogObject(value) ? withPluralCategoriesFromOther(value, categories) : value
if (typeof value === 'string' && key.endsWith('_other')) {
const base = key.slice(0, -'_other'.length)
for (const category of categories) {
filled[`${base}_${category}`] ??= value
}
}
}
return filled
}
+2
View File
@@ -8,6 +8,7 @@ import {
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_SYSTEM,
normalizeUiLanguage
@@ -24,6 +25,7 @@ describe('normalizeUiLanguage', () => {
expect(normalizeUiLanguage(UI_LANGUAGE_SPANISH)).toBe('es')
expect(normalizeUiLanguage(UI_LANGUAGE_FRENCH)).toBe('fr')
expect(normalizeUiLanguage(UI_LANGUAGE_PORTUGUESE)).toBe('pt')
expect(normalizeUiLanguage(UI_LANGUAGE_RUSSIAN)).toBe('ru')
})
it('falls back unknown values to system', () => {
+4 -1
View File
@@ -8,6 +8,7 @@ export const UI_LANGUAGE_JAPANESE = 'ja'
export const UI_LANGUAGE_SPANISH = 'es'
export const UI_LANGUAGE_FRENCH = 'fr'
export const UI_LANGUAGE_PORTUGUESE = 'pt'
export const UI_LANGUAGE_RUSSIAN = 'ru'
export type BuiltInUiLanguage =
| typeof UI_LANGUAGE_SYSTEM
@@ -19,6 +20,7 @@ export type BuiltInUiLanguage =
| typeof UI_LANGUAGE_SPANISH
| typeof UI_LANGUAGE_FRENCH
| typeof UI_LANGUAGE_PORTUGUESE
| typeof UI_LANGUAGE_RUSSIAN
export type PluginUiLanguage = `plugin:${string}`
export type UiLanguage = BuiltInUiLanguage | PluginUiLanguage
@@ -32,7 +34,8 @@ const UI_LANGUAGE_VALUES = new Set<BuiltInUiLanguage>([
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_FRENCH,
UI_LANGUAGE_PORTUGUESE
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN
])
const PLUGIN_UI_LANGUAGE_RE =
+7
View File
@@ -7,8 +7,15 @@ import {
normalizeUiLocaleTag,
shouldShowLanguageSwitcher
} from './ui-locale'
import { UI_LANGUAGE_RUSSIAN, UI_LANGUAGE_SYSTEM } from './ui-language'
describe('ui-locale', () => {
it.each(['ru', 'ru-RU', 'ru-KZ', 'ru_RU.UTF-8'])('resolves Russian %s', (tag) => {
expect(detectUiLanguage([tag], 'native')).toMatchObject({ locale: 'ru', status: 'supported' })
expect(resolveUiLocale(UI_LANGUAGE_SYSTEM, tag)).toBe('ru')
expect(resolveUiLocale(UI_LANGUAGE_RUSSIAN, 'en-US')).toBe('ru')
expect(shouldShowLanguageSwitcher(detectUiLanguage([tag], 'native'))).toBe(true)
})
it('maps Traditional Chinese system locales to zh-Hant and keeps Simplified on zh', () => {
for (const tag of ['zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant']) {
expect(normalizeSupportedUiLocale(tag)).toBe('zh-Hant')
+15 -1
View File
@@ -6,13 +6,24 @@ import {
UI_LANGUAGE_JAPANESE,
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_PORTUGUESE,
UI_LANGUAGE_RUSSIAN,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_SYSTEM,
isPluginUiLanguage,
type UiLanguage
} from './ui-language'
export const SUPPORTED_UI_LOCALES = ['en', 'zh', 'zh-Hant', 'ko', 'ja', 'es', 'fr', 'pt'] as const
export const SUPPORTED_UI_LOCALES = [
'en',
'zh',
'zh-Hant',
'ko',
'ja',
'es',
'fr',
'pt',
'ru'
] as const
export type SupportedUiLocale = (typeof SUPPORTED_UI_LOCALES)[number]
export const DEFAULT_UI_LOCALE: SupportedUiLocale = 'en'
@@ -134,6 +145,9 @@ export function resolveUiLocale(
if (language === UI_LANGUAGE_PORTUGUESE) {
return 'pt'
}
if (language === UI_LANGUAGE_RUSSIAN) {
return 'ru'
}
return normalizeSupportedUiLocale(systemLocale)
}