mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
OrcaWin
Vyacheslav Scherbinin
parent
a1522f94b3
commit
52a43ed99e
@@ -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.
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
"japanese": "日本語",
|
||||
"spanish": "Español",
|
||||
"french": "Français",
|
||||
"portuguese": "Português (Brasil)"
|
||||
"portuguese": "Português (Brasil)",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"statusBar": {
|
||||
"claudeToggleDescription": "アクティブなワークスペースの Claude トークンとコストの使用状況を表示します。",
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
"japanese": "日本語",
|
||||
"spanish": "Español",
|
||||
"french": "Français",
|
||||
"portuguese": "Português (Brasil)"
|
||||
"portuguese": "Português (Brasil)",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"statusBar": {
|
||||
"claudeToggleDescription": "활성 워크스페이스에 대한 Claude 토큰 및 비용 사용량을 표시합니다.",
|
||||
|
||||
@@ -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
@@ -357,7 +357,8 @@
|
||||
"japanese": "日本語",
|
||||
"spanish": "Español",
|
||||
"french": "Français",
|
||||
"portuguese": "Português (Brasil)"
|
||||
"portuguese": "Português (Brasil)",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"statusBar": {
|
||||
"claudeToggleDescription": "顯示目前工作區的 Claude token 用量與費用。",
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
"japanese": "日本語",
|
||||
"spanish": "Español",
|
||||
"french": "Français",
|
||||
"portuguese": "Português (Brasil)"
|
||||
"portuguese": "Português (Brasil)",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"statusBar": {
|
||||
"claudeToggleDescription": "显示当前工作区的 Claude Token 与费用消耗情况。",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user