mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
perf(i18n): load built-in catalogs once, without copies or a runtime plural fill (#35615)
* perf(i18n): load built-in catalogs once, without copies or a runtime plural fill
Each catalog load parsed the JSON, rebuilt it to fill CLDR plural forms
from _other, then deep-copied and deep-merged it into the store (about
26 ms after parsing per 1.3 MB catalog), and concurrent loads of one
locale fetched it twice.
- sync:localization-catalog now commits the plural forms a locale's
CLDR rules need beside each _other (fr/es/pt many, tr one, a few
de/ru), and verify:localization-catalog fails when one is missing.
Plural forms may include {{count}} where English omits it.
- Renderer and main load built-in catalogs as-is; plugin packs keep the
runtime fill.
- Renderer catalog requests are shared per locale; a timed-out request
is dropped so a retry fetches again. Bundles are added with skipCopy.
* fix(i18n): require {{count}} in plural forms when English shows it
* chore(i18n): sync plural forms for catalogs merged from main
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const PLURAL_KEY_RE = /^(.*)_(zero|one|two|few|many|other)$/
|
||||
|
||||
// Locales with more CLDR plural categories than English (ru few/many) source those forms from `_other`.
|
||||
export function resolveLocaleSourceKey(enEntries, localeName, key) {
|
||||
if (enEntries.has(key)) {
|
||||
return key
|
||||
}
|
||||
const plural = PLURAL_KEY_RE.exec(key)
|
||||
if (!plural || !localeName || !enEntries.has(`${plural[1]}_other`)) {
|
||||
return undefined
|
||||
}
|
||||
return pluralCategories(localeName).includes(plural[2]) ? `${plural[1]}_other` : undefined
|
||||
}
|
||||
|
||||
const COUNT_PLACEHOLDER_RE = /^\{\{\s*count\s*[,}]/
|
||||
|
||||
// i18next always passes count to plural forms, so a locale may add it where English omits it (ru 21 uses _one).
|
||||
export function interpolationVariablesMatch(sourceKey, enVariables, localeVariables) {
|
||||
if (enVariables.join('|') === localeVariables.join('|')) {
|
||||
return true
|
||||
}
|
||||
const withoutCount = (variable) => !COUNT_PLACEHOLDER_RE.test(variable)
|
||||
return (
|
||||
PLURAL_KEY_RE.test(sourceKey) &&
|
||||
!enVariables.some((variable) => COUNT_PLACEHOLDER_RE.test(variable)) &&
|
||||
enVariables.join('|') === localeVariables.filter(withoutCount).join('|')
|
||||
)
|
||||
}
|
||||
|
||||
function pluralCategories(localeName) {
|
||||
try {
|
||||
return new Intl.PluralRules(localeName).resolvedOptions().pluralCategories
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Untranslated CLDR forms (fr/es/pt many, tr one) reuse the locale's `_other` instead of English; inserted beside it.
|
||||
function withMissingPluralForms(catalog, categories, prefix = '', added = []) {
|
||||
const filled = {}
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
const keyPath = prefix ? `${prefix}.${key}` : key
|
||||
filled[key] =
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? withMissingPluralForms(value, categories, keyPath, added).catalog
|
||||
: value
|
||||
if (typeof value !== 'string' || !key.endsWith('_other')) {
|
||||
continue
|
||||
}
|
||||
const base = key.slice(0, -'other'.length)
|
||||
for (const category of categories) {
|
||||
if (!Object.hasOwn(catalog, `${base}${category}`)) {
|
||||
filled[`${base}${category}`] = value
|
||||
added.push(`${prefix ? `${prefix}.` : ''}${base}${category}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { catalog: filled, added }
|
||||
}
|
||||
|
||||
/** Verifies (or with fix, writes) the plural forms a desktop locale catalog needs; runtimes load catalogs as-is. */
|
||||
export async function syncLocalePluralForms(catalogPath, catalog, fix) {
|
||||
const fileName = path.basename(catalogPath)
|
||||
const plural = withMissingPluralForms(catalog, pluralCategories(fileName.replace(/\.json$/, '')))
|
||||
if (plural.added.length === 0) {
|
||||
return { ok: true, catalog }
|
||||
}
|
||||
if (!fix) {
|
||||
console.error(`${fileName} is missing CLDR plural forms that runtimes no longer fill:`)
|
||||
console.error(
|
||||
plural.added
|
||||
.slice(0, 20)
|
||||
.map((key) => `missing plural form: ${key}`)
|
||||
.join('\n')
|
||||
)
|
||||
return { ok: false }
|
||||
}
|
||||
await fs.writeFile(catalogPath, `${JSON.stringify(plural.catalog, null, 2)}\n`, 'utf8')
|
||||
console.log(`Added ${plural.added.length} plural form(s) to ${fileName} from \`_other\`.`)
|
||||
return { ok: true, catalog: plural.catalog }
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export const LOCALIZATION_SURFACES = {
|
||||
localesDir: path.join('src', 'renderer', 'src', 'i18n', 'locales'),
|
||||
sourceRoots: [path.join('src', 'renderer', 'src'), path.join('src', 'main')],
|
||||
runtimeRequiredPath: path.join('src', 'renderer', 'src', 'i18n', 'en-runtime-required.json'),
|
||||
// Desktop runtimes load built-in catalogs as-is; plural forms CLDR adds beyond `_other` are committed.
|
||||
fillsPluralForms: true,
|
||||
syncCatalogCommand: 'pnpm run sync:localization-catalog',
|
||||
syncRuntimeCatalogCommand: 'pnpm run sync:localization-runtime-catalog'
|
||||
},
|
||||
|
||||
@@ -9,6 +9,11 @@ import ts from 'typescript-api'
|
||||
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
|
||||
import { LOCALIZATION_SURFACES, readLocalizationSurface } from './localization-surfaces.mjs'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
import {
|
||||
interpolationVariablesMatch,
|
||||
resolveLocaleSourceKey,
|
||||
syncLocalePluralForms
|
||||
} from './locale-plural-forms.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
@@ -283,24 +288,6 @@ function setCatalogEntry(catalog, key, value) {
|
||||
cursor[parts.at(-1)] = value
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -317,7 +304,7 @@ function collectLocaleParityIssues(enCatalog, localeCatalog, localeName) {
|
||||
}
|
||||
const enVariables = collectInterpolationVariables(enEntries.get(sourceKey))
|
||||
const localeVariables = collectInterpolationVariables(localeValue)
|
||||
if (enVariables.join('|') !== localeVariables.join('|')) {
|
||||
if (!interpolationVariablesMatch(sourceKey, enVariables, localeVariables)) {
|
||||
interpolationMismatches.push(key)
|
||||
}
|
||||
}
|
||||
@@ -657,10 +644,20 @@ export async function main(
|
||||
for (const fileName of localeFiles) {
|
||||
const localeName = fileName.replace(/\.json$/, '')
|
||||
const localeCatalogPath = path.join(localesDir, fileName)
|
||||
const localeCatalog = await readCatalogFile(localeCatalogPath)
|
||||
let localeCatalog = await readCatalogFile(localeCatalogPath)
|
||||
if (localeCatalog === undefined || reportDottedCatalogKeys(fileName, localeCatalog) !== 0) {
|
||||
return 1
|
||||
}
|
||||
if (surface.fillsPluralForms) {
|
||||
const plural = await syncLocalePluralForms(localeCatalogPath, localeCatalog, options.fix)
|
||||
if (!plural.ok) {
|
||||
console.error(
|
||||
`Run \`${surface.syncCatalogCommand}\` to copy them from each \`_other\` form.`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
localeCatalog = plural.catalog
|
||||
}
|
||||
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
|
||||
if (exitCode !== 0) {
|
||||
console.error('')
|
||||
|
||||
@@ -183,6 +183,43 @@ describe('verify-localization-catalog', () => {
|
||||
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
|
||||
})
|
||||
|
||||
it('commits CLDR plural forms a locale lacks beside its _other form', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText: 'export {}\n',
|
||||
enCatalog: {
|
||||
files_one: 'One file',
|
||||
files_other: '{{count}} files',
|
||||
name: 'Name'
|
||||
}
|
||||
})
|
||||
const frenchPath = path.join(localesDir, 'fr.json')
|
||||
writeJson(frenchPath, { files_other: '{{count}} fichiers', name: 'Nom' })
|
||||
const report = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
||||
} finally {
|
||||
report.mockRestore()
|
||||
}
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
||||
|
||||
expect(Object.entries(readJson(frenchPath))).toEqual([
|
||||
['files_other', '{{count}} fichiers'],
|
||||
['files_one', '{{count}} fichiers'],
|
||||
['files_many', '{{count}} fichiers'],
|
||||
['name', 'Nom']
|
||||
])
|
||||
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
|
||||
|
||||
// Only an English form without {{count}} lets the locale add it; dropping it is still a mismatch.
|
||||
writeJson(frenchPath, { files_other: 'fichiers', files_one: 'fichier', files_many: 'fichiers' })
|
||||
const mismatch = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
||||
} finally {
|
||||
mismatch.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
|
||||
const { root } = makeProject({
|
||||
sourceText: 'export {}\n',
|
||||
|
||||
@@ -6,10 +6,7 @@ import i18next, {
|
||||
type TOptions
|
||||
} from 'i18next'
|
||||
|
||||
import {
|
||||
resolvePluralFamilyKey,
|
||||
withPluralCategoriesFromOther
|
||||
} from '../../shared/i18n-plural-family'
|
||||
import { resolvePluralFamilyKey } 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'
|
||||
@@ -49,14 +46,7 @@ const lazyLocaleBackend: BackendModule = {
|
||||
return
|
||||
}
|
||||
loader().then(
|
||||
(mod) =>
|
||||
callback(
|
||||
null,
|
||||
withPluralCategoriesFromOther(
|
||||
mod.default,
|
||||
new Intl.PluralRules(language).resolvedOptions().pluralCategories
|
||||
)
|
||||
),
|
||||
(mod) => callback(null, mod.default),
|
||||
(error) => callback(error instanceof Error ? error : new Error(String(error)), false)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,7 @@ import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language
|
||||
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
|
||||
import { replacePluginLanguagePackResources } from '../../../shared/plugins/plugin-language-pack-i18n-resources'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
import {
|
||||
resolvePluralFamilyKey,
|
||||
withPluralCategoriesFromOther
|
||||
} from '../../../shared/i18n-plural-family'
|
||||
import { resolvePluralFamilyKey } from '../../../shared/i18n-plural-family'
|
||||
|
||||
export const i18n: I18nInstance = i18next.createInstance()
|
||||
|
||||
@@ -53,13 +50,30 @@ function localeCatalogUrl(language: string): string | undefined {
|
||||
return Object.entries(NON_DEFAULT_LOCALE_URLS).find(([name]) => name === language)?.[1]
|
||||
}
|
||||
|
||||
async function loadLocaleCatalog(language: string, url: string): Promise<Record<string, unknown>> {
|
||||
// Built-in catalogs ship every CLDR plural form (sync:localization-catalog), so they load as-is.
|
||||
async function fetchLocaleCatalog(url: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(url, { cache: 'no-cache' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Language catalog request failed: ${response.status}`)
|
||||
}
|
||||
const categories = new Intl.PluralRules(language).resolvedOptions().pluralCategories
|
||||
return withPluralCategoriesFromOther(await response.json(), categories)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// The provider, onboarding switcher and backend can ask for one locale at once; share the fetch.
|
||||
const catalogRequests = new Map<string, Promise<Record<string, unknown>>>()
|
||||
|
||||
function loadLocaleCatalog(language: string, url: string): Promise<Record<string, unknown>> {
|
||||
const pending = catalogRequests.get(language)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
const request = fetchLocaleCatalog(url).finally(() => {
|
||||
if (catalogRequests.get(language) === request) {
|
||||
catalogRequests.delete(language)
|
||||
}
|
||||
})
|
||||
catalogRequests.set(language, request)
|
||||
return request
|
||||
}
|
||||
|
||||
const lazyLocaleBackend: BackendModule = {
|
||||
@@ -151,11 +165,19 @@ export async function loadRendererUiLanguage(language: UiLanguage): Promise<stri
|
||||
: resourceLanguage
|
||||
const url = localeCatalogUrl(locale)
|
||||
if (url && !i18n.hasResourceBundle(locale, 'translation')) {
|
||||
const catalog = await withTimeout(loadLocaleCatalog(locale, url), 5000, null)
|
||||
const request = loadLocaleCatalog(locale, url)
|
||||
const catalog = await withTimeout(request, 5000, null)
|
||||
if (catalog === null) {
|
||||
// A retry after a timeout must start a fresh request instead of joining the stalled one.
|
||||
if (catalogRequests.get(locale) === request) {
|
||||
catalogRequests.delete(locale)
|
||||
}
|
||||
throw new Error('Language catalog unavailable')
|
||||
}
|
||||
i18n.addResourceBundle(locale, 'translation', catalog, true, true)
|
||||
if (!i18n.hasResourceBundle(locale, 'translation')) {
|
||||
// Freshly parsed and never mutated, so skip i18next's defensive deep copy.
|
||||
i18n.addResourceBundle(locale, 'translation', catalog, false, false, { skipCopy: true })
|
||||
}
|
||||
}
|
||||
return locale
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
UI_LANGUAGE_RUSSIAN,
|
||||
UI_LANGUAGE_SPANISH
|
||||
} from '../../../shared/ui-language'
|
||||
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from './i18n'
|
||||
import {
|
||||
i18n,
|
||||
loadRendererUiLanguage,
|
||||
setRendererPluginLanguagePacks,
|
||||
setRendererUiLanguage
|
||||
} from './i18n'
|
||||
import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact'
|
||||
|
||||
// Every changeLanguage caller must transparently load real non-English translations before its promise settles.
|
||||
@@ -72,6 +77,18 @@ describe('renderer i18n lazy locale loading', () => {
|
||||
expect(i18n.t('zhOnlyKey', { defaultValue: 'English' })).toBe('English')
|
||||
})
|
||||
|
||||
it('shares one catalog request between concurrent loads of a locale', async () => {
|
||||
i18n.removeResourceBundle('ko', 'translation')
|
||||
const request = vi.spyOn(globalThis, 'fetch')
|
||||
try {
|
||||
await Promise.all([loadRendererUiLanguage('ko'), loadRendererUiLanguage('ko')])
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(i18n.getFixedT('ko')('menu.file', { defaultValue: 'File' })).toBe('파일')
|
||||
} finally {
|
||||
request.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['network', 'http', 'invalid-json'] as const)(
|
||||
'retries a rejected %s catalog load with a fresh request',
|
||||
async (failure) => {
|
||||
|
||||
@@ -5101,7 +5101,8 @@
|
||||
"description": "{{value0}} aktualisieren, damit Agenten den aktuellen Anweisungen für diese Orca-Version folgen.",
|
||||
"updateOne": "Skill aktualisieren",
|
||||
"updateMany": "Skills aktualisieren",
|
||||
"title_other": "{{count}} installierte Orca-Skills sind veraltet"
|
||||
"title_other": "{{count}} installierte Orca-Skills sind veraltet",
|
||||
"title_one": "{{count}} installierte Orca-Skills sind veraltet"
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "Skills aktualisieren",
|
||||
@@ -5124,6 +5125,7 @@
|
||||
"updateServer": "Zum Aktualisieren der dortigen Orca-Skills Orca auf dem Server aktualisieren.",
|
||||
"connectHost": "Eine Verbindung zu diesem SSH-Host herstellen, um die dortigen Orca-Skills zu prüfen.",
|
||||
"upToDate_other": "{{count}} Skills sind auf dem neuesten Stand.",
|
||||
"upToDate_one": "{{count}} Skills sind auf dem neuesten Stand.",
|
||||
"update_one": "{{count}} Aktualisierung verfügbar",
|
||||
"update_other": "{{count}} Aktualisierungen verfügbar",
|
||||
"current_one": "{{count}} Skill ist auf dem neuesten Stand",
|
||||
@@ -5488,7 +5490,8 @@
|
||||
"placementSummaryParts": "Entfernt {{parts}} in {{roots}}.",
|
||||
"placementJoin": " und ",
|
||||
"retainedSource": "Der Skill selbst bleibt unter {{path}} erhalten.",
|
||||
"nothing_other": "Keiner der {{count}} ausgewählten Skills kann hier gelöscht werden."
|
||||
"nothing_other": "Keiner der {{count}} ausgewählten Skills kann hier gelöscht werden.",
|
||||
"nothing_one": "Keiner der {{count}} ausgewählten Skills kann hier gelöscht werden."
|
||||
},
|
||||
"skillPlanOpCopy": {
|
||||
"busy": "Eine andere Installation läuft noch",
|
||||
@@ -18885,11 +18888,13 @@
|
||||
"runWebSearch_one": "{{count}}-mal im Web gesucht",
|
||||
"runWebSearch_other": "{{count}}-mal im Web gesucht",
|
||||
"runBrowser_other": "Browser {{count}}-mal verwendet",
|
||||
"runBrowser_one": "Browser {{count}}-mal verwendet",
|
||||
"runIntegration_one": "{{count}} Integration verwendet",
|
||||
"runIntegration_other": "{{count}} Integrationen verwendet",
|
||||
"runAgent_one": "{{count}} Agent ausgeführt",
|
||||
"runAgent_other": "{{count}} Agenten ausgeführt",
|
||||
"runPlan_other": "Plan {{count}}-mal aktualisiert",
|
||||
"runPlan_one": "Plan {{count}}-mal aktualisiert",
|
||||
"runTool_one": "{{count}} Werkzeug verwendet",
|
||||
"runTool_other": "{{count}} Werkzeuge verwendet",
|
||||
"runLiveRead_one": "{{count}} Datei wird gelesen",
|
||||
@@ -18905,11 +18910,13 @@
|
||||
"runLiveWebSearch_one": "{{count}} Websuche läuft",
|
||||
"runLiveWebSearch_other": "{{count}} Websuchen laufen",
|
||||
"runLiveBrowser_other": "Browser wird {{count}}-mal verwendet",
|
||||
"runLiveBrowser_one": "Browser wird {{count}}-mal verwendet",
|
||||
"runLiveIntegration_one": "{{count}} Integration wird verwendet",
|
||||
"runLiveIntegration_other": "{{count}} Integrationen werden verwendet",
|
||||
"runLiveAgent_one": "{{count}} Agent wird ausgeführt",
|
||||
"runLiveAgent_other": "{{count}} Agenten werden ausgeführt",
|
||||
"runLivePlan_other": "Plan wird {{count}}-mal aktualisiert",
|
||||
"runLivePlan_one": "Plan wird {{count}}-mal aktualisiert",
|
||||
"runLiveTool_one": "{{count}} Werkzeug wird verwendet",
|
||||
"runLiveTool_other": "{{count}} Werkzeuge werden verwendet"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5241,7 +5241,8 @@
|
||||
"updateMany": "Обновить навыки",
|
||||
"title_few": "{{count}} установленных навыка Orca устарели",
|
||||
"title_many": "{{count}} установленных навыков Orca устарели",
|
||||
"title_other": "Устаревшие установленные навыки Orca: {{count}}"
|
||||
"title_other": "Устаревшие установленные навыки Orca: {{count}}",
|
||||
"title_one": "Устаревшие установленные навыки Orca: {{count}}"
|
||||
},
|
||||
"SkillFreshnessUpdateDialog": {
|
||||
"title": "Обновить навыки",
|
||||
@@ -5266,6 +5267,7 @@
|
||||
"upToDate_few": "{{count}} навыка актуальны.",
|
||||
"upToDate_many": "{{count}} навыков актуальны.",
|
||||
"upToDate_other": "Актуальных навыков: {{count}}.",
|
||||
"upToDate_one": "Актуальных навыков: {{count}}.",
|
||||
"update_one": "Доступно {{count}} обновление",
|
||||
"update_few": "Доступно {{count}} обновления",
|
||||
"update_many": "Доступно {{count}} обновлений",
|
||||
@@ -5676,7 +5678,8 @@
|
||||
"retainedSource": "Сам навык останется в {{path}}.",
|
||||
"nothing_few": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}}).",
|
||||
"nothing_many": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}}).",
|
||||
"nothing_other": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}})."
|
||||
"nothing_other": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}}).",
|
||||
"nothing_one": "Нельзя удалить отсюда ни один из выбранных навыков ({{count}})."
|
||||
},
|
||||
"skillPlanOpCopy": {
|
||||
"busy": "Другая установка ещё выполняется",
|
||||
@@ -19404,6 +19407,7 @@
|
||||
"runBrowser_few": "Браузер использован {{count}} раза",
|
||||
"runBrowser_many": "Браузер использован {{count}} раз",
|
||||
"runBrowser_other": "Браузер использован {{count}} раза",
|
||||
"runBrowser_one": "Браузер использован {{count}} раза",
|
||||
"runIntegration_one": "Использована {{count}} интеграция",
|
||||
"runIntegration_few": "Использовано {{count}} интеграции",
|
||||
"runIntegration_many": "Использовано {{count}} интеграций",
|
||||
@@ -19415,6 +19419,7 @@
|
||||
"runPlan_few": "План обновлён {{count}} раза",
|
||||
"runPlan_many": "План обновлён {{count}} раз",
|
||||
"runPlan_other": "План обновлён {{count}} раза",
|
||||
"runPlan_one": "План обновлён {{count}} раза",
|
||||
"runTool_one": "Использован {{count}} инструмент",
|
||||
"runTool_few": "Использовано {{count}} инструмента",
|
||||
"runTool_many": "Использовано {{count}} инструментов",
|
||||
@@ -19446,6 +19451,7 @@
|
||||
"runLiveBrowser_few": "Использует браузер {{count}} раза",
|
||||
"runLiveBrowser_many": "Использует браузер {{count}} раз",
|
||||
"runLiveBrowser_other": "Использует браузер {{count}} раза",
|
||||
"runLiveBrowser_one": "Использует браузер {{count}} раза",
|
||||
"runLiveIntegration_one": "Использует {{count}} интеграцию",
|
||||
"runLiveIntegration_few": "Использует {{count}} интеграции",
|
||||
"runLiveIntegration_many": "Использует {{count}} интеграций",
|
||||
@@ -19457,6 +19463,7 @@
|
||||
"runLivePlan_few": "Обновляет план {{count}} раза",
|
||||
"runLivePlan_many": "Обновляет план {{count}} раз",
|
||||
"runLivePlan_other": "Обновляет план {{count}} раза",
|
||||
"runLivePlan_one": "Обновляет план {{count}} раза",
|
||||
"runLiveTool_one": "Использует {{count}} инструмент",
|
||||
"runLiveTool_few": "Использует {{count}} инструмента",
|
||||
"runLiveTool_many": "Использует {{count}} инструментов",
|
||||
@@ -20667,10 +20674,14 @@
|
||||
"discard": {
|
||||
"deleteUntrackedFileDescription": "Этот неотслеживаемый файл будет безвозвратно удалён. Это действие нельзя отменить.",
|
||||
"deleteUntrackedFilesDescription_other": "Неотслеживаемые файлы ({{count}}) будут безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"deleteUntrackedFilesDescription_few": "Неотслеживаемые файлы ({{count}}) будут безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"deleteUntrackedFilesDescription_many": "Неотслеживаемые файлы ({{count}}) будут безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"restoreStagedVersionDescription": "Будет восстановлена последняя версия из индекса, а удаление отменено. Это действие нельзя отменить.",
|
||||
"unstagedChangesDescription": "Изменения этого файла вне индекса будут отменены. Это действие нельзя отменить.",
|
||||
"unstagedSelectionIncludingUntrackedDescription": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных — безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"unstagedFilesIncludingUntrackedDescription_other": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных ({{count}}) — безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"unstagedFilesIncludingUntrackedDescription_few": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных ({{count}}) — безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"unstagedFilesIncludingUntrackedDescription_many": "Изменения вне индекса будут отменены, а неотслеживаемые файлы среди выбранных ({{count}}) — безвозвратно удалены. Это действие нельзя отменить.",
|
||||
"delete": "Удалить",
|
||||
"deleteCount": "Удалить {{count}}",
|
||||
"deleteUntrackedFilesDescription_one": "Будет безвозвратно удалён {{count}} неотслеживаемый файл. Это действие нельзя отменить.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ 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.
|
||||
// Plugin packs often ship only _one/_other; reuse _other so few/many stay in-language (built-ins commit these forms).
|
||||
export function withPluralCategoriesFromOther(
|
||||
catalog: Record<string, unknown>,
|
||||
categories: readonly string[]
|
||||
|
||||
Reference in New Issue
Block a user