diff --git a/.changeset/10024-search-results-count-plural-forms.md b/.changeset/10024-search-results-count-plural-forms.md index 886fcb0b79..ee99dd90da 100644 --- a/.changeset/10024-search-results-count-plural-forms.md +++ b/.changeset/10024-search-results-count-plural-forms.md @@ -21,3 +21,10 @@ the device both packs already use where two slots are all there are — Russian' two-key convention. The singular half, the call site, the key set and the other eight packs are unchanged; `zh`, `ja` and `ko` keep one string in both halves because their languages have a single plural category. + +⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.** +Later in this same release the `search.resultsCount` / `search.resultsCountPlural` +switch gave way to a `search.resultsCount` count family in all ten packs: +`ru` and `ar` now write a noun form for each CLDR category instead of the count +label above (`2 результата`, `21 результат`), and `search.resultsCountPlural` left +every pack. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10242-ru-ar-count-labels.md b/.changeset/10242-ru-ar-count-labels.md index 858891200d..d5e2207d38 100644 --- a/.changeset/10242-ru-ar-count-labels.md +++ b/.changeset/10242-ru-ar-count-labels.md @@ -28,3 +28,12 @@ That is the device objectui#10024 used for `search.resultsCountPlural`, and the Russian's `collaboration.commentCount` and `collaboration.reactionCount` already use. The singular halves, the key set, the call sites and the other eight packs are unchanged. + +⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.** +Later in this same release the `…One` / plain-key switch on `common.itemCount`, +`detail.reactionCount`, `collaboration.commentCount` and +`collaboration.reactionCount` gave way to one count family per key in all ten +packs: a numeric count picks the noun form its CLDR category needs (`ru` +`2 элемента`, `21 элемент`), the `…One` siblings left every pack, and the count +labels above remain only on the base key, which answers a call made without a +count. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10425-ru-ar-count-labels.md b/.changeset/10425-ru-ar-count-labels.md index 54caee000b..317c850b9c 100644 --- a/.changeset/10425-ru-ar-count-labels.md +++ b/.changeset/10425-ru-ar-count-labels.md @@ -29,3 +29,12 @@ correctly at any number: That is the device objectui#10024 and objectui#10242 used, and the one Russian's `lookup.recordCount` already uses. The singular halves, the key set, the call sites and the other eight packs are unchanged. + +⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.** +Later in this same release the `…One` / plain-key switch on `list.recordCount`, +`lookup.recordCount`, `detail.reactionCount`, `collaboration.presentUserCount`, +`collaboration.moreUserCount` and `search.itemsAvailable` gave way to one count +family per key in all ten packs: a numeric count picks the noun form its CLDR +category needs (`ru` `2 записи`, `ar` `3 سجلات`), the `…One` siblings left every +pack, and the count labels above remain only on the base key, which answers a +call made without a count. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10636-objectview-record-count-singular.md b/.changeset/10636-objectview-record-count-singular.md index a5c0758605..6cec395d9e 100644 --- a/.changeset/10636-objectview-record-count-singular.md +++ b/.changeset/10636-objectview-record-count-singular.md @@ -20,3 +20,11 @@ value held one noun form: Russian read `2 записей` at two and `21 зап twenty-one, and Arabic used the singular noun at every count. Both now use the count label their list bar already uses, `Записей: N` and `عدد السجلات: N`, which is right at any number. At one record they read `1 запись` and `1 سجل`. + +⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.** +Later in this same release the `console.objectView.recordCountOne` / +`console.objectView.recordCount` switch gave way to a +`console.objectView.recordCount` count family in all ten packs, worded like +`list.recordCount`'s: the footer calls the family key with `{ count }`, +`console.objectView.recordCountOne` left every pack, and `ru` and `ar` read a +noun form at each count (`2 записи`, `3 سجلات`) where the count label stood. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11445-app-shell-count-families.md b/.changeset/11445-app-shell-count-families.md new file mode 100644 index 0000000000..40b7ca3105 --- /dev/null +++ b/.changeset/11445-app-shell-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The search results line and the object view's record-count footer read the right noun form in every language at every count (objectui#11445). Each passes `count` to one i18next count family instead of choosing between a `…One` / `…Plural` key on `=== 1`, which gave Russian and Arabic only two forms: Russian now reads `2 результата` and `21 запись`, and Arabic uses its dual at 2. diff --git a/.changeset/11445-collaboration-count-families.md b/.changeset/11445-collaboration-count-families.md new file mode 100644 index 0000000000..896fb678ff --- /dev/null +++ b/.changeset/11445-collaboration-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/collaboration': patch +--- + +The comment thread's header, its reaction tooltip and the presence stack's labels read the right noun form in every language at every count (objectui#11445). Each passes `count` to one i18next count family as a number — not a string, which i18next does not plural-select — instead of choosing between a `…CountOne` key and its pair on `=== 1`. The relative comment timestamps pass a number too, so Arabic reads `قبل 5 دقائق` rather than `قبل 5 دقيقة`. `COLLAB_DEFAULT_TRANSLATIONS` carries each family's `_one` / `_other` rows, mirroring the `en` pack. diff --git a/.changeset/11445-components-count-families.md b/.changeset/11445-components-count-families.md new file mode 100644 index 0000000000..e6832f2351 --- /dev/null +++ b/.changeset/11445-components-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/components': patch +--- + +The data table's "N rows modified" line and the `page:tabs` count badge's accessible name read the right noun form in every language at every count (objectui#11445): both are i18next count families now, so English no longer reads `3 row modified` and Russian reads `2 элемента` rather than a count label. The tab badge passes its number as `count` below 1000; a shortened `1.2k` is passed as the string the badge shows, which i18next does not plural-select, so the base key — count-invariant in every pack — answers it. diff --git a/.changeset/11445-fields-count-families.md b/.changeset/11445-fields-count-families.md new file mode 100644 index 0000000000..5655ab2b0f --- /dev/null +++ b/.changeset/11445-fields-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/fields': patch +--- + +The record picker's record count reads the right noun form in every language at every count (objectui#11445): it passes `count` to the `lookup.recordCount` count family instead of choosing `lookup.recordCountOne` on `=== 1`, so Russian reads `2 записи` and Arabic uses its dual at 2. The provider-less defaults table carries the family's `_one` / `_other` rows. diff --git a/.changeset/11445-i18n-count-families.md b/.changeset/11445-i18n-count-families.md new file mode 100644 index 0000000000..478908bc57 --- /dev/null +++ b/.changeset/11445-i18n-count-families.md @@ -0,0 +1,17 @@ +--- +'@object-ui/i18n': patch +--- + +Count labels read the right noun form in every language at every count: each label a word must agree with is now an i18next count family in all ten packs (objectui#11445). + +**What was wrong.** objectui#11432 gave every count family every CLDR category its language selects. Two other spellings of the same defect sat outside that rule. Fifteen labels were code-selected pairs (`detail.attachmentCount` / `detail.attachmentCountPlural`, `common.itemCount` / `common.itemCountOne`, and thirteen more): the component chose between two keys on `=== 1`, which gives a language two forms, so Russian read `3 вложений` and Arabic `2 مرفقات`. Fifty-three labels were one string at every count, so Russian read `Согласовать 3 запросов?`, Arabic `منذ 5 دقيقة`, Spanish `1 seleccionados` and English `3 row modified`. + +**What changes.** Each of those 68 keys is a family: `en` `_one` / `_other`, `ru` `_one` / `_few` / `_many` / `_other`, `ar` all six categories, and each other pack the categories its language selects. The base key keeps its value and answers a call made without a count. + +**Removed keys.** The fifteen code-selected siblings leave all ten packs: `lookup.recordCountOne`, `common.itemCountOne`, `list.recordCountOne`, `detail.reactionCountOne`, `detail.attachmentCountPlural`, `detail.replyCountPlural`, `console.objectView.recordCountOne`, `search.resultsCountPlural`, `search.itemsAvailableOne`, `ai.formAssist.suggestionCountOne`, `ai.formAssist.appliedCountOne`, `collaboration.commentCountOne`, `collaboration.reactionCountOne`, `collaboration.presentUserCountOne` and `collaboration.moreUserCountOne`. A host that looked one up itself now gets the key back instead of a label: call the family key with `{ count }` instead (`t('detail.replyCount', { count: n })`), and i18next picks the form. + +**Provider-less fallback.** `createSafeTranslation` now reads a count family's `_one` / `_other` row for a numeric `count` before the base row, in the order i18next reads the `en` pack, so a host with no `I18nProvider` keeps rendering "1 reply" / "3 replies". A string `count` still reads the base row, as it does in i18next. + +**Supersedes, in this release.** The notes for objectui#9664, objectui#10024, objectui#10242, objectui#10425 and objectui#10636 describe the `…One` / `…Plural` switch these families replace; each now carries a dated note saying so. Their `ru` / `ar` count labels (`Записей: N`, `عدد السجلات: N`) stay only on the base key, which answers a call made without a count. + +**Pinned.** `count-families-11445.test.ts` fails on any `…Plural` key in a pack and on any `{{count}}` value outside a family unless its count-neutral list names the key with the reason no word agrees with the number. diff --git a/.changeset/11445-plugin-ai-count-families.md b/.changeset/11445-plugin-ai-count-families.md new file mode 100644 index 0000000000..6ef8d77ed4 --- /dev/null +++ b/.changeset/11445-plugin-ai-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-ai': patch +--- + +The AI form assistant's suggestion and applied counts read the right noun form in every language at every count (objectui#11445): each passes `count` to one i18next count family instead of choosing a `…CountOne` key on `=== 1`, so Arabic reads `3 اقتراحات` rather than a count label and Russian `2 предложения`. diff --git a/.changeset/11445-plugin-detail-count-families.md b/.changeset/11445-plugin-detail-count-families.md new file mode 100644 index 0000000000..5b56ae1b14 --- /dev/null +++ b/.changeset/11445-plugin-detail-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-detail': patch +--- + +The comment attachment count, the threaded-reply count and the reaction chip's accessible name read the right noun form in every language at every count (objectui#11445). Each passes `count` to one i18next count family instead of choosing between a `…Plural` / `…One` key on `=== 1`, which gave Russian `3 вложений` and Arabic `2 مرفقات`; they now read `3 вложения` and the Arabic dual. The relative timestamps (`detail.minutesAgo` and its two siblings) and the "Show N empty fields" toggle are count families too, so one empty field reads `Show 1 empty field`. The provider-less defaults table carries each family's `_one` / `_other` rows. diff --git a/.changeset/11445-plugin-gantt-count-families.md b/.changeset/11445-plugin-gantt-count-families.md new file mode 100644 index 0000000000..69bd81e752 --- /dev/null +++ b/.changeset/11445-plugin-gantt-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-gantt': patch +--- + +The schedule-conflict and auto-schedule dialogs' task counts read the right noun form in every language at every count (objectui#11445): `gantt.conflict.body`, `gantt.autoScheduleDlg.body` and `gantt.autoScheduleDlg.skipped` are i18next count families, so German no longer reads `1 betroffene Vorgänge` and Russian reads `3 задачи`. The provider-less fallback reads a family's `_one` / `_other` row for a numeric `count`. diff --git a/.changeset/11445-plugin-grid-count-families.md b/.changeset/11445-plugin-grid-count-families.md new file mode 100644 index 0000000000..a44024bfaa --- /dev/null +++ b/.changeset/11445-plugin-grid-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-grid': patch +--- + +The import wizard's counts read the right noun form in every language at every count (objectui#11445): the auto-match summary, the rows-with-errors and rows-corrected lines, the result counts and the Import button are i18next count families, so Russian reads `Импортировать 3 строки` and English `Import 1 Row`. The auto-match summary passes its count as a number — i18next does not plural-select a string. The wizard's provider-less fallback reads a family's `_one` / `_other` row for a numeric `count`. diff --git a/.changeset/11445-plugin-list-count-families.md b/.changeset/11445-plugin-list-count-families.md new file mode 100644 index 0000000000..abc4e776b5 --- /dev/null +++ b/.changeset/11445-plugin-list-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-list': patch +--- + +The list view's record-count bar reads the right noun form in every language at every count (objectui#11445): it passes `count` to the `list.recordCount` count family instead of choosing `list.recordCountOne` on `=== 1`, so Russian reads `2 записи` and Arabic uses its dual at 2. `LIST_DEFAULT_TRANSLATIONS` carries the family's `_one` / `_other` rows. diff --git a/.changeset/11445-plugin-view-count-families.md b/.changeset/11445-plugin-view-count-families.md new file mode 100644 index 0000000000..6e91e49dad --- /dev/null +++ b/.changeset/11445-plugin-view-count-families.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-view': patch +--- + +The object view's provider-less defaults table carries the `_one` / `_other` rows of the two count families it serves, `console.objectView.bulkDeleteConfirm` and `objectActions.bulkDeleteSuccess` (objectui#11445), so a host with no `I18nProvider` reads `Delete 1 selected record?` at one record. diff --git a/.changeset/9664-search-items-available-plural.md b/.changeset/9664-search-items-available-plural.md index c7727f9957..7344640ed4 100644 --- a/.changeset/9664-search-items-available-plural.md +++ b/.changeset/9664-search-items-available-plural.md @@ -36,3 +36,10 @@ the singular half of that marker was dead weight while the parentheses still rendered at every count the key does serve. It now reads as a singular noun at one item and as a count label at every other count, the shape `ar`'s `common.itemCount`/`itemCountOne` uses (objectui#10425). + +⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.** +Later in this same release the `search.itemsAvailable` / `search.itemsAvailableOne` +switch gave way to a `search.itemsAvailable` count family in all ten packs: the +browse branch calls the family key with `{ count }`, each pack spells every CLDR +category its language selects (so the base-key fall-through described above no +longer arises), and `search.itemsAvailableOne` left every pack. The rest of this entry is kept as the reading of this change. diff --git a/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx b/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx index 733da29bd8..c64d432df7 100644 --- a/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx +++ b/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx @@ -99,12 +99,13 @@ * | `detail.showEmptyRelated{,_one,_other}` suffixed rows | all three in pack, all three agree | compared, AND their key-set mirror is pinned separately (see below) | * | a map row whose key the pack lacks | 0 rows, all three maps | not an allowed state: it fails, naming itself a finding to file | * - * The suffixed `showEmptyRelated` rows exist for a reason stated in - * `useDetailTranslation.ts`: `fallbackT` resolves `defaults[key]` LITERALLY and - * never appends a plural suffix, so only the BASE row is reachable through the - * provider-less path — the two suffixed rows are kept purely so the map's key set - * still mirrors the packs'. That rationale is a key-set claim, so the gate - * enforces it as one, on top of comparing all three values. + * The suffixed `showEmptyRelated` rows were kept, before objectui#11445, purely + * so the map's key set mirrored the packs': `fallbackT` read `defaults[key]` + * literally, so only the BASE row was reachable on the provider-less path. + * objectui#11445 taught `fallbackT` i18next's order (`key_` for a + * numeric `count`, then the base row), so all three rows are live now — and + * every converted count family's `_one` / `_other` rows ride the same path. The + * key-set pin below still holds them, on top of comparing all three values. * * ## Direction, predicted before running (#4118) * @@ -288,11 +289,11 @@ describe('the plugin defaults maps mirror the en pack (objectui#4401)', () => { }); it('the showEmptyRelated family keeps mirroring the packs’ key set', () => { - // `useDetailTranslation.ts` keeps two rows it can never serve: `fallbackT` - // indexes `defaults[key]` literally and never appends a plural suffix, so - // only the base row is reachable without a provider (objectui#3863). Their - // stated reason is that the map's key set still mirrors the packs' — a claim - // about KEYS, pinned here as one. Their values are compared by the + // `useDetailTranslation.ts` keeps the family's base and suffixed rows. Until + // objectui#11445 the suffixed two were unreachable (`fallbackT` read + // `defaults[key]` literally, objectui#3863) and were kept so the map's key + // set mirrored the packs'; `fallbackT` now reads them for a numeric `count`. + // The key-set claim is pinned here as one; their values are compared by the // byte-identity case above like every other row. for (const key of [ 'detail.showEmptyRelated', diff --git a/packages/app-shell/src/views/ObjectView.recordCountFooter-10636.test.tsx b/packages/app-shell/src/views/ObjectView.recordCountFooter-10636.test.tsx index d41c73879e..655c268a86 100644 --- a/packages/app-shell/src/views/ObjectView.recordCountFooter-10636.test.tsx +++ b/packages/app-shell/src/views/ObjectView.recordCountFooter-10636.test.tsx @@ -24,8 +24,15 @@ * count-not-one half is a count label — a label, a colon, then the number — * as objectui#10024, objectui#10242 and objectui#10425 ruled. Every pack's pair * reads the way its `list.recordCount` pair does, so the footer and the list - * bar count the same records in the same words. ⛔ No i18next `_one` / `_few` - * suffixes: `all-locales-key-parity.test.ts` holds every pack to `en`'s key set. + * bar count the same records in the same words. + * + * ## objectui#11445 — one count family + * + * objectui#11432 let a pack hold every CLDR slot its language selects, so + * objectui#11445 made `console.objectView.recordCount` (and `list.recordCount`) + * an i18next count family: the footer passes `count` and i18next picks the slot, + * so `ru` reads «2 записи» and `ar` its dual at 2, and the `…One` sibling left + * all ten packs. The strings below are those. * * ## What this file measures * @@ -184,7 +191,7 @@ type Rows = Record>; /** * Every footer string this file expects. `ru` 2 is few and 21 is one; `ar` 2 - * is two and 11 is many — the categories a single count-not-one noun got wrong. + * is two and 11 is many — each the family slot i18next selects (objectui#11445). */ const FOOTER: Rows = { en: [ @@ -193,13 +200,13 @@ const FOOTER: Rows = { ], ru: [ [1, '1 запись'], - [2, 'Записей: 2'], - [21, 'Записей: 21'], + [2, '2 записи'], + [21, '21 запись'], ], ar: [ [1, '1 سجل'], - [2, 'عدد السجلات: 2'], - [11, 'عدد السجلات: 11'], + [2, 'سجلان (2)'], + [11, '11 سجلًا'], ], }; @@ -236,20 +243,22 @@ describe('ObjectView record-count footer reads right at one and at every other c const packs = Object.keys(builtInLocales); expect(packs.length).toBeGreaterThanOrEqual(10); for (const lang of packs) { - expect(at(lang, 'console.objectView.recordCount'), `${lang} recordCount`).toBe( - at(lang, 'list.recordCount'), - ); - expect(at(lang, 'console.objectView.recordCountOne'), `${lang} recordCountOne`).toBe( - at(lang, 'list.recordCountOne'), - ); + for (const slot of ['', ...new Intl.PluralRules(lang).resolvedOptions().pluralCategories.map((c) => `_${c}`)]) { + expect(at(lang, `console.objectView.recordCount${slot}`), `${lang} recordCount${slot}`).toBe( + at(lang, `list.recordCount${slot}`), + ); + } } }); - it('in ru and ar the count-not-one half is count-invariant: it ends in the number, after a colon', () => { + it('in ru and ar the footer is an i18next count family, and its `…One` sibling is gone', () => { for (const lang of ['ru', 'ar'] as const) { - const value = at(lang, 'console.objectView.recordCount'); - expect(value, `${lang} console.objectView.recordCount`).toMatch(/: \{\{count\}\}$/); - expect(value, `${lang} equals its One half`).not.toBe(at(lang, 'console.objectView.recordCountOne')); + for (const category of new Intl.PluralRules(lang).resolvedOptions().pluralCategories) { + expect(at(lang, `console.objectView.recordCount_${category}`), `${lang} _${category}`).toEqual( + expect.any(String), + ); + } + expect(at(lang, 'console.objectView.recordCountOne'), `${lang} recordCountOne`).toBeUndefined(); } }); }); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 6dfa883c4e..49e402635a 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -3607,13 +3607,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co {typeof recordCount === 'number' && (
- {/* The two-key switch of `ListView`'s record-count bar - (objectui#10636). Packs whose plurals have more forms - than two write the count-not-one half as a count label - (objectui#10425). */} - {recordCount === 1 - ? t('console.objectView.recordCountOne', { count: recordCount }) - : t('console.objectView.recordCount', { count: recordCount })} + {/* One count family, like `ListView`'s record-count bar + (objectui#11445): i18next picks the CLDR slot. */} + {t('console.objectView.recordCount', { count: recordCount })}
)} diff --git a/packages/app-shell/src/views/SearchResultsPage.tsx b/packages/app-shell/src/views/SearchResultsPage.tsx index f9c004685e..2449ecdd76 100644 --- a/packages/app-shell/src/views/SearchResultsPage.tsx +++ b/packages/app-shell/src/views/SearchResultsPage.tsx @@ -235,29 +235,18 @@ export function SearchResultsPage() { {/* Results count */} {/* - Both branches select their key on `=== 1`, and they have to: the browse - branch used to ask for `search.itemsAvailable` at every count, so English - shipped `1 items available` at a single searchable item (objectui#9664). - - The repo's two-key plural convention (`common.itemCount`/`itemCountOne`, - `detail.reactionCount`/`reactionCountOne`), NOT an i18next `_one`/`_other` - family. Key parity caps a family at base + `_one` + `_other`, so every - other CLDR category falls through to the base key — and on THIS key the - base would be the plural, which is what `ar` meets at 2, 3-10 and 11-99 - and `ru` at 2-4. Picking the key here keeps `Intl.PluralRules` and - `fallbackLng` out of the path entirely. - - Two keys still give a pack only two slots. Where the language has more - integer categories than that (`ru`: one/few/many, `ar`: six), the pack - writes the count-not-one half as a count label that reads right at any - number (`ru`/`ar` `search.resultsCountPlural`, objectui#10024) instead of a - `{{count}} ` form that agrees with only some of the counts it serves. + Both branches are i18next count families (objectui#11445): each passes + `count` and i18next picks the slot `Intl.PluralRules` selects, so `ru` + and `ar` read their own noun form at every count rather than the two + slots a `=== 1` key switch gave them. The browse branch once asked one + plural string at every count and shipped `1 items available` + (objectui#9664); its `_one` slot is the repair now. */}
{query.trim() - ? t(totalCount === 1 ? 'search.resultsCount' : 'search.resultsCountPlural', { count: totalCount, query }) - : t(allItems.length === 1 ? 'search.itemsAvailableOne' : 'search.itemsAvailable', { count: allItems.length })} + ? t('search.resultsCount', { count: totalCount, query }) + : t('search.itemsAvailable', { count: allItems.length })} {recordsSearching && ( ` + * for a numeric `count`, then the base key — rather than an inline table, so this * cannot go green against a copy of the English that has drifted from the pack. * The per-pack values are `searchItemsAvailable-plural-9664.test.ts`'s, in * `@object-ui/i18n`. @@ -54,8 +60,13 @@ vi.mock('@object-ui/i18n', async (importOriginal) => { t: (key: string, options?: Record) => { asked.keys.push(key); // `actual.en` is the shipped catalogue, the same object the app resolves - // through `fallbackLng`. Interpolation is i18next's `{{name}}` spelling. - const value = at(actual.en, key); + // through `fallbackLng`. The lookup is i18next's: a count family's slot + // for a NUMERIC count first, then the base key. Interpolation is + // i18next's `{{name}}` spelling. + const count = options?.count; + const slot = + typeof count === 'number' ? at(actual.en, `${key}_${new Intl.PluralRules('en').select(count)}`) : undefined; + const value = typeof slot === 'string' ? slot : at(actual.en, key); if (typeof value !== 'string') return key; return value.replace(/\{\{(\w+)\}\}/g, (_m: string, name: string) => String(options?.[name] ?? ''), @@ -114,10 +125,11 @@ describe('SearchResultsPage browse count agrees with its number (objectui#9664)' expect(screen.getByText('1 item available')).toBeInTheDocument(); // The defect, named as the string it shipped. expect(screen.queryByText('1 items available')).toBeNull(); - // …and the selection that produces it, so a green here cannot come from a - // pack edit that papered over a call site still asking for one key. - expect(asked.keys).toContain('search.itemsAvailableOne'); - expect(asked.keys).not.toContain('search.itemsAvailable'); + // …and the selection that produces it: ONE key with a count, so the + // singular comes from the family's `_one` slot (objectui#11445), not from a + // `=== 1` switch onto a sibling. + expect(asked.keys).toContain('search.itemsAvailable'); + expect(asked.keys).not.toContain('search.itemsAvailableOne'); }); it('reads the plural at every other count, zero included', () => { @@ -129,10 +141,9 @@ describe('SearchResultsPage browse count agrees with its number (objectui#9664)' expect(asked.keys).not.toContain('search.itemsAvailableOne'); }); - it('leaves the query branch alone — it already switched at one', () => { - // The pattern this card copied is on the adjacent line, and the card claims - // nothing about it. Measured rather than assumed, so a later edit to the - // browse branch cannot quietly take the query branch with it. + it('the query branch is one count family too, and the browse branch stays unevaluated', () => { + // The adjacent line, converted by objectui#11445 the same way: one key with a + // count, no `search.resultsCountPlural` sibling. app.navigation = navItems(3); url.search = 'q=Object 0'; render(); diff --git a/packages/app-shell/src/views/__tests__/SearchResultsPage.resultsCountPlural-10024.test.tsx b/packages/app-shell/src/views/__tests__/SearchResultsPage.resultsCountPlural-10024.test.tsx index 1fb9786ae8..944b06672e 100644 --- a/packages/app-shell/src/views/__tests__/SearchResultsPage.resultsCountPlural-10024.test.tsx +++ b/packages/app-shell/src/views/__tests__/SearchResultsPage.resultsCountPlural-10024.test.tsx @@ -31,11 +31,13 @@ * The `ru` pack's own header comment on `perm.facet` records the device as * applied throughout that pack and correct at ANY number. * - * ⛔ Not an i18next family with `_few`/`_many`/`_two` slots. No pack in the - * repo defines one, and `all-locales-key-parity.test.ts` holds every pack to - * `en`'s key set, so a `ru` `_few` is a key `en` lacks. The routes that WOULD - * render a real few-form are recorded on objectui#10024's pull request as a - * question for the maintainer; this file does not pick one. + * ## objectui#11445 — the real few-form, now + * + * The route this card left open was taken: objectui#11432 let a pack hold every + * CLDR slot its language selects, and objectui#11445 made `search.resultsCount` + * an i18next count family. The page passes `count`, i18next picks the slot, and + * `search.resultsCountPlural` left all ten packs — so `ru` reads «2 результата» + * and «21 результат», and `ar` its dual at 2. The strings below are those. * * ## What this file measures * @@ -140,20 +142,20 @@ const EXPECTED: Record> = { [2, '2 results for "Object"'], ], ru: [ - [1, '1 результат для "Object"'], - [2, 'Результатов по запросу "Object": 2'], - [5, 'Результатов по запросу "Object": 5'], - [21, 'Результатов по запросу "Object": 21'], - [22, 'Результатов по запросу "Object": 22'], - [25, 'Результатов по запросу "Object": 25'], + [1, '1 результат по запросу "Object"'], + [2, '2 результата по запросу "Object"'], + [5, '5 результатов по запросу "Object"'], + [21, '21 результат по запросу "Object"'], + [22, '22 результата по запросу "Object"'], + [25, '25 результатов по запросу "Object"'], ], ar: [ - [0, 'عدد نتائج البحث عن "Object": 0'], + [0, '0 نتيجة لـ "Object"'], [1, '1 نتيجة لـ "Object"'], - [2, 'عدد نتائج البحث عن "Object": 2'], - [3, 'عدد نتائج البحث عن "Object": 3'], - [11, 'عدد نتائج البحث عن "Object": 11'], - [100, 'عدد نتائج البحث عن "Object": 100'], + [2, 'نتيجتان لـ "Object" (2)'], + [3, '3 نتائج لـ "Object"'], + [11, '11 نتيجة لـ "Object"'], + [100, '100 نتيجة لـ "Object"'], ], }; @@ -186,8 +188,8 @@ describe('search results count reads correctly at every CLDR category (objectui# expect(countLine('ru', 22)).not.toBe('22 результатов для "Object"'); }); - it('ar says something different at one than at two — the switch buys it a form', () => { - // Both `ar` slots used to be the same bytes, so the line read identically + it('ar says something different at one than at two — the family gives it the dual', () => { + // Both `ar` slots once were the same bytes, so the line read identically // at every count but for the number. Compared with the number masked, so // this is about the sentence, not the digit. const shape = (text: string) => text.replace(/\d+/g, 'N'); @@ -208,15 +210,15 @@ describe('search results count reads correctly at every CLDR category (objectui# } }); - it('the ru and ar count-not-one half is count-invariant: no noun follows the number', () => { - // The mechanism of the repair, stated on the pack values so a later - // "natural-sounding" rewrite back to `{{count}} ` fails here with the - // reason attached: two slots cannot give that noun the right form. + it('search.resultsCount is an i18next count family in ru and ar; the `Plural` sibling is gone', () => { + // The mechanism of the objectui#11445 repair, on the pack values: every CLDR + // slot the language selects is spelled, so i18next chooses the form. for (const lang of ['ru', 'ar'] as const) { - const { resultsCount, resultsCountPlural } = builtInLocales[lang].search; - expect(resultsCountPlural, `${lang} resultsCountPlural`).toMatch(/: \{\{count\}\}$/); - // …and the halves are two different sentences in both packs now. - expect(resultsCount, `${lang} halves are identical`).not.toBe(resultsCountPlural); + const search = builtInLocales[lang].search as Record; + for (const category of new Intl.PluralRules(lang).resolvedOptions().pluralCategories) { + expect(search[`resultsCount_${category}`], `${lang} resultsCount_${category}`).toEqual(expect.any(String)); + } + expect(search.resultsCountPlural, `${lang} resultsCountPlural`).toBeUndefined(); } }); }); diff --git a/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx index 6e744ad977..a0993fb671 100644 --- a/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx +++ b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10242.test.tsx @@ -30,9 +30,17 @@ * (`Комментариев: {{count}}`) and `collaboration.reactionCount` * (`Реакций: {{count}}`). The `ar` values use the `عدد …: {{count}}` shape of * `search.resultsCountPlural` and `calendar.a11y.dayCell`. The singular halves, - * the key set and the call sites are unchanged. ⛔ Not an i18next `_few` / - * `_two` family: `all-locales-key-parity.test.ts` holds every pack to `en`'s - * key set, so a `ru` `_few` is a key `en` lacks. + * the key set and the call sites were unchanged then. + * + * ## objectui#11445 — the repair this file pins now + * + * A count label reads right at any number, but it is not the noun form the + * language uses. objectui#11432 let a pack hold every CLDR slot its language + * selects, so objectui#11445 made each of these keys an i18next count family: + * the components pass `count`, i18next picks the slot, and `ru` reads + * «2 элемента», «5 элементов», «21 элемент» while `ar` reads the dual at 2, the + * plural at 3-10 and the accusative singular at 11-99. The `…One` siblings + * left all ten packs. The strings below are those, read from a real render. * * ## Why this file sits beside the objectui#10024 pin * @@ -166,9 +174,10 @@ function commentThread(lang: Lang, count: number): { header: string; tooltip: st type Row = [count: number, badge: string, header: string, tooltip: string]; /** - * Every string this file expects. `1` is the untouched singular half. The - * other counts are the triage's representative numbers: `ru` 2 (few), 5 (many) - * and 21 (one); `ar` 2 (two), 3 (few) and 11 (many). + * Every string this file expects. The counts are the triage's representative + * numbers: `ru` 2 (few), 5 (many) and 21 (one); `ar` 2 (two), 3 (few) and 11 + * (many). Since objectui#11445 each is the family slot i18next selects — before + * it, every count but 1 read the count label («Элементов: 2», «عدد العناصر: 2»). */ const EXPECTED: Record = { en: [ @@ -177,15 +186,15 @@ const EXPECTED: Record = { ], ru: [ [1, '1 элемент', '1 комментарий', '1 реакция'], - [2, 'Элементов: 2', 'Комментариев: 2', 'Реакций: 2'], - [5, 'Элементов: 5', 'Комментариев: 5', 'Реакций: 5'], - [21, 'Элементов: 21', 'Комментариев: 21', 'Реакций: 21'], + [2, '2 элемента', '2 комментария', '2 реакции'], + [5, '5 элементов', '5 комментариев', '5 реакций'], + [21, '21 элемент', '21 комментарий', '21 реакция'], ], ar: [ [1, '1 عنصر', '1 تعليق', '1 تفاعل'], - [2, 'عدد العناصر: 2', 'عدد التعليقات: 2', 'عدد التفاعلات: 2'], - [3, 'عدد العناصر: 3', 'عدد التعليقات: 3', 'عدد التفاعلات: 3'], - [11, 'عدد العناصر: 11', 'عدد التعليقات: 11', 'عدد التفاعلات: 11'], + [2, 'عنصران (2)', 'تعليقان (2)', 'تفاعلان (2)'], + [3, '3 عناصر', '3 تعليقات', '3 تفاعلات'], + [11, '11 عنصرًا', '11 تعليقًا', '11 تفاعلًا'], ], }; @@ -200,9 +209,9 @@ const CHIP: Record<'en' | 'ru', Array<[number, string]>> = { ], ru: [ [1, '👍 1 реакция'], - [2, '👍 Реакций: 2'], - [5, '👍 Реакций: 5'], - [21, '👍 Реакций: 21'], + [2, '👍 2 реакции'], + [5, '👍 5 реакций'], + [21, '👍 21 реакция'], ], }; @@ -247,24 +256,21 @@ describe('count labels read correctly at every CLDR category in ru and ar (objec expect(commentThread('ar', 11).tooltip).not.toBe('11 تفاعلات'); }); - it('the count-not-one half is count-invariant: it ends in the number, after a colon', () => { - // The mechanism of the repair, stated on the pack values, so a later - // "natural-sounding" rewrite back to a number-then-noun form fails here - // with the reason attached: two slots cannot give that noun the right form. + it('each label is an i18next count family in ru and ar, and its `…One` sibling is gone', () => { + // The mechanism of the objectui#11445 repair, stated on the pack values: every + // CLDR slot the language selects is spelled, so i18next — not a `=== 1` key + // switch in the component — chooses the form. const at = (lang: 'ru' | 'ar', dotted: string) => dotted .split('.') .reduce((node, part) => (node as Record)?.[part], builtInLocales[lang]); - const KEYS: Record<'ru' | 'ar', string[]> = { - ru: ['common.itemCount', 'detail.reactionCount'], - ar: ['common.itemCount', 'collaboration.commentCount', 'collaboration.reactionCount'], - }; + const KEYS = ['common.itemCount', 'detail.reactionCount', 'collaboration.commentCount', 'collaboration.reactionCount']; for (const lang of ['ru', 'ar'] as const) { - for (const key of KEYS[lang]) { - const value = at(lang, key); - expect(value, `${lang} ${key}`).toMatch(/: \{\{count\}\}$/); - // …and the two halves are two different sentences. - expect(value, `${lang} ${key} equals its One half`).not.toBe(at(lang, `${key}One`)); + for (const key of KEYS) { + for (const category of new Intl.PluralRules(lang).resolvedOptions().pluralCategories) { + expect(at(lang, `${key}_${category}`), `${lang} ${key}_${category}`).toEqual(expect.any(String)); + } + expect(at(lang, `${key}One`), `${lang} ${key}One`).toBeUndefined(); } } }); diff --git a/packages/app-shell/src/views/__tests__/countLabels.ruAr-10425.test.tsx b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10425.test.tsx index 7f946dcaf2..d086ac473d 100644 --- a/packages/app-shell/src/views/__tests__/countLabels.ruAr-10425.test.tsx +++ b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10425.test.tsx @@ -33,9 +33,16 @@ * number — so no noun has to agree with the number. The `ru` value is the one * `ru` `lookup.recordCount` already reads (`Записей: {{count}}`). The `ar` * values use the `عدد …: {{count}}` shape of objectui#10242. The singular - * halves, the key set and the call sites are unchanged. ⛔ Not an i18next - * `_few` / `_two` family: `all-locales-key-parity.test.ts` holds every pack to - * `en`'s key set. + * halves, the key set and the call sites were unchanged then. + * + * ## objectui#11445 — the repair this file pins now + * + * objectui#11432 let a pack hold every CLDR slot its language selects, so + * objectui#11445 made each of these keys an i18next count family: the six + * surfaces pass `count` and i18next picks the slot, so `ru` reads «2 записи» / + * «21 запись» and `ar` the dual at 2, the plural at 3-10 and the accusative + * singular at 11-99, rather than a count label. The `…One` siblings left all + * ten packs. The strings below are those, read from a real render. * * ## Why this file sits beside the objectui#10242 pin * @@ -279,9 +286,9 @@ function browseCount(lang: Lang, count: number): string { type Rows = Partial>>; /** - * Every string this file expects. `1` is the untouched singular half. The - * other counts are the representative numbers of objectui#10242: `ru` 2 (few), - * 5 (many) and 21 (one); `ar` 2 (two), 3 (few) and 11 (many). + * Every string this file expects, at the representative numbers of + * objectui#10242: `ru` 2 (few), 5 (many) and 21 (one); `ar` 2 (two), 3 (few) + * and 11 (many). Each is the family slot i18next selects (objectui#11445). */ const LIST_BAR: Rows = { en: [ @@ -290,15 +297,15 @@ const LIST_BAR: Rows = { ], ru: [ [1, '1 запись'], - [2, 'Записей: 2'], - [5, 'Записей: 5'], - [21, 'Записей: 21'], + [2, '2 записи'], + [5, '5 записей'], + [21, '21 запись'], ], ar: [ [1, '1 سجل'], - [2, 'عدد السجلات: 2'], - [3, 'عدد السجلات: 3'], - [11, 'عدد السجلات: 11'], + [2, 'سجلان (2)'], + [3, '3 سجلات'], + [11, '11 سجلًا'], ], }; @@ -307,17 +314,16 @@ const PICKER: Rows = { [1, '1 record'], [2, '2 records'], ], - // The control: `ru` already read this way, and the `ru` list bar now matches it. ru: [ [1, '1 запись'], - [2, 'Записей: 2'], - [21, 'Записей: 21'], + [2, '2 записи'], + [21, '21 запись'], ], ar: [ - [1, 'سجل واحد'], - [2, 'عدد السجلات: 2'], - [3, 'عدد السجلات: 3'], - [11, 'عدد السجلات: 11'], + [1, '1 سجل'], + [2, 'سجلان (2)'], + [3, '3 سجلات'], + [11, '11 سجلًا'], ], }; @@ -328,9 +334,9 @@ const CHIP: Rows = { ], ar: [ [1, '👍 1 تفاعل'], - [2, '👍 عدد التفاعلات: 2'], - [3, '👍 عدد التفاعلات: 3'], - [11, '👍 عدد التفاعلات: 11'], + [2, '👍 تفاعلان (2)'], + [3, '👍 3 تفاعلات'], + [11, '👍 11 تفاعلًا'], ], }; @@ -339,17 +345,16 @@ const PRESENCE: Rows = { [1, '1 user present'], [2, '2 users present'], ], - // The control: `ru` already read this way. ru: [ [1, 'Присутствует 1 пользователь'], - [2, 'Присутствует пользователей: 2'], - [21, 'Присутствует пользователей: 21'], + [2, 'Присутствуют 2 пользователя'], + [21, 'Присутствует 21 пользователь'], ], ar: [ [1, '1 مستخدم متواجد'], - [2, 'عدد المستخدمين المتواجدين: 2'], - [3, 'عدد المستخدمين المتواجدين: 3'], - [11, 'عدد المستخدمين المتواجدين: 11'], + [2, 'مستخدمان متواجدان (2)'], + [3, '3 مستخدمين متواجدين'], + [11, '11 مستخدمًا متواجدًا'], ], }; @@ -358,17 +363,16 @@ const OVERFLOW: Rows = { [1, '1 more user'], [2, '2 more users'], ], - // The control: `ru` already read this way. ru: [ [1, 'Ещё 1 пользователь'], - [2, 'Ещё пользователей: 2'], - [21, 'Ещё пользователей: 21'], + [2, 'Ещё 2 пользователя'], + [21, 'Ещё 21 пользователь'], ], ar: [ [1, '1 مستخدم آخر'], - [2, 'عدد المستخدمين الآخرين: 2'], - [3, 'عدد المستخدمين الآخرين: 3'], - [11, 'عدد المستخدمين الآخرين: 11'], + [2, 'مستخدمان آخران (2)'], + [3, '3 مستخدمين آخرين'], + [11, '11 مستخدمًا آخر'], ], }; @@ -379,9 +383,9 @@ const BROWSE: Rows = { ], ar: [ [1, '1 عنصر متاح'], - [2, 'عدد العناصر المتاحة: 2'], - [3, 'عدد العناصر المتاحة: 3'], - [11, 'عدد العناصر المتاحة: 11'], + [2, 'عنصران متاحان (2)'], + [3, '3 عناصر متاحة'], + [11, '11 عنصرًا متاحًا'], ], }; @@ -449,31 +453,28 @@ describe('count labels read correctly at every CLDR category in ru and ar (objec expect(browseCount('ar', 2)).not.toBe('2 عناصر متاحة'); }); - it('the count-not-one half is count-invariant: it ends in the number, after a colon', () => { - // The mechanism of the repair, stated on the pack values, so a later - // "natural-sounding" rewrite back to a number-then-noun form fails here - // with the reason attached: two slots cannot give that noun the right form. + it('each label is an i18next count family in ru and ar, and its `…One` sibling is gone', () => { + // The mechanism of the objectui#11445 repair, stated on the pack values: every + // CLDR slot the language selects is spelled, so i18next — not a `=== 1` key + // switch in the component — chooses the form. const at = (lang: 'ru' | 'ar', dotted: string) => dotted .split('.') .reduce((node, part) => (node as Record)?.[part], builtInLocales[lang]); - const KEYS: Record<'ru' | 'ar', string[]> = { - ru: ['list.recordCount'], - ar: [ - 'list.recordCount', - 'lookup.recordCount', - 'detail.reactionCount', - 'collaboration.presentUserCount', - 'collaboration.moreUserCount', - 'search.itemsAvailable', - ], - }; + const KEYS = [ + 'list.recordCount', + 'lookup.recordCount', + 'detail.reactionCount', + 'collaboration.presentUserCount', + 'collaboration.moreUserCount', + 'search.itemsAvailable', + ]; for (const lang of ['ru', 'ar'] as const) { - for (const key of KEYS[lang]) { - const value = at(lang, key); - expect(value, `${lang} ${key}`).toMatch(/: \{\{count\}\}$/); - // …and the two halves are two different sentences. - expect(value, `${lang} ${key} equals its One half`).not.toBe(at(lang, `${key}One`)); + for (const key of KEYS) { + for (const category of new Intl.PluralRules(lang).resolvedOptions().pluralCategories) { + expect(at(lang, `${key}_${category}`), `${lang} ${key}_${category}`).toEqual(expect.any(String)); + } + expect(at(lang, `${key}One`), `${lang} ${key}One`).toBeUndefined(); } } }); diff --git a/packages/collaboration/src/CommentThread.tsx b/packages/collaboration/src/CommentThread.tsx index b3733ea1a9..f0ba93c8b8 100644 --- a/packages/collaboration/src/CommentThread.tsx +++ b/packages/collaboration/src/CommentThread.tsx @@ -120,11 +120,13 @@ function formatTimestamp(iso: string, t: CollaborationTranslate, locale: string) const diff = now.getTime() - date.getTime(); const minutes = Math.floor(diff / 60000); if (minutes < 1) return t('collaboration.justNow'); - if (minutes < 60) return t('collaboration.minutesAgo', { count: String(minutes) }); + // NUMBER counts: these are count families (objectui#11445), and i18next + // plural-selects only for a number — `ar` needs its own noun form at 3-10. + if (minutes < 60) return t('collaboration.minutesAgo', { count: minutes }); const hours = Math.floor(minutes / 60); - if (hours < 24) return t('collaboration.hoursAgo', { count: String(hours) }); + if (hours < 24) return t('collaboration.hoursAgo', { count: hours }); const days = Math.floor(hours / 24); - if (days < 7) return t('collaboration.daysAgo', { count: String(days) }); + if (days < 7) return t('collaboration.daysAgo', { count: days }); return formatAbsoluteDate(date, locale); } catch { return iso; @@ -596,16 +598,12 @@ export function CommentThread({ ...(userIds.includes(currentUser.id) ? styles.reactionBtnActive : {}), }, onClick: () => onReaction?.(comment.id, emoji), - // Dedicated key pair — `detail.reactionCount` interpolates an - // `{{emoji}}` this tooltip has no value for (the emoji is the + // A dedicated count family — `detail.reactionCount` interpolates + // an `{{emoji}}` this tooltip has no value for (the emoji is the // button's visible label), so reusing it would leave a literal - // `{{emoji}}` in the accessible name under every locale. - title: t( - userIds.length === 1 - ? 'collaboration.reactionCountOne' - : 'collaboration.reactionCount', - { count: String(userIds.length) }, - ), + // `{{emoji}}` in the accessible name under every locale. The + // count is a NUMBER so i18next plural-selects (objectui#11445). + title: t('collaboration.reactionCount', { count: userIds.length }), }, `${emoji} ${userIds.length}`), ), // The reaction-bar picker (objectui#3478). Its content is the literal @@ -687,17 +685,11 @@ export function CommentThread({ // Header React.createElement('div', { style: styles.header }, React.createElement('span', null, - // Two keys instead of an English `s` glued on at render time. The old - // `` `${n} comment${n !== 1 ? 's' : ''}` `` produced correct *English* - // — the bug is that the plural RULE was compiled into the component, - // so no locale could apply its own (ru needs three forms, ja needs - // none, and neither could ever be expressed). - t( - comments.length === 1 - ? 'collaboration.commentCountOne' - : 'collaboration.commentCount', - { count: String(comments.length) }, - ), + // An i18next count family instead of an English `s` glued on at + // render time: the old `` `${n} comment${n !== 1 ? 's' : ''}` `` + // compiled the plural RULE into the component. A NUMBER count, so + // i18next selects each pack's own CLDR slot (objectui#11445). + t('collaboration.commentCount', { count: comments.length }), resolved ? t('collaboration.resolvedSuffix') : '', ), React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } }, diff --git a/packages/collaboration/src/PresenceAvatars.tsx b/packages/collaboration/src/PresenceAvatars.tsx index 5df919e74f..0adbca3673 100644 --- a/packages/collaboration/src/PresenceAvatars.tsx +++ b/packages/collaboration/src/PresenceAvatars.tsx @@ -158,29 +158,18 @@ export function PresenceAvatars({ style: containerStyle, className, role: 'group', - // Two keys instead of an English `s` glued on at render time. The old - // `` `${n} user${n !== 1 ? 's' : ''} present` `` produced correct *English* - // — the defect is that the plural RULE was compiled into the component, so - // no locale could apply its own (ru needs three forms, ja needs none, and - // neither could ever be expressed). Same treatment as the comment count in - // objectui#3424. - 'aria-label': t( - users.length === 1 - ? 'collaboration.presentUserCountOne' - : 'collaboration.presentUserCount', - { count: String(users.length) }, - ), + // An i18next count family, not an English `s` glued on at render time: + // the old `` `${n} user${n !== 1 ? 's' : ''} present` `` compiled the + // plural RULE into the component. The count goes in as a NUMBER, because + // i18next plural-selects only for a number (objectui#11445) — then each + // pack's own CLDR categories apply (ru three forms, ar six, ja none). + 'aria-label': t('collaboration.presentUserCount', { count: users.length }), }, // Overflow badge (rendered first because of row-reverse) overflowCount > 0 && React.createElement('div', { key: 'overflow', style: overflowStyle, - title: t( - overflowCount === 1 - ? 'collaboration.moreUserCountOne' - : 'collaboration.moreUserCount', - { count: String(overflowCount) }, - ), + title: t('collaboration.moreUserCount', { count: overflowCount }), }, `+${overflowCount}`), // Avatars reversedVisible.map((user, idx) => diff --git a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx index 8db2ba9753..7c67f35f8f 100644 --- a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx +++ b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx @@ -289,7 +289,7 @@ describe('COLLAB_DEFAULT_TRANSLATIONS is the package-wide English source', () => expect(keys.length).toBeGreaterThanOrEqual(25); expect(keys).toContain('collaboration.reactionCount'); - expect(keys).toContain('collaboration.reactionCountOne'); + expect(keys).toContain('collaboration.reactionCount_one'); // The shared action words are borrowed from `common`, not re-spelled. expect(keys).toContain('common.save'); expect(keys.some((k) => k.startsWith('collaboration.save'))).toBe(false); @@ -306,7 +306,8 @@ describe('COLLAB_DEFAULT_TRANSLATIONS is the package-wide English source', () => */ it('keeps the reaction tooltip free of an {{emoji}} placeholder', () => { expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCount']).toBe('{{count}} reactions'); - expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCountOne']).toBe('{{count}} reaction'); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCount_one']).toBe('{{count}} reaction'); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCount_other']).toBe('{{count}} reactions'); }); /** diff --git a/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx b/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx index 3fc33e2242..3410d95c1d 100644 --- a/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx +++ b/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx @@ -109,14 +109,18 @@ describe('PresenceAvatars group label (objectui#3440)', () => { expect(groupLabel()).toBe('5 anwesende Benutzer'); }); - /** A language that needs three forms; the pack picks, not the component. */ - it('uses the Russian pack’s own singular / general forms', () => { + /** + * A language that needs three forms; the pack picks, not the component. A + * count family since objectui#11445, so 5 reads the `many` noun form where + * the code-selected pair's plural half could only give a count label. + */ + it('uses the Russian pack’s own one / many forms', () => { renderStack('ru', { users: [users[0]] }); expect(groupLabel()).toBe('Присутствует 1 пользователь'); cleanup(); renderStack('ru'); - expect(groupLabel()).toBe('Присутствует пользователей: 5'); + expect(groupLabel()).toBe('Присутствует 5 пользователей'); }); /** A language that needs none — one form for both, and no stray `s`. */ @@ -241,12 +245,14 @@ describe('the collaboration defaults map mirrors the en pack (objectui#3440)', ( expect(fromMap).toEqual({ ...en.collaboration }); }); - it('carries the eight presence keys this change added', () => { + it('carries the presence keys this change added (the two counts as families, objectui#11445)', () => { for (const key of [ 'presentUserCount', - 'presentUserCountOne', + 'presentUserCount_one', + 'presentUserCount_other', 'moreUserCount', - 'moreUserCountOne', + 'moreUserCount_one', + 'moreUserCount_other', 'userStatusTitle', 'statusActive', 'statusIdle', diff --git a/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx b/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx index 2abe4112ef..72b7f67d05 100644 --- a/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx +++ b/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx @@ -131,9 +131,11 @@ describe('COLLAB_DEFAULT_TRANSLATIONS covers the presence stack', () => { it('has real English copy for every presence key, never the key itself', () => { for (const key of [ 'collaboration.presentUserCount', - 'collaboration.presentUserCountOne', + 'collaboration.presentUserCount_one', + 'collaboration.presentUserCount_other', 'collaboration.moreUserCount', - 'collaboration.moreUserCountOne', + 'collaboration.moreUserCount_one', + 'collaboration.moreUserCount_other', 'collaboration.userStatusTitle', 'collaboration.statusActive', 'collaboration.statusIdle', @@ -145,11 +147,12 @@ describe('COLLAB_DEFAULT_TRANSLATIONS covers the presence stack', () => { } }); - it('keeps the count placeholder in both halves of each plural pair', () => { - expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.presentUserCount']).toContain('{{count}}'); - expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.presentUserCountOne']).toContain('{{count}}'); - expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.moreUserCount']).toContain('{{count}}'); - expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.moreUserCountOne']).toContain('{{count}}'); + it('keeps the count placeholder in every row of each count family (objectui#11445)', () => { + for (const base of ['collaboration.presentUserCount', 'collaboration.moreUserCount']) { + for (const key of [base, `${base}_one`, `${base}_other`]) { + expect(COLLAB_DEFAULT_TRANSLATIONS[key], key).toContain('{{count}}'); + } + } }); it('gives the tooltip both of its placeholders', () => { diff --git a/packages/collaboration/src/useCollaborationTranslation.ts b/packages/collaboration/src/useCollaborationTranslation.ts index ce95a054da..f87cab7722 100644 --- a/packages/collaboration/src/useCollaborationTranslation.ts +++ b/packages/collaboration/src/useCollaborationTranslation.ts @@ -27,11 +27,9 @@ * below is therefore the authoritative English copy of this package, and it * must stay byte-identical to the `en` locale pack's `collaboration` namespace. * - * Plural keys come in PAIRS (`…Count` / `…CountOne`) rather than i18next - * `_one`/`_other` suffixes: zh/ja/ko have no separate singular form, so those - * packs would legitimately omit the `_one` half and `all-locales-key-parity` - * reads a legitimately-absent half as a lost key. Same convention as - * `detail.reactionCount` / `common.itemCount`. + * Count labels are i18next count families (objectui#11445): the map carries + * each family's `_one` / `_other` rows exactly as the `en` pack does, and + * `fallbackT` reads them for a numeric `count` in i18next's own order. */ import { createSafeTranslation } from '@object-ui/i18n'; @@ -46,7 +44,8 @@ import { createSafeTranslation } from '@object-ui/i18n'; export const COLLAB_DEFAULT_TRANSLATIONS: Record = { // Thread header 'collaboration.commentCount': '{{count}} comments', - 'collaboration.commentCountOne': '{{count}} comment', + 'collaboration.commentCount_one': '{{count}} comment', + 'collaboration.commentCount_other': '{{count}} comments', 'collaboration.resolvedSuffix': ' · Resolved', 'collaboration.sortComments': 'Sort comments', 'collaboration.sortOldest': 'Oldest', @@ -58,15 +57,22 @@ export const COLLAB_DEFAULT_TRANSLATIONS: Record = { // library is introduced. 'collaboration.justNow': 'just now', 'collaboration.minutesAgo': '{{count}}m ago', + 'collaboration.minutesAgo_one': '{{count}}m ago', + 'collaboration.minutesAgo_other': '{{count}}m ago', 'collaboration.hoursAgo': '{{count}}h ago', + 'collaboration.hoursAgo_one': '{{count}}h ago', + 'collaboration.hoursAgo_other': '{{count}}h ago', 'collaboration.daysAgo': '{{count}}d ago', + 'collaboration.daysAgo_one': '{{count}}d ago', + 'collaboration.daysAgo_other': '{{count}}d ago', 'collaboration.edited': '(edited)', - // Reactions. A DEDICATED pair, deliberately not `detail.reactionCount` — + // Reactions. A DEDICATED family, deliberately not `detail.reactionCount` — // that one interpolates `{{emoji}}` and this tooltip has no emoji to hand it // (the emoji is the button's visible content), so reuse would render a // stray `{{emoji}}` under every locale. 'collaboration.reactionCount': '{{count}} reactions', - 'collaboration.reactionCountOne': '{{count}} reaction', + 'collaboration.reactionCount_one': '{{count}} reaction', + 'collaboration.reactionCount_other': '{{count}} reactions', 'collaboration.addThumbsUp': 'Add thumbs up', // Per-comment actions and the reply banner 'collaboration.reply': 'Reply', @@ -89,11 +95,13 @@ export const COLLAB_DEFAULT_TRANSLATIONS: Record = { 'collaboration.send': 'Send', // Presence avatar stack (objectui#3440). The group's `aria-label` IS the // control for a screen reader — the stack itself is images and initials — - // so this pair is not decoration. + // so these labels are not decoration. 'collaboration.presentUserCount': '{{count}} users present', - 'collaboration.presentUserCountOne': '{{count}} user present', + 'collaboration.presentUserCount_one': '{{count}} user present', + 'collaboration.presentUserCount_other': '{{count}} users present', 'collaboration.moreUserCount': '{{count}} more users', - 'collaboration.moreUserCountOne': '{{count}} more user', + 'collaboration.moreUserCount_one': '{{count}} more user', + 'collaboration.moreUserCount_other': '{{count}} more users', // Avatar tooltip. The parentheses belong to the translation so a translator // owns the whole shape rather than inheriting English-shaped glue. 'collaboration.userStatusTitle': '{{name}} ({{status}})', diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 6c805397ee..a66e61b25e 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -128,6 +128,10 @@ const TABLE_DEFAULT_TRANSLATIONS: Record = { 'table.open': 'Open', 'table.search': 'Search…', 'table.modified': '{{count}} row modified', + // A count family (objectui#11445): `fallbackT` reads the `_one` / `_other` + // row for a numeric `count`, as i18next reads the `en` pack. + 'table.modified_one': '{{count}} row modified', + 'table.modified_other': '{{count}} rows modified', 'table.saveFailed': 'Save failed', 'table.selected': '{{count}} selected', 'table.edit': 'Edit', diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 663c4d5517..6fe937ae63 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -111,11 +111,9 @@ const MANUAL_REFRESH_SPIN_MS = 650; * screen reader is concerned — and it used to be built by a template literal * that hardcoded both the English word and English pluralization. * - * Two keys, NOT an i18next `_one`/`_other` pair: zh/ja/ko have no separate - * singular form, so those packs would legitimately omit the `_one` half and - * `all-locales-key-parity` would read that as a missing key. Same convention as - * `detail.reactionCount`/`reactionCountOne` and `detail.relatedRecords`/ - * `relatedRecordOne`. + * An i18next count family (objectui#11445): every pack spells out each CLDR + * category its language selects, and the `_one` / `_other` rows below are what + * `fallbackT` reads for a numeric `count` when no provider is mounted. * * `createSafeTranslation` rather than the per-call `useSafeTranslate` used * elsewhere in this file because these keys interpolate `{{count}}` and @@ -123,7 +121,8 @@ const MANUAL_REFRESH_SPIN_MS = 650; */ const TABS_DEFAULT_TRANSLATIONS: Record = { 'common.itemCount': '{{count}} items', - 'common.itemCountOne': '{{count}} item', + 'common.itemCount_one': '{{count}} item', + 'common.itemCount_other': '{{count}} items', }; const useTabsTranslation = createSafeTranslation(TABS_DEFAULT_TRANSLATIONS, 'common.itemCount'); @@ -954,16 +953,16 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { {item.count !== undefined && item.count !== null && item.count !== '' && Number(item.count) > 0 && ( {formatTabCount(item.count)} diff --git a/packages/fields/src/widgets/LocationField.tsx b/packages/fields/src/widgets/LocationField.tsx index 70b3915b87..182f3ba996 100644 --- a/packages/fields/src/widgets/LocationField.tsx +++ b/packages/fields/src/widgets/LocationField.tsx @@ -335,15 +335,14 @@ function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string { * handed to it through a hole. So the verb is not a hole: it lives inside two * SIBLING KEYS picked here, at the call site. * - * ⛔ Deliberately NOT i18next's `_one`/`_other` suffixes. This repo's own - * plural convention is a `X` / `XOne` pair branched at the call site — - * `lookup.recordCount`/`recordCountOne` in this very defaults map - * (`RecordPickerDialog`), `list.recordCount`/`recordCountOne`, - * `detail.reactionCount`/`reactionCountOne` — because zh/ja/ko have no separate - * singular form and would legitimately omit a `_one` half, which - * `all-locales-key-parity` reads as a missing key (objectstack#5430). The - * suffix form also needs a base key to cover the categories no pack enumerates - * (`ru` few/many, `ar` two/zero — objectui#3863); the sibling pair needs none. + * ⛔ Deliberately NOT i18next's `_one`/`_other` suffixes — and not because of + * any plural convention: this is not a COUNT. The two values interpolate + * different holes (one half, or both halves with `{{otherName}}` / + * `{{otherText}}`), so the arm selects on which halves the residue holds, a + * structural choice a count family cannot express. Every count label in this + * repo is an i18next family since objectui#11445 retired the code-selected + * `xxxCountOne` pairs; `count-families-11445.test.ts` pins that, and this pair + * carries no `{{count}}`, so it is outside that rule by construction. * * The arity here is exactly binary and always will be: `residue` is * `COORDINATE_LABELS` filtered, and a draft that is not two comma-separated diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 6a06ea3247..61de55d55a 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -1338,7 +1338,7 @@ export function RecordPickerDialog({ data-testid="record-picker-pagination" > - {totalCount === 1 ? t('lookup.recordCountOne') : t('lookup.recordCount', { count: totalCount })} + {t('lookup.recordCount', { count: totalCount })} {totalPages > 1 && ` · ${t('lookup.pageOf', { current: currentPage, total: totalPages })}`} {totalPages > 1 && ( diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 57d82d398a..6589bfd9df 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -28,7 +28,10 @@ const FIELD_DEFAULTS: Record = { 'lookup.selectFirst': 'Select {{fields}} first', 'lookup.selectRecord': 'Select record', 'lookup.recordCount': '{{count}} records', - 'lookup.recordCountOne': '1 record', + // A count family (objectui#11445): `fallbackT` reads the `_one` / `_other` + // row for a numeric `count`, as i18next reads the `en` pack. + 'lookup.recordCount_one': '{{count}} record', + 'lookup.recordCount_other': '{{count}} records', 'lookup.pageOf': 'Page {{current}} of {{total}}', 'lookup.filters': 'Filters', 'lookup.clear': 'Clear', @@ -124,9 +127,9 @@ const FIELD_DEFAULTS: Record = { // bilingual rather than one screen. // // Two SIBLING keys, not an i18next `_one`/`_other` family: the verb is - // English grammar and cannot be a hole, and this repo's plural convention is - // the `X`/`XOne` pair branched at the call site (`lookup.recordCount` / - // `recordCountOne` above is the same map's own instance). The coordinate + // English grammar and cannot be a hole, and the two arities interpolate + // different holes — a structural pair selected by which halves the residue + // holds, not a count (see `refusedResidueMessage`). The coordinate // NOUNS are keyed once each and interpolated into both, so a locale spells // `latitude` in exactly one place. `{{text}}`/`{{otherText}}` are what the // person typed and stay untouched by every pack. diff --git a/packages/i18n/README.md b/packages/i18n/README.md index dfabac0466..4359d3b2cf 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -119,6 +119,33 @@ function LanguageBar() { } ``` +#### Count labels are i18next count families + +A label a word must agree with — `3 replies`, `Approve 3 requests?` — is one +i18next count family: pass the number as `count` and let i18next pick the slot +`Intl.PluralRules` selects for the language. Every pack spells out each +category its language has (`en` `_one`/`_other`; `ru` adds `_few`/`_many`; `ar` +has all six), plus a count-invariant base key for a call made without a count. + +```tsx +import { useObjectTranslation } from '@object-ui/i18n'; + +function ReplyCount({ replies }: { replies: readonly string[] }) { + const { t } = useObjectTranslation(); + + // "1 reply", "3 replies"; Russian «3 ответа», «5 ответов» + return {t('detail.replyCount', { count: replies.length })}; +} +``` + +Pass `count` as a **number** — i18next selects a slot for nothing else — and +never choose the key yourself (`count === 1 ? 'xOne' : 'x'`): two keys give a +language two forms, and Russian needs three, Arabic five. A +`createSafeTranslation` defaults table answers the same way on a provider-less +host: it reads a family's `_one` / `_other` row for a numeric `count`, then the +base row. `count-families-11445.test.ts` holds every `{{count}}` value in the +ten packs to this rule. + ### createI18n Factory for creating an i18n instance outside React: diff --git a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index a5856a49fb..5eca1ab597 100644 --- a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -90,11 +90,12 @@ * needs no call site. Families are taken as the union over `en` and the pack, so a * new pack or a new family that misses a category goes red here at PR time. * - * ⛔ Out of this rule's reach, by construction: a count label selected IN CODE - * (`xxxCount` / `xxxCountOne`, `xxxCountPlural`) — the component, not i18next, - * picks the key, so the pack has no category slot to judge. Those are pinned per key - * where they were repaired (`searchItemsAvailable-plural-9664.test.ts`, and - * `countLabels.ruAr-10242.test.tsx` in `@object-ui/app-shell`). + * ⛔ Out of this rule's reach, by construction: a count label that is NOT a family — + * one selected in code (`xxxCountOne` / `xxxCountPlural`), or one string at every + * count — has no category slot to judge. objectui#11445 converted every such label + * that agrees a word with its number into a family, and + * `count-families-11445.test.ts` keeps it that way: no `…Plural` key, and every + * `{{count}}` leaf in a family or named on its count-neutral list with a reason. * * The rendering half — that i18next really selects each slot written here, through a * real instance over the shipped packs — is `plural-categories-11432.test.ts`. diff --git a/packages/i18n/src/__tests__/count-families-11445.test.ts b/packages/i18n/src/__tests__/count-families-11445.test.ts new file mode 100644 index 0000000000..a9e0227d54 --- /dev/null +++ b/packages/i18n/src/__tests__/count-families-11445.test.ts @@ -0,0 +1,241 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11445 — a count that a word must agree with is an i18next count + * family, in every pack. No other spelling of a count label may appear. + * + * ## What was broken + * + * objectui#11432 gave every i18next count family every CLDR category its + * language selects, and `plural-categories-11432.test.ts` proves i18next + * selects each slot. Two other spellings of the same defect sat outside that + * rule, because i18next was not the one choosing: + * + * - **code-selected pairs** — `detail.attachmentCount` / `…CountPlural`, + * `common.itemCount` / `…CountOne` and thirteen more. The component picked + * the key on `=== 1`, which gives a pack two slots; `ru` needs three integer + * forms and `ar` five, so `ru` read «3 вложений» and `ar` «2 مرفقات». + * - **single-form count keys** — one string at every count, so `ru` read + * «Согласовать 3 запросов?» and `en` itself «3 row modified». + * + * Both became families: the component passes `count`, and i18next picks the + * slot `Intl.PluralRules` selects. From then on the two older pins judge them + * by construction — `all-locales-key-parity.test.ts` that every slot exists, + * `plural-categories-11432.test.ts` that i18next selects it. + * + * ## What this file owns: that no third spelling appears unpinned + * + * 1. No pack holds a `…Plural` key. That was the code-selected pair's + * spelling; a component that picks a plural key itself is the defect. + * 2. Every leaf that interpolates `{{count}}`, in any pack, is a member of a + * family — or is named on `COUNT_NEUTRAL` below with the reason no word + * agrees with its number. The `…One` spelling of a code-selected pair is + * caught here: its plural half interpolates `{{count}}` outside a family. + * + * The list can only shrink. Each entry must still exist, still interpolate + * `{{count}}` and still be outside a family — so converting a key forces its + * entry out, and a key cannot be listed in advance. + * + * ⛔ What this file does NOT judge: whether a family's WORDS are right in a + * language (no test can; a translator does), and whether each call site passes + * `count` as a NUMBER — i18next plural-selects nothing else + * (`needsPluralHandling` is `count !== undefined && !isString(count)` in i18next + * 26). `check:i18n-keys` holds every call site to passing `count`; the number + * type is read at the call sites this card converted, not pinned. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +type LocaleCode = keyof typeof builtInLocales; +const LANGS = Object.keys(builtInLocales) as LocaleCode[]; + +const SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other'] as const; + +function leaves(node: unknown, prefix = ''): Array<[string, unknown]> { + return node !== null && typeof node === 'object' + ? Object.entries(node as Record).flatMap(([k, v]) => + leaves(v, prefix ? `${prefix}.${k}` : k), + ) + : [[prefix, node]]; +} + +/** The stem a leaf belongs to, if its name ends in one of i18next's CLDR suffixes. */ +const stemOf = (path: string): string | undefined => { + const suffix = SUFFIXES.find((s) => path.endsWith(s) && path.length > s.length); + return suffix === undefined ? undefined : path.slice(0, -suffix.length); +}; + +/** Families the way i18next reads them — union over all ten packs. */ +const FAMILIES = new Set( + LANGS.flatMap((lang) => + leaves(builtInLocales[lang]).flatMap(([path]) => { + const stem = stemOf(path); + return stem === undefined ? [] : [stem]; + }), + ), +); + +const inAFamily = (path: string) => FAMILIES.has(path) || FAMILIES.has(stemOf(path) ?? ''); + +/** Every leaf, in any pack, whose value interpolates `{{count}}`. */ +const COUNT_LEAVES = [ + ...new Set( + LANGS.flatMap((lang) => + leaves(builtInLocales[lang]) + .filter(([, v]) => typeof v === 'string' && v.includes('{{count}}')) + .map(([path]) => path), + ), + ), +].sort(); + +/** + * The `{{count}}` keys that stay outside a family, grouped by the reason no + * word agrees with the number in ANY pack. A key goes here only when that + * reason holds in all ten packs; one pack whose form agrees with the number + * makes it a family instead (that is how most of objectui#11445's 68 families + * were found: the `en` form was fine and a `ru`, `ar`, `de` or `es` one was not). + */ +const COUNT_NEUTRAL = { + /** The number stands alone — in parentheses, after `+`, beside `/ {{total}}`, + * or after a label or verb — so nothing has to agree with it. */ + tally: [ + 'perm.facet.more', + 'lookup.showAllResults', + 'fields.textarea.characterCount', + 'fields.textarea.charactersRemaining', + 'table.totalRecords', + 'table.saveAll', + 'grid.bulk.overLimit', + 'grid.bulk.affectedRecords', + 'grid.bulk.andMore', + 'grid.bulk.processed', + 'grid.bulk.processedFailed', + 'grid.bulk.succeeded', + 'grid.bulk.resultFailed', + 'calendar.moreEvents', + 'calendar.unscheduled', + 'view.moreViews', + 'detail.showDiscussion', + 'console.ai.buildDoctorDrawer.timeline', + 'marketplace.installedCount', + 'approvalsInbox.approveN', + 'approvalsInbox.rejectN', + ], + /** Every pack phrases it count-invariantly: an `(s)` marker in `en`/`de`/ + * `fr`/`es`/`pt` (the convention `perm-home-namespace-3546.test.tsx` pins), + * a label, partitive or impersonal form in `ru`/`ar`, a counter in zh/ja/ko. */ + invariant: [ + 'grid.import.imported', + 'grid.import.skippedCount', + 'console.ai.pendingDrafts.count', + 'console.ai.buildDoctorDrawer.allLive', + 'console.ai.buildDoctorDrawer.discrepancies', + 'console.ai.buildDoctorDrawer.issueCount', + 'console.ai.buildDoctorDrawer.platformNoise', + 'home.pendingDrafts.publishedVerified', + 'home.pendingDrafts.capabilityWarn', + 'preview.history.items', + ], + /** A relative time whose unit is abbreviated in every pack (`5m ago`, + * `vor 5 Min.`, `5 мин назад`, `منذ 5 د`); an abbreviation does not inflect. */ + abbreviated: [ + 'console.ai.minutesAgo', + 'console.ai.hoursAgo', + 'console.ai.daysAgo', + 'layout.activityFeed.relativeSecondsAgo', + 'layout.activityFeed.relativeMinutesAgo', + 'layout.activityFeed.relativeHoursAgo', + 'layout.activityFeed.relativeDaysAgo', + 'marketplace.relativeTime.daysAgo', + ], + /** Rendered only at 2 or more, and every pack's form is right at every such + * count: the cross-page bulk banner needs `totalMatching > pageSize` + * (`BulkActionBar`), and the inbox group header needs a group of two + * (`InboxPopover` renders a single item as its own row). */ + twoOrMore: [ + 'grid.bulkSelectedAllMatches', + 'grid.bulkSelectAllMatching', + 'grid.bulkAllMatchingSelected', + 'notifications.groupCount', + ], + /** No `t()` caller asks for it — `check:i18n-dead-keys` lists each as a + * retirement candidate. Retiring a dead key is that gate's business; a + * family for a string nobody renders would be dead weight in ten packs. */ + unreached: [ + 'detail.relatedRecords', + 'detail.relatedRecordOne', + 'appDesigner.objectManager.fieldCount', + 'console.objectView.toolbarEnabledCount', + 'layout.appSwitcher.appsAvailable', + ], +} as const; + +const NEUTRAL_KEYS: string[] = Object.values(COUNT_NEUTRAL).flat(); + +describe('a count label is an i18next count family, or names why it needs none (objectui#11445)', () => { + it('the walk is real — packs, families and count leaves are all non-empty', () => { + expect(LANGS).toHaveLength(10); + // objectui#11432's 14 families plus objectui#11445's 68 — a floor, so a + // later family raises it for free and only a lost family is a failure. + expect(FAMILIES.size).toBeGreaterThanOrEqual(82); + expect(COUNT_LEAVES.length).toBeGreaterThan(200); + // A family the card converted, from each spelling, is really a family. + for (const key of ['detail.attachmentCount', 'common.itemCount', 'approvalsInbox.bulkApproveTitle']) { + expect(FAMILIES.has(key), key).toBe(true); + } + }); + + it('no pack holds a `…Plural` key — the code-selected pair spelling is gone', () => { + const plural = LANGS.flatMap((lang) => + leaves(builtInLocales[lang]) + .map(([path]) => path) + .filter((path) => (path.split('.').pop() ?? '').endsWith('Plural')) + .map((path) => `${lang}:${path}`), + ); + expect(plural, `${plural.length} \`…Plural\` key(s)`).toEqual([]); + }); + + it('every `{{count}}` leaf is in a family or on the count-neutral list', () => { + const outside = COUNT_LEAVES.filter((path) => !inAFamily(path) && !NEUTRAL_KEYS.includes(path)); + expect( + outside, + `${outside.length} count label(s) outside any family — make each an i18next count family ` + + '(the component passes `count`, every pack spells each CLDR slot), or, if no word agrees ' + + 'with the number in ANY pack, name it on COUNT_NEUTRAL with that reason', + ).toEqual([]); + }); + + it('every count-neutral entry is still live — present, counting, and outside a family', () => { + const en = new Map(leaves(builtInLocales.en)); + const stale = NEUTRAL_KEYS.flatMap((key) => { + if (typeof en.get(key) !== 'string') return [`${key}: no longer an en key`]; + if (!COUNT_LEAVES.includes(key)) return [`${key}: interpolates no {{count}} any more`]; + if (inAFamily(key)) return [`${key}: is a family now — take it off the list`]; + return []; + }); + expect(stale).toEqual([]); + expect(new Set(NEUTRAL_KEYS).size, 'a key is listed under two reasons').toBe(NEUTRAL_KEYS.length); + for (const [reason, keys] of Object.entries(COUNT_NEUTRAL)) { + expect(keys.length, `${reason} lists nothing — drop the reason`).toBeGreaterThan(0); + } + }); + + it('the rule is not vacuous — it fails a single-form count key and a `…Plural` key', () => { + // A leaf shaped like the two retired spellings, judged by the same + // predicates the cases above use, so the predicates cannot pass by + // accepting everything. + const probe = { probe: { rowCount: '{{count}} rows', rowCountPlural: '{{count}} rows' } }; + const counting = leaves(probe).filter(([, v]) => String(v).includes('{{count}}')); + expect(counting.filter(([path]) => !inAFamily(path)).map(([path]) => path)).toEqual([ + 'probe.rowCount', + 'probe.rowCountPlural', + ]); + expect(leaves(probe).some(([path]) => path.endsWith('Plural'))).toBe(true); + }); +}); diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 7349c36d40..961ab5e557 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -146,7 +146,9 @@ const FIXED: Array<[string, string[]]> = [ ['console.objectView.ufShowAllRecords', ['„Alle Datensätze“']], ['home.gettingStarted.description', ['„Zuletzt geöffnet“']], ['search.resultsCount', ['„{{query}}“']], - ['search.resultsCountPlural', ['„{{query}}“']], + // objectui#11445 folded `search.resultsCountPlural` into the `resultsCount` + // count family: the value objectui#3876 fixed, and this span, live in `_other`. + ['search.resultsCount_other', ['„{{query}}“']], ['empty.objectNotFoundDescription', ['„{{name}}“']], ['empty.pageNotFoundDescription', ['„{{name}}“']], ['empty.dashboardNotFoundDescription', ['„{{name}}“']], @@ -302,7 +304,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 69 once objectui#11131 added `fields.grid.noItemsAddHint`, the list-mode // line-items grid's empty text, which quotes the Add button's label // („{{label}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(69); + // 70 once objectui#11445 made `search.resultsCount` a count family — its + // `_one` and `_other` slots quote the query, and `resultsCountPlural` left. + expect(okSpans, 'correctly paired spans').toBe(70); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -338,8 +342,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // more matched pair. 68 / 68 / 0 after objectui#11000 added // `form.noPermissionToCreate` and `form.noPermissionToEdit`, one matched // pair each. 69 / 69 / 0 after objectui#11131 added - // `fields.grid.noItemsAddHint`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 69, close: 69, rdq: 0 }); + // `fields.grid.noItemsAddHint`, one more matched pair. 70 / 70 / 0 after + // objectui#11445 made `search.resultsCount` a count family: `_one` and + // `_other` carry one matched pair each and `resultsCountPlural` left. + expect({ open, close, rdq }).toEqual({ open: 70, close: 70, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/__tests__/i18n.test.ts b/packages/i18n/src/__tests__/i18n.test.ts index 85ac286740..4f454b1d6d 100644 --- a/packages/i18n/src/__tests__/i18n.test.ts +++ b/packages/i18n/src/__tests__/i18n.test.ts @@ -397,8 +397,28 @@ describe('@object-ui/i18n', () => { it('all locales have common section keys matching English', () => { const codes = Object.keys(builtInLocales) as Array; const enCommonKeys = Object.keys(builtInLocales.en.common).sort(); + // The one key a pack may hold that `en` lacks: the slot of an `en` count + // family for a CLDR category the pack's OWN language selects — `ru` + // `itemCount_few`, `ar` `itemCount_two` (objectui#11432 / objectui#11445; + // `all-locales-key-parity.test.ts` owns that rule repo-wide). + const SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other']; + const stem = (k: string) => { + const s = SUFFIXES.find((x) => k.endsWith(x)); + return s ? { base: k.slice(0, -s.length), category: s.slice(1) } : undefined; + }; + const enFamilies = new Set(enCommonKeys.flatMap((k) => (stem(k) ? [stem(k)!.base] : []))); + const isLocalSlot = (code: string, k: string) => { + const s = stem(k); + return ( + s !== undefined && + !enCommonKeys.includes(k) && + enFamilies.has(s.base) && + (new Intl.PluralRules(code).resolvedOptions().pluralCategories as string[]).includes(s.category) + ); + }; for (const code of codes) { - expect(Object.keys(builtInLocales[code].common).sort()).toEqual(enCommonKeys); + const keys = Object.keys(builtInLocales[code].common).filter((k) => !isLocalSlot(code, k)); + expect(keys.sort()).toEqual(enCommonKeys); } }); }); diff --git a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index 7be8815eee..892383fe2b 100644 --- a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -302,6 +302,25 @@ const DECLARED: Exemption[] = [ "`tzOffsetMs`'s machine-read PARSER: `formatToParts` is read back through `Number()`, so the tag must " + 'guarantee Latin digits and the 24-hour cycle the arithmetic assumes; nothing is shown to a reader.', }, + { + file: 'packages/plugin-gantt/src/useGanttTranslation.ts', + expression: "const EN_PLURAL_RULES = new Intl.PluralRules('en');", + count: 1, + verdict: 'not applicable', + reason: + 'picks the `_one` / `_other` row of the provider-less ENGLISH defaults table for a count family ' + + '(objectui#11445): the rows are English, so English plural rules select among them; nothing is formatted, ' + + 'and a provider-mounted host reads the pack through i18next instead.', + }, + { + file: 'packages/plugin-grid/src/ImportWizard.tsx', + expression: "const EN_PLURAL_RULES = new Intl.PluralRules('en');", + count: 1, + verdict: 'not applicable', + reason: + "the same English-row selector in the import wizard's hand-rolled defaults table (objectui#11445): it picks " + + 'which English string a count family answers with on a provider-less host; no number or date is formatted.', + }, ]; const matches = (e: Exemption, s: Site): boolean => s.file === e.file && s.text.includes(e.expression); diff --git a/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts b/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts index edd0710c6f..df91690cb1 100644 --- a/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts +++ b/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts @@ -117,11 +117,18 @@ const at = (pack: unknown, path: string): unknown => * `toolbar` was not — a split with no reason behind it, which is the shape a * substring false-positive leaves. * - * ⚠️ The total below is 211 and that is deliberate: 211 is the count of keys + * ⚠️ The total below is 217 and that is deliberate: 217 is the count of keys * that ever EXISTED in this namespace, split into a retired half that only * grows and a surviving half that shrinks to match. A retirement moves the * split; only a genuinely new key moves the total (objectui#10636's - * `recordCountOne` moved it from 210). + * `recordCountOne` moved it from 210; objectui#11445 retired that key and + * added six count-family slots, `_one` / `_other` on `recordCount`, + * `importedToast` and `bulkDeleteConfirm`). + * + * ## The objectui#11445 change — 118 retired become 119 + * + * `recordCountOne` was the singular half of a code-selected pair; the footer + * now asks the `recordCount` count family, so the sibling left all ten packs. */ const RETIRED_OBJECT_VIEW_KEYS = [ 'accessibility', @@ -242,26 +249,31 @@ const RETIRED_OBJECT_VIEW_KEYS = [ 'userFilters', 'viewTabs', 'wrapHeaders', + // objectui#11445 — the record-count footer's code-selected singular, folded + // into the `recordCount` count family. + 'recordCountOne', ] as const; /** How many keys the namespace keeps — the live + indirect-reference remainder. */ -const SURVIVING_KEY_COUNT = 93; +const SURVIVING_KEY_COUNT = 98; describe('console.objectView config-panel keys are retired (objectui#4730)', () => { it('the retired list is the measured set', () => { // Guards the premise the rest of the file rests on. If this arithmetic ever // stops holding, the assertions below are checking a set nobody chose. - expect(RETIRED_OBJECT_VIEW_KEYS).toHaveLength(118); - expect(new Set(RETIRED_OBJECT_VIEW_KEYS).size).toBe(118); + expect(RETIRED_OBJECT_VIEW_KEYS).toHaveLength(119); + expect(new Set(RETIRED_OBJECT_VIEW_KEYS).size).toBe(119); // 209 at objectui#4730's landing; 210 since objectui#5232 added // `viewConfigPermissionDenied`; 211 since objectui#10636 added - // `recordCountOne`, the record-count footer's singular. The RETIRED half is a ratchet that never + // `recordCountOne`, the record-count footer's singular; 217 since + // objectui#11445 added six count-family slots (and retired `recordCountOne`, + // which moved the split, not the total). The RETIRED half is a ratchet that never // runs BACKWARDS — it is pinned twice above, and objectui#8754 advanced it // 116 -> 118 — while the surviving half is a live namespace that grows on a // new key and shrinks by exactly what the retired half gains. Splitting // them is the point: folding a new key into the total would be // indistinguishable from a retired key coming back. - expect(RETIRED_OBJECT_VIEW_KEYS.length + SURVIVING_KEY_COUNT).toBe(211); + expect(RETIRED_OBJECT_VIEW_KEYS.length + SURVIVING_KEY_COUNT).toBe(217); expect(LANGS).toHaveLength(10); }); @@ -279,17 +291,37 @@ describe('console.objectView config-panel keys are retired (objectui#4730)', () expect(revived).toEqual([]); }); - it('the namespace root survives, with the same 94-key shape in every pack', () => { + it('the namespace root survives, with the same surviving key shape in every pack', () => { // Deliberately NOT "the root is gone": most of this namespace is live. The // shape to pin is the surviving key SET, identical across packs — a root // that regrows a retired key is the regression this file exists for, and a // root that vanishes takes the live create-view dialog with it. const enKeys = Object.keys(at(builtInLocales.en, 'console.objectView') as Record); expect(enKeys).toHaveLength(SURVIVING_KEY_COUNT); + // The one key a pack may hold that `en` lacks: an `en` count family's slot + // for a CLDR category the pack's OWN language selects (`ru` `_few`, `ar` + // `_two` — `all-locales-key-parity.test.ts` owns that rule repo-wide). + const SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other']; + const enFamilies = new Set( + enKeys.flatMap((k) => { + const s = SUFFIXES.find((x) => k.endsWith(x)); + return s ? [k.slice(0, -s.length)] : []; + }), + ); + const isLocalSlot = (lang: string, k: string) => { + const s = SUFFIXES.find((x) => k.endsWith(x)); + return ( + s !== undefined && + !enKeys.includes(k) && + enFamilies.has(k.slice(0, -s.length)) && + (new Intl.PluralRules(lang).resolvedOptions().pluralCategories as string[]).includes(s.slice(1)) + ); + }; for (const lang of LANGS) { const ns = at(builtInLocales[lang], 'console.objectView'); expect(ns, `${lang} lost the console.objectView root`).toBeDefined(); - expect(Object.keys(ns as Record).sort(), lang).toEqual([...enKeys].sort()); + const keys = Object.keys(ns as Record).filter((k) => !isLocalSlot(lang, k)); + expect(keys.sort(), lang).toEqual([...enKeys].sort()); } }); diff --git a/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts b/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts index 894423f672..9744a70dec 100644 --- a/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts +++ b/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts @@ -9,41 +9,35 @@ /** * objectui#9664 — `search.itemsAvailable` broke ENGLISH at one item. * - * The full-page search header is one ternary with two branches. The query - * branch already picked its key on `totalCount === 1` - * (`search.resultsCount`/`search.resultsCountPlural`); the browse branch asked - * for `search.itemsAvailable` at every count, so `en`'s `{{count}} items - * available` shipped `1 items available` at a single searchable item — in the - * default language, with no plural family and no sibling key to fall to. - * `de`/`es`/`fr`/`pt` read the same way (`1 Elemente verfügbar`, …). + * The full-page search header is one ternary with two branches. The browse + * branch asked for `search.itemsAvailable` at every count, so `en`'s + * `{{count}} items available` shipped `1 items available` at a single + * searchable item — in the default language. `de`/`es`/`fr`/`pt` read the same + * way (`1 Elemente verfügbar`, …). * - * ## The repair, and why it is the two-key shape + * ## The repair, twice * - * `itemsAvailable`/`itemsAvailableOne`, selected at the call site — this repo's - * two-key plural convention, the one `common.itemCount`/`itemCountOne`, - * `detail.reactionCount`/`reactionCountOne` and - * `collaboration.commentCount`/`commentCountOne` already use, and the same - * device (in its other spelling) as the `search.resultsCount` pair on the - * adjacent branch. ⛔ NOT an i18next `_one`/`_other` family: when this landed, - * full key parity across ten packs capped a family at base + `_one` + `_other`, - * so every CLDR category a pack did not spell out fell through to the BASE key — - * here the plural — which `ar` meets at 2, 3-10 and 11-99 and `ru` at 2-4. - * Selecting the key in the component keeps `Intl.PluralRules` and `fallbackLng` - * out of the path: both halves exist in all ten packs, so no count in any - * language can reach English. (objectui#11432 lifted that cap — a family is now - * held to every category its language selects — but this key is selected in - * code, so converting it is a call-site change as well as a pack change.) + * objectui#9664 added a code-selected singular sibling, `search.itemsAvailableOne`, + * picked on `=== 1` — the shape the repo's other count labels used then. Two + * keys give a language two forms, so `ru` and `ar` had to phrase the plural half + * count-invariantly to stay right (objectui#10425). * - * ## What this file does NOT own + * objectui#11445 made the key an i18next count family: the page passes `count` + * and i18next picks the slot `Intl.PluralRules` selects, so `ar` reads its own + * form at 2, 3-10, 11-99 and 100+, and the sibling left all ten packs. The + * defect this file was written for is now the family's `_one` slot. * - * That both halves exist in all ten packs and hold `en`'s placeholders is - * `all-locales-key-parity.test.ts`'s, and that the call site picks the singular - * at one item is `SearchResultsPage.itemsAvailablePlural-9664.test.tsx`'s. This - * file owns the VALUES: that the singular half really is singular where the - * language has one, that the four packs which repeat the string do so for a - * stated reason, and that neither half carries a parenthesised plural marker. + * ## What this file owns, and what it does not + * + * That every slot exists is `all-locales-key-parity.test.ts`'s; that i18next + * selects each is `plural-categories-11432.test.ts`'s; that the page asks the + * family is `SearchResultsPage.itemsAvailablePlural-9664.test.tsx`'s. This file + * owns the VALUES: that the `_one` slot is a singular wherever the language has + * one, that `ru` keeps its noun-free phrasing, and that no slot carries a + * parenthesised plural marker. */ import { describe, it, expect } from 'vitest'; +import i18next from 'i18next'; import { builtInLocales } from '../locales'; type LocaleCode = keyof typeof builtInLocales; @@ -54,167 +48,79 @@ const at = (pack: unknown, dotted: string) => dotted.split('.').reduce((n, p) => (n as Record)?.[p], pack); const KEY = 'search.itemsAvailable'; -const KEY_ONE = 'search.itemsAvailableOne'; - -/** - * The count ≠ 1 half, unchanged by this card except in `ar` (see below). The - * `ar` value is objectui#10425's: this half serves two, few, many and other, - * so it is a count label (a label, a colon, then the number) rather than the - * 3-10 plural noun this card first gave it. - */ -const PLURAL: Record = { - en: '{{count}} items available', - zh: '共 {{count}} 项可搜索', - ja: '{{count}} 件利用可能', - ko: '{{count}}개 항목 사용 가능', - de: '{{count}} Elemente verfügbar', - fr: '{{count}} éléments disponibles', - es: '{{count}} elementos disponibles', - pt: '{{count}} itens disponíveis', - ru: '{{count}} доступно', - ar: 'عدد العناصر المتاحة: {{count}}', -}; -/** The count === 1 half, added by this card. */ -const ONE: Record = { - en: '{{count}} item available', - zh: '共 {{count}} 项可搜索', - ja: '{{count}} 件利用可能', - ko: '{{count}}개 항목 사용 가능', - de: '{{count}} Element verfügbar', - fr: '{{count}} élément disponible', - es: '{{count}} elemento disponible', - pt: '{{count}} item disponível', - ru: '{{count}} доступно', - ar: '{{count}} عنصر متاح', -}; +/** Every slot this family has in one pack — the categories its language selects. */ +const slotsOf = (lang: LocaleCode) => + new Intl.PluralRules(lang).resolvedOptions().pluralCategories.map((c) => `${KEY}_${c}`); -/** - * The four packs whose two halves are deliberately the SAME string, so a later - * reader does not "de-duplicate" them back into one key: - * - * - `zh`/`ja`/`ko` have no separate singular form at all — the counter word - * carries the number (共 N 项 / N 件 / N개 항목). This is the same reason - * `common.itemCount`/`itemCountOne` repeat in those three packs, and it is - * the reason the repo writes count labels as two keys rather than an - * i18next family: with a family these packs would want to omit the `_one` - * half, and key parity reads a legitimately-absent half as a lost key. - * - `ru` phrases the label WITHOUT the noun (`{{count}} доступно`), so it has - * no word to agree with the number. That is also why Russian needs nothing - * from the `few`/`many` categories on this key. - */ -const NO_SINGULAR_FORM: LocaleCode[] = ['zh', 'ja', 'ko', 'ru']; +// The i18next this package ships with, configured the way `createI18n` does. +const instance = i18next.createInstance(); +void instance.init({ + resources: Object.fromEntries( + LANGS.map((lang) => [lang, { translation: builtInLocales[lang] as unknown as Record }]), + ), + lng: 'en', + fallbackLng: 'en', + initAsync: false, + interpolation: { escapeValue: false }, +}); -describe('search.itemsAvailable carries a singular half in every pack (objectui#9664)', () => { +describe('search.itemsAvailable reads a singular at one item, in every pack (objectui#9664)', () => { it('the walk covers all ten packs — not an empty assertion', () => { expect(LANGS).toHaveLength(10); - expect(Object.keys(PLURAL).sort()).toEqual([...LANGS].sort()); - expect(Object.keys(ONE).sort()).toEqual([...LANGS].sort()); + for (const lang of LANGS) expect(slotsOf(lang).length, lang).toBeGreaterThan(0); }); - it.each(LANGS)('%s defines both halves, with en\'s one hole in each', (lang) => { - expect(at(builtInLocales[lang], KEY), `${lang} ${KEY}`).toBe(PLURAL[lang]); - expect(at(builtInLocales[lang], KEY_ONE), `${lang} ${KEY_ONE}`).toBe(ONE[lang]); - // A half that drops `{{count}}` renders a sentence with the number missing - // and no error. Parity compares placeholder SHAPE against `en`; this is the - // absolute form, so a pack-wide rewrite cannot satisfy both halves by - // agreeing with a broken `en`. - for (const value of [PLURAL[lang], ONE[lang]]) { - expect(value.match(/\{\{count\}\}/g), `${lang} holes in "${value}"`).toHaveLength(1); - } + it('English at exactly one item reads "1 item available" — the shipped defect, through i18next', () => { + const t = instance.getFixedT('en'); + expect(t(KEY, { count: 1 })).toBe('1 item available'); + expect(t(KEY, { count: 1 })).not.toBe('1 items available'); + expect(t(KEY, { count: 0 })).toBe('0 items available'); + expect(t(KEY, { count: 2 })).toBe('2 items available'); }); - it('the singular half is a DIFFERENT sentence wherever the language has one', () => { - const distinguishing = LANGS.filter((l) => !NO_SINGULAR_FORM.includes(l)); - // The five packs the card measured as plainly wrong at one item, plus `ar`, - // which dodged the question with a parenthesised marker instead. - expect(distinguishing.sort()).toEqual(['ar', 'de', 'en', 'es', 'fr', 'pt']); - for (const lang of distinguishing) { - expect(ONE[lang], `${lang} singular is still the plural sentence`).not.toBe(PLURAL[lang]); - } - for (const lang of NO_SINGULAR_FORM) { - expect(ONE[lang], `${lang} halves drifted apart`).toBe(PLURAL[lang]); + it('the code-selected sibling is gone from every pack (objectui#11445)', () => { + for (const lang of LANGS) { + expect(at(builtInLocales[lang], 'search.itemsAvailableOne'), `${lang} itemsAvailableOne`).toBeUndefined(); } }); - it('English at exactly one item reads "1 item available"', () => { - // The shipped defect, stated as the string a user saw. `en` is `fallbackLng` - // and the default language, so this one is the whole reason the card exists. - const render = (value: string, count: number) => value.replace('{{count}}', String(count)); - expect(render(ONE.en, 1)).toBe('1 item available'); - expect(render(ONE.en, 1)).not.toBe('1 items available'); - // …and the plural half still answers every other count, zero included. - expect(render(PLURAL.en, 0)).toBe('0 items available'); - expect(render(PLURAL.en, 2)).toBe('2 items available'); + it('the `_one` slot differs from the slot two items read, wherever the language has a singular', () => { + // `ru` phrases the label WITHOUT a noun («{{count}} доступно»), so it has + // no word to agree with the number and every slot is one string; zh/ja/ko + // select only `other`. The five Latin packs and `ar` have a singular — and + // in `ar` the slot two items read is the dual, `_two`, not `_other` (which + // serves 100+ with the singular noun, as Arabic does). + const singular: LocaleCode[] = ['en', 'de', 'fr', 'es', 'pt', 'ar']; + for (const lang of singular) { + const atTwo = `${KEY}_${new Intl.PluralRules(lang).select(2)}`; + expect(at(builtInLocales[lang], `${KEY}_one`), `${lang} _one vs ${atTwo}`).not.toBe( + at(builtInLocales[lang], atTwo), + ); + } + for (const slot of slotsOf('ru')) { + expect(at(builtInLocales.ru, slot), `ru ${slot}`).toBe('{{count}} доступно'); + } + expect(instance.getFixedT('de')(KEY, { count: 1 })).toBe('1 Element verfügbar'); + expect(instance.getFixedT('ar')(KEY, { count: 2 })).toBe('عنصران متاحان (2)'); }); - it('neither half carries a parenthesised plural marker, in any pack', () => { - // `ar` used to write BOTH numbers into one string as `عنصر(عناصر) متاح(ة)`. - // That device is the one this repo has already measured and refused - // (`marketplace-preview-namespace-3546.test.tsx`, key-scoped to - // `preview.history.items`); here it is not merely unidiomatic but dead - // weight, because the key it sat on can no longer be reached at one item — - // the parentheses would render at every count it DOES serve. It was - // replaced with a singular noun at one and, since objectui#10425, a count - // label at every other count: the shape `ar.common.itemCount`/`itemCountOne` - // uses. + it('no slot carries a parenthesised plural marker, in any pack', () => { + // `ar` once wrote BOTH numbers into one string as `عنصر(عناصر) متاح(ة)`; a + // family has a slot per category, so no slot needs the device. // // The class is Unicode-aware on purpose (objectui#3866): JS `\w` is // [A-Za-z0-9_] with or without `u`, so an ASCII formulation is constant-false - // for exactly the non-Latin packs this guard is written to watch. - // - // ⚠️ The repetition bound is this file's own, and it is WIDER than the - // `{1,4}` the sibling pin on `preview.history.items` uses. Measured on the - // value this key actually carried: `متاح(ة)` is one letter inside the - // parentheses and `عنصر(عناصر)` is five, so a `{1,4}` bound scores the - // second marker of the same value as absent. Both counter-examples are - // pinned below so the bound cannot be narrowed back without going red. + // for exactly the non-Latin packs this guard is written to watch. The bound + // is `{1,6}` because `عنصر(عناصر)` holds five letters in its parentheses. const MARKER = /\([\p{L}]{1,6}\)/u; for (const lang of LANGS) { - expect(MARKER.test(PLURAL[lang]), `${lang} ${KEY} has a "(s)" marker`).toBe(false); - expect(MARKER.test(ONE[lang]), `${lang} ${KEY_ONE} has a "(s)" marker`).toBe(false); + for (const slot of [KEY, ...slotsOf(lang)]) { + expect(MARKER.test(String(at(builtInLocales[lang], slot))), `${lang} ${slot} has a "(s)" marker`).toBe(false); + } } - expect(at(builtInLocales.ar, KEY)).not.toContain('('); - // The counter-examples: both markers the retired `ar` value carried score - // present under this class, and neither does under the ASCII form. expect(MARKER.test('عنصر(عناصر)')).toBe(true); expect(MARKER.test('متاح(ة)')).toBe(true); expect(/\(\w{1,6}\)/.test('عنصر(عناصر)')).toBe(false); - expect(/\(\w{1,6}\)/.test('متاح(ة)')).toBe(false); - // …and the narrower bound really is the thing that would have missed one. - expect(/\([\p{L}]{1,4}\)/u.test('عنصر(عناصر)')).toBe(false); - expect(/\([\p{L}]{1,4}\)/u.test('متاح(ة)')).toBe(true); - }); - - it('⛔ is NOT an i18next plural family — no pack may grow a suffixed half', () => { - // The call site passes `count`, so i18next WOULD select a suffixed half of - // this key — and with only some categories spelled out, the rest fall to the - // base key, the PLURAL: `ru` 2-4, `ar` 2, 3-10 and 11-99. A conversion to a - // family has to carry every category (objectui#11432) and retire the - // code-side `One` selection with it, so a lone suffixed half fails here. - for (const lang of LANGS) { - for (const suffix of ['_zero', '_one', '_two', '_few', '_many', '_other']) { - expect(at(builtInLocales[lang], `${KEY}${suffix}`), `${lang} ${KEY}${suffix}`).toBeUndefined(); - } - } - // The categories that make the paragraph above true rather than asserted. - expect(new Intl.PluralRules('ru').select(3)).toBe('few'); - expect(new Intl.PluralRules('ar').select(11)).toBe('many'); - }); - - it('the pattern it mirrors is still on the adjacent branch, untouched', () => { - // The premise of the whole repair: the query branch of the same ternary - // already switches keys at one, and this card changed nothing about it. - for (const lang of LANGS) { - expect(at(builtInLocales[lang], 'search.resultsCount'), `${lang} resultsCount`).toEqual( - expect.any(String), - ); - expect( - at(builtInLocales[lang], 'search.resultsCountPlural'), - `${lang} resultsCountPlural`, - ).toEqual(expect.any(String)); - } - expect(at(builtInLocales.en, 'search.resultsCount')).toBe('{{count}} result for "{{query}}"'); - expect(at(builtInLocales.en, 'search.resultsCountPlural')).toBe('{{count}} results for "{{query}}"'); }); }); diff --git a/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx b/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx index a63de3f84d..df5b729efb 100644 --- a/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx +++ b/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx @@ -51,6 +51,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import React from 'react'; import { I18nProvider, useObjectTranslation } from '../provider'; +import { createSafeTranslation } from '../useSafeTranslation'; import { builtInLocales } from '../locales/index'; /** @@ -302,38 +303,39 @@ describe('objectui#3863 — detail.showEmptyRelated carries a base key in all te }); describe('the provider-less path — the same key through the defaults table', () => { - it("fallbackT resolves the base key literally, which is why the table needed one", () => { - // `createSafeTranslation`'s `fallbackT` indexes the table with the key AS GIVEN - // and never appends a plural suffix, so the two suffixed rows are unreachable - // through it: before this fix the provider-less path answered with the RAW KEY. - // Pinned against the source so a refactor that teaches `fallbackT` plural - // resolution has to come past this comment. + it('fallbackT reads the family rows i18next reads, and the base row without a count', () => { + // objectui#3863 pinned the opposite: `createSafeTranslation`'s `fallbackT` + // indexed the table with the key AS GIVEN, so the suffixed rows were kept only + // for key-set parity and the base row was the one a provider-less host could + // reach — without it, the raw key rendered. This case said a refactor teaching + // `fallbackT` plural resolution "has to come past this comment"; objectui#11445 + // is that refactor. `fallbackT` now walks i18next's order — `key_` + // for a NUMERIC count, then the base row — because that card converted the + // code-selected `xxxCountOne` pairs into families, and without it a + // provider-less host would have lost the "1 reply" those pairs rendered. // - // objectui#3865 moved the line this used to quote verbatim (`let value = - // defaults[key] || key;`): the chain gained the call site's inline - // `defaultValue` between the table and the key, so a key the table lacks now - // renders the call site's English instead of the raw key. That is a change to - // WHAT ANSWERS ON A MISS, not to how the table is indexed — the premise this - // case rests on is untouched, and the base row is still the only way a plural - // family is reachable here. Re-pinned in two halves so the next move of that - // line cannot quietly take the invariant with it: the literal index below, and - // the absence of any suffix machinery. + // The table still carries all three rows, and still NEEDS the base one: it is + // what a call made without a numeric count reads, on both paths. const defaults = sourceOf(DEFAULTS); expect(defaults).toContain("'detail.showEmptyRelated': '+ {{count}} empty',"); expect(defaults).toContain("'detail.showEmptyRelated_one': '+ {{count}} empty',"); - const helper = sourceOf('packages/i18n/src/useSafeTranslation.ts'); - // The table is read at the key itself — no suffix is ever built. - expect(helper).toContain('defaults[key] ||'); - // …and the fallback carries no plural machinery of any kind. `count` may still - // be interpolated into a `{{count}}` hole; what must not appear is a SUFFIX - // being appended to the lookup key. - const fallbackBody = helper.slice( - helper.indexOf('const fallbackT ='), - helper.indexOf('return function useSafeTranslation()'), + expect(defaults).toContain("'detail.showEmptyRelated_other': '+ {{count}} empty',"); + // The behaviour, through the real factory, on rows that differ so the + // assertions can tell the three apart. + const useT = createSafeTranslation( + { + 'objectui3863.anchor': 'Anchor', + [KEY]: 'BASE {{count}}', + [`${KEY}_one`]: 'ONE {{count}}', + [`${KEY}_other`]: 'OTHER {{count}}', + }, + 'objectui3863.anchor', ); - expect(fallbackBody).not.toMatch(/_one|_other|_few|_many|_zero|_two/); - expect(fallbackBody).not.toMatch(/PluralRules|select\(/); - expect(fallbackBody).not.toMatch(/defaults\[[^\]]*\+/); + const { result } = renderHook(() => useT()); + expect(result.current.t(KEY, { count: 1 })).toBe('ONE 1'); + expect(result.current.t(KEY, { count: 3 })).toBe('OTHER 3'); + expect(result.current.t(KEY, { count: '3' })).toBe('BASE 3'); + expect(result.current.t(KEY)).toBe('BASE {{count}}'); }); }); diff --git a/packages/i18n/src/__tests__/useSafeTranslation.test.tsx b/packages/i18n/src/__tests__/useSafeTranslation.test.tsx index 6b68124337..f71eaa39ef 100644 --- a/packages/i18n/src/__tests__/useSafeTranslation.test.tsx +++ b/packages/i18n/src/__tests__/useSafeTranslation.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; import React from 'react'; +import i18next from 'i18next'; import { createSafeTranslation, useSafeTranslate } from '../useSafeTranslation'; // objectui#3865's control block mounts a real provider to show the i18next path // is untouched. `children` goes IN the props object (see provider.test.tsx's @@ -249,6 +250,58 @@ describe('createSafeTranslation with an I18nProvider (objectui#3865 control)', ( }); }); +// objectui#11445 — a count family's `_one` / `_other` rows answer on the +// provider-less path, in i18next's own order. Before that card `fallbackT` +// read `defaults[key]` literally, so a family's suffixed rows were dead and a +// provider-less host could render "1 reply" only through a code-selected +// `xxxCountOne` pair; the card converted those pairs into families. Every +// expectation below is compared against a real i18next instance holding the +// same rows as an `en` pack, so the two paths cannot drift apart unseen. +describe('createSafeTranslation reads a count family the way i18next does (objectui#11445)', () => { + const FAMILY = { + 'detail.test': 'Test Anchor', + 'detail.replyCount': '{{count}} reply', + 'detail.replyCount_one': '{{count}} reply', + 'detail.replyCount_other': '{{count}} replies', + // A family with no rows in the table: the base answers, as i18next's would. + 'detail.flat': '{{count}} things', + }; + const useT = createSafeTranslation(FAMILY, 'detail.test'); + const reference = i18next.createInstance(); + void reference.init({ + resources: { en: { translation: FAMILY } }, + lng: 'en', + keySeparator: false, + initAsync: false, + interpolation: { escapeValue: false }, + }); + + it.each([0, 1, 2, 21, 1.5])('count %s selects the slot i18next selects', (count) => { + const { result } = renderHook(() => useT()); + const want = reference.t('detail.replyCount', { count }); + expect(result.current.t('detail.replyCount', { count })).toBe(want); + }); + + it('one and other read different rows — the matrix above is not vacuous', () => { + const { result } = renderHook(() => useT()); + expect(result.current.t('detail.replyCount', { count: 1 })).toBe('1 reply'); + expect(result.current.t('detail.replyCount', { count: 3 })).toBe('3 replies'); + }); + + it('a string count reads the base row on both paths (i18next selects only for a number)', () => { + const { result } = renderHook(() => useT()); + expect(reference.t('detail.replyCount', { count: '3' })).toBe('3 reply'); + expect(result.current.t('detail.replyCount', { count: '3' })).toBe('3 reply'); + }); + + it('no count, or a key without suffixed rows, reads the base row', () => { + const { result } = renderHook(() => useT()); + expect(result.current.t('detail.replyCount')).toBe('{{count}} reply'); + expect(result.current.t('detail.flat', { count: 1 })).toBe(reference.t('detail.flat', { count: 1 })); + expect(result.current.t('detail.flat', { count: 1 })).toBe('1 things'); + }); +}); + describe('useSafeTranslate (no I18nProvider)', () => { it('returns the call-site fallback and supports key-chain form', () => { const { result } = renderHook(() => useSafeTranslate()); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index ccf556ecf2..2648bd9180 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -90,11 +90,16 @@ const ar = { remove: 'إزالة {{label}}', selectFirst: 'اختر {{fields}} أولاً', selectRecord: 'اختيار سجل', - // objectui#10425 — the picker switches keys only at exactly 1, so this half - // serves two, few (3-10), many (11-99) and other (100+). A count label - // («عدد …: {{count}}») reads correctly at every one of them. + // objectui#11445 — a count family: each of the six CLDR slots carries its + // own noun form. The base key, a count-invariant form, answers a call made + // without a count. recordCount: 'عدد السجلات: {{count}}', - recordCountOne: 'سجل واحد', + recordCount_zero: '{{count}} سجل', + recordCount_one: '{{count}} سجل', + recordCount_two: 'سجلان ({{count}})', + recordCount_few: '{{count}} سجلات', + recordCount_many: '{{count}} سجلًا', + recordCount_other: '{{count}} سجل', pageOf: 'صفحة {{current}} من {{total}}', filters: 'عوامل التصفية', clear: 'مسح', @@ -146,12 +151,16 @@ const ar = { closeChat: "إغلاق المحادثة", closePanel: "إغلاق اللوحة", resizeDrawer: "تغيير عرض اللوحة", - // objectui#10242 — the tab-count badge switches keys only at exactly 1, so - // this half serves two (2), few (3-10), many (11-99) and other (100+), - // which need different noun forms. A count label («عدد …: {{count}}», as - // `search.resultsCountPlural` does) reads correctly at every one of them. + // objectui#11445 — a count family: each of the six CLDR slots carries its + // own noun form. The base key, a count-invariant form, answers a call made + // without a count. itemCount: "عدد العناصر: {{count}}", - itemCountOne: "{{count}} عنصر", + itemCount_zero: "{{count}} عنصر", + itemCount_one: "{{count}} عنصر", + itemCount_two: "عنصران ({{count}})", + itemCount_few: "{{count}} عناصر", + itemCount_many: "{{count}} عنصرًا", + itemCount_other: "{{count}} عنصر", toggleSidebar: "تبديل الشريط الجانبي", package: "الحزمة", done: "تم", @@ -291,6 +300,12 @@ const ar = { }, relativeDate: { overdue: "متأخر {{count}} يوم", + overdue_zero: "متأخر {{count}} يوم", + overdue_one: "متأخر {{count}} يوم", + overdue_two: "متأخر يومين ({{count}})", + overdue_few: "متأخر {{count}} أيام", + overdue_many: "متأخر {{count}} يومًا", + overdue_other: "متأخر {{count}} يوم", }, file: { dragDropHere: 'اسحب الملفات وأفلتها هنا', @@ -427,23 +442,59 @@ const ar = { open: "فتح", search: "بحث…", modified: "{{count}} صف(صفوف) معدّل(ة)", + modified_zero: "{{count}} صف معدّل", + modified_one: "{{count}} صف معدّل", + modified_two: "صفان معدّلان ({{count}})", + modified_few: "{{count}} صفوف معدّلة", + modified_many: "{{count}} صفًا معدّلًا", + modified_other: "{{count}} صف معدّل", saveFailed: 'فشل الحفظ', selected: "{{count}} محدد(ة)", + selected_zero: "تم تحديد {{count}}", + selected_one: "تم تحديد {{count}}", + selected_two: "تم تحديد {{count}}", + selected_few: "تم تحديد {{count}}", + selected_many: "تم تحديد {{count}}", + selected_other: "تم تحديد {{count}}", edit: "تعديل", delete: "حذف", }, grid: { bulkSelected: "تم تحديد {{count}}", + bulkSelected_zero: "تم تحديد {{count}}", + bulkSelected_one: "تم تحديد {{count}}", + bulkSelected_two: "تم تحديد {{count}}", + bulkSelected_few: "تم تحديد {{count}}", + bulkSelected_many: "تم تحديد {{count}}", + bulkSelected_other: "تم تحديد {{count}}", bulkSelectedAllMatches: "تم تحديد {{count}} (كل النتائج المطابقة)", bulkClear: "إلغاء التحديد", bulkAllOnPage: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", + bulkAllOnPage_zero: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", + bulkAllOnPage_one: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", + bulkAllOnPage_two: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", + bulkAllOnPage_few: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", + bulkAllOnPage_many: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", + bulkAllOnPage_other: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", bulkSelectAllMatching: "تحديد كل النتائج المطابقة ({{count}})", bulkAllMatchingSelected: "تم تحديد جميع السجلات المطابقة ({{count}}).", bulk: { confirmDefault: "سيُطبَّق هذا على {{count}} من السجلات.", + confirmDefault_zero: "سيُطبَّق هذا على {{count}} من السجلات.", + confirmDefault_one: "سيُطبَّق هذا على {{count}} من السجلات.", + confirmDefault_two: "سيُطبَّق هذا على {{count}} من السجلات.", + confirmDefault_few: "سيُطبَّق هذا على {{count}} من السجلات.", + confirmDefault_many: "سيُطبَّق هذا على {{count}} من السجلات.", + confirmDefault_other: "سيُطبَّق هذا على {{count}} من السجلات.", overLimit: "التحديد ({{count}}) يتجاوز حد الإجراء ({{limit}}). قلّل التحديد للمتابعة.", affectedRecords: "السجلات المتأثرة ({{count}}):", skippedIneligible: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', + skippedIneligible_zero: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', + skippedIneligible_one: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', + skippedIneligible_two: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', + skippedIneligible_few: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', + skippedIneligible_many: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', + skippedIneligible_other: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.', rowFallback: "الصف {{index}}", andMore: "… و{{count}} أخرى", processed: "تمت معالجة {{count}} / {{total}}", @@ -519,6 +570,12 @@ const ar = { templateFileName: "{{object}}-import-template", autoMatched: "تمت المطابقة تلقائيًا", autoMatchedSummary: "تمت مطابقة {{count}} عمودًا تلقائيًا — راجعها وعدّلها أدناه.", + autoMatchedSummary_zero: "تمت مطابقة {{count}} عمود تلقائيًا — راجِع وعدِّل أدناه.", + autoMatchedSummary_one: "تمت مطابقة {{count}} عمود تلقائيًا — راجِع وعدِّل أدناه.", + autoMatchedSummary_two: "تمت مطابقة عمودين ({{count}}) تلقائيًا — راجِع وعدِّل أدناه.", + autoMatchedSummary_few: "تمت مطابقة {{count}} أعمدة تلقائيًا — راجِع وعدِّل أدناه.", + autoMatchedSummary_many: "تمت مطابقة {{count}} عمودًا تلقائيًا — راجِع وعدِّل أدناه.", + autoMatchedSummary_other: "تمت مطابقة {{count}} عمود تلقائيًا — راجِع وعدِّل أدناه.", confidence: { high: "ثقة عالية", medium: "ثقة متوسطة", @@ -531,8 +588,26 @@ const ar = { cancelImport: "إلغاء الاستيراد", importCancelled: "تم إلغاء الاستيراد", resultsTruncated: "عرض نتائج أول {{count}} صفًا (من أصل {{total}}).", + resultsTruncated_zero: "عرض نتائج الصفوف الأولى: {{count}} (من أصل {{total}}).", + resultsTruncated_one: "عرض نتائج الصفوف الأولى: {{count}} (من أصل {{total}}).", + resultsTruncated_two: "عرض نتائج الصفوف الأولى: {{count}} (من أصل {{total}}).", + resultsTruncated_few: "عرض نتائج الصفوف الأولى: {{count}} (من أصل {{total}}).", + resultsTruncated_many: "عرض نتائج الصفوف الأولى: {{count}} (من أصل {{total}}).", + resultsTruncated_other: "عرض نتائج الصفوف الأولى: {{count}} (من أصل {{total}}).", createdCount: "تم إنشاء {{count}}", + createdCount_zero: "تم إنشاء {{count}}", + createdCount_one: "تم إنشاء {{count}}", + createdCount_two: "تم إنشاء {{count}}", + createdCount_few: "تم إنشاء {{count}}", + createdCount_many: "تم إنشاء {{count}}", + createdCount_other: "تم إنشاء {{count}}", updatedCount: "تم تحديث {{count}}", + updatedCount_zero: "تم تحديث {{count}}", + updatedCount_one: "تم تحديث {{count}}", + updatedCount_two: "تم تحديث {{count}}", + updatedCount_few: "تم تحديث {{count}}", + updatedCount_many: "تم تحديث {{count}}", + updatedCount_other: "تم تحديث {{count}}", downloadFailed: "تنزيل الصفوف الفاشلة", options: "خيارات الاستيراد", writeMode: "عند تطابق صف مع سجل موجود", @@ -580,6 +655,12 @@ const ar = { historyColResult: "النتيجة", historyColTime: "الوقت", errorCount: "{{count}} من الأخطاء", + errorCount_zero: "{{count}} من الأخطاء", + errorCount_one: "{{count}} من الأخطاء", + errorCount_two: "{{count}} من الأخطاء", + errorCount_few: "{{count}} من الأخطاء", + errorCount_many: "{{count}} من الأخطاء", + errorCount_other: "{{count}} من الأخطاء", undoImport: "التراجع عن الاستيراد", undoing: "جارٍ التراجع…", undoConfirm: "هل تريد التراجع عن هذا الاستيراد؟ ستُحذف السجلات التي أنشأها وتُستعاد السجلات التي حدّثها إلى قيمها السابقة.", @@ -640,7 +721,19 @@ const ar = { mapped: "مُعيَّن", skipped: "مُتخطَّى", rowsWithErrors: "{{count}} صف(صفوف) بها أخطاء", + rowsWithErrors_zero: "{{count}} صف بها أخطاء", + rowsWithErrors_one: "{{count}} صف به أخطاء", + rowsWithErrors_two: "صفان بهما أخطاء ({{count}})", + rowsWithErrors_few: "{{count}} صفوف بها أخطاء", + rowsWithErrors_many: "{{count}} صفًا بها أخطاء", + rowsWithErrors_other: "{{count}} صف بها أخطاء", rowsCorrected: "{{count}} صف(صفوف) تم تصحيحها", + rowsCorrected_zero: "تم تصحيح {{count}} صف", + rowsCorrected_one: "تم تصحيح {{count}} صف", + rowsCorrected_two: "تم تصحيح صفين ({{count}})", + rowsCorrected_few: "تم تصحيح {{count}} صفوف", + rowsCorrected_many: "تم تصحيح {{count}} صفًا", + rowsCorrected_other: "تم تصحيح {{count}} صف", clickToFix: "— انقر على خلية مميزة للتعديل.", showingRows: "عرض {{shown}} من {{total}} صف", importing: "جارٍ الاستيراد… {{progress}}%", @@ -648,11 +741,23 @@ const ar = { imported: "{{count}} مُستورَد(ة)", skippedCount: "{{count}} مُتخطَّى(ة)", moreErrors: "…و{{count}} خطأ(أخطاء) أخرى", + moreErrors_zero: "…و{{count}} خطأ آخر", + moreErrors_one: "…و{{count}} خطأ آخر", + moreErrors_two: "…وخطآن آخران ({{count}})", + moreErrors_few: "…و{{count}} أخطاء أخرى", + moreErrors_many: "…و{{count}} خطأً آخر", + moreErrors_other: "…و{{count}} خطأ آخر", cancel: "إلغاء", back: "رجوع", next: "التالي", close: "إغلاق", importNRows: "استيراد {{count}} صف", + importNRows_zero: "استيراد {{count}} صف", + importNRows_one: "استيراد {{count}} صف", + importNRows_two: "استيراد صفين ({{count}})", + importNRows_few: "استيراد {{count}} صفوف", + importNRows_many: "استيراد {{count}} صفًا", + importNRows_other: "استيراد {{count}} صف", importingProgress: "جارٍ الاستيراد…", requiredMark: "*", required: "مطلوب", @@ -740,11 +845,16 @@ const ar = { }, refresh: "تحديث", loading: "جارٍ تحميل السجلات…", - // objectui#10425 — the record-count bar picks this half at every count but - // 1 (two, few, many, other), so it is a count label («عدد …: {{count}}») - // rather than one noun form that fits only 3-10. + // objectui#11445 — a count family: each of the six CLDR slots carries its + // own noun form. The base key, a count-invariant form, answers a call made + // without a count. recordCount: "عدد السجلات: {{count}}", - recordCountOne: "{{count}} سجل", + recordCount_zero: "{{count}} سجل", + recordCount_one: "{{count}} سجل", + recordCount_two: "سجلان ({{count}})", + recordCount_few: "{{count}} سجلات", + recordCount_many: "{{count}} سجلًا", + recordCount_other: "{{count}} سجل", addRecord: "إضافة سجل", tabs: "علامات التبويب", allRecords: "جميع السجلات", @@ -919,13 +1029,31 @@ const ar = { conflict: { title: "تعارض في الجدولة", body: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", + body_zero: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", + body_one: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", + body_two: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", + body_few: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", + body_many: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", + body_other: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", confirm: "إعادة الجدولة تلقائيًا", cancel: "الإبقاء كما هو", }, autoScheduleDlg: { title: "الجدولة التلقائية", body: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + body_zero: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + body_one: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + body_two: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + body_few: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + body_many: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + body_other: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", skipped: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + skipped_zero: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + skipped_one: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + skipped_two: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + skipped_few: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + skipped_many: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + skipped_other: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", confirm: "تطبيق", cancel: "إلغاء", none: "جميع التبعيات مستوفاة — لا شيء لإعادة جدولته.", @@ -1031,11 +1159,16 @@ const ar = { addReaction: "إضافة تفاعل", pageHeaderActions: "إجراءات رأس الصفحة", emojiPicker: "منتقي الرموز التعبيرية", - // objectui#10425 — the chip picks this half at every count but 1 (two, - // few, many, other); a count label reads right at all of them, the same - // device as `collaboration.reactionCount`. + // objectui#11445 — a count family: each of the six CLDR slots carries its + // own noun form. The base key, a count-invariant form, answers a call made + // without a count. reactionCount: "{{emoji}} عدد التفاعلات: {{count}}", - reactionCountOne: "{{emoji}} {{count}} تفاعل", + reactionCount_zero: "{{emoji}} {{count}} تفاعل", + reactionCount_one: "{{emoji}} {{count}} تفاعل", + reactionCount_two: "{{emoji}} تفاعلان ({{count}})", + reactionCount_few: "{{emoji}} {{count}} تفاعلات", + reactionCount_many: "{{emoji}} {{count}} تفاعلًا", + reactionCount_other: "{{emoji}} {{count}} تفاعل", recordDetail: "تفاصيل السجل", recordDetailWithLabel: "تفاصيل {{label}}", openAsFullPage: "فتح كصفحة كاملة", @@ -1072,11 +1205,34 @@ const ar = { unpin: "إلغاء التثبيت", justNow: "الآن", minutesAgo: "منذ {{count}} دقيقة", + minutesAgo_zero: "منذ {{count}} دقيقة", + minutesAgo_one: "منذ {{count}} دقيقة", + minutesAgo_two: "منذ دقيقتين ({{count}})", + minutesAgo_few: "منذ {{count}} دقائق", + minutesAgo_many: "منذ {{count}} دقيقة", + minutesAgo_other: "منذ {{count}} دقيقة", hoursAgo: "منذ {{count}} ساعة", + hoursAgo_zero: "منذ {{count}} ساعة", + hoursAgo_one: "منذ {{count}} ساعة", + hoursAgo_two: "منذ ساعتين ({{count}})", + hoursAgo_few: "منذ {{count}} ساعات", + hoursAgo_many: "منذ {{count}} ساعة", + hoursAgo_other: "منذ {{count}} ساعة", daysAgo: "منذ {{count}} يوم", + daysAgo_zero: "منذ {{count}} يوم", + daysAgo_one: "منذ {{count}} يوم", + daysAgo_two: "منذ يومين ({{count}})", + daysAgo_few: "منذ {{count}} أيام", + daysAgo_many: "منذ {{count}} يومًا", + daysAgo_other: "منذ {{count}} يوم", dropFilesToUpload: "اسحب الملفات هنا أو انقر للرفع", attachmentCount: "{{count}} مرفق", - attachmentCountPlural: "{{count}} مرفقات", + attachmentCount_zero: "{{count}} مرفق", + attachmentCount_one: "{{count}} مرفق", + attachmentCount_two: "مرفقان ({{count}})", + attachmentCount_few: "{{count}} مرفقات", + attachmentCount_many: "{{count}} مرفقًا", + attachmentCount_other: "{{count}} مرفق", removeAttachment: "إزالة المرفق", // Record Attachments panel (enable.files, objectstack#4358) attachments: "المرفقات", @@ -1135,7 +1291,12 @@ const ar = { via: "عبر {{source}}", viewSource: "عرض المصدر", replyCount: "{{count}} رد", - replyCountPlural: "{{count}} ردود", + replyCount_zero: "{{count}} رد", + replyCount_one: "{{count}} رد", + replyCount_two: "ردّان ({{count}})", + replyCount_few: "{{count}} ردود", + replyCount_many: "{{count}} ردًّا", + replyCount_other: "{{count}} رد", replyPlaceholder: "رد…", filterActivity: "تصفية النشاط", openDiscussion: "فتح لوحة المناقشة", @@ -1222,6 +1383,12 @@ const ar = { copyPhone: "نسخ الهاتف", copyRecordId: "نسخ معرف السجل", showEmptyFields: "إظهار {{count}} حقل(حقول) فارغة", + showEmptyFields_zero: "إظهار {{count}} حقل فارغ", + showEmptyFields_one: "إظهار {{count}} حقل فارغ", + showEmptyFields_two: "إظهار حقلين فارغين ({{count}})", + showEmptyFields_few: "إظهار {{count}} حقول فارغة", + showEmptyFields_many: "إظهار {{count}} حقلًا فارغًا", + showEmptyFields_other: "إظهار {{count}} حقل فارغ", hideEmptyFields: "إخفاء الحقول الفارغة", noValue: "لا قيمة", unresolvedReference: "مرجع غير مُحلّل: لم يتم تحويل {{value}} إلى مستخدم", @@ -1599,7 +1766,19 @@ const ar = { backToHome: "العودة إلى الرئيسية", }, saveAdvisoryTitle: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + saveAdvisoryTitle_zero: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + saveAdvisoryTitle_one: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + saveAdvisoryTitle_two: "تم الحفظ — أنتج فحص التأليف ملاحظتين إرشاديتين ({{count}})", + saveAdvisoryTitle_few: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظات إرشادية", + saveAdvisoryTitle_many: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + saveAdvisoryTitle_other: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", publishAdvisoryTitle: "تم النشر — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + publishAdvisoryTitle_zero: "تم النشر — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + publishAdvisoryTitle_one: "تم النشر — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + publishAdvisoryTitle_two: "تم النشر — أنتج فحص التأليف ملاحظتين إرشاديتين ({{count}})", + publishAdvisoryTitle_few: "تم النشر — أنتج فحص التأليف {{count}} ملاحظات إرشادية", + publishAdvisoryTitle_many: "تم النشر — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + publishAdvisoryTitle_other: "تم النشر — أنتج فحص التأليف {{count}} ملاحظة إرشادية", importMappingsUnavailable: "تعذّر تحميل تعيينات الاستيراد المحفوظة لـ {{object}}", importMappingsRefused: "رفض الخادم هذا الطلب، لذلك فإن هذه القائمة فارغة لأنه تعذّرت قراءتها، لا لأنه لا يوجد شيء مسجَّل. سجّل الدخول مرة أخرى أو اطلب صلاحية الوصول من المسؤول.", importMappingsUnreadable: "هذه القائمة فارغة لأنه تعذّرت قراءتها، لا لأنه لا يوجد شيء مسجَّل. أعد المحاولة، وأبلغ عن المشكلة إذا استمرت.", @@ -2087,11 +2266,16 @@ const ar = { searchFields: "البحث في الحقول…", title: "العنوان", viewType: "نوع العرض", - // objectui#10636 — the footer picks this half at every count but 1, which spans - // zero, two (2), few (3-10), many (11-99) and other (100+); a count label reads - // right at all of them, the same form as `list.recordCount`. + // objectui#11445 — a count family: each of the six CLDR slots carries its + // own noun form. The base key, a count-invariant form, answers a call made + // without a count. recordCount: "عدد السجلات: {{count}}", - recordCountOne: "{{count}} سجل", + recordCount_zero: "{{count}} سجل", + recordCount_one: "{{count}} سجل", + recordCount_two: "سجلان ({{count}})", + recordCount_few: "{{count}} سجلات", + recordCount_many: "{{count}} سجلًا", + recordCount_other: "{{count}} سجل", save: "حفظ", discard: "تجاهل", createView: "إنشاء عرض", @@ -2107,6 +2291,12 @@ const ar = { import: "استيراد", importTitle: "استيراد من CSV", importedToast: "تم استيراد {{count}} صف(صفوف).", + importedToast_zero: "تم استيراد {{count}} صف.", + importedToast_one: "تم استيراد {{count}} صف.", + importedToast_two: "تم استيراد صفين ({{count}}).", + importedToast_few: "تم استيراد {{count}} صفوف.", + importedToast_many: "تم استيراد {{count}} صفًا.", + importedToast_other: "تم استيراد {{count}} صف.", importedWithSkipped: "تم استيراد {{ok}} صف(صفوف)؛ تم تخطي {{skipped}}.", createViewDesc: "اختر نوع العرض وأعطه اسماً. يمكن تغيير جميع الإعدادات لاحقاً.", cancel: "إلغاء", @@ -2115,6 +2305,12 @@ const ar = { deleteViewTitle: "حذف العرض", deleteViewConfirm: "حذف العرض \"{{name}}\"؟ لا يمكن التراجع عن ذلك.", bulkDeleteConfirm: "حذف {{count}} سجل(ات) محدد(ة)؟ لا يمكن التراجع عن ذلك.", + bulkDeleteConfirm_zero: "حذف {{count}} سجل محدد؟ لا يمكن التراجع عن ذلك.", + bulkDeleteConfirm_one: "حذف {{count}} سجل محدد؟ لا يمكن التراجع عن ذلك.", + bulkDeleteConfirm_two: "حذف سجلين محددين ({{count}})؟ لا يمكن التراجع عن ذلك.", + bulkDeleteConfirm_few: "حذف {{count}} سجلات محددة؟ لا يمكن التراجع عن ذلك.", + bulkDeleteConfirm_many: "حذف {{count}} سجلًا محددًا؟ لا يمكن التراجع عن ذلك.", + bulkDeleteConfirm_other: "حذف {{count}} سجل محدد؟ لا يمكن التراجع عن ذلك.", duplicateViewName: "يوجد عرض بهذا الاسم بالفعل.", viewTypeGrid: "شبكة", viewTypeGridDesc: "جدول سجلات بأسلوب جدول البيانات.", @@ -2572,6 +2768,12 @@ const ar = { }, pendingDrafts: { message: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + message_zero: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + message_one: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + message_two: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + message_few: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + message_many: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", + message_other: "لديك {{count}} من التغييرات غير المنشورة — انشرها لتصبح فعّالة.", cta: "نشر", publishing: "جارٍ النشر…", published: "تم النشر! تغييراتك فعّالة الآن.", @@ -2695,16 +2897,22 @@ const ar = { placeholder: "ابحث في الكائنات، لوحات التحكم، الصفحات، التقارير…", inputAriaLabel: "ابحث في الكائنات، لوحات التحكم، الصفحات، التقارير", resultsCount: "{{count}} نتيجة لـ \"{{query}}\"", - // objectui#10024 — the page switches keys only at exactly 1, so this half - // serves zero, two, few (3-10), many (11-99) and other (100+), which need - // four different noun forms. It used to repeat the singular above byte for - // byte; a count label («عدد …: {{count}}», as `calendar.a11y.dayCell` does) - // reads correctly at every one of them. - resultsCountPlural: "عدد نتائج البحث عن \"{{query}}\": {{count}}", - // objectui#10425 — the browse branch of the same line switches at exactly - // 1 too, so this half needs the same count label. + resultsCount_zero: "{{count}} نتيجة لـ \"{{query}}\"", + resultsCount_one: "{{count}} نتيجة لـ \"{{query}}\"", + resultsCount_two: "نتيجتان لـ \"{{query}}\" ({{count}})", + resultsCount_few: "{{count}} نتائج لـ \"{{query}}\"", + resultsCount_many: "{{count}} نتيجة لـ \"{{query}}\"", + resultsCount_other: "{{count}} نتيجة لـ \"{{query}}\"", + // objectui#11445 — a count family: each of the six CLDR slots carries its + // own noun form. The base key, a count-invariant form, answers a call made + // without a count. itemsAvailable: "عدد العناصر المتاحة: {{count}}", - itemsAvailableOne: "{{count}} عنصر متاح", + itemsAvailable_zero: "{{count}} عنصر متاح", + itemsAvailable_one: "{{count}} عنصر متاح", + itemsAvailable_two: "عنصران متاحان ({{count}})", + itemsAvailable_few: "{{count}} عناصر متاحة", + itemsAvailable_many: "{{count}} عنصرًا متاحًا", + itemsAvailable_other: "{{count}} عنصر متاح", noResults: "لم يتم العثور على نتائج", noResultsHint: "جرب تعديل مصطلحات البحث", typeObjects: "الكائنات", @@ -2781,6 +2989,12 @@ const ar = { noRecordId: "لم يتم توفير معرف سجل", deleteConfirm: "حذف هذا السجل؟", bulkDeleteSuccess: "تم حذف {{count}} سجل(ات) {{label}}", + bulkDeleteSuccess_zero: "تم حذف {{count}} من سجلات {{label}}", + bulkDeleteSuccess_one: "تم حذف {{count}} من سجلات {{label}}", + bulkDeleteSuccess_two: "تم حذف {{count}} من سجلات {{label}}", + bulkDeleteSuccess_few: "تم حذف {{count}} من سجلات {{label}}", + bulkDeleteSuccess_many: "تم حذف {{count}} من سجلات {{label}}", + bulkDeleteSuccess_other: "تم حذف {{count}} من سجلات {{label}}", bulkDeletePartial: "تم حذف {{succeeded}}، فشل {{failed}}", }, objectViewActions: { @@ -2981,12 +3195,24 @@ const ar = { regionLabel: "الإشعارات", groupCount: "{{count}} من الإشعارات", groupUnread: "{{count}} غير مقروء", + groupUnread_zero: "غير المقروءة: {{count}}", + groupUnread_one: "غير المقروءة: {{count}}", + groupUnread_two: "غير المقروءة: {{count}}", + groupUnread_few: "غير المقروءة: {{count}}", + groupUnread_many: "غير المقروءة: {{count}}", + groupUnread_other: "غير المقروءة: {{count}}", groupMarkRead: "تعليم كمقروء", groupMarkReadTitle: "تعليم كل هذا النوع كمقروء", empty: "لا توجد إشعارات", markAllRead: "تعليم الكل كمقروء", viewAll: "عرض جميع الإشعارات", approvalsPending: "{{count}} موافقات معلقة", + approvalsPending_zero: "{{count}} موافقة معلقة", + approvalsPending_one: "{{count}} موافقة معلقة", + approvalsPending_two: "موافقتان معلقتان ({{count}})", + approvalsPending_few: "{{count}} موافقات معلقة", + approvalsPending_many: "{{count}} موافقة معلقة", + approvalsPending_other: "{{count}} موافقة معلقة", viewApprovals: "عرض الموافقات", noPendingApprovals: "لا توجد موافقات معلقة", openApprovalsInbox: "فتح صندوق الموافقات", @@ -2994,7 +3220,19 @@ const ar = { badgeNotifications: "{{unread}} إشعارات", badgeApprovals: "{{approvals}} موافقات معلقة", arrivalMany: "{{count}} رسائل جديدة", + arrivalMany_zero: "{{count}} رسالة جديدة", + arrivalMany_one: "{{count}} رسالة جديدة", + arrivalMany_two: "رسالتان جديدتان ({{count}})", + arrivalMany_few: "{{count}} رسائل جديدة", + arrivalMany_many: "{{count}} رسالة جديدة", + arrivalMany_other: "{{count}} رسالة جديدة", arrivalRepeats: "{{count}} رسائل جديدة في هذا الموضوع", + arrivalRepeats_zero: "{{count}} رسالة جديدة في هذا الموضوع", + arrivalRepeats_one: "{{count}} رسالة جديدة في هذا الموضوع", + arrivalRepeats_two: "رسالتان جديدتان ({{count}}) في هذا الموضوع", + arrivalRepeats_few: "{{count}} رسائل جديدة في هذا الموضوع", + arrivalRepeats_many: "{{count}} رسالة جديدة في هذا الموضوع", + arrivalRepeats_other: "{{count}} رسالة جديدة في هذا الموضوع", arrivalOpen: "عرض", toastEnabled: "تنبيهات داخل التطبيق", desktopEnabled: "إشعارات سطح المكتب", @@ -3071,6 +3309,12 @@ const ar = { noImportableFields: "لا تحتوي هذه الكائن على حقول قابلة للاستيراد.", schemaReadFailed: "تعذّرت قراءة مخطط الكائن.", imported: "تم استيراد {{count}} صف إلى {{object}}.", + imported_zero: "تم استيراد {{count}} صف إلى {{object}}.", + imported_one: "تم استيراد {{count}} صف إلى {{object}}.", + imported_two: "تم استيراد صفين ({{count}}) إلى {{object}}.", + imported_few: "تم استيراد {{count}} صفوف إلى {{object}}.", + imported_many: "تم استيراد {{count}} صفًا إلى {{object}}.", + imported_other: "تم استيراد {{count}} صف إلى {{object}}.", importFrom: "استيراد الصفوف الفعلية من", into: "إلى", opening: "جارٍ الفتح…", @@ -3100,11 +3344,21 @@ const ar = { formAssist: { title: "اقتراحات الذكاء الاصطناعي", suggestionCount: "عدد الاقتراحات: {{count}}", - suggestionCountOne: "عدد الاقتراحات: {{count}}", + suggestionCount_zero: "{{count}} اقتراح", + suggestionCount_one: "{{count}} اقتراح", + suggestionCount_two: "اقتراحان ({{count}})", + suggestionCount_few: "{{count}} اقتراحات", + suggestionCount_many: "{{count}} اقتراحًا", + suggestionCount_other: "{{count}} اقتراح", applyAll: "تطبيق الكل", confidence: "الثقة: {{percent}}", appliedCount: "عدد الاقتراحات المطبّقة: {{count}}", - appliedCountOne: "عدد الاقتراحات المطبّقة: {{count}}", + appliedCount_zero: "تم تطبيق {{count}} اقتراح", + appliedCount_one: "تم تطبيق {{count}} اقتراح", + appliedCount_two: "تم تطبيق اقتراحين ({{count}})", + appliedCount_few: "تم تطبيق {{count}} اقتراحات", + appliedCount_many: "تم تطبيق {{count}} اقتراحًا", + appliedCount_other: "تم تطبيق {{count}} اقتراح", }, recommendations: { title: "التوصيات", @@ -3414,10 +3668,22 @@ const ar = { purgeSampleData: 'حذف البيانات التجريبية', purgeConfirm: 'حذف جميع السجلات التجريبية التي أنشأتها هذه الحزمة؟ لن تتأثر السجلات التي أضافها المستخدم.', purgeSuccess: 'تم حذف {{count}} سجل تجريبي.', + purgeSuccess_zero: 'تم حذف {{count}} سجل تجريبي.', + purgeSuccess_one: 'تم حذف {{count}} سجل تجريبي.', + purgeSuccess_two: 'تم حذف سجلين تجريبيين ({{count}}).', + purgeSuccess_few: 'تم حذف {{count}} سجلات تجريبية.', + purgeSuccess_many: 'تم حذف {{count}} سجلًا تجريبيًا.', + purgeSuccess_other: 'تم حذف {{count}} سجل تجريبي.', purgeNoData: 'لا توجد سجلات تجريبية للحذف.', reseedQueued: 'ستتم إعادة تحميل البيانات التجريبية عند الوصول التالي إلى البيئة.', reseedLocalSuccess: 'تمت إعادة تحميل البيانات التجريبية: {{inserted}} مضافة، {{updated}} محدثة.', reseedPartialErrors: '(فشل في كتابة {{count}} سجل)', + reseedPartialErrors_zero: '(فشل في كتابة {{count}} سجل)', + reseedPartialErrors_one: '(فشل في كتابة {{count}} سجل)', + reseedPartialErrors_two: '(فشل في كتابة سجلين: {{count}})', + reseedPartialErrors_few: '(فشل في كتابة {{count}} سجلات)', + reseedPartialErrors_many: '(فشل في كتابة {{count}} سجلًا)', + reseedPartialErrors_other: '(فشل في كتابة {{count}} سجل)', sampleDataKernelUnavailable: 'لا يحتوي مستوى التحكم هذا على نواة بيئة، لذا لا يمكن إعادة تحميل البيانات التجريبية أو حذفها من هنا. نفّذ ذلك من وقت التشغيل الخاص بالبيئة نفسها.', updateAvailable: 'يتوفر تحديث', }, @@ -3515,7 +3781,19 @@ const ar = { today: "اليوم", daysAgo: "منذ {{count}} ي", monthsAgo: "منذ {{count}} شهر(أشهر)", + monthsAgo_zero: "منذ {{count}} شهر", + monthsAgo_one: "منذ {{count}} شهر", + monthsAgo_two: "منذ شهرين ({{count}})", + monthsAgo_few: "منذ {{count}} أشهر", + monthsAgo_many: "منذ {{count}} شهرًا", + monthsAgo_other: "منذ {{count}} شهر", yearsAgo: "منذ {{count}} سنة(سنوات)", + yearsAgo_zero: "منذ {{count}} سنة", + yearsAgo_one: "منذ {{count}} سنة", + yearsAgo_two: "منذ سنتين ({{count}})", + yearsAgo_few: "منذ {{count}} سنوات", + yearsAgo_many: "منذ {{count}} سنة", + yearsAgo_other: "منذ {{count}} سنة", }, }, approvalsInbox: { @@ -3538,6 +3816,12 @@ const ar = { requestInfoSent: 'أُعيد إلى مقدم الطلب لاستكمال المعلومات', remindBtn: 'تذكير', remindSuccess: 'أُرسل تذكير إلى {{count}} موافق', + remindSuccess_zero: 'أُرسل تذكير إلى {{count}} موافق', + remindSuccess_one: 'أُرسل تذكير إلى {{count}} موافق', + remindSuccess_two: 'أُرسل تذكير إلى موافقَين ({{count}})', + remindSuccess_few: 'أُرسل تذكير إلى {{count}} موافقين', + remindSuccess_many: 'أُرسل تذكير إلى {{count}} موافقًا', + remindSuccess_other: 'أُرسل تذكير إلى {{count}} موافق', remindThrottled: 'أُرسل تذكير مؤخرًا — حاول لاحقًا.', replyPlaceholder: 'الرد على هذا الطلب…', slaRemaining: 'SLA المتبقي {{dur}}', @@ -3554,6 +3838,12 @@ const ar = { progressApprovals: 'الموافقات — {{got}} من {{need}}', progressGroups: 'تقدم التوقيعات — {{got}} من {{need}} مجموعات', progressEligible: '{{count}} معتمِد مؤهل', + progressEligible_zero: '{{count}} معتمِد مؤهل', + progressEligible_one: '{{count}} معتمِد مؤهل', + progressEligible_two: 'معتمِدان مؤهلان ({{count}})', + progressEligible_few: '{{count}} معتمِدين مؤهلين', + progressEligible_many: '{{count}} معتمِدًا مؤهلًا', + progressEligible_other: '{{count}} معتمِد مؤهل', progressBar: 'تقدم القرار', declaredActions: 'الإجراءات', attachmentChip: 'مرفق', @@ -3585,6 +3875,12 @@ const ar = { filterCount: '{{shown}} من {{total}}', selected: 'محدد', actionableCount: '({{count}} قابلة للتنفيذ)', + actionableCount_zero: '({{count}} قابلة للتنفيذ)', + actionableCount_one: '({{count}} قابلة للتنفيذ)', + actionableCount_two: '({{count}} قابلة للتنفيذ)', + actionableCount_few: '({{count}} قابلة للتنفيذ)', + actionableCount_many: '({{count}} قابلة للتنفيذ)', + actionableCount_other: '({{count}} قابلة للتنفيذ)', selectAll: 'تحديد الكل', selectRow: 'تحديد الطلب', colRequest: 'الطلب', @@ -3658,11 +3954,35 @@ const ar = { approveN: 'الموافقة على {{count}}', rejectN: 'رفض {{count}}', bulkApproveTitle: 'الموافقة على {{count}} طلبات؟', + bulkApproveTitle_zero: 'الموافقة على {{count}} طلب؟', + bulkApproveTitle_one: 'الموافقة على {{count}} طلب؟', + bulkApproveTitle_two: 'الموافقة على طلبين ({{count}})؟', + bulkApproveTitle_few: 'الموافقة على {{count}} طلبات؟', + bulkApproveTitle_many: 'الموافقة على {{count}} طلبًا؟', + bulkApproveTitle_other: 'الموافقة على {{count}} طلب؟', bulkApproveBody: 'ستتم الموافقة على كل طلب بهويتك ويتابع التدفق عبر فرع الموافقة.', bulkRejectTitle: 'رفض {{count}} طلبات؟', + bulkRejectTitle_zero: 'رفض {{count}} طلب؟', + bulkRejectTitle_one: 'رفض {{count}} طلب؟', + bulkRejectTitle_two: 'رفض طلبين ({{count}})؟', + bulkRejectTitle_few: 'رفض {{count}} طلبات؟', + bulkRejectTitle_many: 'رفض {{count}} طلبًا؟', + bulkRejectTitle_other: 'رفض {{count}} طلب؟', bulkRejectBody: 'سيتم رفض الطلبات المحددة وإخطار مقدميها.', bulkApproved: 'تمت الموافقة على {{count}} طلبات', + bulkApproved_zero: 'تمت الموافقة على {{count}} طلب', + bulkApproved_one: 'تمت الموافقة على {{count}} طلب', + bulkApproved_two: 'تمت الموافقة على طلبين ({{count}})', + bulkApproved_few: 'تمت الموافقة على {{count}} طلبات', + bulkApproved_many: 'تمت الموافقة على {{count}} طلبًا', + bulkApproved_other: 'تمت الموافقة على {{count}} طلب', bulkRejected: 'تم رفض {{count}} طلبات', + bulkRejected_zero: 'تم رفض {{count}} طلب', + bulkRejected_one: 'تم رفض {{count}} طلب', + bulkRejected_two: 'تم رفض طلبين ({{count}})', + bulkRejected_few: 'تم رفض {{count}} طلبات', + bulkRejected_many: 'تم رفض {{count}} طلبًا', + bulkRejected_other: 'تم رفض {{count}} طلب', bulkPartial: 'نجح {{ok}}، فشل {{fail}}: {{which}}', rejectOneTitle: 'رفض "{{title}}"؟', rejectOneBody: 'سيتم رفض الطلب وإخطار مقدمه.', @@ -3687,8 +4007,26 @@ const ar = { alreadyDecided: 'تم البت في هذا الطلب بالفعل. حدّث القائمة.', justNow: 'الآن', minutesAgo: 'قبل {{count}} دقيقة', + minutesAgo_zero: 'قبل {{count}} دقيقة', + minutesAgo_one: 'قبل {{count}} دقيقة', + minutesAgo_two: 'قبل دقيقتين ({{count}})', + minutesAgo_few: 'قبل {{count}} دقائق', + minutesAgo_many: 'قبل {{count}} دقيقة', + minutesAgo_other: 'قبل {{count}} دقيقة', hoursAgo: 'قبل {{count}} ساعة', + hoursAgo_zero: 'قبل {{count}} ساعة', + hoursAgo_one: 'قبل {{count}} ساعة', + hoursAgo_two: 'قبل ساعتين ({{count}})', + hoursAgo_few: 'قبل {{count}} ساعات', + hoursAgo_many: 'قبل {{count}} ساعة', + hoursAgo_other: 'قبل {{count}} ساعة', daysAgo: 'قبل {{count}} يوم', + daysAgo_zero: 'قبل {{count}} يوم', + daysAgo_one: 'قبل {{count}} يوم', + daysAgo_two: 'قبل يومين ({{count}})', + daysAgo_few: 'قبل {{count}} أيام', + daysAgo_many: 'قبل {{count}} يومًا', + daysAgo_other: 'قبل {{count}} يوم', }, preview: { empty: { @@ -3723,11 +4061,23 @@ const ar = { detailNone: 'لم يتم اكتشاف اختلافات — المسودة مطابقة للنسخة المنشورة.', detailChangedKeys: 'تغييرات أخرى:', confirmNote: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', + confirmNote_zero: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', + confirmNote_one: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', + confirmNote_two: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', + confirmNote_few: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', + confirmNote_many: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', + confirmNote_other: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', publishConfirm: 'نشر الكل', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'سيتم رفض النشر — {{count}} عنصر (عناصر) بحاجة إلى قرار أولاً', + securityBlockTitle_zero: 'سيتم رفض النشر — {{count}} عنصر بحاجة إلى قرار أولاً', + securityBlockTitle_one: 'سيتم رفض النشر — {{count}} عنصر بحاجة إلى قرار أولاً', + securityBlockTitle_two: 'سيتم رفض النشر — عنصران ({{count}}) بحاجة إلى قرار أولاً', + securityBlockTitle_few: 'سيتم رفض النشر — {{count}} عناصر بحاجة إلى قرار أولاً', + securityBlockTitle_many: 'سيتم رفض النشر — {{count}} عنصرًا بحاجة إلى قرار أولاً', + securityBlockTitle_other: 'سيتم رفض النشر — {{count}} عنصر بحاجة إلى قرار أولاً', securityBlockWhere: 'أصلحه في الكائن ضمن الإعدادات ← مشاركة السجلات، ثم انشر مرة أخرى.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3816,11 +4166,16 @@ const ar = { removeSort: "إزالة الترتيب", }, collaboration: { - // objectui#10242 — `commentCount` and `reactionCount` below serve every - // count but 1 (zero, two, few, many, other), so each is a count label - // («عدد …: {{count}}») rather than one noun form that fits only 3-10. + // objectui#11445 — `commentCount` and `reactionCount` below are count + // families: each of the six CLDR slots carries its own noun form, and the + // base key, a count label, answers a call made without a count. commentCount: "عدد التعليقات: {{count}}", - commentCountOne: "{{count}} تعليق", + commentCount_zero: "{{count}} تعليق", + commentCount_one: "{{count}} تعليق", + commentCount_two: "تعليقان ({{count}})", + commentCount_few: "{{count}} تعليقات", + commentCount_many: "{{count}} تعليقًا", + commentCount_other: "{{count}} تعليق", resolvedSuffix: " · تم الحل", sortComments: "ترتيب التعليقات", sortOldest: "الأقدم أولاً", @@ -3829,11 +4184,34 @@ const ar = { reopen: "إعادة الفتح", justNow: "الآن", minutesAgo: "قبل {{count}} دقيقة", + minutesAgo_zero: "قبل {{count}} دقيقة", + minutesAgo_one: "قبل {{count}} دقيقة", + minutesAgo_two: "قبل دقيقتين ({{count}})", + minutesAgo_few: "قبل {{count}} دقائق", + minutesAgo_many: "قبل {{count}} دقيقة", + minutesAgo_other: "قبل {{count}} دقيقة", hoursAgo: "قبل {{count}} ساعة", + hoursAgo_zero: "قبل {{count}} ساعة", + hoursAgo_one: "قبل {{count}} ساعة", + hoursAgo_two: "قبل ساعتين ({{count}})", + hoursAgo_few: "قبل {{count}} ساعات", + hoursAgo_many: "قبل {{count}} ساعة", + hoursAgo_other: "قبل {{count}} ساعة", daysAgo: "قبل {{count}} يوم", + daysAgo_zero: "قبل {{count}} يوم", + daysAgo_one: "قبل {{count}} يوم", + daysAgo_two: "قبل يومين ({{count}})", + daysAgo_few: "قبل {{count}} أيام", + daysAgo_many: "قبل {{count}} يومًا", + daysAgo_other: "قبل {{count}} يوم", edited: "(تم التعديل)", reactionCount: "عدد التفاعلات: {{count}}", - reactionCountOne: "{{count}} تفاعل", + reactionCount_zero: "{{count}} تفاعل", + reactionCount_one: "{{count}} تفاعل", + reactionCount_two: "تفاعلان ({{count}})", + reactionCount_few: "{{count}} تفاعلات", + reactionCount_many: "{{count}} تفاعلًا", + reactionCount_other: "{{count}} تفاعل", addThumbsUp: "إضافة إعجاب", reply: "رد", reactThumbsUp: "التفاعل بإعجاب", @@ -3843,13 +4221,22 @@ const ar = { replyingToComment: "الرد على التعليق…", commentPlaceholder: "أضف تعليقًا… (استخدم @ للإشارة)", send: "إرسال", - // objectui#10425 — the presence stack's name and its overflow badge switch - // keys only at exactly 1, so the count-not-one half of each pair below is - // a count label too. + // objectui#11445 — the presence stack's name and its overflow badge are + // count families too. presentUserCount: "عدد المستخدمين المتواجدين: {{count}}", - presentUserCountOne: "{{count}} مستخدم متواجد", + presentUserCount_zero: "{{count}} مستخدم متواجد", + presentUserCount_one: "{{count}} مستخدم متواجد", + presentUserCount_two: "مستخدمان متواجدان ({{count}})", + presentUserCount_few: "{{count}} مستخدمين متواجدين", + presentUserCount_many: "{{count}} مستخدمًا متواجدًا", + presentUserCount_other: "{{count}} مستخدم متواجد", moreUserCount: "عدد المستخدمين الآخرين: {{count}}", - moreUserCountOne: "{{count}} مستخدم آخر", + moreUserCount_zero: "{{count}} مستخدم آخر", + moreUserCount_one: "{{count}} مستخدم آخر", + moreUserCount_two: "مستخدمان آخران ({{count}})", + moreUserCount_few: "{{count}} مستخدمين آخرين", + moreUserCount_many: "{{count}} مستخدمًا آخر", + moreUserCount_other: "{{count}} مستخدم آخر", userStatusTitle: "{{name}} ({{status}})", statusActive: "نشط", statusIdle: "خامل", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f6c608e2fb..e5d0462b45 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -69,7 +69,8 @@ const de = { selectFirst: 'Zuerst {{fields}} auswählen', selectRecord: 'Datensatz auswählen', recordCount: '{{count}} Datensätze', - recordCountOne: '1 Datensatz', + recordCount_one: '{{count}} Datensatz', + recordCount_other: '{{count}} Datensätze', pageOf: 'Seite {{current}} von {{total}}', filters: 'Filter', clear: 'Zurücksetzen', @@ -122,7 +123,8 @@ const de = { closePanel: "Panel schließen", resizeDrawer: "Panelbreite anpassen", itemCount: "{{count}} Elemente", - itemCountOne: "{{count}} Element", + itemCount_one: "{{count}} Element", + itemCount_other: "{{count}} Elemente", toggleSidebar: "Seitenleiste umschalten", package: "Paket", done: "Fertig", @@ -262,6 +264,8 @@ const de = { }, relativeDate: { overdue: "{{count}} T. überfällig", + overdue_one: "{{count}} T. überfällig", + overdue_other: "{{count}} T. überfällig", }, file: { dragDropHere: 'Dateien hierher ziehen und ablegen', @@ -398,23 +402,35 @@ const de = { open: "Öffnen", search: "Suchen…", modified: "{{count}} Zeile geändert", + modified_one: "{{count}} Zeile geändert", + modified_other: "{{count}} Zeilen geändert", saveFailed: 'Speichern fehlgeschlagen', selected: "{{count}} ausgewählt", + selected_one: "{{count}} ausgewählt", + selected_other: "{{count}} ausgewählt", edit: "Bearbeiten", delete: "Löschen", }, grid: { bulkSelected: "{{count}} ausgewählt", + bulkSelected_one: "{{count}} ausgewählt", + bulkSelected_other: "{{count}} ausgewählt", bulkSelectedAllMatches: "{{count}} ausgewählt (alle Treffer)", bulkClear: "Auswahl aufheben", bulkAllOnPage: "Alle {{count}} auf dieser Seite sind ausgewählt.", + bulkAllOnPage_one: "{{count}} Datensatz auf dieser Seite ist ausgewählt.", + bulkAllOnPage_other: "Alle {{count}} auf dieser Seite sind ausgewählt.", bulkSelectAllMatching: "Alle {{count}} Treffer auswählen", bulkAllMatchingSelected: "Alle {{count}} übereinstimmenden Datensätze sind ausgewählt.", bulk: { confirmDefault: "Dies wird auf {{count}} Datensätze angewendet.", + confirmDefault_one: "Dies wird auf {{count}} Datensatz angewendet.", + confirmDefault_other: "Dies wird auf {{count}} Datensätze angewendet.", overLimit: "Die Auswahl ({{count}}) überschreitet das Aktionslimit ({{limit}}). Verkleinern Sie die Auswahl, um fortzufahren.", affectedRecords: "Betroffene Datensätze ({{count}}):", skippedIneligible: '{{count}} der ausgewählten Datensätze sind für diese Aktion nicht zulässig und werden übersprungen.', + skippedIneligible_one: '{{count}} der ausgewählten Datensätze ist für diese Aktion nicht zulässig und wird übersprungen.', + skippedIneligible_other: '{{count}} der ausgewählten Datensätze sind für diese Aktion nicht zulässig und werden übersprungen.', rowFallback: "Zeile {{index}}", andMore: "… und {{count}} weitere", processed: "{{count}} / {{total}} verarbeitet", @@ -490,6 +506,8 @@ const de = { templateFileName: "{{object}}-import-template", autoMatched: "Automatisch zugeordnet", autoMatchedSummary: "{{count}} Spalte(n) automatisch zugeordnet — unten prüfen und anpassen.", + autoMatchedSummary_one: "{{count}} Spalte automatisch zugeordnet — unten prüfen und anpassen.", + autoMatchedSummary_other: "{{count}} Spalten automatisch zugeordnet — unten prüfen und anpassen.", confidence: { high: "Hohe Konfidenz", medium: "Mittlere Konfidenz", @@ -502,8 +520,14 @@ const de = { cancelImport: "Import abbrechen", importCancelled: "Import abgebrochen", resultsTruncated: "Es werden die ersten {{count}} Zeilenergebnisse (von {{total}}) angezeigt.", + resultsTruncated_one: "Es wird das erste {{count}} Zeilenergebnis (von {{total}}) angezeigt.", + resultsTruncated_other: "Es werden die ersten {{count}} Zeilenergebnisse (von {{total}}) angezeigt.", createdCount: "{{count}} erstellt", + createdCount_one: "{{count}} erstellt", + createdCount_other: "{{count}} erstellt", updatedCount: "{{count}} aktualisiert", + updatedCount_one: "{{count}} aktualisiert", + updatedCount_other: "{{count}} aktualisiert", downloadFailed: "Fehlgeschlagene Zeilen herunterladen", options: "Importoptionen", writeMode: "Wenn eine Zeile zu einem vorhandenen Datensatz passt", @@ -551,6 +575,8 @@ const de = { historyColResult: "Ergebnis", historyColTime: "Wann", errorCount: "{{count}} Fehler", + errorCount_one: "{{count}} Fehler", + errorCount_other: "{{count}} Fehler", undoImport: "Import rückgängig machen", undoing: "Wird rückgängig gemacht…", undoConfirm: "Diesen Import rückgängig machen? Dabei erstellte Datensätze werden gelöscht und aktualisierte Datensätze auf ihre vorherigen Werte zurückgesetzt.", @@ -611,7 +637,11 @@ const de = { mapped: "Zugeordnet", skipped: "Übersprungen", rowsWithErrors: "{{count}} Zeile(n) mit Fehlern", + rowsWithErrors_one: "{{count}} Zeile mit Fehlern", + rowsWithErrors_other: "{{count}} Zeilen mit Fehlern", rowsCorrected: "{{count}} Zeile(n) korrigiert", + rowsCorrected_one: "{{count}} Zeile korrigiert", + rowsCorrected_other: "{{count}} Zeilen korrigiert", clickToFix: "— klicken Sie auf eine hervorgehobene Zelle, um sie zu bearbeiten.", showingRows: "{{shown}} von {{total}} Zeilen angezeigt", importing: "Importiere… {{progress}}%", @@ -619,11 +649,15 @@ const de = { imported: "{{count}} importiert", skippedCount: "{{count}} übersprungen", moreErrors: "…und {{count}} weitere Fehler", + moreErrors_one: "…und {{count}} weiterer Fehler", + moreErrors_other: "…und {{count}} weitere Fehler", cancel: "Abbrechen", back: "Zurück", next: "Weiter", close: "Schließen", importNRows: "{{count}} Zeilen importieren", + importNRows_one: "{{count}} Zeile importieren", + importNRows_other: "{{count}} Zeilen importieren", importingProgress: "Importiere…", requiredMark: "*", required: "Erforderlich", @@ -708,7 +742,8 @@ const de = { refresh: "Aktualisieren", loading: "Datensätze werden geladen…", recordCount: "{{count}} Datensätze", - recordCountOne: "{{count}} Datensatz", + recordCount_one: "{{count}} Datensatz", + recordCount_other: "{{count}} Datensätze", addRecord: "Datensatz hinzufügen", tabs: "Tabs", allRecords: "Alle Datensätze", @@ -883,13 +918,19 @@ const de = { conflict: { title: "Terminkonflikt", body: "Diese Verschiebung verstößt gegen Abhängigkeitsbedingungen. {{count}} betroffene Vorgänge automatisch neu planen?", + body_one: "Diese Verschiebung verstößt gegen Abhängigkeitsbedingungen. {{count}} betroffenen Vorgang automatisch neu planen?", + body_other: "Diese Verschiebung verstößt gegen Abhängigkeitsbedingungen. {{count}} betroffene Vorgänge automatisch neu planen?", confirm: "Automatisch neu planen", cancel: "Unverändert lassen", }, autoScheduleDlg: { title: "Automatisch planen", body: "{{count}} Vorgänge nach hinten verschieben, um die Abhängigkeiten einzuhalten?", + body_one: "{{count}} Vorgang nach hinten verschieben, um die Abhängigkeiten einzuhalten?", + body_other: "{{count}} Vorgänge nach hinten verschieben, um die Abhängigkeiten einzuhalten?", skipped: "{{count}} gesperrte Vorgänge verletzen die Verknüpfungen ebenfalls und wurden übersprungen.", + skipped_one: "{{count}} gesperrter Vorgang verletzt die Verknüpfungen ebenfalls und wurde übersprungen.", + skipped_other: "{{count}} gesperrte Vorgänge verletzen die Verknüpfungen ebenfalls und wurden übersprungen.", confirm: "Anwenden", cancel: "Abbrechen", none: "Alle Abhängigkeiten sind erfüllt — nichts neu zu planen.", @@ -994,7 +1035,8 @@ const de = { pageHeaderActions: "Aktionen der Seitenkopfzeile", emojiPicker: "Emoji-Auswahl", reactionCount: "{{emoji}} {{count}} Reaktionen", - reactionCountOne: "{{emoji}} {{count}} Reaktion", + reactionCount_one: "{{emoji}} {{count}} Reaktion", + reactionCount_other: "{{emoji}} {{count}} Reaktionen", recordDetail: "Datensatzdetails", recordDetailWithLabel: "{{label}}-Details", openAsFullPage: "Als ganze Seite öffnen", @@ -1031,11 +1073,18 @@ const de = { unpin: "Lösen", justNow: "gerade eben", minutesAgo: "vor {{count}} Min.", + minutesAgo_one: "vor {{count}} Min.", + minutesAgo_other: "vor {{count}} Min.", hoursAgo: "vor {{count}} Std.", + hoursAgo_one: "vor {{count}} Std.", + hoursAgo_other: "vor {{count}} Std.", daysAgo: "vor {{count}} Tg.", + daysAgo_one: "vor {{count}} Tg.", + daysAgo_other: "vor {{count}} Tg.", dropFilesToUpload: "Dateien hierher ziehen oder klicken zum Hochladen", attachmentCount: "{{count}} Anhang", - attachmentCountPlural: "{{count}} Anhänge", + attachmentCount_one: "{{count}} Anhang", + attachmentCount_other: "{{count}} Anhänge", removeAttachment: "Anhang entfernen", // Record Attachments panel (enable.files, objectstack#4358) attachments: "Anhänge", @@ -1094,7 +1143,8 @@ const de = { via: "über {{source}}", viewSource: "Quelle anzeigen", replyCount: "{{count}} Antwort", - replyCountPlural: "{{count}} Antworten", + replyCount_one: "{{count}} Antwort", + replyCount_other: "{{count}} Antworten", replyPlaceholder: "Antworten…", filterActivity: "Aktivität filtern", openDiscussion: "Diskussionspanel öffnen", @@ -1176,6 +1226,8 @@ const de = { copyPhone: "Telefonnummer kopieren", copyRecordId: "Datensatz-ID kopieren", showEmptyFields: "{{count}} leere Felder anzeigen", + showEmptyFields_one: "{{count}} leeres Feld anzeigen", + showEmptyFields_other: "{{count}} leere Felder anzeigen", hideEmptyFields: "Leere Felder ausblenden", noValue: "Kein Wert", unresolvedReference: "Nicht aufgelöste Referenz: {{value}} wurde keinem Benutzer zugeordnet", @@ -1545,7 +1597,11 @@ const de = { backToHome: "Zurück zur Startseite", }, saveAdvisoryTitle: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweis(e)", + saveAdvisoryTitle_one: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweis", + saveAdvisoryTitle_other: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweise", publishAdvisoryTitle: "Veröffentlicht — die Autorenprüfung ergab {{count}} Hinweis(e)", + publishAdvisoryTitle_one: "Veröffentlicht — die Autorenprüfung ergab {{count}} Hinweis", + publishAdvisoryTitle_other: "Veröffentlicht — die Autorenprüfung ergab {{count}} Hinweise", importMappingsUnavailable: "Gespeicherte Importzuordnungen für {{object}} konnten nicht geladen werden", importMappingsRefused: "Der Server hat diese Anfrage abgelehnt. Die Liste ist also leer, weil sie nicht gelesen werden konnte — nicht, weil nichts registriert ist. Melden Sie sich erneut an oder bitten Sie eine Administratorin oder einen Administrator um Zugriff.", importMappingsUnreadable: "Diese Liste ist leer, weil sie nicht gelesen werden konnte, nicht weil nichts registriert ist. Versuchen Sie es erneut und melden Sie das Problem, wenn es weiterhin auftritt.", @@ -2022,7 +2078,8 @@ const de = { title: "Titel", viewType: "Ansichtstyp", recordCount: "{{count}} Datensätze", - recordCountOne: "{{count}} Datensatz", + recordCount_one: "{{count}} Datensatz", + recordCount_other: "{{count}} Datensätze", save: "Speichern", discard: "Verwerfen", createView: "Ansicht erstellen", @@ -2038,6 +2095,8 @@ const de = { import: "Importieren", importTitle: "Aus CSV importieren", importedToast: "{{count}} Zeile(n) importiert.", + importedToast_one: "{{count}} Zeile importiert.", + importedToast_other: "{{count}} Zeilen importiert.", importedWithSkipped: "{{ok}} Zeile(n) importiert; {{skipped}} übersprungen.", createViewDesc: "Wählen Sie einen Ansichtstyp und geben Sie ihm einen Namen. Alle Einstellungen können danach geändert werden.", cancel: "Abbrechen", @@ -2046,6 +2105,8 @@ const de = { deleteViewTitle: "Ansicht löschen", deleteViewConfirm: "Möchten Sie die Ansicht „{{name}}“ wirklich löschen? Dies kann nicht rückgängig gemacht werden.", bulkDeleteConfirm: "{{count}} ausgewählte Datensätze löschen? Dies kann nicht rückgängig gemacht werden.", + bulkDeleteConfirm_one: "{{count}} ausgewählten Datensatz löschen? Dies kann nicht rückgängig gemacht werden.", + bulkDeleteConfirm_other: "{{count}} ausgewählte Datensätze löschen? Dies kann nicht rückgängig gemacht werden.", duplicateViewName: "Eine Ansicht mit diesem Namen existiert bereits.", viewTypeGrid: "Raster", viewTypeGridDesc: "Tabellenkalkulationsartige Tabelle von Datensätzen.", @@ -2503,6 +2564,8 @@ const de = { }, pendingDrafts: { message: "Sie haben {{count}} unveröffentlichte Änderung(en) — veröffentlichen Sie sie, um sie live zu schalten.", + message_one: "Sie haben {{count}} unveröffentlichte Änderung — veröffentlichen Sie sie, um sie live zu schalten.", + message_other: "Sie haben {{count}} unveröffentlichte Änderungen — veröffentlichen Sie sie, um sie live zu schalten.", cta: "Veröffentlichen", publishing: "Wird veröffentlicht…", published: "Veröffentlicht! Ihre Änderungen sind live.", @@ -2626,9 +2689,11 @@ const de = { placeholder: "Objekte, Dashboards, Seiten, Berichte suchen…", inputAriaLabel: "Objekte, Dashboards, Seiten, Berichte suchen", resultsCount: "{{count}} Ergebnis für „{{query}}“", - resultsCountPlural: "{{count}} Ergebnisse für „{{query}}“", + resultsCount_one: "{{count}} Ergebnis für „{{query}}“", + resultsCount_other: "{{count}} Ergebnisse für „{{query}}“", itemsAvailable: "{{count}} Elemente verfügbar", - itemsAvailableOne: "{{count}} Element verfügbar", + itemsAvailable_one: "{{count}} Element verfügbar", + itemsAvailable_other: "{{count}} Elemente verfügbar", noResults: "Keine Ergebnisse gefunden", noResultsHint: "Versuchen Sie, Ihre Suchbegriffe anzupassen", typeObjects: "Objekte", @@ -2705,6 +2770,8 @@ const de = { noRecordId: "Keine Datensatz-ID angegeben", deleteConfirm: "Möchten Sie diesen Datensatz wirklich löschen?", bulkDeleteSuccess: "{{count}} {{label}}-Datensätze gelöscht", + bulkDeleteSuccess_one: "{{count}} {{label}}-Datensatz gelöscht", + bulkDeleteSuccess_other: "{{count}} {{label}}-Datensätze gelöscht", bulkDeletePartial: "{{succeeded}} gelöscht, {{failed}} fehlgeschlagen", }, objectViewActions: { @@ -2905,12 +2972,16 @@ const de = { regionLabel: "Benachrichtigungen", groupCount: "{{count}} Benachrichtigungen", groupUnread: "{{count}} ungelesen", + groupUnread_one: "{{count}} ungelesen", + groupUnread_other: "{{count}} ungelesen", groupMarkRead: "Als gelesen markieren", groupMarkReadTitle: "Alle dieses Typs als gelesen markieren", empty: "Keine Benachrichtigungen", markAllRead: "Alle als gelesen markieren", viewAll: "Alle Benachrichtigungen anzeigen", approvalsPending: "{{count}} ausstehende Genehmigungen", + approvalsPending_one: "{{count}} ausstehende Genehmigung", + approvalsPending_other: "{{count}} ausstehende Genehmigungen", viewApprovals: "Genehmigungen anzeigen", noPendingApprovals: "Keine ausstehenden Genehmigungen", openApprovalsInbox: "Genehmigungs-Posteingang öffnen", @@ -2918,7 +2989,11 @@ const de = { badgeNotifications: "{{unread}} Benachrichtigungen", badgeApprovals: "{{approvals}} ausstehende Genehmigungen", arrivalMany: "{{count}} neue Nachrichten", + arrivalMany_one: "{{count}} neue Nachricht", + arrivalMany_other: "{{count}} neue Nachrichten", arrivalRepeats: "{{count}} neue Nachrichten zu diesem Thema", + arrivalRepeats_one: "{{count}} neue Nachricht zu diesem Thema", + arrivalRepeats_other: "{{count}} neue Nachrichten zu diesem Thema", arrivalOpen: "Ansehen", toastEnabled: "Hinweise in der App", desktopEnabled: "Desktop-Benachrichtigungen", @@ -2995,6 +3070,8 @@ const de = { noImportableFields: "Dieses Objekt hat keine importierbaren Felder.", schemaReadFailed: "Das Objektschema konnte nicht gelesen werden.", imported: "{{count}} Zeile(n) in {{object}} importiert.", + imported_one: "{{count}} Zeile in {{object}} importiert.", + imported_other: "{{count}} Zeilen in {{object}} importiert.", importFrom: "Echte Zeilen importieren aus", into: "nach", opening: "Wird geöffnet…", @@ -3024,11 +3101,13 @@ const de = { formAssist: { title: "KI-Vorschläge", suggestionCount: "{{count}} Vorschläge", - suggestionCountOne: "{{count}} Vorschlag", + suggestionCount_one: "{{count}} Vorschlag", + suggestionCount_other: "{{count}} Vorschläge", applyAll: "Alle übernehmen", confidence: "{{percent}} Konfidenz", appliedCount: "{{count}} Vorschläge übernommen", - appliedCountOne: "{{count}} Vorschlag übernommen", + appliedCount_one: "{{count}} Vorschlag übernommen", + appliedCount_other: "{{count}} Vorschläge übernommen", }, recommendations: { title: "Empfehlungen", @@ -3326,10 +3405,14 @@ const de = { purgeSampleData: 'Beispieldaten löschen', purgeConfirm: 'Alle von diesem Paket erzeugten Beispieldatensätze löschen? Vom Benutzer hinzugefügte Datensätze bleiben unberührt.', purgeSuccess: '{{count}} Beispieldatensatz/-sätze entfernt.', + purgeSuccess_one: '{{count}} Beispieldatensatz entfernt.', + purgeSuccess_other: '{{count}} Beispieldatensätze entfernt.', purgeNoData: 'Keine Beispieldatensätze zum Löschen gefunden.', reseedQueued: 'Beispieldaten werden beim nächsten Zugriff auf die Umgebung neu geladen.', reseedLocalSuccess: 'Beispieldaten neu geladen: {{inserted}} hinzugefügt, {{updated}} aktualisiert.', reseedPartialErrors: '({{count}} Datensatz/-sätze konnten nicht geschrieben werden)', + reseedPartialErrors_one: '({{count}} Datensatz konnte nicht geschrieben werden)', + reseedPartialErrors_other: '({{count}} Datensätze konnten nicht geschrieben werden)', sampleDataKernelUnavailable: 'Diese Control Plane hat keinen Umgebungs-Kernel, daher können Beispieldaten hier weder neu geladen noch gelöscht werden. Führen Sie dies im eigenen Laufzeitsystem der Umgebung aus.', updateAvailable: 'Update verfügbar', }, @@ -3427,7 +3510,11 @@ const de = { today: "heute", daysAgo: "vor {{count}}d", monthsAgo: "vor {{count}} Monaten", + monthsAgo_one: "vor {{count}} Monat", + monthsAgo_other: "vor {{count}} Monaten", yearsAgo: "vor {{count}} Jahren", + yearsAgo_one: "vor {{count}} Jahr", + yearsAgo_other: "vor {{count}} Jahren", }, }, approvalsInbox: { @@ -3450,6 +3537,8 @@ const de = { requestInfoSent: 'Zur Ergänzung an den Antragsteller zurückgegeben', remindBtn: 'Erinnern', remindSuccess: 'Erinnerung an {{count}} Genehmiger gesendet', + remindSuccess_one: 'Erinnerung an {{count}} Genehmiger gesendet', + remindSuccess_other: 'Erinnerung an {{count}} Genehmiger gesendet', remindThrottled: 'Kürzlich wurde bereits erinnert — bitte später erneut.', replyPlaceholder: 'Auf diese Anfrage antworten…', slaRemaining: 'SLA noch {{dur}}', @@ -3466,6 +3555,8 @@ const de = { progressApprovals: 'Genehmigungen — {{got}} von {{need}}', progressGroups: 'Freigabefortschritt — {{got}} von {{need}} Gruppen', progressEligible: '{{count}} berechtigte Genehmiger', + progressEligible_one: '{{count}} berechtigter Genehmiger', + progressEligible_other: '{{count}} berechtigte Genehmiger', progressBar: 'Entscheidungsfortschritt', declaredActions: 'Aktionen', attachmentChip: 'Anhang', @@ -3497,6 +3588,8 @@ const de = { filterCount: '{{shown}} von {{total}}', selected: 'ausgewählt', actionableCount: '({{count}} bearbeitbar)', + actionableCount_one: '({{count}} bearbeitbar)', + actionableCount_other: '({{count}} bearbeitbar)', selectAll: 'Alle auswählen', selectRow: 'Anfrage auswählen', colRequest: 'Anfrage', @@ -3570,11 +3663,19 @@ const de = { approveN: '{{count}} genehmigen', rejectN: '{{count}} ablehnen', bulkApproveTitle: '{{count}} Anfragen genehmigen?', + bulkApproveTitle_one: '{{count}} Anfrage genehmigen?', + bulkApproveTitle_other: '{{count}} Anfragen genehmigen?', bulkApproveBody: 'Jede Anfrage wird mit Ihrer Identität genehmigt; der Flow folgt dem Genehmigen-Zweig.', bulkRejectTitle: '{{count}} Anfragen ablehnen?', + bulkRejectTitle_one: '{{count}} Anfrage ablehnen?', + bulkRejectTitle_other: '{{count}} Anfragen ablehnen?', bulkRejectBody: 'Die ausgewählten Anfragen werden abgelehnt und die Antragsteller benachrichtigt.', bulkApproved: '{{count}} Anfragen genehmigt', + bulkApproved_one: '{{count}} Anfrage genehmigt', + bulkApproved_other: '{{count}} Anfragen genehmigt', bulkRejected: '{{count}} Anfragen abgelehnt', + bulkRejected_one: '{{count}} Anfrage abgelehnt', + bulkRejected_other: '{{count}} Anfragen abgelehnt', bulkPartial: '{{ok}} erfolgreich, {{fail}} fehlgeschlagen: {{which}}', rejectOneTitle: '„{{title}}“ ablehnen?', rejectOneBody: 'Die Anfrage wird abgelehnt und der Antragsteller benachrichtigt.', @@ -3599,8 +3700,14 @@ const de = { alreadyDecided: 'Diese Anfrage wurde bereits entschieden. Liste aktualisieren.', justNow: 'gerade eben', minutesAgo: 'vor {{count}} Min.', + minutesAgo_one: 'vor {{count}} Min.', + minutesAgo_other: 'vor {{count}} Min.', hoursAgo: 'vor {{count}} Std.', + hoursAgo_one: 'vor {{count}} Std.', + hoursAgo_other: 'vor {{count}} Std.', daysAgo: 'vor {{count}} Tagen', + daysAgo_one: 'vor {{count}} Tag', + daysAgo_other: 'vor {{count}} Tagen', }, preview: { empty: { @@ -3635,11 +3742,15 @@ const de = { detailNone: 'Keine Unterschiede erkannt — der Entwurf entspricht der veröffentlichten Version.', detailChangedKeys: 'Ebenfalls geändert:', confirmNote: 'Beim Veröffentlichen werden alle {{count}} ausstehenden Entwürfe dieses Pakets atomar freigegeben.', + confirmNote_one: 'Beim Veröffentlichen wird der {{count}} ausstehende Entwurf dieses Pakets atomar freigegeben.', + confirmNote_other: 'Beim Veröffentlichen werden alle {{count}} ausstehenden Entwürfe dieses Pakets atomar freigegeben.', publishConfirm: 'Alle veröffentlichen', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'Veröffentlichen wird abgelehnt — {{count}} Element(e) benötigen zuerst eine Entscheidung', + securityBlockTitle_one: 'Veröffentlichen wird abgelehnt — {{count}} Element benötigt zuerst eine Entscheidung', + securityBlockTitle_other: 'Veröffentlichen wird abgelehnt — {{count}} Elemente benötigen zuerst eine Entscheidung', securityBlockWhere: 'Korrigieren Sie es am Objekt unter Einstellungen → Datensatzfreigabe und veröffentlichen Sie erneut.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3729,7 +3840,8 @@ const de = { }, collaboration: { commentCount: "{{count}} Kommentare", - commentCountOne: "{{count}} Kommentar", + commentCount_one: "{{count}} Kommentar", + commentCount_other: "{{count}} Kommentare", resolvedSuffix: " · Gelöst", sortComments: "Kommentare sortieren", sortOldest: "Älteste zuerst", @@ -3738,11 +3850,18 @@ const de = { reopen: "Erneut öffnen", justNow: "gerade eben", minutesAgo: "vor {{count}} Min.", + minutesAgo_one: "vor {{count}} Min.", + minutesAgo_other: "vor {{count}} Min.", hoursAgo: "vor {{count}} Std.", + hoursAgo_one: "vor {{count}} Std.", + hoursAgo_other: "vor {{count}} Std.", daysAgo: "vor {{count}} T.", + daysAgo_one: "vor {{count}} T.", + daysAgo_other: "vor {{count}} T.", edited: "(bearbeitet)", reactionCount: "{{count}} Reaktionen", - reactionCountOne: "{{count}} Reaktion", + reactionCount_one: "{{count}} Reaktion", + reactionCount_other: "{{count}} Reaktionen", addThumbsUp: "Daumen hoch hinzufügen", reply: "Antworten", reactThumbsUp: "Mit Daumen hoch reagieren", @@ -3753,9 +3872,11 @@ const de = { commentPlaceholder: "Kommentar hinzufügen … (@ für Erwähnungen)", send: "Senden", presentUserCount: "{{count}} anwesende Benutzer", - presentUserCountOne: "{{count}} anwesender Benutzer", + presentUserCount_one: "{{count}} anwesender Benutzer", + presentUserCount_other: "{{count}} anwesende Benutzer", moreUserCount: "{{count}} weitere Benutzer", - moreUserCountOne: "{{count}} weiterer Benutzer", + moreUserCount_one: "{{count}} weiterer Benutzer", + moreUserCount_other: "{{count}} weitere Benutzer", userStatusTitle: "{{name}} ({{status}})", statusActive: "aktiv", statusIdle: "inaktiv", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4667e91179..220a92b604 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -87,7 +87,8 @@ const en = { selectFirst: 'Select {{fields}} first', selectRecord: 'Select record', recordCount: '{{count}} records', - recordCountOne: '1 record', + recordCount_one: '{{count}} record', + recordCount_other: '{{count}} records', pageOf: 'Page {{current}} of {{total}}', filters: 'Filters', clear: 'Clear', @@ -144,10 +145,12 @@ const en = { closeChat: 'Close chat', closePanel: 'Close panel', resizeDrawer: 'Resize drawer', - // Count badge on a tab / section header. Two keys, NOT an i18next - // `_one`/`_other` pair — see the `reactionCount` note under `detail`. + // Count badge on a tab / section header. An i18next count family + // (objectui#11445): the badge passes `count` and i18next picks the slot + // each pack's CLDR rules select. itemCount: '{{count}} items', - itemCountOne: '{{count}} item', + itemCount_one: '{{count}} item', + itemCount_other: '{{count}} items', toggleSidebar: 'Toggle sidebar', package: 'Package', done: 'Done', @@ -356,6 +359,8 @@ const en = { fields: { relativeDate: { overdue: 'Overdue {{count}}d', + overdue_one: 'Overdue {{count}}d', + overdue_other: 'Overdue {{count}}d', }, file: { dragDropHere: 'Drag & drop files here', @@ -611,8 +616,12 @@ const en = { open: 'Open', search: 'Search…', modified: '{{count}} row modified', + modified_one: '{{count}} row modified', + modified_other: '{{count}} rows modified', saveFailed: 'Save failed', selected: '{{count}} selected', + selected_one: '{{count}} selected', + selected_other: '{{count}} selected', edit: 'Edit', delete: 'Delete', }, @@ -629,9 +638,13 @@ const en = { openRecord: 'Open record', openMenu: 'Open menu', bulkSelected: '{{count}} selected', + bulkSelected_one: '{{count}} selected', + bulkSelected_other: '{{count}} selected', bulkSelectedAllMatches: '{{count}} selected (all matches)', bulkClear: 'Clear', bulkAllOnPage: 'All {{count}} on this page are selected.', + bulkAllOnPage_one: '{{count}} record on this page is selected.', + bulkAllOnPage_other: 'All {{count}} on this page are selected.', bulkSelectAllMatching: 'Select all {{count}} matching', bulkAllMatchingSelected: 'All {{count}} matching records are selected.', rowHeight: 'Row height: {{mode}}', @@ -721,6 +734,8 @@ const en = { typeMismatch: 'Looks like {{type}}', autoMatched: 'Auto-matched', autoMatchedSummary: 'Auto-matched {{count}} column(s) — review and adjust below.', + autoMatchedSummary_one: 'Auto-matched {{count}} column — review and adjust below.', + autoMatchedSummary_other: 'Auto-matched {{count}} columns — review and adjust below.', confidence: { high: 'High confidence', medium: 'Medium confidence', @@ -739,7 +754,11 @@ const en = { mapped: 'Mapped', skipped: 'Skipped', rowsWithErrors: '{{count}} row(s) with errors', + rowsWithErrors_one: '{{count}} row with errors', + rowsWithErrors_other: '{{count}} rows with errors', rowsCorrected: '{{count}} row(s) corrected', + rowsCorrected_one: '{{count}} row corrected', + rowsCorrected_other: '{{count}} rows corrected', clickToFix: '— click a highlighted cell to fix it inline.', showingRows: 'Showing {{shown}} of {{total}} rows', importing: 'Importing… {{progress}}%', @@ -750,12 +769,20 @@ const en = { cancelImport: 'Cancel import', importCancelled: 'Import cancelled', resultsTruncated: 'Showing the first {{count}} row results (of {{total}}).', + resultsTruncated_one: 'Showing the first {{count}} row result (of {{total}}).', + resultsTruncated_other: 'Showing the first {{count}} row results (of {{total}}).', importComplete: 'Import Complete', imported: '{{count}} imported', createdCount: '{{count}} created', + createdCount_one: '{{count}} created', + createdCount_other: '{{count}} created', updatedCount: '{{count}} updated', + updatedCount_one: '{{count}} updated', + updatedCount_other: '{{count}} updated', skippedCount: '{{count}} skipped', moreErrors: '…and {{count}} more errors', + moreErrors_one: '…and {{count}} more error', + moreErrors_other: '…and {{count}} more errors', downloadFailed: 'Download failed rows', options: 'Import options', writeMode: 'When a row matches an existing record', @@ -803,6 +830,8 @@ const en = { historyColResult: 'Result', historyColTime: 'When', errorCount: '{{count}} errors', + errorCount_one: '{{count}} error', + errorCount_other: '{{count}} errors', undoImport: 'Undo import', undoing: 'Undoing…', undoConfirm: 'Undo this import? Records it created will be deleted and records it updated will be restored to their previous values.', @@ -819,6 +848,8 @@ const en = { next: 'Next', close: 'Close', importNRows: 'Import {{count}} Rows', + importNRows_one: 'Import {{count}} Row', + importNRows_other: 'Import {{count}} Rows', importingProgress: 'Importing…', required: 'Required', invalidType: 'Invalid {{type}}', @@ -830,9 +861,13 @@ const en = { }, bulk: { confirmDefault: 'This will apply to {{count}} record(s).', + confirmDefault_one: 'This will apply to {{count}} record.', + confirmDefault_other: 'This will apply to {{count}} records.', overLimit: 'Selection ({{count}}) exceeds the action limit ({{limit}}). Reduce the selection to proceed.', affectedRecords: 'Affected records ({{count}}):', skippedIneligible: '{{count}} selected record(s) are not eligible for this action and will be skipped.', + skippedIneligible_one: '{{count}} selected record is not eligible for this action and will be skipped.', + skippedIneligible_other: '{{count}} selected records are not eligible for this action and will be skipped.', rowFallback: 'Row {{index}}', andMore: '\u2026 and {{count}} more', processed: '{{count}} / {{total}} processed', @@ -893,7 +928,8 @@ const en = { list: { loading: 'Loading records…', recordCount: '{{count}} records', - recordCountOne: '{{count}} record', + recordCount_one: '{{count}} record', + recordCount_other: '{{count}} records', addRecord: 'Add record', tabs: 'Tabs', allRecords: 'All Records', @@ -1139,13 +1175,19 @@ const en = { conflict: { title: 'Schedule conflict', body: 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?', + body_one: 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task?', + body_other: 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected tasks?', confirm: 'Auto-reschedule', cancel: 'Keep as is', }, autoScheduleDlg: { title: 'Auto-schedule', body: 'Shift {{count}} task(s) later to satisfy dependency links?', + body_one: 'Shift {{count}} task later to satisfy dependency links?', + body_other: 'Shift {{count}} tasks later to satisfy dependency links?', skipped: '{{count}} locked task(s) also violate links and were skipped.', + skipped_one: '{{count}} locked task also violates links and was skipped.', + skipped_other: '{{count}} locked tasks also violate links and were skipped.', confirm: 'Apply', cancel: 'Cancel', none: 'All dependencies satisfied — nothing to reschedule.', @@ -1274,7 +1316,8 @@ const en = { pageHeaderActions: 'Page header actions', emojiPicker: 'Emoji picker', reactionCount: '{{emoji}} {{count}} reactions', - reactionCountOne: '{{emoji}} {{count}} reaction', + reactionCount_one: '{{emoji}} {{count}} reaction', + reactionCount_other: '{{emoji}} {{count}} reactions', // Record-overlay chrome (objectstack#5506). `recordDetail` is the VISIBLE // heading the overlay falls back to when the host passes no title; // `openAsFullPage` is the icon-only expand button's default accessible @@ -1340,8 +1383,14 @@ const en = { unpin: 'Unpin', justNow: 'just now', minutesAgo: '{{count}}m ago', + minutesAgo_one: '{{count}}m ago', + minutesAgo_other: '{{count}}m ago', hoursAgo: '{{count}}h ago', + hoursAgo_one: '{{count}}h ago', + hoursAgo_other: '{{count}}h ago', daysAgo: '{{count}}d ago', + daysAgo_one: '{{count}}d ago', + daysAgo_other: '{{count}}d ago', // Activity feed actors systemActor: 'System', unknownUser: 'Unknown', @@ -1355,7 +1404,8 @@ const en = { // Attachments dropFilesToUpload: 'Drop files here or click to upload', attachmentCount: '{{count}} attachment', - attachmentCountPlural: '{{count}} attachments', + attachmentCount_one: '{{count}} attachment', + attachmentCount_other: '{{count}} attachments', removeAttachment: 'Remove attachment', // Record Attachments panel (enable.files, objectstack#4358) attachments: 'Attachments', @@ -1428,7 +1478,8 @@ const en = { viewSource: 'View source', // Replies replyCount: '{{count}} reply', - replyCountPlural: '{{count}} replies', + replyCount_one: '{{count}} reply', + replyCount_other: '{{count}} replies', replyPlaceholder: 'Reply…', // Aria labels filterActivity: 'Filter activity', @@ -1441,6 +1492,8 @@ const en = { copyPhone: 'Copy phone number', copyRecordId: 'Copy record ID', showEmptyFields: 'Show {{count}} empty fields', + showEmptyFields_one: 'Show {{count}} empty field', + showEmptyFields_other: 'Show {{count}} empty fields', hideEmptyFields: 'Hide empty fields', noValue: 'No value', unresolvedReference: 'Unresolved reference: {{value}} was not resolved to a user', @@ -1452,8 +1505,9 @@ const en = { // `count === 1 ? 'file' : 'files'`, which is not plural-safe anywhere else. // One channel, two call sites. // - // REAL i18next plural families, NOT the two-sibling-key `xxxCountOne` shape - // used by `common.itemCount` above. i18next asks `Intl.PluralRules` for the + // REAL i18next plural families — the one count shape this repo uses since + // objectui#11445 retired the code-selected `xxxCountOne` / `xxxCountPlural` + // sibling keys. i18next asks `Intl.PluralRules` for the // ONE suffix a number needs, so each pack carries every category its language // selects (`ru` `_few`/`_many`, `ar` `_zero`/`_two`/`_few`/`_many` — // objectui#11432) plus the base key (objectui#3863). @@ -1883,7 +1937,11 @@ const en = { backToHome: 'Back to home', }, saveAdvisoryTitle: 'Saved — the authoring check raised {{count}} advisory finding(s)', + saveAdvisoryTitle_one: 'Saved — the authoring check raised {{count}} advisory finding', + saveAdvisoryTitle_other: 'Saved — the authoring check raised {{count}} advisory findings', publishAdvisoryTitle: 'Published — the authoring check raised {{count}} advisory finding(s)', + publishAdvisoryTitle_one: 'Published — the authoring check raised {{count}} advisory finding', + publishAdvisoryTitle_other: 'Published — the authoring check raised {{count}} advisory findings', importMappingsUnavailable: 'Saved import mappings for {{object}} could not be loaded', importMappingsRefused: 'The server refused this request, so this list is empty because it could not be read — not because nothing is registered. Sign in again, or ask an administrator for access.', importMappingsUnreadable: 'This list is empty because it could not be read, not because nothing is registered. Try again, and report this if it keeps happening.', @@ -1963,8 +2021,7 @@ const en = { discard: 'Discard', saveChanges: 'Save changes', // Save-bar counter. A REAL i18next plural family, not an English-only - // `change(s)` and not the two-sibling-key `xxxCountOne` shape used - // elsewhere in this file. + // `change(s)`. // // i18next asks `Intl.PluralRules` for the one suffix a language needs, so // each pack spells out every category its language selects — `ru` has @@ -2457,6 +2514,8 @@ const en = { import: 'Import', importTitle: 'Import from CSV', importedToast: 'Imported {{count}} row(s).', + importedToast_one: 'Imported {{count}} row.', + importedToast_other: 'Imported {{count}} rows.', importedWithSkipped: 'Imported {{ok}} row(s); skipped {{skipped}}.', configureView: 'Configure View', toolbarEnabledCount: '{{count}} of {{total}} enabled', @@ -2464,7 +2523,8 @@ const en = { title: 'Title', viewType: 'View type', recordCount: '{{count}} records', - recordCountOne: '{{count}} record', + recordCount_one: '{{count}} record', + recordCount_other: '{{count}} records', save: 'Save', discard: 'Discard', createView: 'Create View', @@ -2475,6 +2535,8 @@ const en = { deleteViewTitle: 'Delete view', deleteViewConfirm: 'Are you sure you want to delete the view "{{name}}"? This cannot be undone.', bulkDeleteConfirm: 'Delete {{count}} selected records? This cannot be undone.', + bulkDeleteConfirm_one: 'Delete {{count}} selected record? This cannot be undone.', + bulkDeleteConfirm_other: 'Delete {{count}} selected records? This cannot be undone.', duplicateViewName: 'A view with this name already exists.', viewTypeGrid: 'Grid', viewTypeGridDesc: 'A spreadsheet-style table of records.', @@ -2988,6 +3050,8 @@ const en = { }, pendingDrafts: { message: 'You have {{count}} unpublished change(s) — publish to make them live.', + message_one: 'You have {{count}} unpublished change — publish to make it live.', + message_other: 'You have {{count}} unpublished changes — publish to make them live.', cta: 'Publish', publishing: 'Publishing…', published: 'Published! Your changes are live.', @@ -3093,9 +3157,11 @@ const en = { placeholder: 'Search objects, dashboards, pages, reports…', inputAriaLabel: 'Search objects, dashboards, pages, reports', resultsCount: '{{count}} result for "{{query}}"', - resultsCountPlural: '{{count}} results for "{{query}}"', + resultsCount_one: '{{count}} result for "{{query}}"', + resultsCount_other: '{{count}} results for "{{query}}"', itemsAvailable: '{{count}} items available', - itemsAvailableOne: '{{count}} item available', + itemsAvailable_one: '{{count}} item available', + itemsAvailable_other: '{{count}} items available', noResults: 'No results found', noResultsHint: 'Try adjusting your search terms', typeObjects: 'Objects', @@ -3171,11 +3237,15 @@ const en = { detailNone: 'No differences detected — the draft matches the published version.', detailChangedKeys: 'Also changed:', confirmNote: 'Publishing releases all {{count}} pending drafts of this package atomically.', + confirmNote_one: 'Publishing releases the {{count}} pending draft of this package atomically.', + confirmNote_other: 'Publishing releases all {{count}} pending drafts of this package atomically.', publishConfirm: 'Publish all', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'Publishing will be refused — {{count}} item(s) need a decision first', + securityBlockTitle_one: 'Publishing will be refused — {{count}} item needs a decision first', + securityBlockTitle_other: 'Publishing will be refused — {{count}} items need a decision first', securityBlockWhere: 'Fix it on the object under Settings → Record sharing, then publish again.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3213,10 +3283,9 @@ const en = { // grammar needs no plural for, and `de`/`fr`/`es`/`pt`/`ru`/`ar` use the // label-colon form (`Elemente: 3`, `Элементов: 3`), which is the shape // `fields.textarea.charactersRemaining` already uses for this exact - // reason. ⛔ Do not "upgrade" this to a `_one`/`_other` family: identical - // key sets across ten packs (all-locales-key-parity) leave `ru` without - // `_few` and `ar` without `_two`/`_many`, so those categories land on the - // base key and `ru` goes back to reading `2 элементов`. + // reason. It needs no family: every pack's form reads at any number, + // which is why `count-families-11445.test.ts` names it on its + // count-neutral list rather than holding it to the family rule. items: '{{count}} item(s)', revertAction: 'Revert', reverted: 'Reverted — the change has been undone.', @@ -3267,6 +3336,8 @@ const en = { 'Deleting resets it to the shipped baseline and discards your environment customization. Continue?', resetPackageSetSuccess: 'Permission set reset to its shipped baseline', bulkDeleteSuccess: 'Deleted {{count}} {{label}} records', + bulkDeleteSuccess_one: 'Deleted {{count}} {{label}} record', + bulkDeleteSuccess_other: 'Deleted {{count}} {{label}} records', bulkDeletePartial: '{{succeeded}} deleted, {{failed}} failed', }, objectViewActions: { @@ -3456,9 +3527,13 @@ const en = { viewAll: 'View all notifications', groupCount: '{{count}} notifications', groupUnread: '{{count}} unread', + groupUnread_one: '{{count}} unread', + groupUnread_other: '{{count}} unread', groupMarkRead: 'Mark read', groupMarkReadTitle: 'Mark all of this type read', approvalsPending: '{{count}} pending approvals', + approvalsPending_one: '{{count}} pending approval', + approvalsPending_other: '{{count}} pending approvals', viewApprovals: 'View approvals', noPendingApprovals: 'No pending approvals', openApprovalsInbox: 'Open Approvals Inbox', @@ -3471,7 +3546,11 @@ const en = { // objectui#7011 — the arrival announcement (toast / desktop notification) // and the two switches that govern it. arrivalMany: '{{count}} new messages', + arrivalMany_one: '{{count}} new message', + arrivalMany_other: '{{count}} new messages', arrivalRepeats: '{{count}} new messages on this topic', + arrivalRepeats_one: '{{count}} new message on this topic', + arrivalRepeats_other: '{{count}} new messages on this topic', arrivalOpen: 'View', toastEnabled: 'In-app alerts', desktopEnabled: 'Desktop notifications', @@ -3552,6 +3631,8 @@ const en = { noImportableFields: 'That object has no importable fields.', schemaReadFailed: 'Could not read the object schema.', imported: 'Imported {{count}} row(s) into {{object}}.', + imported_one: 'Imported {{count}} row into {{object}}.', + imported_other: 'Imported {{count}} rows into {{object}}.', importFrom: 'Import the real rows from', into: 'into', opening: 'Opening…', @@ -3570,9 +3651,9 @@ const en = { current: 'Current plan', }, // `@object-ui/plugin-ai` — the `nl-query`, `ai-form-assist` and - // `ai-recommendations` components (objectui#10232). The `*One` rows are this - // repo's two-key plural convention (see `search.itemsAvailableOne`): the - // component picks the key at exactly one, so no CLDR category falls to `en`. + // `ai-recommendations` components (objectui#10232). The two count labels are + // i18next count families (objectui#11445): the component passes `count` and + // each pack carries every CLDR category its language selects. ai: { nlQuery: { placeholder: 'Ask a question about your data…', @@ -3586,11 +3667,13 @@ const en = { formAssist: { title: 'AI Suggestions', suggestionCount: '{{count}} suggestions', - suggestionCountOne: '{{count}} suggestion', + suggestionCount_one: '{{count}} suggestion', + suggestionCount_other: '{{count}} suggestions', applyAll: 'Apply All', confidence: '{{percent}} confidence', appliedCount: '{{count}} suggestions applied', - appliedCountOne: '{{count}} suggestion applied', + appliedCount_one: '{{count}} suggestion applied', + appliedCount_other: '{{count}} suggestions applied', }, recommendations: { title: 'Recommendations', @@ -3898,10 +3981,14 @@ const en = { purgeSampleData: 'Purge sample data', purgeConfirm: 'Delete all sample records seeded by this package? User-added records will NOT be touched.', purgeSuccess: 'Removed {{count}} sample record(s).', + purgeSuccess_one: 'Removed {{count}} sample record.', + purgeSuccess_other: 'Removed {{count}} sample records.', purgeNoData: 'No sample records found to purge.', reseedQueued: 'Sample data will be re-seeded on next environment access.', reseedLocalSuccess: 'Re-seeded sample data: {{inserted}} inserted, {{updated}} updated.', reseedPartialErrors: '({{count}} record(s) failed to write)', + reseedPartialErrors_one: '({{count}} record failed to write)', + reseedPartialErrors_other: '({{count}} records failed to write)', sampleDataKernelUnavailable: 'This control plane has no environment kernel, so sample data cannot be re-seeded or purged from here. Do it from the environment\'s own runtime.', updateAvailable: 'Update available', }, @@ -4011,7 +4098,11 @@ const en = { today: 'today', daysAgo: '{{count}}d ago', monthsAgo: '{{count}}mo ago', + monthsAgo_one: '{{count}}mo ago', + monthsAgo_other: '{{count}}mo ago', yearsAgo: '{{count}}y ago', + yearsAgo_one: '{{count}}y ago', + yearsAgo_other: '{{count}}y ago', }, }, approvalsInbox: { @@ -4034,6 +4125,8 @@ const en = { requestInfoSent: 'Sent back to the requester for more information', remindBtn: 'Send reminder', remindSuccess: 'Reminder sent to {{count}} approver(s)', + remindSuccess_one: 'Reminder sent to {{count}} approver', + remindSuccess_other: 'Reminder sent to {{count}} approvers', remindThrottled: 'A reminder was sent recently — try again later.', replyPlaceholder: 'Reply on this request…', slaRemaining: 'SLA {{dur}} left', @@ -4050,6 +4143,8 @@ const en = { progressApprovals: 'Approvals — {{got}} of {{need}}', progressGroups: 'Sign-off progress — {{got}} of {{need}} groups', progressEligible: '{{count}} eligible approver(s)', + progressEligible_one: '{{count}} eligible approver', + progressEligible_other: '{{count}} eligible approvers', progressBar: 'Decision progress', declaredActions: 'Actions', attachmentChip: 'Attachment', @@ -4081,6 +4176,8 @@ const en = { filterCount: '{{shown}} of {{total}}', selected: 'selected', actionableCount: '({{count}} actionable)', + actionableCount_one: '({{count}} actionable)', + actionableCount_other: '({{count}} actionable)', selectAll: 'Select all', selectRow: 'Select request', colRequest: 'Request', @@ -4154,11 +4251,19 @@ const en = { approveN: 'Approve {{count}}', rejectN: 'Reject {{count}}', bulkApproveTitle: 'Approve {{count}} requests?', + bulkApproveTitle_one: 'Approve {{count}} request?', + bulkApproveTitle_other: 'Approve {{count}} requests?', bulkApproveBody: 'Each request is approved with your identity and its flow continues down the approve branch.', bulkRejectTitle: 'Reject {{count}} requests?', + bulkRejectTitle_one: 'Reject {{count}} request?', + bulkRejectTitle_other: 'Reject {{count}} requests?', bulkRejectBody: 'This rejects the selected requests and notifies their submitters.', bulkApproved: 'Approved {{count}} requests', + bulkApproved_one: 'Approved {{count}} request', + bulkApproved_other: 'Approved {{count}} requests', bulkRejected: 'Rejected {{count}} requests', + bulkRejected_one: 'Rejected {{count}} request', + bulkRejected_other: 'Rejected {{count}} requests', bulkPartial: '{{ok}} succeeded, {{fail}} failed: {{which}}', rejectOneTitle: 'Reject "{{title}}"?', rejectOneBody: 'This rejects the request and notifies the submitter.', @@ -4183,8 +4288,14 @@ const en = { alreadyDecided: 'This request was already decided. Refresh the list.', justNow: 'just now', minutesAgo: '{{count}}m ago', + minutesAgo_one: '{{count}}m ago', + minutesAgo_other: '{{count}}m ago', hoursAgo: '{{count}}h ago', + hoursAgo_one: '{{count}}h ago', + hoursAgo_other: '{{count}}h ago', daysAgo: '{{count}}d ago', + daysAgo_one: '{{count}}d ago', + daysAgo_other: '{{count}}d ago', }, filterBuilder: { where: 'Where', @@ -4249,13 +4360,11 @@ const en = { // The generic action words (Save / Cancel / Edit / Delete) are NOT repeated // here — the thread reads them from `common`. collaboration: { - // Thread header. `commentCount`/`commentCountOne` are two keys, NOT an - // i18next `_one`/`_other` pair — see the `reactionCount` note under - // `detail`: zh/ja/ko have no separate singular form, so those packs would - // legitimately omit the `_one` half and `all-locales-key-parity` reads a - // legitimately-absent half as a lost key. + // Thread header. An i18next count family (objectui#11445): the component + // passes `count` and i18next picks the slot each pack's CLDR rules select. commentCount: '{{count}} comments', - commentCountOne: '{{count}} comment', + commentCount_one: '{{count}} comment', + commentCount_other: '{{count}} comments', // Appended to the count, separator included, so a translator owns the // whole phrase rather than inheriting an English-shaped ` · ` glue. resolvedSuffix: ' · Resolved', @@ -4268,14 +4377,21 @@ const en = { // existing minute/hour/day buckets and no date library was introduced. justNow: 'just now', minutesAgo: '{{count}}m ago', + minutesAgo_one: '{{count}}m ago', + minutesAgo_other: '{{count}}m ago', hoursAgo: '{{count}}h ago', + hoursAgo_one: '{{count}}h ago', + hoursAgo_other: '{{count}}h ago', daysAgo: '{{count}}d ago', + daysAgo_one: '{{count}}d ago', + daysAgo_other: '{{count}}d ago', edited: '(edited)', // Reaction-chip tooltip. A DEDICATED pair rather than `detail.reactionCount`: // that one interpolates `{{emoji}}`, and here the emoji is the chip's // visible label with nothing to hand the placeholder. reactionCount: '{{count}} reactions', - reactionCountOne: '{{count}} reaction', + reactionCount_one: '{{count}} reaction', + reactionCount_other: '{{count}} reactions', addThumbsUp: 'Add thumbs up', reply: 'Reply', // Accessible names for the three emoji-only controls (objectui#3441). @@ -4301,12 +4417,14 @@ const en = { send: 'Send', // Presence avatar stack (objectui#3440). `presentUserCount*` is the avatar // group's `aria-label` — with only images and initials inside, that label - // IS the control for a screen reader. Two keys, same reason as - // `commentCount` above. + // IS the control for a screen reader. Count families, like `commentCount` + // above. presentUserCount: '{{count}} users present', - presentUserCountOne: '{{count}} user present', + presentUserCount_one: '{{count}} user present', + presentUserCount_other: '{{count}} users present', moreUserCount: '{{count}} more users', - moreUserCountOne: '{{count}} more user', + moreUserCount_one: '{{count}} more user', + moreUserCount_other: '{{count}} more users', // Avatar tooltip. The parentheses are part of the translation, spacing // included, so the CJK packs can drop the space English puts before `(`. userStatusTitle: '{{name}} ({{status}})', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 82d05f0a0e..ef76eadee1 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -72,7 +72,9 @@ const es = { selectFirst: 'Seleccione primero {{fields}}', selectRecord: 'Seleccionar registro', recordCount: '{{count}} registros', - recordCountOne: '1 registro', + recordCount_one: '{{count}} registro', + recordCount_many: '{{count}} de registros', + recordCount_other: '{{count}} registros', pageOf: 'Página {{current}} de {{total}}', filters: 'Filtros', clear: 'Limpiar', @@ -125,7 +127,9 @@ const es = { closePanel: "Cerrar panel", resizeDrawer: "Ajustar el ancho del panel", itemCount: "{{count}} elementos", - itemCountOne: "{{count}} elemento", + itemCount_one: "{{count}} elemento", + itemCount_many: "{{count}} de elementos", + itemCount_other: "{{count}} elementos", toggleSidebar: "Alternar barra lateral", package: "Paquete", done: "Listo", @@ -270,6 +274,9 @@ const es = { }, relativeDate: { overdue: "Atrasado {{count}} d", + overdue_one: "Atrasado {{count}} d", + overdue_many: "Atrasado {{count}} d", + overdue_other: "Atrasado {{count}} d", }, file: { dragDropHere: 'Arrastra y suelta archivos aquí', @@ -406,23 +413,41 @@ const es = { open: "Abrir", search: "Buscar…", modified: "{{count}} filas modificadas", + modified_one: "{{count}} fila modificada", + modified_many: "{{count}} de filas modificadas", + modified_other: "{{count}} filas modificadas", saveFailed: 'Error al guardar', selected: "{{count}} seleccionados", + selected_one: "{{count}} seleccionado", + selected_many: "{{count}} seleccionados", + selected_other: "{{count}} seleccionados", edit: "Editar", delete: "Eliminar", }, grid: { bulkSelected: "{{count}} seleccionados", + bulkSelected_one: "{{count}} seleccionado", + bulkSelected_many: "{{count}} seleccionados", + bulkSelected_other: "{{count}} seleccionados", bulkSelectedAllMatches: "{{count}} seleccionados (todas las coincidencias)", bulkClear: "Quitar selección", bulkAllOnPage: "Están seleccionados los {{count}} de esta página.", + bulkAllOnPage_one: "{{count}} registro de esta página está seleccionado.", + bulkAllOnPage_many: "Están seleccionados los {{count}} de esta página.", + bulkAllOnPage_other: "Están seleccionados los {{count}} de esta página.", bulkSelectAllMatching: "Seleccionar las {{count}} coincidencias", bulkAllMatchingSelected: "Están seleccionados los {{count}} registros coincidentes.", bulk: { confirmDefault: "Esto se aplicará a {{count}} registro(s).", + confirmDefault_one: "Esto se aplicará a {{count}} registro.", + confirmDefault_many: "Esto se aplicará a {{count}} de registros.", + confirmDefault_other: "Esto se aplicará a {{count}} registros.", overLimit: "La selección ({{count}}) supera el límite de la acción ({{limit}}). Reduzca la selección para continuar.", affectedRecords: "Registros afectados ({{count}}):", skippedIneligible: '{{count}} de los registros seleccionados no son aptos para esta acción y se omitirán.', + skippedIneligible_one: '{{count}} de los registros seleccionados no es apto para esta acción y se omitirá.', + skippedIneligible_many: '{{count}} de los registros seleccionados no son aptos para esta acción y se omitirán.', + skippedIneligible_other: '{{count}} de los registros seleccionados no son aptos para esta acción y se omitirán.', rowFallback: "Fila {{index}}", andMore: "… y {{count}} más", processed: "{{count}} / {{total}} procesados", @@ -498,6 +523,9 @@ const es = { templateFileName: "{{object}}-import-template", autoMatched: "Emparejado automáticamente", autoMatchedSummary: "{{count}} columna(s) emparejada(s) automáticamente: revise y ajuste abajo.", + autoMatchedSummary_one: "{{count}} columna emparejada automáticamente: revise y ajuste abajo.", + autoMatchedSummary_many: "{{count}} de columnas emparejadas automáticamente: revise y ajuste abajo.", + autoMatchedSummary_other: "{{count}} columnas emparejadas automáticamente: revise y ajuste abajo.", confidence: { high: "Confianza alta", medium: "Confianza media", @@ -510,8 +538,17 @@ const es = { cancelImport: "Cancelar la importación", importCancelled: "Importación cancelada", resultsTruncated: "Mostrando los primeros {{count}} resultados de filas (de {{total}}).", + resultsTruncated_one: "Mostrando el primer resultado de fila ({{count}} de {{total}}).", + resultsTruncated_many: "Mostrando los primeros {{count}} resultados de filas (de {{total}}).", + resultsTruncated_other: "Mostrando los primeros {{count}} resultados de filas (de {{total}}).", createdCount: "{{count}} creados", + createdCount_one: "{{count}} creado", + createdCount_many: "{{count}} creados", + createdCount_other: "{{count}} creados", updatedCount: "{{count}} actualizados", + updatedCount_one: "{{count}} actualizado", + updatedCount_many: "{{count}} actualizados", + updatedCount_other: "{{count}} actualizados", downloadFailed: "Descargar las filas con error", options: "Opciones de importación", writeMode: "Cuando una fila coincide con un registro existente", @@ -559,6 +596,9 @@ const es = { historyColResult: "Resultado", historyColTime: "Cuándo", errorCount: "{{count}} errores", + errorCount_one: "{{count}} error", + errorCount_many: "{{count}} de errores", + errorCount_other: "{{count}} errores", undoImport: "Deshacer la importación", undoing: "Deshaciendo…", undoConfirm: "¿Deshacer esta importación? Los registros que creó se eliminarán y los que actualizó se restaurarán a sus valores anteriores.", @@ -619,7 +659,13 @@ const es = { mapped: "Asignado", skipped: "Omitido", rowsWithErrors: "{{count}} fila(s) con errores", + rowsWithErrors_one: "{{count}} fila con errores", + rowsWithErrors_many: "{{count}} de filas con errores", + rowsWithErrors_other: "{{count}} filas con errores", rowsCorrected: "{{count}} fila(s) corregida(s)", + rowsCorrected_one: "{{count}} fila corregida", + rowsCorrected_many: "{{count}} de filas corregidas", + rowsCorrected_other: "{{count}} filas corregidas", clickToFix: "— haga clic en una celda resaltada para editar en línea.", showingRows: "Mostrando {{shown}} de {{total}} filas", importing: "Importando… {{progress}}%", @@ -627,11 +673,17 @@ const es = { imported: "{{count}} importado(s)", skippedCount: "{{count}} omitido(s)", moreErrors: "…y {{count}} error(es) más", + moreErrors_one: "…y {{count}} error más", + moreErrors_many: "…y {{count}} de errores más", + moreErrors_other: "…y {{count}} errores más", cancel: "Cancelar", back: "Atrás", next: "Siguiente", close: "Cerrar", importNRows: "Importar {{count}} filas", + importNRows_one: "Importar {{count}} fila", + importNRows_many: "Importar {{count}} de filas", + importNRows_other: "Importar {{count}} filas", importingProgress: "Importando…", requiredMark: "*", required: "Obligatorio", @@ -717,7 +769,9 @@ const es = { refresh: "Actualizar", loading: "Cargando registros…", recordCount: "{{count}} registros", - recordCountOne: "{{count}} registro", + recordCount_one: "{{count}} registro", + recordCount_many: "{{count}} de registros", + recordCount_other: "{{count}} registros", addRecord: "Agregar registro", tabs: "Pestañas", allRecords: "Todos los registros", @@ -892,13 +946,22 @@ const es = { conflict: { title: "Conflicto de programación", body: "Este movimiento entra en conflicto con las restricciones de dependencia. ¿Reprogramar automáticamente {{count}} tarea(s) afectada(s)?", + body_one: "Este movimiento entra en conflicto con las restricciones de dependencia. ¿Reprogramar automáticamente {{count}} tarea afectada?", + body_many: "Este movimiento entra en conflicto con las restricciones de dependencia. ¿Reprogramar automáticamente {{count}} de tareas afectadas?", + body_other: "Este movimiento entra en conflicto con las restricciones de dependencia. ¿Reprogramar automáticamente {{count}} tareas afectadas?", confirm: "Reprogramar automáticamente", cancel: "Dejarlo como está", }, autoScheduleDlg: { title: "Programación automática", body: "¿Retrasar {{count}} tarea(s) para respetar los vínculos de dependencia?", + body_one: "¿Retrasar {{count}} tarea para respetar los vínculos de dependencia?", + body_many: "¿Retrasar {{count}} de tareas para respetar los vínculos de dependencia?", + body_other: "¿Retrasar {{count}} tareas para respetar los vínculos de dependencia?", skipped: "{{count}} tarea(s) bloqueada(s) también incumplen los vínculos y se han omitido.", + skipped_one: "{{count}} tarea bloqueada también incumple los vínculos y se ha omitido.", + skipped_many: "{{count}} de tareas bloqueadas también incumplen los vínculos y se han omitido.", + skipped_other: "{{count}} tareas bloqueadas también incumplen los vínculos y se han omitido.", confirm: "Aplicar", cancel: "Cancelar", none: "Todas las dependencias se cumplen: no hay nada que reprogramar.", @@ -1003,7 +1066,9 @@ const es = { pageHeaderActions: "Acciones del encabezado de página", emojiPicker: "Selector de emojis", reactionCount: "{{emoji}} {{count}} reacciones", - reactionCountOne: "{{emoji}} {{count}} reacción", + reactionCount_one: "{{emoji}} {{count}} reacción", + reactionCount_many: "{{emoji}} {{count}} de reacciones", + reactionCount_other: "{{emoji}} {{count}} reacciones", recordDetail: "Detalle del registro", recordDetailWithLabel: "Detalle de {{label}}", openAsFullPage: "Abrir como página completa", @@ -1040,11 +1105,22 @@ const es = { unpin: "Desfijar", justNow: "justo ahora", minutesAgo: "hace {{count}} min", + minutesAgo_one: "hace {{count}} min", + minutesAgo_many: "hace {{count}} min", + minutesAgo_other: "hace {{count}} min", hoursAgo: "hace {{count}} h", + hoursAgo_one: "hace {{count}} h", + hoursAgo_many: "hace {{count}} h", + hoursAgo_other: "hace {{count}} h", daysAgo: "hace {{count}} d", + daysAgo_one: "hace {{count}} d", + daysAgo_many: "hace {{count}} d", + daysAgo_other: "hace {{count}} d", dropFilesToUpload: "Arrastra archivos aquí o haz clic para subir", attachmentCount: "{{count}} archivo adjunto", - attachmentCountPlural: "{{count}} archivos adjuntos", + attachmentCount_one: "{{count}} archivo adjunto", + attachmentCount_many: "{{count}} de archivos adjuntos", + attachmentCount_other: "{{count}} archivos adjuntos", removeAttachment: "Eliminar adjunto", // Record Attachments panel (enable.files, objectstack#4358) attachments: "Adjuntos", @@ -1103,7 +1179,9 @@ const es = { via: "vía {{source}}", viewSource: "Ver origen", replyCount: "{{count}} respuesta", - replyCountPlural: "{{count}} respuestas", + replyCount_one: "{{count}} respuesta", + replyCount_many: "{{count}} de respuestas", + replyCount_other: "{{count}} respuestas", replyPlaceholder: "Responder…", filterActivity: "Filtrar actividad", openDiscussion: "Abrir panel de discusión", @@ -1187,6 +1265,9 @@ const es = { copyPhone: "Copiar teléfono", copyRecordId: "Copiar ID de registro", showEmptyFields: "Mostrar {{count}} campos vacíos", + showEmptyFields_one: "Mostrar {{count}} campo vacío", + showEmptyFields_many: "Mostrar {{count}} de campos vacíos", + showEmptyFields_other: "Mostrar {{count}} campos vacíos", hideEmptyFields: "Ocultar campos vacíos", noValue: "Sin valor", unresolvedReference: "Referencia sin resolver: {{value}} no se resolvió como un usuario", @@ -1558,7 +1639,13 @@ const es = { backToHome: "Volver al inicio", }, saveAdvisoryTitle: "Guardado: la comprobación de creación generó {{count}} recomendación(es)", + saveAdvisoryTitle_one: "Guardado: la comprobación de creación generó {{count}} recomendación", + saveAdvisoryTitle_many: "Guardado: la comprobación de creación generó {{count}} de recomendaciones", + saveAdvisoryTitle_other: "Guardado: la comprobación de creación generó {{count}} recomendaciones", publishAdvisoryTitle: "Publicado: la comprobación de creación generó {{count}} recomendación(es)", + publishAdvisoryTitle_one: "Publicado: la comprobación de creación generó {{count}} recomendación", + publishAdvisoryTitle_many: "Publicado: la comprobación de creación generó {{count}} de recomendaciones", + publishAdvisoryTitle_other: "Publicado: la comprobación de creación generó {{count}} recomendaciones", importMappingsUnavailable: "No se pudieron cargar las asignaciones de importación guardadas de {{object}}", importMappingsRefused: "El servidor rechazó esta solicitud, por lo que la lista está vacía porque no se pudo leer, no porque no haya nada registrado. Vuelve a iniciar sesión o pide acceso a un administrador.", importMappingsUnreadable: "Esta lista está vacía porque no se pudo leer, no porque no haya nada registrado. Inténtalo de nuevo e informa del problema si continúa.", @@ -2038,7 +2125,9 @@ const es = { title: "Título", viewType: "Tipo de vista", recordCount: "{{count}} registros", - recordCountOne: "{{count}} registro", + recordCount_one: "{{count}} registro", + recordCount_many: "{{count}} de registros", + recordCount_other: "{{count}} registros", save: "Guardar", discard: "Descartar", createView: "Crear vista", @@ -2054,6 +2143,9 @@ const es = { import: "Importar", importTitle: "Importar desde CSV", importedToast: "{{count}} fila(s) importada(s).", + importedToast_one: "{{count}} fila importada.", + importedToast_many: "{{count}} de filas importadas.", + importedToast_other: "{{count}} filas importadas.", importedWithSkipped: "{{ok}} fila(s) importada(s); {{skipped}} omitida(s).", createViewDesc: "Elija un tipo de vista y asígnele un nombre. Todas las configuraciones se pueden cambiar después.", cancel: "Cancelar", @@ -2062,6 +2154,9 @@ const es = { deleteViewTitle: "Eliminar vista", deleteViewConfirm: "¿Eliminar la vista \"{{name}}\"? Esto no se puede deshacer.", bulkDeleteConfirm: "¿Eliminar {{count}} registros seleccionados? Esto no se puede deshacer.", + bulkDeleteConfirm_one: "¿Eliminar {{count}} registro seleccionado? Esto no se puede deshacer.", + bulkDeleteConfirm_many: "¿Eliminar {{count}} de registros seleccionados? Esto no se puede deshacer.", + bulkDeleteConfirm_other: "¿Eliminar {{count}} registros seleccionados? Esto no se puede deshacer.", duplicateViewName: "Ya existe una vista con ese nombre.", viewTypeGrid: "Cuadrícula", viewTypeGridDesc: "Tabla tipo hoja de cálculo de registros.", @@ -2519,6 +2614,9 @@ const es = { }, pendingDrafts: { message: "Tiene {{count}} cambio(s) sin publicar — publíquelos para activarlos.", + message_one: "Tiene {{count}} cambio sin publicar — publíquelo para activarlo.", + message_many: "Tiene {{count}} de cambios sin publicar — publíquelos para activarlos.", + message_other: "Tiene {{count}} cambios sin publicar — publíquelos para activarlos.", cta: "Publicar", publishing: "Publicando…", published: "¡Publicado! Sus cambios están activos.", @@ -2642,9 +2740,13 @@ const es = { placeholder: "Buscar objetos, paneles, páginas, informes…", inputAriaLabel: "Buscar objetos, paneles, páginas, informes", resultsCount: "{{count}} resultado para \"{{query}}\"", - resultsCountPlural: "{{count}} resultados para \"{{query}}\"", + resultsCount_one: "{{count}} resultado para \"{{query}}\"", + resultsCount_many: "{{count}} de resultados para \"{{query}}\"", + resultsCount_other: "{{count}} resultados para \"{{query}}\"", itemsAvailable: "{{count}} elementos disponibles", - itemsAvailableOne: "{{count}} elemento disponible", + itemsAvailable_one: "{{count}} elemento disponible", + itemsAvailable_many: "{{count}} de elementos disponibles", + itemsAvailable_other: "{{count}} elementos disponibles", noResults: "Sin resultados encontrados", noResultsHint: "Intente ajustar sus términos de búsqueda", typeObjects: "Objetos", @@ -2721,6 +2823,9 @@ const es = { noRecordId: "No se proporcionó ID de registro", deleteConfirm: "¿Eliminar este registro?", bulkDeleteSuccess: "{{count}} registros de {{label}} eliminados", + bulkDeleteSuccess_one: "{{count}} registro de {{label}} eliminado", + bulkDeleteSuccess_many: "{{count}} de registros de {{label}} eliminados", + bulkDeleteSuccess_other: "{{count}} registros de {{label}} eliminados", bulkDeletePartial: "{{succeeded}} eliminados, {{failed}} fallidos", }, objectViewActions: { @@ -2921,12 +3026,18 @@ const es = { regionLabel: "Notificaciones", groupCount: "{{count}} notificaciones", groupUnread: "{{count}} sin leer", + groupUnread_one: "{{count}} sin leer", + groupUnread_many: "{{count}} sin leer", + groupUnread_other: "{{count}} sin leer", groupMarkRead: "Marcar como leídas", groupMarkReadTitle: "Marcar como leídas todas las de este tipo", empty: "Sin notificaciones", markAllRead: "Marcar todo como leído", viewAll: "Ver todas las notificaciones", approvalsPending: "{{count}} aprobaciones pendientes", + approvalsPending_one: "{{count}} aprobación pendiente", + approvalsPending_many: "{{count}} de aprobaciones pendientes", + approvalsPending_other: "{{count}} aprobaciones pendientes", viewApprovals: "Ver aprobaciones", noPendingApprovals: "Sin aprobaciones pendientes", openApprovalsInbox: "Abrir bandeja de aprobaciones", @@ -2934,7 +3045,13 @@ const es = { badgeNotifications: "{{unread}} notificaciones", badgeApprovals: "{{approvals}} aprobaciones pendientes", arrivalMany: "{{count}} mensajes nuevos", + arrivalMany_one: "{{count}} mensaje nuevo", + arrivalMany_many: "{{count}} de mensajes nuevos", + arrivalMany_other: "{{count}} mensajes nuevos", arrivalRepeats: "{{count}} mensajes nuevos sobre este tema", + arrivalRepeats_one: "{{count}} mensaje nuevo sobre este tema", + arrivalRepeats_many: "{{count}} de mensajes nuevos sobre este tema", + arrivalRepeats_other: "{{count}} mensajes nuevos sobre este tema", arrivalOpen: "Ver", toastEnabled: "Avisos en la aplicación", desktopEnabled: "Notificaciones de escritorio", @@ -3011,6 +3128,9 @@ const es = { noImportableFields: "Ese objeto no tiene campos importables.", schemaReadFailed: "No se pudo leer el esquema del objeto.", imported: "Se importaron {{count}} fila(s) en {{object}}.", + imported_one: "Se importó {{count}} fila en {{object}}.", + imported_many: "Se importaron {{count}} de filas en {{object}}.", + imported_other: "Se importaron {{count}} filas en {{object}}.", importFrom: "Importar las filas reales desde", into: "a", opening: "Abriendo…", @@ -3040,11 +3160,15 @@ const es = { formAssist: { title: "Sugerencias de IA", suggestionCount: "{{count}} sugerencias", - suggestionCountOne: "{{count}} sugerencia", + suggestionCount_one: "{{count}} sugerencia", + suggestionCount_many: "{{count}} de sugerencias", + suggestionCount_other: "{{count}} sugerencias", applyAll: "Aplicar todo", confidence: "Confianza: {{percent}}", appliedCount: "{{count}} sugerencias aplicadas", - appliedCountOne: "{{count}} sugerencia aplicada", + appliedCount_one: "{{count}} sugerencia aplicada", + appliedCount_many: "{{count}} de sugerencias aplicadas", + appliedCount_other: "{{count}} sugerencias aplicadas", }, recommendations: { title: "Recomendaciones", @@ -3345,10 +3469,16 @@ const es = { purgeSampleData: 'Eliminar datos de ejemplo', purgeConfirm: '¿Eliminar todos los registros de ejemplo creados por este paquete? Los registros añadidos por el usuario NO se verán afectados.', purgeSuccess: 'Se eliminaron {{count}} registro(s) de ejemplo.', + purgeSuccess_one: 'Se eliminó {{count}} registro de ejemplo.', + purgeSuccess_many: 'Se eliminaron {{count}} de registros de ejemplo.', + purgeSuccess_other: 'Se eliminaron {{count}} registros de ejemplo.', purgeNoData: 'No se encontraron registros de ejemplo para eliminar.', reseedQueued: 'Los datos de ejemplo se volverán a cargar en el próximo acceso al entorno.', reseedLocalSuccess: 'Datos de ejemplo recargados: {{inserted}} insertados, {{updated}} actualizados.', reseedPartialErrors: '({{count}} registro(s) no se pudieron escribir)', + reseedPartialErrors_one: '({{count}} registro no se pudo escribir)', + reseedPartialErrors_many: '({{count}} de registros no se pudieron escribir)', + reseedPartialErrors_other: '({{count}} registros no se pudieron escribir)', sampleDataKernelUnavailable: 'Este plano de control no tiene un kernel de entorno, por lo que los datos de ejemplo no se pueden volver a cargar ni eliminar desde aquí. Hágalo desde el propio runtime del entorno.', updateAvailable: 'Actualización disponible', }, @@ -3446,7 +3576,13 @@ const es = { today: "hoy", daysAgo: "hace {{count}}d", monthsAgo: "hace {{count}} meses", + monthsAgo_one: "hace {{count}} mes", + monthsAgo_many: "hace {{count}} meses", + monthsAgo_other: "hace {{count}} meses", yearsAgo: "hace {{count}} años", + yearsAgo_one: "hace {{count}} año", + yearsAgo_many: "hace {{count}} años", + yearsAgo_other: "hace {{count}} años", }, }, approvalsInbox: { @@ -3469,6 +3605,9 @@ const es = { requestInfoSent: 'Devuelta al solicitante para más información', remindBtn: 'Recordatorio', remindSuccess: 'Recordatorio enviado a {{count}} aprobador(es)', + remindSuccess_one: 'Recordatorio enviado a {{count}} aprobador', + remindSuccess_many: 'Recordatorio enviado a {{count}} de aprobadores', + remindSuccess_other: 'Recordatorio enviado a {{count}} aprobadores', remindThrottled: 'Ya se envió un recordatorio hace poco; inténtalo más tarde.', replyPlaceholder: 'Responder en esta solicitud…', slaRemaining: 'SLA: quedan {{dur}}', @@ -3485,6 +3624,9 @@ const es = { progressApprovals: 'Aprobaciones — {{got}} de {{need}}', progressGroups: 'Progreso de firmas — {{got}} de {{need}} grupos', progressEligible: '{{count}} aprobador(es) elegible(s)', + progressEligible_one: '{{count}} aprobador elegible', + progressEligible_many: '{{count}} de aprobadores elegibles', + progressEligible_other: '{{count}} aprobadores elegibles', progressBar: 'Progreso de la decisión', declaredActions: 'Acciones', attachmentChip: 'Adjunto', @@ -3516,6 +3658,9 @@ const es = { filterCount: '{{shown}} de {{total}}', selected: 'seleccionadas', actionableCount: '({{count}} accionables)', + actionableCount_one: '({{count}} accionable)', + actionableCount_many: '({{count}} accionables)', + actionableCount_other: '({{count}} accionables)', selectAll: 'Seleccionar todo', selectRow: 'Seleccionar solicitud', colRequest: 'Solicitud', @@ -3589,11 +3734,23 @@ const es = { approveN: 'Aprobar {{count}}', rejectN: 'Rechazar {{count}}', bulkApproveTitle: '¿Aprobar {{count}} solicitudes?', + bulkApproveTitle_one: '¿Aprobar {{count}} solicitud?', + bulkApproveTitle_many: '¿Aprobar {{count}} de solicitudes?', + bulkApproveTitle_other: '¿Aprobar {{count}} solicitudes?', bulkApproveBody: 'Cada solicitud se aprueba con tu identidad y su flujo continúa por la rama de aprobación.', bulkRejectTitle: '¿Rechazar {{count}} solicitudes?', + bulkRejectTitle_one: '¿Rechazar {{count}} solicitud?', + bulkRejectTitle_many: '¿Rechazar {{count}} de solicitudes?', + bulkRejectTitle_other: '¿Rechazar {{count}} solicitudes?', bulkRejectBody: 'Se rechazarán las solicitudes seleccionadas y se notificará a los solicitantes.', bulkApproved: '{{count}} solicitudes aprobadas', + bulkApproved_one: '{{count}} solicitud aprobada', + bulkApproved_many: '{{count}} de solicitudes aprobadas', + bulkApproved_other: '{{count}} solicitudes aprobadas', bulkRejected: '{{count}} solicitudes rechazadas', + bulkRejected_one: '{{count}} solicitud rechazada', + bulkRejected_many: '{{count}} de solicitudes rechazadas', + bulkRejected_other: '{{count}} solicitudes rechazadas', bulkPartial: '{{ok}} con éxito, {{fail}} fallidas: {{which}}', rejectOneTitle: '¿Rechazar "{{title}}"?', rejectOneBody: 'Se rechaza la solicitud y se notifica al solicitante.', @@ -3618,8 +3775,17 @@ const es = { alreadyDecided: 'Esta solicitud ya fue decidida. Actualiza la lista.', justNow: 'justo ahora', minutesAgo: 'hace {{count}} min', + minutesAgo_one: 'hace {{count}} min', + minutesAgo_many: 'hace {{count}} min', + minutesAgo_other: 'hace {{count}} min', hoursAgo: 'hace {{count}} h', + hoursAgo_one: 'hace {{count}} h', + hoursAgo_many: 'hace {{count}} h', + hoursAgo_other: 'hace {{count}} h', daysAgo: 'hace {{count}} días', + daysAgo_one: 'hace {{count}} día', + daysAgo_many: 'hace {{count}} días', + daysAgo_other: 'hace {{count}} días', }, preview: { empty: { @@ -3654,11 +3820,17 @@ const es = { detailNone: 'No se detectaron diferencias — el borrador coincide con la versión publicada.', detailChangedKeys: 'También cambió:', confirmNote: 'Publicar libera atómicamente los {{count}} borradores pendientes de este paquete.', + confirmNote_one: 'Publicar libera atómicamente {{count}} borrador pendiente de este paquete.', + confirmNote_many: 'Publicar libera atómicamente los {{count}} de borradores pendientes de este paquete.', + confirmNote_other: 'Publicar libera atómicamente los {{count}} borradores pendientes de este paquete.', publishConfirm: 'Publicar todo', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'La publicación será rechazada: {{count}} elemento(s) necesitan una decisión primero', + securityBlockTitle_one: 'La publicación será rechazada: {{count}} elemento necesita una decisión primero', + securityBlockTitle_many: 'La publicación será rechazada: {{count}} de elementos necesitan una decisión primero', + securityBlockTitle_other: 'La publicación será rechazada: {{count}} elementos necesitan una decisión primero', securityBlockWhere: 'Corríjalo en el objeto en Configuración → Uso compartido de registros y vuelva a publicar.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3748,7 +3920,9 @@ const es = { }, collaboration: { commentCount: "{{count}} comentarios", - commentCountOne: "{{count}} comentario", + commentCount_one: "{{count}} comentario", + commentCount_many: "{{count}} de comentarios", + commentCount_other: "{{count}} comentarios", resolvedSuffix: " · Resuelto", sortComments: "Ordenar comentarios", sortOldest: "Más antiguos", @@ -3757,11 +3931,22 @@ const es = { reopen: "Reabrir", justNow: "ahora mismo", minutesAgo: "hace {{count}} min", + minutesAgo_one: "hace {{count}} min", + minutesAgo_many: "hace {{count}} min", + minutesAgo_other: "hace {{count}} min", hoursAgo: "hace {{count}} h", + hoursAgo_one: "hace {{count}} h", + hoursAgo_many: "hace {{count}} h", + hoursAgo_other: "hace {{count}} h", daysAgo: "hace {{count}} d", + daysAgo_one: "hace {{count}} d", + daysAgo_many: "hace {{count}} d", + daysAgo_other: "hace {{count}} d", edited: "(editado)", reactionCount: "{{count}} reacciones", - reactionCountOne: "{{count}} reacción", + reactionCount_one: "{{count}} reacción", + reactionCount_many: "{{count}} de reacciones", + reactionCount_other: "{{count}} reacciones", addThumbsUp: "Agregar me gusta", reply: "Responder", reactThumbsUp: "Reaccionar con me gusta", @@ -3772,9 +3957,13 @@ const es = { commentPlaceholder: "Agregar un comentario… (usa @ para mencionar)", send: "Enviar", presentUserCount: "{{count}} usuarios presentes", - presentUserCountOne: "{{count}} usuario presente", + presentUserCount_one: "{{count}} usuario presente", + presentUserCount_many: "{{count}} de usuarios presentes", + presentUserCount_other: "{{count}} usuarios presentes", moreUserCount: "{{count}} usuarios más", - moreUserCountOne: "{{count}} usuario más", + moreUserCount_one: "{{count}} usuario más", + moreUserCount_many: "{{count}} de usuarios más", + moreUserCount_other: "{{count}} usuarios más", userStatusTitle: "{{name}} ({{status}})", statusActive: "activo", statusIdle: "inactivo", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 777029daea..ebc4f9553f 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -73,7 +73,9 @@ const fr = { selectFirst: 'Sélectionnez d’abord {{fields}}', selectRecord: 'Sélectionner un enregistrement', recordCount: '{{count}} enregistrements', - recordCountOne: '1 enregistrement', + recordCount_one: '{{count}} enregistrement', + recordCount_many: "{{count}} d'enregistrements", + recordCount_other: '{{count}} enregistrements', pageOf: 'Page {{current}} sur {{total}}', filters: 'Filtres', clear: 'Effacer', @@ -126,7 +128,9 @@ const fr = { closePanel: "Fermer le panneau", resizeDrawer: "Redimensionner le panneau", itemCount: "{{count}} éléments", - itemCountOne: "{{count}} élément", + itemCount_one: "{{count}} élément", + itemCount_many: "{{count}} d'éléments", + itemCount_other: "{{count}} éléments", toggleSidebar: "Basculer la barre latérale", package: "Package", done: "Terminé", @@ -266,6 +270,9 @@ const fr = { }, relativeDate: { overdue: "En retard de {{count}} j", + overdue_one: "En retard de {{count}} j", + overdue_many: "En retard de {{count}} j", + overdue_other: "En retard de {{count}} j", }, file: { dragDropHere: 'Glissez-déposez des fichiers ici', @@ -402,23 +409,41 @@ const fr = { open: "Ouvrir", search: "Rechercher…", modified: "{{count}} ligne(s) modifiée(s)", + modified_one: "{{count}} ligne modifiée", + modified_many: "{{count}} de lignes modifiées", + modified_other: "{{count}} lignes modifiées", saveFailed: 'Échec de l’enregistrement', selected: "{{count}} sélectionné(s)", + selected_one: "{{count}} sélectionné", + selected_many: "{{count}} sélectionnés", + selected_other: "{{count}} sélectionnés", edit: "Modifier", delete: "Supprimer", }, grid: { bulkSelected: "{{count}} sélectionné(s)", + bulkSelected_one: "{{count}} sélectionné", + bulkSelected_many: "{{count}} sélectionnés", + bulkSelected_other: "{{count}} sélectionnés", bulkSelectedAllMatches: "{{count}} sélectionné(s) (tous les résultats)", bulkClear: "Effacer", bulkAllOnPage: "Les {{count}} éléments de cette page sont sélectionnés.", + bulkAllOnPage_one: "{{count}} enregistrement de cette page est sélectionné.", + bulkAllOnPage_many: "Les {{count}} éléments de cette page sont sélectionnés.", + bulkAllOnPage_other: "Les {{count}} éléments de cette page sont sélectionnés.", bulkSelectAllMatching: "Sélectionner les {{count}} correspondances", bulkAllMatchingSelected: "Les {{count}} enregistrements correspondants sont tous sélectionnés.", bulk: { confirmDefault: "Ceci s'appliquera à {{count}} enregistrement(s).", + confirmDefault_one: "Ceci s'appliquera à {{count}} enregistrement.", + confirmDefault_many: "Ceci s'appliquera à {{count}} d'enregistrements.", + confirmDefault_other: "Ceci s'appliquera à {{count}} enregistrements.", overLimit: "La sélection ({{count}}) dépasse la limite de l'action ({{limit}}). Réduisez la sélection pour continuer.", affectedRecords: "Enregistrements concernés ({{count}}) :", skippedIneligible: '{{count}} des enregistrements sélectionnés ne sont pas éligibles à cette action et seront ignorés.', + skippedIneligible_one: "{{count}} des enregistrements sélectionnés n'est pas éligible à cette action et sera ignoré.", + skippedIneligible_many: '{{count}} des enregistrements sélectionnés ne sont pas éligibles à cette action et seront ignorés.', + skippedIneligible_other: '{{count}} des enregistrements sélectionnés ne sont pas éligibles à cette action et seront ignorés.', rowFallback: "Ligne {{index}}", andMore: "… et {{count}} de plus", processed: "{{count}} / {{total}} traités", @@ -494,6 +519,9 @@ const fr = { templateFileName: "{{object}}-import-template", autoMatched: "Associé automatiquement", autoMatchedSummary: "{{count}} colonne(s) associée(s) automatiquement — vérifiez et ajustez ci-dessous.", + autoMatchedSummary_one: "{{count}} colonne associée automatiquement — vérifiez et ajustez ci-dessous.", + autoMatchedSummary_many: "{{count}} de colonnes associées automatiquement — vérifiez et ajustez ci-dessous.", + autoMatchedSummary_other: "{{count}} colonnes associées automatiquement — vérifiez et ajustez ci-dessous.", confidence: { high: "Confiance élevée", medium: "Confiance moyenne", @@ -506,8 +534,17 @@ const fr = { cancelImport: "Annuler l'import", importCancelled: "Import annulé", resultsTruncated: "Affichage des {{count}} premiers résultats de lignes (sur {{total}}).", + resultsTruncated_one: "Affichage du premier résultat de ligne ({{count}} sur {{total}}).", + resultsTruncated_many: "Affichage des {{count}} premiers résultats de lignes (sur {{total}}).", + resultsTruncated_other: "Affichage des {{count}} premiers résultats de lignes (sur {{total}}).", createdCount: "{{count}} créé(s)", + createdCount_one: "{{count}} créé", + createdCount_many: "{{count}} créés", + createdCount_other: "{{count}} créés", updatedCount: "{{count}} mis à jour", + updatedCount_one: "{{count}} mis à jour", + updatedCount_many: "{{count}} mis à jour", + updatedCount_other: "{{count}} mis à jour", downloadFailed: "Télécharger les lignes en échec", options: "Options d'import", writeMode: "Lorsqu'une ligne correspond à un enregistrement existant", @@ -555,6 +592,9 @@ const fr = { historyColResult: "Résultat", historyColTime: "Quand", errorCount: "{{count}} erreurs", + errorCount_one: "{{count}} erreur", + errorCount_many: "{{count}} d'erreurs", + errorCount_other: "{{count}} erreurs", undoImport: "Annuler l'import", undoing: "Annulation…", undoConfirm: "Annuler cet import ? Les enregistrements créés seront supprimés et les enregistrements mis à jour seront rétablis à leurs valeurs précédentes.", @@ -615,7 +655,13 @@ const fr = { mapped: "Mappé", skipped: "Ignoré", rowsWithErrors: "{{count}} ligne(s) avec des erreurs", + rowsWithErrors_one: "{{count}} ligne avec des erreurs", + rowsWithErrors_many: "{{count}} de lignes avec des erreurs", + rowsWithErrors_other: "{{count}} lignes avec des erreurs", rowsCorrected: "{{count}} ligne(s) corrigée(s)", + rowsCorrected_one: "{{count}} ligne corrigée", + rowsCorrected_many: "{{count}} de lignes corrigées", + rowsCorrected_other: "{{count}} lignes corrigées", clickToFix: "— cliquez sur une cellule mise en surbrillance pour modifier en ligne.", showingRows: "Affichage de {{shown}} sur {{total}} lignes", importing: "Importation… {{progress}}%", @@ -623,11 +669,17 @@ const fr = { imported: "{{count}} importé(s)", skippedCount: "{{count}} ignoré(s)", moreErrors: "…et {{count}} erreur(s) de plus", + moreErrors_one: "…et {{count}} erreur de plus", + moreErrors_many: "…et {{count}} d'erreurs de plus", + moreErrors_other: "…et {{count}} erreurs de plus", cancel: "Annuler", back: "Retour", next: "Suivant", close: "Fermer", importNRows: "Importer {{count}} lignes", + importNRows_one: "Importer {{count}} ligne", + importNRows_many: "Importer {{count}} de lignes", + importNRows_other: "Importer {{count}} lignes", importingProgress: "Importation…", requiredMark: "*", required: "Obligatoire", @@ -713,7 +765,9 @@ const fr = { refresh: "Actualiser", loading: "Chargement des enregistrements…", recordCount: "{{count}} enregistrements", - recordCountOne: "{{count}} enregistrement", + recordCount_one: "{{count}} enregistrement", + recordCount_many: "{{count}} d'enregistrements", + recordCount_other: "{{count}} enregistrements", addRecord: "Ajouter un enregistrement", tabs: "Onglets", allRecords: "Tous les enregistrements", @@ -888,13 +942,22 @@ const fr = { conflict: { title: "Conflit de planning", body: "Ce déplacement entre en conflit avec les contraintes de dépendance. Replanifier automatiquement {{count}} tâche(s) concernée(s) ?", + body_one: "Ce déplacement entre en conflit avec les contraintes de dépendance. Replanifier automatiquement {{count}} tâche concernée ?", + body_many: "Ce déplacement entre en conflit avec les contraintes de dépendance. Replanifier automatiquement {{count}} de tâches concernées ?", + body_other: "Ce déplacement entre en conflit avec les contraintes de dépendance. Replanifier automatiquement {{count}} tâches concernées ?", confirm: "Replanifier automatiquement", cancel: "Laisser tel quel", }, autoScheduleDlg: { title: "Planification automatique", body: "Décaler {{count}} tâche(s) plus tard pour respecter les liens de dépendance ?", + body_one: "Décaler {{count}} tâche plus tard pour respecter les liens de dépendance ?", + body_many: "Décaler {{count}} de tâches plus tard pour respecter les liens de dépendance ?", + body_other: "Décaler {{count}} tâches plus tard pour respecter les liens de dépendance ?", skipped: "{{count}} tâche(s) verrouillée(s) violent aussi les liens et ont été ignorées.", + skipped_one: "{{count}} tâche verrouillée viole aussi les liens et a été ignorée.", + skipped_many: "{{count}} de tâches verrouillées violent aussi les liens et ont été ignorées.", + skipped_other: "{{count}} tâches verrouillées violent aussi les liens et ont été ignorées.", confirm: "Appliquer", cancel: "Annuler", none: "Toutes les dépendances sont respectées — rien à replanifier.", @@ -1001,7 +1064,9 @@ const fr = { pageHeaderActions: "Actions de l'en-tête de page", emojiPicker: "Sélecteur d'émojis", reactionCount: "{{emoji}} {{count}} réactions", - reactionCountOne: "{{emoji}} {{count}} réaction", + reactionCount_one: "{{emoji}} {{count}} réaction", + reactionCount_many: "{{emoji}} {{count}} de réactions", + reactionCount_other: "{{emoji}} {{count}} réactions", recordDetail: "Détail de l'enregistrement", recordDetailWithLabel: "Détail — {{label}}", openAsFullPage: "Ouvrir en pleine page", @@ -1038,11 +1103,22 @@ const fr = { unpin: "Désépingler", justNow: "à l'instant", minutesAgo: "il y a {{count}} min", + minutesAgo_one: "il y a {{count}} min", + minutesAgo_many: "il y a {{count}} min", + minutesAgo_other: "il y a {{count}} min", hoursAgo: "il y a {{count}} h", + hoursAgo_one: "il y a {{count}} h", + hoursAgo_many: "il y a {{count}} h", + hoursAgo_other: "il y a {{count}} h", daysAgo: "il y a {{count}} j", + daysAgo_one: "il y a {{count}} j", + daysAgo_many: "il y a {{count}} j", + daysAgo_other: "il y a {{count}} j", dropFilesToUpload: "Déposez les fichiers ici ou cliquez pour télécharger", attachmentCount: "{{count}} pièce jointe", - attachmentCountPlural: "{{count}} pièces jointes", + attachmentCount_one: "{{count}} pièce jointe", + attachmentCount_many: "{{count}} de pièces jointes", + attachmentCount_other: "{{count}} pièces jointes", removeAttachment: "Supprimer la pièce jointe", // Record Attachments panel (enable.files, objectstack#4358) attachments: "Pièces jointes", @@ -1101,7 +1177,9 @@ const fr = { via: "via {{source}}", viewSource: "Voir la source", replyCount: "{{count}} réponse", - replyCountPlural: "{{count}} réponses", + replyCount_one: "{{count}} réponse", + replyCount_many: "{{count}} de réponses", + replyCount_other: "{{count}} réponses", replyPlaceholder: "Répondre…", filterActivity: "Filtrer l'activité", openDiscussion: "Ouvrir le panneau de discussion", @@ -1184,6 +1262,9 @@ const fr = { copyPhone: "Copier le téléphone", copyRecordId: "Copier l'ID", showEmptyFields: "Afficher {{count}} champs vides", + showEmptyFields_one: "Afficher {{count}} champ vide", + showEmptyFields_many: "Afficher {{count}} de champs vides", + showEmptyFields_other: "Afficher {{count}} champs vides", hideEmptyFields: "Masquer les champs vides", noValue: "Aucune valeur", unresolvedReference: "Référence non résolue : {{value}} n'a pas été résolue en utilisateur", @@ -1555,7 +1636,13 @@ const fr = { backToHome: "Retour à l'accueil", }, saveAdvisoryTitle: "Enregistré — le contrôle de création a signalé {{count}} recommandation(s)", + saveAdvisoryTitle_one: "Enregistré — le contrôle de création a signalé {{count}} recommandation", + saveAdvisoryTitle_many: "Enregistré — le contrôle de création a signalé {{count}} de recommandations", + saveAdvisoryTitle_other: "Enregistré — le contrôle de création a signalé {{count}} recommandations", publishAdvisoryTitle: "Publié — le contrôle de création a signalé {{count}} recommandation(s)", + publishAdvisoryTitle_one: "Publié — le contrôle de création a signalé {{count}} recommandation", + publishAdvisoryTitle_many: "Publié — le contrôle de création a signalé {{count}} de recommandations", + publishAdvisoryTitle_other: "Publié — le contrôle de création a signalé {{count}} recommandations", importMappingsUnavailable: "Impossible de charger les mappages d’import enregistrés pour {{object}}", importMappingsRefused: "Le serveur a refusé cette requête : cette liste est donc vide parce qu’elle n’a pas pu être lue, et non parce que rien n’est enregistré. Reconnectez-vous ou demandez un accès à un administrateur.", importMappingsUnreadable: "Cette liste est vide parce qu’elle n’a pas pu être lue, et non parce que rien n’est enregistré. Réessayez, et signalez le problème s’il persiste.", @@ -2035,7 +2122,9 @@ const fr = { title: "Titre", viewType: "Type de vue", recordCount: "{{count}} enregistrements", - recordCountOne: "{{count}} enregistrement", + recordCount_one: "{{count}} enregistrement", + recordCount_many: "{{count}} d'enregistrements", + recordCount_other: "{{count}} enregistrements", save: "Enregistrer", discard: "Annuler", createView: "Créer une vue", @@ -2051,6 +2140,9 @@ const fr = { import: "Importer", importTitle: "Importer depuis CSV", importedToast: "{{count}} ligne(s) importée(s).", + importedToast_one: "{{count}} ligne importée.", + importedToast_many: "{{count}} de lignes importées.", + importedToast_other: "{{count}} lignes importées.", importedWithSkipped: "{{ok}} ligne(s) importée(s) ; {{skipped}} ignorée(s).", createViewDesc: "Choisissez un type de vue et donnez-lui un nom. Tous les paramètres peuvent être modifiés ensuite.", cancel: "Annuler", @@ -2059,6 +2151,9 @@ const fr = { deleteViewTitle: "Supprimer la vue", deleteViewConfirm: "Supprimer la vue \"{{name}}\" ? Ceci est irréversible.", bulkDeleteConfirm: "Supprimer {{count}} enregistrements sélectionnés ? Ceci est irréversible.", + bulkDeleteConfirm_one: "Supprimer {{count}} enregistrement sélectionné ? Ceci est irréversible.", + bulkDeleteConfirm_many: "Supprimer {{count}} d'enregistrements sélectionnés ? Ceci est irréversible.", + bulkDeleteConfirm_other: "Supprimer {{count}} enregistrements sélectionnés ? Ceci est irréversible.", duplicateViewName: "Une vue avec ce nom existe déjà.", viewTypeGrid: "Grille", viewTypeGridDesc: "Tableau de type tableur d'enregistrements.", @@ -2516,6 +2611,9 @@ const fr = { }, pendingDrafts: { message: "Vous avez {{count}} modification(s) non publiée(s) — publiez-les pour les activer.", + message_one: "Vous avez {{count}} modification non publiée — publiez-la pour l'activer.", + message_many: "Vous avez {{count}} de modifications non publiées — publiez-les pour les activer.", + message_other: "Vous avez {{count}} modifications non publiées — publiez-les pour les activer.", cta: "Publier", publishing: "Publication…", published: "Publié ! Vos modifications sont actives.", @@ -2639,9 +2737,13 @@ const fr = { placeholder: "Rechercher des objets, tableaux de bord, pages, rapports…", inputAriaLabel: "Rechercher des objets, tableaux de bord, pages, rapports", resultsCount: "{{count}} résultat pour \"{{query}}\"", - resultsCountPlural: "{{count}} résultats pour \"{{query}}\"", + resultsCount_one: "{{count}} résultat pour \"{{query}}\"", + resultsCount_many: "{{count}} de résultats pour \"{{query}}\"", + resultsCount_other: "{{count}} résultats pour \"{{query}}\"", itemsAvailable: "{{count}} éléments disponibles", - itemsAvailableOne: "{{count}} élément disponible", + itemsAvailable_one: "{{count}} élément disponible", + itemsAvailable_many: "{{count}} d'éléments disponibles", + itemsAvailable_other: "{{count}} éléments disponibles", noResults: "Aucun résultat trouvé", noResultsHint: "Essayez d'ajuster vos termes de recherche", typeObjects: "Objets", @@ -2718,6 +2820,9 @@ const fr = { noRecordId: "Aucun ID d'enregistrement fourni", deleteConfirm: "Supprimer cet enregistrement ?", bulkDeleteSuccess: "{{count}} enregistrement(s) {{label}} supprimé(s)", + bulkDeleteSuccess_one: "{{count}} enregistrement {{label}} supprimé", + bulkDeleteSuccess_many: "{{count}} d'enregistrements {{label}} supprimés", + bulkDeleteSuccess_other: "{{count}} enregistrements {{label}} supprimés", bulkDeletePartial: "{{succeeded}} supprimé(s), {{failed}} échoué(s)", }, objectViewActions: { @@ -2918,12 +3023,18 @@ const fr = { regionLabel: "Notifications", groupCount: "{{count}} notifications", groupUnread: "{{count}} non lues", + groupUnread_one: "{{count}} non lue", + groupUnread_many: "{{count}} non lues", + groupUnread_other: "{{count}} non lues", groupMarkRead: "Marquer comme lu", groupMarkReadTitle: "Tout marquer comme lu pour ce type", empty: "Aucune notification", markAllRead: "Tout marquer comme lu", viewAll: "Voir toutes les notifications", approvalsPending: "{{count}} approbations en attente", + approvalsPending_one: "{{count}} approbation en attente", + approvalsPending_many: "{{count}} d'approbations en attente", + approvalsPending_other: "{{count}} approbations en attente", viewApprovals: "Voir les approbations", noPendingApprovals: "Aucune approbation en attente", openApprovalsInbox: "Ouvrir la boîte d'approbations", @@ -2931,7 +3042,13 @@ const fr = { badgeNotifications: "{{unread}} notifications", badgeApprovals: "{{approvals}} approbations en attente", arrivalMany: "{{count}} nouveaux messages", + arrivalMany_one: "{{count}} nouveau message", + arrivalMany_many: "{{count}} de nouveaux messages", + arrivalMany_other: "{{count}} nouveaux messages", arrivalRepeats: "{{count}} nouveaux messages sur ce sujet", + arrivalRepeats_one: "{{count}} nouveau message sur ce sujet", + arrivalRepeats_many: "{{count}} de nouveaux messages sur ce sujet", + arrivalRepeats_other: "{{count}} nouveaux messages sur ce sujet", arrivalOpen: "Afficher", toastEnabled: "Alertes dans l'application", desktopEnabled: "Notifications du bureau", @@ -3008,6 +3125,9 @@ const fr = { noImportableFields: "Cet objet n'a aucun champ importable.", schemaReadFailed: "Impossible de lire le schéma de l'objet.", imported: "{{count}} ligne(s) importée(s) dans {{object}}.", + imported_one: "{{count}} ligne importée dans {{object}}.", + imported_many: "{{count}} de lignes importées dans {{object}}.", + imported_other: "{{count}} lignes importées dans {{object}}.", importFrom: "Importer les lignes réelles depuis", into: "vers", opening: "Ouverture…", @@ -3037,11 +3157,15 @@ const fr = { formAssist: { title: "Suggestions de l’IA", suggestionCount: "{{count}} suggestions", - suggestionCountOne: "{{count}} suggestion", + suggestionCount_one: "{{count}} suggestion", + suggestionCount_many: "{{count}} de suggestions", + suggestionCount_other: "{{count}} suggestions", applyAll: "Tout appliquer", confidence: "Confiance : {{percent}}", appliedCount: "{{count}} suggestions appliquées", - appliedCountOne: "{{count}} suggestion appliquée", + appliedCount_one: "{{count}} suggestion appliquée", + appliedCount_many: "{{count}} de suggestions appliquées", + appliedCount_other: "{{count}} suggestions appliquées", }, recommendations: { title: "Recommandations", @@ -3342,10 +3466,16 @@ const fr = { purgeSampleData: 'Supprimer les données d\'exemple', purgeConfirm: 'Supprimer tous les enregistrements d\'exemple créés par ce paquet ? Les enregistrements ajoutés par l\'utilisateur ne seront PAS touchés.', purgeSuccess: '{{count}} enregistrement(s) d\'exemple supprimé(s).', + purgeSuccess_one: "{{count}} enregistrement d'exemple supprimé.", + purgeSuccess_many: "{{count}} d'enregistrements d'exemple supprimés.", + purgeSuccess_other: "{{count}} enregistrements d'exemple supprimés.", purgeNoData: 'Aucun enregistrement d\'exemple à supprimer.', reseedQueued: 'Les données d\'exemple seront rechargées au prochain accès à l\'environnement.', reseedLocalSuccess: 'Données d\'exemple rechargées : {{inserted}} ajoutés, {{updated}} mis à jour.', reseedPartialErrors: '({{count}} enregistrement(s) n\'ont pas pu être écrits)', + reseedPartialErrors_one: "({{count}} enregistrement n'a pas pu être écrit)", + reseedPartialErrors_many: "({{count}} d'enregistrements n'ont pas pu être écrits)", + reseedPartialErrors_other: "({{count}} enregistrements n'ont pas pu être écrits)", sampleDataKernelUnavailable: 'Ce plan de contrôle n\'a pas de noyau d\'environnement : les données d\'exemple ne peuvent donc être ni rechargées ni supprimées ici. Faites-le depuis le runtime propre à l\'environnement.', updateAvailable: 'Mise à jour disponible', }, @@ -3443,7 +3573,13 @@ const fr = { today: "aujourd'hui", daysAgo: "il y a {{count}}j", monthsAgo: "il y a {{count}} mois", + monthsAgo_one: "il y a {{count}} mois", + monthsAgo_many: "il y a {{count}} mois", + monthsAgo_other: "il y a {{count}} mois", yearsAgo: "il y a {{count}} ans", + yearsAgo_one: "il y a {{count}} an", + yearsAgo_many: "il y a {{count}} ans", + yearsAgo_other: "il y a {{count}} ans", }, }, approvalsInbox: { @@ -3466,6 +3602,9 @@ const fr = { requestInfoSent: 'Renvoyée au demandeur pour complément', remindBtn: 'Relancer', remindSuccess: 'Relance envoyée à {{count}} approbateur(s)', + remindSuccess_one: 'Relance envoyée à {{count}} approbateur', + remindSuccess_many: "Relance envoyée à {{count}} d'approbateurs", + remindSuccess_other: 'Relance envoyée à {{count}} approbateurs', remindThrottled: 'Une relance a déjà été envoyée récemment — réessayez plus tard.', replyPlaceholder: 'Répondre sur cette demande…', slaRemaining: 'SLA : {{dur}} restants', @@ -3482,6 +3621,9 @@ const fr = { progressApprovals: 'Approbations — {{got}} sur {{need}}', progressGroups: 'Avancement des visas — {{got}} sur {{need}} groupes', progressEligible: '{{count}} approbateur(s) éligible(s)', + progressEligible_one: '{{count}} approbateur éligible', + progressEligible_many: "{{count}} d'approbateurs éligibles", + progressEligible_other: '{{count}} approbateurs éligibles', progressBar: 'Avancement de la décision', declaredActions: 'Actions', attachmentChip: 'Pièce jointe', @@ -3513,6 +3655,9 @@ const fr = { filterCount: '{{shown}} sur {{total}}', selected: 'sélectionnées', actionableCount: '({{count}} actionnables)', + actionableCount_one: '({{count}} actionnable)', + actionableCount_many: '({{count}} actionnables)', + actionableCount_other: '({{count}} actionnables)', selectAll: 'Tout sélectionner', selectRow: 'Sélectionner la demande', colRequest: 'Demande', @@ -3586,11 +3731,23 @@ const fr = { approveN: 'Approuver {{count}}', rejectN: 'Rejeter {{count}}', bulkApproveTitle: 'Approuver {{count}} demandes ?', + bulkApproveTitle_one: 'Approuver {{count}} demande ?', + bulkApproveTitle_many: 'Approuver {{count}} de demandes ?', + bulkApproveTitle_other: 'Approuver {{count}} demandes ?', bulkApproveBody: 'Chaque demande est approuvée avec votre identité et son flux continue sur la branche d\'approbation.', bulkRejectTitle: 'Rejeter {{count}} demandes ?', + bulkRejectTitle_one: 'Rejeter {{count}} demande ?', + bulkRejectTitle_many: 'Rejeter {{count}} de demandes ?', + bulkRejectTitle_other: 'Rejeter {{count}} demandes ?', bulkRejectBody: 'Les demandes sélectionnées seront rejetées et leurs demandeurs notifiés.', bulkApproved: '{{count}} demandes approuvées', + bulkApproved_one: '{{count}} demande approuvée', + bulkApproved_many: '{{count}} de demandes approuvées', + bulkApproved_other: '{{count}} demandes approuvées', bulkRejected: '{{count}} demandes rejetées', + bulkRejected_one: '{{count}} demande rejetée', + bulkRejected_many: '{{count}} de demandes rejetées', + bulkRejected_other: '{{count}} demandes rejetées', bulkPartial: '{{ok}} réussies, {{fail}} échouées : {{which}}', rejectOneTitle: 'Rejeter « {{title}} » ?', rejectOneBody: 'La demande sera rejetée et le demandeur notifié.', @@ -3615,8 +3772,17 @@ const fr = { alreadyDecided: 'Cette demande a déjà été traitée. Actualisez la liste.', justNow: 'à l\'instant', minutesAgo: 'il y a {{count}} min', + minutesAgo_one: 'il y a {{count}} min', + minutesAgo_many: 'il y a {{count}} min', + minutesAgo_other: 'il y a {{count}} min', hoursAgo: 'il y a {{count}} h', + hoursAgo_one: 'il y a {{count}} h', + hoursAgo_many: 'il y a {{count}} h', + hoursAgo_other: 'il y a {{count}} h', daysAgo: 'il y a {{count}} jours', + daysAgo_one: 'il y a {{count}} jour', + daysAgo_many: 'il y a {{count}} jours', + daysAgo_other: 'il y a {{count}} jours', }, preview: { empty: { @@ -3651,11 +3817,17 @@ const fr = { detailNone: 'Aucune différence détectée — le brouillon correspond à la version publiée.', detailChangedKeys: 'Également modifié :', confirmNote: 'La publication libère atomiquement les {{count}} brouillons en attente de ce paquet.', + confirmNote_one: 'La publication libère atomiquement {{count}} brouillon en attente de ce paquet.', + confirmNote_many: 'La publication libère atomiquement les {{count}} de brouillons en attente de ce paquet.', + confirmNote_other: 'La publication libère atomiquement les {{count}} brouillons en attente de ce paquet.', publishConfirm: 'Tout publier', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'La publication sera refusée — {{count}} élément(s) nécessitent d’abord une décision', + securityBlockTitle_one: 'La publication sera refusée — {{count}} élément nécessite d’abord une décision', + securityBlockTitle_many: 'La publication sera refusée — {{count}} d’éléments nécessitent d’abord une décision', + securityBlockTitle_other: 'La publication sera refusée — {{count}} éléments nécessitent d’abord une décision', securityBlockWhere: 'Corrigez-le sur l’objet dans Paramètres → Partage d’enregistrements, puis publiez à nouveau.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3745,7 +3917,9 @@ const fr = { }, collaboration: { commentCount: "{{count}} commentaires", - commentCountOne: "{{count}} commentaire", + commentCount_one: "{{count}} commentaire", + commentCount_many: "{{count}} de commentaires", + commentCount_other: "{{count}} commentaires", resolvedSuffix: " · Résolu", sortComments: "Trier les commentaires", sortOldest: "Plus anciens", @@ -3754,11 +3928,22 @@ const fr = { reopen: "Rouvrir", justNow: "à l'instant", minutesAgo: "il y a {{count}} min", + minutesAgo_one: "il y a {{count}} min", + minutesAgo_many: "il y a {{count}} min", + minutesAgo_other: "il y a {{count}} min", hoursAgo: "il y a {{count}} h", + hoursAgo_one: "il y a {{count}} h", + hoursAgo_many: "il y a {{count}} h", + hoursAgo_other: "il y a {{count}} h", daysAgo: "il y a {{count}} j", + daysAgo_one: "il y a {{count}} j", + daysAgo_many: "il y a {{count}} j", + daysAgo_other: "il y a {{count}} j", edited: "(modifié)", reactionCount: "{{count}} réactions", - reactionCountOne: "{{count}} réaction", + reactionCount_one: "{{count}} réaction", + reactionCount_many: "{{count}} de réactions", + reactionCount_other: "{{count}} réactions", addThumbsUp: "Ajouter un pouce levé", reply: "Répondre", reactThumbsUp: "Réagir avec un pouce levé", @@ -3769,9 +3954,13 @@ const fr = { commentPlaceholder: "Ajouter un commentaire… (utilisez @ pour mentionner)", send: "Envoyer", presentUserCount: "{{count}} utilisateurs présents", - presentUserCountOne: "{{count}} utilisateur présent", + presentUserCount_one: "{{count}} utilisateur présent", + presentUserCount_many: "{{count}} d'utilisateurs présents", + presentUserCount_other: "{{count}} utilisateurs présents", moreUserCount: "{{count}} autres utilisateurs", - moreUserCountOne: "{{count}} autre utilisateur", + moreUserCount_one: "{{count}} autre utilisateur", + moreUserCount_many: "{{count}} d'autres utilisateurs", + moreUserCount_other: "{{count}} autres utilisateurs", userStatusTitle: "{{name}} ({{status}})", statusActive: "actif", statusIdle: "inactif", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 8422c7f2e3..a30e21892d 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -69,7 +69,8 @@ const ja = { selectFirst: 'まず {{fields}} を選択してください', selectRecord: 'レコードを選択', recordCount: '{{count}} 件のレコード', - recordCountOne: '1 件のレコード', + recordCount_one: '{{count}} 件のレコード', + recordCount_other: '{{count}} 件のレコード', pageOf: '{{total}} ページ中 {{current}} ページ', filters: 'フィルター', clear: 'クリア', @@ -122,7 +123,8 @@ const ja = { closePanel: "パネルを閉じる", resizeDrawer: "パネル幅を調整", itemCount: "{{count}} 件", - itemCountOne: "{{count}} 件", + itemCount_one: "{{count}} 件", + itemCount_other: "{{count}} 件", toggleSidebar: "サイドバーを切り替え", package: "パッケージ", done: "完了", @@ -262,6 +264,8 @@ const ja = { }, relativeDate: { overdue: "期限超過 {{count}}日", + overdue_one: "期限超過 {{count}}日", + overdue_other: "期限超過 {{count}}日", }, file: { dragDropHere: 'ファイルをここにドラッグ&ドロップ', @@ -398,23 +402,35 @@ const ja = { open: "開く", search: "検索…", modified: "{{count}} 行が変更されました", + modified_one: "{{count}} 行が変更されました", + modified_other: "{{count}} 行が変更されました", saveFailed: '保存に失敗しました', selected: "{{count}} 件選択中", + selected_one: "{{count}} 件選択中", + selected_other: "{{count}} 件選択中", edit: "編集", delete: "削除", }, grid: { bulkSelected: "{{count}} 件を選択中", + bulkSelected_one: "{{count}} 件を選択中", + bulkSelected_other: "{{count}} 件を選択中", bulkSelectedAllMatches: "{{count}} 件を選択中(該当すべて)", bulkClear: "選択解除", bulkAllOnPage: "このページの {{count}} 件がすべて選択されています。", + bulkAllOnPage_one: "このページの {{count}} 件がすべて選択されています。", + bulkAllOnPage_other: "このページの {{count}} 件がすべて選択されています。", bulkSelectAllMatching: "該当する {{count}} 件すべてを選択", bulkAllMatchingSelected: "該当する {{count}} 件のレコードがすべて選択されています。", bulk: { confirmDefault: "{{count}} 件のレコードに適用されます。", + confirmDefault_one: "{{count}} 件のレコードに適用されます。", + confirmDefault_other: "{{count}} 件のレコードに適用されます。", overLimit: "選択件数({{count}})がアクションの上限({{limit}})を超えています。選択を減らしてください。", affectedRecords: "対象レコード({{count}}):", skippedIneligible: "選択したレコードのうち {{count}} 件はこのアクションの対象外のため、スキップされます。", + skippedIneligible_one: "選択したレコードのうち {{count}} 件はこのアクションの対象外のため、スキップされます。", + skippedIneligible_other: "選択したレコードのうち {{count}} 件はこのアクションの対象外のため、スキップされます。", rowFallback: "{{index}} 行目", andMore: "… 他 {{count}} 件", processed: "{{count}} / {{total}} 件を処理", @@ -490,6 +506,8 @@ const ja = { templateFileName: "{{object}}-import-template", autoMatched: "自動マッピング済み", autoMatchedSummary: "{{count}} 列を自動マッピングしました — 下で確認・調整してください。", + autoMatchedSummary_one: "{{count}} 列を自動マッピングしました — 下で確認・調整してください。", + autoMatchedSummary_other: "{{count}} 列を自動マッピングしました — 下で確認・調整してください。", confidence: { high: "確度: 高", medium: "確度: 中", @@ -502,8 +520,14 @@ const ja = { cancelImport: "インポートを中止", importCancelled: "インポートを中止しました", resultsTruncated: "{{total}} 行のうち先頭 {{count}} 行の結果を表示しています。", + resultsTruncated_one: "{{total}} 行のうち先頭 {{count}} 行の結果を表示しています。", + resultsTruncated_other: "{{total}} 行のうち先頭 {{count}} 行の結果を表示しています。", createdCount: "{{count}} 件を作成", + createdCount_one: "{{count}} 件を作成", + createdCount_other: "{{count}} 件を作成", updatedCount: "{{count}} 件を更新", + updatedCount_one: "{{count}} 件を更新", + updatedCount_other: "{{count}} 件を更新", downloadFailed: "失敗した行をダウンロード", options: "インポート設定", writeMode: "既存レコードに一致した行の扱い", @@ -551,6 +575,8 @@ const ja = { historyColResult: "結果", historyColTime: "日時", errorCount: "エラー {{count}} 件", + errorCount_one: "エラー {{count}} 件", + errorCount_other: "エラー {{count}} 件", undoImport: "インポートを取り消す", undoing: "取り消し中…", undoConfirm: "このインポートを取り消しますか?作成されたレコードは削除され、更新されたレコードは以前の値に戻されます。", @@ -611,7 +637,11 @@ const ja = { mapped: "マッピング済み", skipped: "スキップ済み", rowsWithErrors: "{{count}} 行にエラー", + rowsWithErrors_one: "{{count}} 行にエラー", + rowsWithErrors_other: "{{count}} 行にエラー", rowsCorrected: "{{count}} 行が修正されました", + rowsCorrected_one: "{{count}} 行が修正されました", + rowsCorrected_other: "{{count}} 行が修正されました", clickToFix: "— ハイライトされたセルをクリックしてインラインで修正。", showingRows: "{{total}} 行中 {{shown}} 行を表示", importing: "インポート中… {{progress}}%", @@ -619,11 +649,15 @@ const ja = { imported: "{{count}} 件インポートされました", skippedCount: "{{count}} 件スキップされました", moreErrors: "…他 {{count}} 件のエラー", + moreErrors_one: "…他 {{count}} 件のエラー", + moreErrors_other: "…他 {{count}} 件のエラー", cancel: "キャンセル", back: "戻る", next: "次へ", close: "閉じる", importNRows: "{{count}} 行をインポート", + importNRows_one: "{{count}} 行をインポート", + importNRows_other: "{{count}} 行をインポート", importingProgress: "インポート中…", requiredMark: "*", required: "必須", @@ -708,7 +742,8 @@ const ja = { refresh: "更新", loading: "レコードを読み込み中…", recordCount: "{{count}} 件のレコード", - recordCountOne: "{{count}} 件のレコード", + recordCount_one: "{{count}} 件のレコード", + recordCount_other: "{{count}} 件のレコード", addRecord: "レコードを追加", tabs: "タブ", allRecords: "すべてのレコード", @@ -883,13 +918,19 @@ const ja = { conflict: { title: "スケジュールの競合", body: "この移動は依存関係の制約と競合します。影響を受ける {{count}} 件のタスクを自動で再スケジュールしますか?", + body_one: "この移動は依存関係の制約と競合します。影響を受ける {{count}} 件のタスクを自動で再スケジュールしますか?", + body_other: "この移動は依存関係の制約と競合します。影響を受ける {{count}} 件のタスクを自動で再スケジュールしますか?", confirm: "自動で再スケジュール", cancel: "そのままにする", }, autoScheduleDlg: { title: "自動スケジュール", body: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?", + body_one: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?", + body_other: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?", skipped: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。", + skipped_one: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。", + skipped_other: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。", confirm: "適用", cancel: "キャンセル", none: "依存関係はすべて満たされています — 再スケジュールの必要はありません。", @@ -994,7 +1035,8 @@ const ja = { pageHeaderActions: "ページヘッダーの操作", emojiPicker: "絵文字ピッカー", reactionCount: "{{emoji}} {{count}} 件のリアクション", - reactionCountOne: "{{emoji}} {{count}} 件のリアクション", + reactionCount_one: "{{emoji}} {{count}} 件のリアクション", + reactionCount_other: "{{emoji}} {{count}} 件のリアクション", recordDetail: "レコード詳細", recordDetailWithLabel: "{{label}}の詳細", openAsFullPage: "フルページで開く", @@ -1043,11 +1085,18 @@ const ja = { unpin: "ピン解除", justNow: "たった今", minutesAgo: "{{count}}分前", + minutesAgo_one: "{{count}}分前", + minutesAgo_other: "{{count}}分前", hoursAgo: "{{count}}時間前", + hoursAgo_one: "{{count}}時間前", + hoursAgo_other: "{{count}}時間前", daysAgo: "{{count}}日前", + daysAgo_one: "{{count}}日前", + daysAgo_other: "{{count}}日前", dropFilesToUpload: "ファイルをここにドロップまたはクリックしてアップロード", attachmentCount: "{{count}} 件の添付ファイル", - attachmentCountPlural: "{{count}} 件の添付ファイル", + attachmentCount_one: "{{count}} 件の添付ファイル", + attachmentCount_other: "{{count}} 件の添付ファイル", removeAttachment: "添付ファイルを削除", // Record Attachments panel (enable.files, objectstack#4358) attachments: "添付ファイル", @@ -1106,7 +1155,8 @@ const ja = { via: "{{source}} 経由", viewSource: "ソースを表示", replyCount: "{{count}} 件の返信", - replyCountPlural: "{{count}} 件の返信", + replyCount_one: "{{count}} 件の返信", + replyCount_other: "{{count}} 件の返信", replyPlaceholder: "返信…", filterActivity: "アクティビティをフィルター", openDiscussion: "ディスカッションパネルを開く", @@ -1176,6 +1226,8 @@ const ja = { copyPhone: "電話番号をコピー", copyRecordId: "レコードIDをコピー", showEmptyFields: "{{count}} 件の空フィールドを表示", + showEmptyFields_one: "{{count}} 件の空フィールドを表示", + showEmptyFields_other: "{{count}} 件の空フィールドを表示", hideEmptyFields: "空フィールドを非表示", noValue: "値なし", unresolvedReference: "未解決の参照: {{value}} はユーザーとして解決されませんでした", @@ -1545,7 +1597,11 @@ const ja = { backToHome: "ホームに戻る", }, saveAdvisoryTitle: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", + saveAdvisoryTitle_one: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", + saveAdvisoryTitle_other: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", publishAdvisoryTitle: "公開しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", + publishAdvisoryTitle_one: "公開しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", + publishAdvisoryTitle_other: "公開しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", importMappingsUnavailable: "{{object}} の保存済みインポートマッピングを読み込めませんでした", importMappingsRefused: "サーバーがこのリクエストを拒否しました。つまりこのリストが空なのは読み取れなかったためであり、何も登録されていないためではありません。再度サインインするか、管理者にアクセス権を依頼してください。", importMappingsUnreadable: "このリストが空なのは読み取れなかったためであり、何も登録されていないためではありません。再試行し、繰り返し発生する場合は報告してください。", @@ -2024,7 +2080,8 @@ const ja = { title: "タイトル", viewType: "ビュータイプ", recordCount: "{{count}} 件のレコード", - recordCountOne: "{{count}} 件のレコード", + recordCount_one: "{{count}} 件のレコード", + recordCount_other: "{{count}} 件のレコード", save: "保存", discard: "破棄", createView: "ビューを作成", @@ -2040,6 +2097,8 @@ const ja = { import: "インポート", importTitle: "CSVからインポート", importedToast: "{{count}} 行をインポートしました。", + importedToast_one: "{{count}} 行をインポートしました。", + importedToast_other: "{{count}} 行をインポートしました。", importedWithSkipped: "{{ok}} 行をインポートしました。{{skipped}} 行をスキップしました。", createViewDesc: "ビュータイプを選択して名前を付けてください。設定は後で変更できます。", cancel: "キャンセル", @@ -2048,6 +2107,8 @@ const ja = { deleteViewTitle: "ビューを削除", deleteViewConfirm: "ビュー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。", bulkDeleteConfirm: "選択した {{count}} 件のレコードを削除しますか?この操作は元に戻せません。", + bulkDeleteConfirm_one: "選択した {{count}} 件のレコードを削除しますか?この操作は元に戻せません。", + bulkDeleteConfirm_other: "選択した {{count}} 件のレコードを削除しますか?この操作は元に戻せません。", duplicateViewName: "この名前のビューはすでに存在します。", viewTypeGrid: "グリッド", viewTypeGridDesc: "スプレッドシート形式のレコード一覧。", @@ -2505,6 +2566,8 @@ const ja = { }, pendingDrafts: { message: "未公開の変更が {{count}} 件あります — 公開すると反映されます。", + message_one: "未公開の変更が {{count}} 件あります — 公開すると反映されます。", + message_other: "未公開の変更が {{count}} 件あります — 公開すると反映されます。", cta: "公開", publishing: "公開しています…", published: "公開しました。変更が反映されています。", @@ -2628,9 +2691,11 @@ const ja = { placeholder: "オブジェクト、ダッシュボード、ページ、レポートを検索…", inputAriaLabel: "オブジェクト、ダッシュボード、ページ、レポートを検索", resultsCount: "\"{{query}}\" の結果 {{count}} 件", - resultsCountPlural: "\"{{query}}\" の結果 {{count}} 件", + resultsCount_one: "\"{{query}}\" の結果 {{count}} 件", + resultsCount_other: "\"{{query}}\" の結果 {{count}} 件", itemsAvailable: "{{count}} 件利用可能", - itemsAvailableOne: "{{count}} 件利用可能", + itemsAvailable_one: "{{count}} 件利用可能", + itemsAvailable_other: "{{count}} 件利用可能", noResults: "結果が見つかりません", noResultsHint: "検索語句を調整してみてください", typeObjects: "オブジェクト", @@ -2707,6 +2772,8 @@ const ja = { noRecordId: "レコードIDが指定されていません", deleteConfirm: "このレコードを削除してもよろしいですか?", bulkDeleteSuccess: "{{count}} 件の {{label}} レコードを削除しました", + bulkDeleteSuccess_one: "{{count}} 件の {{label}} レコードを削除しました", + bulkDeleteSuccess_other: "{{count}} 件の {{label}} レコードを削除しました", bulkDeletePartial: "{{succeeded}} 件削除、{{failed}} 件失敗", }, objectViewActions: { @@ -2907,12 +2974,16 @@ const ja = { regionLabel: "通知", groupCount: "通知 {{count}} 件", groupUnread: "未読 {{count}} 件", + groupUnread_one: "未読 {{count}} 件", + groupUnread_other: "未読 {{count}} 件", groupMarkRead: "既読にする", groupMarkReadTitle: "この種類をすべて既読にする", empty: "通知はありません", markAllRead: "すべて既読にする", viewAll: "すべての通知を表示", approvalsPending: "{{count}} 件の承認待ち", + approvalsPending_one: "{{count}} 件の承認待ち", + approvalsPending_other: "{{count}} 件の承認待ち", viewApprovals: "承認を表示", noPendingApprovals: "承認待ちはありません", openApprovalsInbox: "承認ボックスを開く", @@ -2920,7 +2991,11 @@ const ja = { badgeNotifications: "通知 {{unread}} 件", badgeApprovals: "承認待ち {{approvals}} 件", arrivalMany: "新着メッセージ {{count}} 件", + arrivalMany_one: "新着メッセージ {{count}} 件", + arrivalMany_other: "新着メッセージ {{count}} 件", arrivalRepeats: "このトピックの新着メッセージ {{count}} 件", + arrivalRepeats_one: "このトピックの新着メッセージ {{count}} 件", + arrivalRepeats_other: "このトピックの新着メッセージ {{count}} 件", arrivalOpen: "表示", toastEnabled: "アプリ内通知", desktopEnabled: "デスクトップ通知", @@ -2997,6 +3072,8 @@ const ja = { noImportableFields: "そのオブジェクトにはインポート可能なフィールドがありません。", schemaReadFailed: "オブジェクトのスキーマを読み取れませんでした。", imported: "{{count}} 行を {{object}} にインポートしました。", + imported_one: "{{count}} 行を {{object}} にインポートしました。", + imported_other: "{{count}} 行を {{object}} にインポートしました。", importFrom: "実データのインポート元", into: "→", opening: "開いています…", @@ -3026,11 +3103,13 @@ const ja = { formAssist: { title: "AI の提案", suggestionCount: "{{count}} 件の提案", - suggestionCountOne: "{{count}} 件の提案", + suggestionCount_one: "{{count}} 件の提案", + suggestionCount_other: "{{count}} 件の提案", applyAll: "すべて適用", confidence: "信頼度 {{percent}}", appliedCount: "{{count}} 件の提案を適用しました", - appliedCountOne: "{{count}} 件の提案を適用しました", + appliedCount_one: "{{count}} 件の提案を適用しました", + appliedCount_other: "{{count}} 件の提案を適用しました", }, recommendations: { title: "おすすめ", @@ -3328,10 +3407,14 @@ const ja = { purgeSampleData: 'サンプルデータを削除', purgeConfirm: 'このパッケージが投入したサンプルレコードをすべて削除しますか?ユーザーが追加したレコードは削除されません。', purgeSuccess: '{{count}} 件のサンプルレコードを削除しました。', + purgeSuccess_one: '{{count}} 件のサンプルレコードを削除しました。', + purgeSuccess_other: '{{count}} 件のサンプルレコードを削除しました。', purgeNoData: '削除するサンプルレコードがありません。', reseedQueued: 'サンプルデータは次回の環境アクセス時に再投入されます。', reseedLocalSuccess: 'サンプルデータを再投入しました:{{inserted}} 件追加、{{updated}} 件更新。', reseedPartialErrors: '({{count}} 件のレコードの書き込みに失敗しました)', + reseedPartialErrors_one: '({{count}} 件のレコードの書き込みに失敗しました)', + reseedPartialErrors_other: '({{count}} 件のレコードの書き込みに失敗しました)', sampleDataKernelUnavailable: 'このコントロールプレーンには環境カーネルがないため、ここではサンプルデータを再投入・削除できません。環境自身のランタイムから実行してください。', updateAvailable: 'アップデートあり', }, @@ -3429,7 +3512,11 @@ const ja = { today: "今日", daysAgo: "{{count}}日前", monthsAgo: "{{count}}ヶ月前", + monthsAgo_one: "{{count}}ヶ月前", + monthsAgo_other: "{{count}}ヶ月前", yearsAgo: "{{count}}年前", + yearsAgo_one: "{{count}}年前", + yearsAgo_other: "{{count}}年前", }, }, approvalsInbox: { @@ -3452,6 +3539,8 @@ const ja = { requestInfoSent: '追加情報を求めて差し戻しました', remindBtn: 'リマインド', remindSuccess: '{{count}} 名の承認者に通知しました', + remindSuccess_one: '{{count}} 名の承認者に通知しました', + remindSuccess_other: '{{count}} 名の承認者に通知しました', remindThrottled: 'リマインドは送信済みです。しばらくしてからお試しください。', replyPlaceholder: 'このリクエストに返信…', slaRemaining: 'SLA 残り {{dur}}', @@ -3468,6 +3557,8 @@ const ja = { progressApprovals: '承認 — {{got}} / {{need}}', progressGroups: 'グループ承認 — {{got}} / {{need}} グループ', progressEligible: '承認可能者 {{count}} 名', + progressEligible_one: '承認可能者 {{count}} 名', + progressEligible_other: '承認可能者 {{count}} 名', progressBar: '承認の進捗', declaredActions: 'アクション', attachmentChip: '添付ファイル', @@ -3499,6 +3590,8 @@ const ja = { filterCount: '{{shown}} / {{total}} 件', selected: '選択中', actionableCount: '({{count}} 件操作可能)', + actionableCount_one: '({{count}} 件操作可能)', + actionableCount_other: '({{count}} 件操作可能)', selectAll: 'すべて選択', selectRow: 'このリクエストを選択', colRequest: '承認事項', @@ -3572,11 +3665,19 @@ const ja = { approveN: '{{count}} 件を承認', rejectN: '{{count}} 件を却下', bulkApproveTitle: 'この {{count}} 件を承認しますか?', + bulkApproveTitle_one: 'この {{count}} 件を承認しますか?', + bulkApproveTitle_other: 'この {{count}} 件を承認しますか?', bulkApproveBody: '各リクエストはあなたのIDで承認され、フローは承認ブランチに進みます。', bulkRejectTitle: 'この {{count}} 件を却下しますか?', + bulkRejectTitle_one: 'この {{count}} 件を却下しますか?', + bulkRejectTitle_other: 'この {{count}} 件を却下しますか?', bulkRejectBody: '選択したリクエストを却下し、各申請者に通知します。', bulkApproved: '{{count}} 件を承認しました', + bulkApproved_one: '{{count}} 件を承認しました', + bulkApproved_other: '{{count}} 件を承認しました', bulkRejected: '{{count}} 件を却下しました', + bulkRejected_one: '{{count}} 件を却下しました', + bulkRejected_other: '{{count}} 件を却下しました', bulkPartial: '{{ok}} 件成功、{{fail}} 件失敗:{{which}}', rejectOneTitle: '「{{title}}」を却下しますか?', rejectOneBody: 'リクエストを却下し、申請者に通知します。', @@ -3601,8 +3702,14 @@ const ja = { alreadyDecided: 'このリクエストは処理済みです。一覧を更新してください。', justNow: 'たった今', minutesAgo: '{{count}} 分前', + minutesAgo_one: '{{count}} 分前', + minutesAgo_other: '{{count}} 分前', hoursAgo: '{{count}} 時間前', + hoursAgo_one: '{{count}} 時間前', + hoursAgo_other: '{{count}} 時間前', daysAgo: '{{count}} 日前', + daysAgo_one: '{{count}} 日前', + daysAgo_other: '{{count}} 日前', }, preview: { empty: { @@ -3637,11 +3744,15 @@ const ja = { detailNone: '差分は検出されませんでした — ドラフトは公開版と一致しています。', detailChangedKeys: 'その他の変更:', confirmNote: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。', + confirmNote_one: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。', + confirmNote_other: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。', publishConfirm: 'すべて公開', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: '公開は拒否されます — {{count}} 件の項目に先に決定が必要です', + securityBlockTitle_one: '公開は拒否されます — {{count}} 件の項目に先に決定が必要です', + securityBlockTitle_other: '公開は拒否されます — {{count}} 件の項目に先に決定が必要です', securityBlockWhere: 'オブジェクトの「設定 → レコード共有」で修正してから、もう一度公開してください。', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3731,7 +3842,8 @@ const ja = { }, collaboration: { commentCount: "コメント {{count}} 件", - commentCountOne: "コメント {{count}} 件", + commentCount_one: "コメント {{count}} 件", + commentCount_other: "コメント {{count}} 件", resolvedSuffix: " · 解決済み", sortComments: "コメントの並べ替え", sortOldest: "古い順", @@ -3740,11 +3852,18 @@ const ja = { reopen: "再オープン", justNow: "たった今", minutesAgo: "{{count}} 分前", + minutesAgo_one: "{{count}} 分前", + minutesAgo_other: "{{count}} 分前", hoursAgo: "{{count}} 時間前", + hoursAgo_one: "{{count}} 時間前", + hoursAgo_other: "{{count}} 時間前", daysAgo: "{{count}} 日前", + daysAgo_one: "{{count}} 日前", + daysAgo_other: "{{count}} 日前", edited: "(編集済み)", reactionCount: "リアクション {{count}} 件", - reactionCountOne: "リアクション {{count}} 件", + reactionCount_one: "リアクション {{count}} 件", + reactionCount_other: "リアクション {{count}} 件", addThumbsUp: "いいねを追加", reply: "返信", reactThumbsUp: "いいねでリアクション", @@ -3755,9 +3874,11 @@ const ja = { commentPlaceholder: "コメントを追加…(@ でメンション)", send: "送信", presentUserCount: "オンライン {{count}} 人", - presentUserCountOne: "オンライン {{count}} 人", + presentUserCount_one: "オンライン {{count}} 人", + presentUserCount_other: "オンライン {{count}} 人", moreUserCount: "他 {{count}} 人", - moreUserCountOne: "他 {{count}} 人", + moreUserCount_one: "他 {{count}} 人", + moreUserCount_other: "他 {{count}} 人", userStatusTitle: "{{name}}({{status}})", statusActive: "アクティブ", statusIdle: "アイドル", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 0c91cf45ff..67a7934b3a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -69,7 +69,8 @@ const ko = { selectFirst: '먼저 {{fields}}을(를) 선택하세요', selectRecord: '레코드 선택', recordCount: '레코드 {{count}}개', - recordCountOne: '레코드 1개', + recordCount_one: '레코드 {{count}}개', + recordCount_other: '레코드 {{count}}개', pageOf: '{{total}}페이지 중 {{current}}페이지', filters: '필터', clear: '지우기', @@ -122,7 +123,8 @@ const ko = { closePanel: "패널 닫기", resizeDrawer: "패널 너비 조정", itemCount: "{{count}}개 항목", - itemCountOne: "{{count}}개 항목", + itemCount_one: "{{count}}개 항목", + itemCount_other: "{{count}}개 항목", toggleSidebar: "사이드바 전환", package: "패키지", done: "완료", @@ -262,6 +264,8 @@ const ko = { }, relativeDate: { overdue: "기한 초과 {{count}}일", + overdue_one: "기한 초과 {{count}}일", + overdue_other: "기한 초과 {{count}}일", }, file: { dragDropHere: '파일을 여기로 끌어다 놓기', @@ -398,23 +402,35 @@ const ko = { open: "열기", search: "검색…", modified: "{{count}}개 행 수정됨", + modified_one: "{{count}}개 행 수정됨", + modified_other: "{{count}}개 행 수정됨", saveFailed: '저장 실패', selected: "{{count}}개 선택됨", + selected_one: "{{count}}개 선택됨", + selected_other: "{{count}}개 선택됨", edit: "편집", delete: "삭제", }, grid: { bulkSelected: "{{count}}건 선택됨", + bulkSelected_one: "{{count}}건 선택됨", + bulkSelected_other: "{{count}}건 선택됨", bulkSelectedAllMatches: "{{count}}건 선택됨(일치 항목 전체)", bulkClear: "선택 해제", bulkAllOnPage: "이 페이지의 {{count}}건이 모두 선택되었습니다.", + bulkAllOnPage_one: "이 페이지의 {{count}}건이 모두 선택되었습니다.", + bulkAllOnPage_other: "이 페이지의 {{count}}건이 모두 선택되었습니다.", bulkSelectAllMatching: "일치하는 {{count}}건 모두 선택", bulkAllMatchingSelected: "일치하는 레코드 {{count}}건이 모두 선택되었습니다.", bulk: { confirmDefault: "레코드 {{count}}건에 적용됩니다.", + confirmDefault_one: "레코드 {{count}}건에 적용됩니다.", + confirmDefault_other: "레코드 {{count}}건에 적용됩니다.", overLimit: "선택 건수({{count}})가 작업 한도({{limit}})를 초과합니다. 선택을 줄여 주세요.", affectedRecords: "영향을 받는 레코드({{count}}):", skippedIneligible: "선택한 레코드 중 {{count}}건은 이 작업의 대상이 아니므로 건너뜁니다.", + skippedIneligible_one: "선택한 레코드 중 {{count}}건은 이 작업의 대상이 아니므로 건너뜁니다.", + skippedIneligible_other: "선택한 레코드 중 {{count}}건은 이 작업의 대상이 아니므로 건너뜁니다.", rowFallback: "{{index}}행", andMore: "… 외 {{count}}건", processed: "{{count}} / {{total}} 처리됨", @@ -490,6 +506,8 @@ const ko = { templateFileName: "{{object}}-import-template", autoMatched: "자동 매핑됨", autoMatchedSummary: "{{count}}개 열을 자동 매핑했습니다 — 아래에서 확인하고 조정하세요.", + autoMatchedSummary_one: "{{count}}개 열을 자동 매핑했습니다 — 아래에서 확인하고 조정하세요.", + autoMatchedSummary_other: "{{count}}개 열을 자동 매핑했습니다 — 아래에서 확인하고 조정하세요.", confidence: { high: "신뢰도 높음", medium: "신뢰도 보통", @@ -502,8 +520,14 @@ const ko = { cancelImport: "가져오기 취소", importCancelled: "가져오기를 취소했습니다", resultsTruncated: "{{total}}행 중 처음 {{count}}행의 결과를 표시합니다.", + resultsTruncated_one: "{{total}}행 중 처음 {{count}}행의 결과를 표시합니다.", + resultsTruncated_other: "{{total}}행 중 처음 {{count}}행의 결과를 표시합니다.", createdCount: "{{count}}건 생성", + createdCount_one: "{{count}}건 생성", + createdCount_other: "{{count}}건 생성", updatedCount: "{{count}}건 업데이트", + updatedCount_one: "{{count}}건 업데이트", + updatedCount_other: "{{count}}건 업데이트", downloadFailed: "실패한 행 다운로드", options: "가져오기 옵션", writeMode: "기존 레코드와 일치하는 행의 처리", @@ -551,6 +575,8 @@ const ko = { historyColResult: "결과", historyColTime: "시각", errorCount: "오류 {{count}}건", + errorCount_one: "오류 {{count}}건", + errorCount_other: "오류 {{count}}건", undoImport: "가져오기 되돌리기", undoing: "되돌리는 중…", undoConfirm: "이 가져오기를 되돌릴까요? 생성된 레코드는 삭제되고 업데이트된 레코드는 이전 값으로 복원됩니다.", @@ -611,7 +637,11 @@ const ko = { mapped: "매핑됨", skipped: "건너뜀", rowsWithErrors: "오류가 있는 {{count}}개 행", + rowsWithErrors_one: "오류가 있는 {{count}}개 행", + rowsWithErrors_other: "오류가 있는 {{count}}개 행", rowsCorrected: "{{count}}개 행 수정됨", + rowsCorrected_one: "{{count}}개 행 수정됨", + rowsCorrected_other: "{{count}}개 행 수정됨", clickToFix: "— 강조 표시된 셀을 클릭하여 인라인 편집.", showingRows: "{{total}}개 중 {{shown}}개 행 표시", importing: "가져오는 중… {{progress}}%", @@ -619,11 +649,15 @@ const ko = { imported: "{{count}}개 가져옴", skippedCount: "{{count}}개 건너뜀", moreErrors: "…{{count}}개 오류 더", + moreErrors_one: "…{{count}}개 오류 더", + moreErrors_other: "…{{count}}개 오류 더", cancel: "취소", back: "뒤로", next: "다음", close: "닫기", importNRows: "{{count}}개 행 가져오기", + importNRows_one: "{{count}}개 행 가져오기", + importNRows_other: "{{count}}개 행 가져오기", importingProgress: "가져오는 중…", requiredMark: "*", required: "필수", @@ -708,7 +742,8 @@ const ko = { refresh: "새로고침", loading: "레코드 로딩 중…", recordCount: "{{count}}개 레코드", - recordCountOne: "{{count}}개 레코드", + recordCount_one: "{{count}}개 레코드", + recordCount_other: "{{count}}개 레코드", addRecord: "레코드 추가", tabs: "탭", allRecords: "전체 레코드", @@ -883,13 +918,19 @@ const ko = { conflict: { title: "일정 충돌", body: "이 이동은 종속성 제약과 충돌합니다. 영향을 받는 작업 {{count}}건의 일정을 자동으로 조정할까요?", + body_one: "이 이동은 종속성 제약과 충돌합니다. 영향을 받는 작업 {{count}}건의 일정을 자동으로 조정할까요?", + body_other: "이 이동은 종속성 제약과 충돌합니다. 영향을 받는 작업 {{count}}건의 일정을 자동으로 조정할까요?", confirm: "자동 일정 조정", cancel: "그대로 두기", }, autoScheduleDlg: { title: "자동 일정 조정", body: "종속성 연결을 충족하도록 작업 {{count}}건을 뒤로 미룰까요?", + body_one: "종속성 연결을 충족하도록 작업 {{count}}건을 뒤로 미룰까요?", + body_other: "종속성 연결을 충족하도록 작업 {{count}}건을 뒤로 미룰까요?", skipped: "잠긴 작업 {{count}}건도 연결을 위반하지만 건너뛰었습니다.", + skipped_one: "잠긴 작업 {{count}}건도 연결을 위반하지만 건너뛰었습니다.", + skipped_other: "잠긴 작업 {{count}}건도 연결을 위반하지만 건너뛰었습니다.", confirm: "적용", cancel: "취소", none: "모든 종속성이 충족되었습니다 — 조정할 일정이 없습니다.", @@ -994,7 +1035,8 @@ const ko = { pageHeaderActions: "페이지 헤더 작업", emojiPicker: "이모지 선택기", reactionCount: "{{emoji}} 반응 {{count}}개", - reactionCountOne: "{{emoji}} 반응 {{count}}개", + reactionCount_one: "{{emoji}} 반응 {{count}}개", + reactionCount_other: "{{emoji}} 반응 {{count}}개", recordDetail: "레코드 세부 정보", recordDetailWithLabel: "{{label}} 세부 정보", openAsFullPage: "전체 페이지로 열기", @@ -1031,11 +1073,18 @@ const ko = { unpin: "고정 해제", justNow: "방금", minutesAgo: "{{count}}분 전", + minutesAgo_one: "{{count}}분 전", + minutesAgo_other: "{{count}}분 전", hoursAgo: "{{count}}시간 전", + hoursAgo_one: "{{count}}시간 전", + hoursAgo_other: "{{count}}시간 전", daysAgo: "{{count}}일 전", + daysAgo_one: "{{count}}일 전", + daysAgo_other: "{{count}}일 전", dropFilesToUpload: "파일을 여기에 끌어놓거나 클릭하여 업로드", attachmentCount: "첨부파일 {{count}}개", - attachmentCountPlural: "첨부파일 {{count}}개", + attachmentCount_one: "첨부파일 {{count}}개", + attachmentCount_other: "첨부파일 {{count}}개", removeAttachment: "첨부파일 제거", // Record Attachments panel (enable.files, objectstack#4358) attachments: "첨부파일", @@ -1094,7 +1143,8 @@ const ko = { via: "{{source}} 통해", viewSource: "원본 보기", replyCount: "답글 {{count}}개", - replyCountPlural: "답글 {{count}}개", + replyCount_one: "답글 {{count}}개", + replyCount_other: "답글 {{count}}개", replyPlaceholder: "답글…", filterActivity: "활동 필터", openDiscussion: "토론 패널 열기", @@ -1176,6 +1226,8 @@ const ko = { copyPhone: "전화번호 복사", copyRecordId: "레코드 ID 복사", showEmptyFields: "{{count}}개의 빈 필드 표시", + showEmptyFields_one: "{{count}}개의 빈 필드 표시", + showEmptyFields_other: "{{count}}개의 빈 필드 표시", hideEmptyFields: "빈 필드 숨기기", noValue: "값 없음", unresolvedReference: "확인되지 않은 참조: {{value}}을(를) 사용자로 확인하지 못했습니다", @@ -1545,7 +1597,11 @@ const ko = { backToHome: "홈으로 돌아가기", }, saveAdvisoryTitle: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", + saveAdvisoryTitle_one: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", + saveAdvisoryTitle_other: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", publishAdvisoryTitle: "게시되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", + publishAdvisoryTitle_one: "게시되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", + publishAdvisoryTitle_other: "게시되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", importMappingsUnavailable: "{{object}}의 저장된 가져오기 매핑을 불러오지 못했습니다", importMappingsRefused: "서버가 이 요청을 거부했습니다. 따라서 이 목록이 비어 있는 것은 읽지 못했기 때문이며, 등록된 항목이 없어서가 아닙니다. 다시 로그인하거나 관리자에게 접근 권한을 요청하세요.", importMappingsUnreadable: "이 목록이 비어 있는 것은 읽지 못했기 때문이며, 등록된 항목이 없어서가 아닙니다. 다시 시도하고, 계속 발생하면 문제를 보고하세요.", @@ -2022,7 +2078,8 @@ const ko = { title: "제목", viewType: "뷰 유형", recordCount: "{{count}}개 레코드", - recordCountOne: "{{count}}개 레코드", + recordCount_one: "{{count}}개 레코드", + recordCount_other: "{{count}}개 레코드", save: "저장", discard: "취소", createView: "뷰 생성", @@ -2038,6 +2095,8 @@ const ko = { import: "가져오기", importTitle: "CSV에서 가져오기", importedToast: "{{count}}개 행 가져옴.", + importedToast_one: "{{count}}개 행 가져옴.", + importedToast_other: "{{count}}개 행 가져옴.", importedWithSkipped: "{{ok}}개 행 가져옴; {{skipped}}개 건너뜀.", createViewDesc: "보기 유형을 선택하고 이름을 지정하세요. 모든 설정은 나중에 변경할 수 있습니다.", cancel: "취소", @@ -2046,6 +2105,8 @@ const ko = { deleteViewTitle: "보기 삭제", deleteViewConfirm: "\"{{name}}\" 보기를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", bulkDeleteConfirm: "선택한 {{count}}개 레코드를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + bulkDeleteConfirm_one: "선택한 {{count}}개 레코드를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + bulkDeleteConfirm_other: "선택한 {{count}}개 레코드를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", duplicateViewName: "해당 이름의 보기가 이미 존재합니다.", viewTypeGrid: "그리드", viewTypeGridDesc: "레코드의 스프레드시트 형식 테이블.", @@ -2503,6 +2564,8 @@ const ko = { }, pendingDrafts: { message: "게시되지 않은 변경이 {{count}}건 있습니다 — 게시하면 반영됩니다.", + message_one: "게시되지 않은 변경이 {{count}}건 있습니다 — 게시하면 반영됩니다.", + message_other: "게시되지 않은 변경이 {{count}}건 있습니다 — 게시하면 반영됩니다.", cta: "게시", publishing: "게시하는 중…", published: "게시했습니다! 변경 사항이 반영되었습니다.", @@ -2625,9 +2688,11 @@ const ko = { placeholder: "오브젝트, 대시보드, 페이지, 보고서 검색…", inputAriaLabel: "오브젝트, 대시보드, 페이지, 보고서 검색", resultsCount: "\"{{query}}\"에 대한 {{count}}개 결과", - resultsCountPlural: "\"{{query}}\"에 대한 {{count}}개 결과", + resultsCount_one: "\"{{query}}\"에 대한 {{count}}개 결과", + resultsCount_other: "\"{{query}}\"에 대한 {{count}}개 결과", itemsAvailable: "{{count}}개 항목 사용 가능", - itemsAvailableOne: "{{count}}개 항목 사용 가능", + itemsAvailable_one: "{{count}}개 항목 사용 가능", + itemsAvailable_other: "{{count}}개 항목 사용 가능", noResults: "결과를 찾을 수 없습니다", noResultsHint: "검색어를 조정해 보세요", typeObjects: "오브젝트", @@ -2704,6 +2769,8 @@ const ko = { noRecordId: "레코드 ID가 없습니다", deleteConfirm: "이 레코드를 삭제하시겠습니까?", bulkDeleteSuccess: "{{count}}개의 {{label}} 레코드 삭제됨", + bulkDeleteSuccess_one: "{{count}}개의 {{label}} 레코드 삭제됨", + bulkDeleteSuccess_other: "{{count}}개의 {{label}} 레코드 삭제됨", bulkDeletePartial: "{{succeeded}}개 삭제됨, {{failed}}개 실패", }, objectViewActions: { @@ -2904,12 +2971,16 @@ const ko = { regionLabel: "알림", groupCount: "알림 {{count}}건", groupUnread: "읽지 않음 {{count}}건", + groupUnread_one: "읽지 않음 {{count}}건", + groupUnread_other: "읽지 않음 {{count}}건", groupMarkRead: "읽음 표시", groupMarkReadTitle: "이 유형 전체를 읽음으로 표시", empty: "알림 없음", markAllRead: "모두 읽음으로 표시", viewAll: "모든 알림 보기", approvalsPending: "{{count}}건의 승인 대기 중", + approvalsPending_one: "{{count}}건의 승인 대기 중", + approvalsPending_other: "{{count}}건의 승인 대기 중", viewApprovals: "승인 보기", noPendingApprovals: "대기 중인 승인 없음", openApprovalsInbox: "승인 보관함 열기", @@ -2917,7 +2988,11 @@ const ko = { badgeNotifications: "알림 {{unread}}건", badgeApprovals: "승인 대기 {{approvals}}건", arrivalMany: "새 메시지 {{count}}건", + arrivalMany_one: "새 메시지 {{count}}건", + arrivalMany_other: "새 메시지 {{count}}건", arrivalRepeats: "이 주제의 새 메시지 {{count}}건", + arrivalRepeats_one: "이 주제의 새 메시지 {{count}}건", + arrivalRepeats_other: "이 주제의 새 메시지 {{count}}건", arrivalOpen: "보기", toastEnabled: "앱 내 알림", desktopEnabled: "데스크톱 알림", @@ -2994,6 +3069,8 @@ const ko = { noImportableFields: "해당 오브젝트에는 가져올 수 있는 필드가 없습니다.", schemaReadFailed: "오브젝트 스키마를 읽을 수 없습니다.", imported: "{{count}}개 행을 {{object}}(으)로 가져왔습니다.", + imported_one: "{{count}}개 행을 {{object}}(으)로 가져왔습니다.", + imported_other: "{{count}}개 행을 {{object}}(으)로 가져왔습니다.", importFrom: "실제 데이터 가져오기 원본", into: "→", opening: "여는 중…", @@ -3023,11 +3100,13 @@ const ko = { formAssist: { title: "AI 제안", suggestionCount: "제안 {{count}}개", - suggestionCountOne: "제안 {{count}}개", + suggestionCount_one: "제안 {{count}}개", + suggestionCount_other: "제안 {{count}}개", applyAll: "모두 적용", confidence: "신뢰도 {{percent}}", appliedCount: "제안 {{count}}개 적용됨", - appliedCountOne: "제안 {{count}}개 적용됨", + appliedCount_one: "제안 {{count}}개 적용됨", + appliedCount_other: "제안 {{count}}개 적용됨", }, recommendations: { title: "추천", @@ -3325,10 +3404,14 @@ const ko = { purgeSampleData: '샘플 데이터 삭제', purgeConfirm: '이 패키지가 생성한 모든 샘플 레코드를 삭제하시겠습니까? 사용자가 추가한 레코드는 삭제되지 않습니다.', purgeSuccess: '샘플 레코드 {{count}}개를 삭제했습니다.', + purgeSuccess_one: '샘플 레코드 {{count}}개를 삭제했습니다.', + purgeSuccess_other: '샘플 레코드 {{count}}개를 삭제했습니다.', purgeNoData: '삭제할 샘플 레코드가 없습니다.', reseedQueued: '샘플 데이터는 다음 환경 접속 시 다시 적재됩니다.', reseedLocalSuccess: '샘플 데이터를 다시 적재했습니다: {{inserted}}개 추가, {{updated}}개 업데이트.', reseedPartialErrors: '({{count}}개 레코드 쓰기 실패)', + reseedPartialErrors_one: '({{count}}개 레코드 쓰기 실패)', + reseedPartialErrors_other: '({{count}}개 레코드 쓰기 실패)', sampleDataKernelUnavailable: '이 컨트롤 플레인에는 환경 커널이 없어 여기서는 샘플 데이터를 다시 적재하거나 삭제할 수 없습니다. 해당 환경 자체의 런타임에서 실행하세요.', updateAvailable: '업데이트 가능', }, @@ -3426,7 +3509,11 @@ const ko = { today: "오늘", daysAgo: "{{count}}일 전", monthsAgo: "{{count}}개월 전", + monthsAgo_one: "{{count}}개월 전", + monthsAgo_other: "{{count}}개월 전", yearsAgo: "{{count}}년 전", + yearsAgo_one: "{{count}}년 전", + yearsAgo_other: "{{count}}년 전", }, }, approvalsInbox: { @@ -3449,6 +3536,8 @@ const ko = { requestInfoSent: '추가 정보 요청을 보냈습니다', remindBtn: '독촉', remindSuccess: '승인자 {{count}}명에게 알렸습니다', + remindSuccess_one: '승인자 {{count}}명에게 알렸습니다', + remindSuccess_other: '승인자 {{count}}명에게 알렸습니다', remindThrottled: '최근에 이미 독촉했습니다. 잠시 후 다시 시도하세요.', replyPlaceholder: '이 요청에 답하기…', slaRemaining: 'SLA {{dur}} 남음', @@ -3465,6 +3554,8 @@ const ko = { progressApprovals: '승인 — {{got}} / {{need}}', progressGroups: '그룹 승인 — {{got}} / {{need}} 그룹', progressEligible: '승인 가능자 {{count}}명', + progressEligible_one: '승인 가능자 {{count}}명', + progressEligible_other: '승인 가능자 {{count}}명', progressBar: '결정 진행 상황', declaredActions: '작업', attachmentChip: '첨부 파일', @@ -3496,6 +3587,8 @@ const ko = { filterCount: '{{total}}개 중 {{shown}}개', selected: '선택됨', actionableCount: '({{count}}개 처리 가능)', + actionableCount_one: '({{count}}개 처리 가능)', + actionableCount_other: '({{count}}개 처리 가능)', selectAll: '전체 선택', selectRow: '이 요청 선택', colRequest: '승인 항목', @@ -3569,11 +3662,19 @@ const ko = { approveN: '{{count}}개 승인', rejectN: '{{count}}개 거부', bulkApproveTitle: '{{count}}개 요청을 승인할까요?', + bulkApproveTitle_one: '{{count}}개 요청을 승인할까요?', + bulkApproveTitle_other: '{{count}}개 요청을 승인할까요?', bulkApproveBody: '각 요청은 내 ID로 승인되며 해당 플로우는 승인 분기로 진행됩니다.', bulkRejectTitle: '{{count}}개 요청을 거부할까요?', + bulkRejectTitle_one: '{{count}}개 요청을 거부할까요?', + bulkRejectTitle_other: '{{count}}개 요청을 거부할까요?', bulkRejectBody: '선택한 요청을 거부하고 신청자에게 알립니다.', bulkApproved: '{{count}}개 요청을 승인했습니다', + bulkApproved_one: '{{count}}개 요청을 승인했습니다', + bulkApproved_other: '{{count}}개 요청을 승인했습니다', bulkRejected: '{{count}}개 요청을 거부했습니다', + bulkRejected_one: '{{count}}개 요청을 거부했습니다', + bulkRejected_other: '{{count}}개 요청을 거부했습니다', bulkPartial: '{{ok}}개 성공, {{fail}}개 실패: {{which}}', rejectOneTitle: '"{{title}}"을(를) 거부할까요?', rejectOneBody: '요청을 거부하고 신청자에게 알립니다.', @@ -3598,8 +3699,14 @@ const ko = { alreadyDecided: '이미 처리된 요청입니다. 목록을 새로고침하세요.', justNow: '방금 전', minutesAgo: '{{count}}분 전', + minutesAgo_one: '{{count}}분 전', + minutesAgo_other: '{{count}}분 전', hoursAgo: '{{count}}시간 전', + hoursAgo_one: '{{count}}시간 전', + hoursAgo_other: '{{count}}시간 전', daysAgo: '{{count}}일 전', + daysAgo_one: '{{count}}일 전', + daysAgo_other: '{{count}}일 전', }, preview: { empty: { @@ -3634,11 +3741,15 @@ const ko = { detailNone: '차이가 감지되지 않았습니다 — 초안이 게시된 버전과 일치합니다.', detailChangedKeys: '기타 변경:', confirmNote: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.', + confirmNote_one: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.', + confirmNote_other: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.', publishConfirm: '모두 게시', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: '게시가 거부됩니다 — {{count}}개 항목에 먼저 결정이 필요합니다', + securityBlockTitle_one: '게시가 거부됩니다 — {{count}}개 항목에 먼저 결정이 필요합니다', + securityBlockTitle_other: '게시가 거부됩니다 — {{count}}개 항목에 먼저 결정이 필요합니다', securityBlockWhere: '객체의 설정 → 레코드 공유에서 수정한 후 다시 게시하세요.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3728,7 +3839,8 @@ const ko = { }, collaboration: { commentCount: "댓글 {{count}}개", - commentCountOne: "댓글 {{count}}개", + commentCount_one: "댓글 {{count}}개", + commentCount_other: "댓글 {{count}}개", resolvedSuffix: " · 해결됨", sortComments: "댓글 정렬", sortOldest: "오래된 순", @@ -3737,11 +3849,18 @@ const ko = { reopen: "다시 열기", justNow: "방금", minutesAgo: "{{count}}분 전", + minutesAgo_one: "{{count}}분 전", + minutesAgo_other: "{{count}}분 전", hoursAgo: "{{count}}시간 전", + hoursAgo_one: "{{count}}시간 전", + hoursAgo_other: "{{count}}시간 전", daysAgo: "{{count}}일 전", + daysAgo_one: "{{count}}일 전", + daysAgo_other: "{{count}}일 전", edited: "(수정됨)", reactionCount: "반응 {{count}}개", - reactionCountOne: "반응 {{count}}개", + reactionCount_one: "반응 {{count}}개", + reactionCount_other: "반응 {{count}}개", addThumbsUp: "좋아요 추가", reply: "답글", reactThumbsUp: "좋아요로 반응", @@ -3752,9 +3871,11 @@ const ko = { commentPlaceholder: "댓글 추가…(@로 멘션)", send: "보내기", presentUserCount: "{{count}}명 접속 중", - presentUserCountOne: "{{count}}명 접속 중", + presentUserCount_one: "{{count}}명 접속 중", + presentUserCount_other: "{{count}}명 접속 중", moreUserCount: "외 {{count}}명", - moreUserCountOne: "외 {{count}}명", + moreUserCount_one: "외 {{count}}명", + moreUserCount_other: "외 {{count}}명", userStatusTitle: "{{name}}({{status}})", statusActive: "활성", statusIdle: "유휴", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 8cd0577ff0..e5ca4b5989 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -72,7 +72,9 @@ const pt = { selectFirst: 'Selecione primeiro {{fields}}', selectRecord: 'Selecionar registro', recordCount: '{{count}} registros', - recordCountOne: '1 registro', + recordCount_one: '{{count}} registro', + recordCount_many: '{{count}} de registros', + recordCount_other: '{{count}} registros', pageOf: 'Página {{current}} de {{total}}', filters: 'Filtros', clear: 'Limpar', @@ -125,7 +127,9 @@ const pt = { closePanel: "Fechar painel", resizeDrawer: "Redimensionar painel", itemCount: "{{count}} itens", - itemCountOne: "{{count}} item", + itemCount_one: "{{count}} item", + itemCount_many: "{{count}} de itens", + itemCount_other: "{{count}} itens", toggleSidebar: "Alternar barra lateral", package: "Pacote", done: "Concluído", @@ -265,6 +269,9 @@ const pt = { }, relativeDate: { overdue: "Atrasado {{count}} d", + overdue_one: "Atrasado {{count}} d", + overdue_many: "Atrasado {{count}} d", + overdue_other: "Atrasado {{count}} d", }, file: { dragDropHere: 'Arraste e solte arquivos aqui', @@ -401,23 +408,41 @@ const pt = { open: "Abrir", search: "Pesquisar…", modified: "{{count}} linha(s) modificada(s)", + modified_one: "{{count}} linha modificada", + modified_many: "{{count}} de linhas modificadas", + modified_other: "{{count}} linhas modificadas", saveFailed: 'Falha ao salvar', selected: "{{count}} selecionado(s)", + selected_one: "{{count}} selecionado", + selected_many: "{{count}} selecionados", + selected_other: "{{count}} selecionados", edit: "Editar", delete: "Excluir", }, grid: { bulkSelected: "{{count}} selecionados", + bulkSelected_one: "{{count}} selecionado", + bulkSelected_many: "{{count}} selecionados", + bulkSelected_other: "{{count}} selecionados", bulkSelectedAllMatches: "{{count}} selecionados (todas as correspondências)", bulkClear: "Limpar seleção", bulkAllOnPage: "Os {{count}} desta página estão selecionados.", + bulkAllOnPage_one: "{{count}} registro desta página está selecionado.", + bulkAllOnPage_many: "Os {{count}} desta página estão selecionados.", + bulkAllOnPage_other: "Os {{count}} desta página estão selecionados.", bulkSelectAllMatching: "Selecionar todas as {{count}} correspondências", bulkAllMatchingSelected: "Todos os {{count}} registros correspondentes estão selecionados.", bulk: { confirmDefault: "Isto será aplicado a {{count}} registro(s).", + confirmDefault_one: "Isto será aplicado a {{count}} registro.", + confirmDefault_many: "Isto será aplicado a {{count}} de registros.", + confirmDefault_other: "Isto será aplicado a {{count}} registros.", overLimit: "A seleção ({{count}}) excede o limite da ação ({{limit}}). Reduza a seleção para prosseguir.", affectedRecords: "Registros afetados ({{count}}):", skippedIneligible: '{{count}} dos registros selecionados não são elegíveis para esta ação e serão ignorados.', + skippedIneligible_one: '{{count}} dos registros selecionados não é elegível para esta ação e será ignorado.', + skippedIneligible_many: '{{count}} dos registros selecionados não são elegíveis para esta ação e serão ignorados.', + skippedIneligible_other: '{{count}} dos registros selecionados não são elegíveis para esta ação e serão ignorados.', rowFallback: "Linha {{index}}", andMore: "… e mais {{count}}", processed: "{{count}} / {{total}} processados", @@ -493,6 +518,9 @@ const pt = { templateFileName: "{{object}}-import-template", autoMatched: "Correspondência automática", autoMatchedSummary: "{{count}} coluna(s) com correspondência automática — revise e ajuste abaixo.", + autoMatchedSummary_one: "{{count}} coluna com correspondência automática — revise e ajuste abaixo.", + autoMatchedSummary_many: "{{count}} de colunas com correspondência automática — revise e ajuste abaixo.", + autoMatchedSummary_other: "{{count}} colunas com correspondência automática — revise e ajuste abaixo.", confidence: { high: "Confiança alta", medium: "Confiança média", @@ -505,8 +533,17 @@ const pt = { cancelImport: "Cancelar a importação", importCancelled: "Importação cancelada", resultsTruncated: "Mostrando os primeiros {{count}} resultados de linhas (de {{total}}).", + resultsTruncated_one: "Mostrando o primeiro resultado de linha ({{count}} de {{total}}).", + resultsTruncated_many: "Mostrando os primeiros {{count}} resultados de linhas (de {{total}}).", + resultsTruncated_other: "Mostrando os primeiros {{count}} resultados de linhas (de {{total}}).", createdCount: "{{count}} criados", + createdCount_one: "{{count}} criado", + createdCount_many: "{{count}} criados", + createdCount_other: "{{count}} criados", updatedCount: "{{count}} atualizados", + updatedCount_one: "{{count}} atualizado", + updatedCount_many: "{{count}} atualizados", + updatedCount_other: "{{count}} atualizados", downloadFailed: "Baixar as linhas com falha", options: "Opções de importação", writeMode: "Quando uma linha corresponde a um registro existente", @@ -554,6 +591,9 @@ const pt = { historyColResult: "Resultado", historyColTime: "Quando", errorCount: "{{count}} erros", + errorCount_one: "{{count}} erro", + errorCount_many: "{{count}} de erros", + errorCount_other: "{{count}} erros", undoImport: "Desfazer a importação", undoing: "Desfazendo…", undoConfirm: "Desfazer esta importação? Os registros criados serão excluídos e os registros atualizados voltarão aos valores anteriores.", @@ -614,7 +654,13 @@ const pt = { mapped: "Mapeado", skipped: "Pulado", rowsWithErrors: "{{count}} linha(s) com erros", + rowsWithErrors_one: "{{count}} linha com erros", + rowsWithErrors_many: "{{count}} de linhas com erros", + rowsWithErrors_other: "{{count}} linhas com erros", rowsCorrected: "{{count}} linha(s) corrigida(s)", + rowsCorrected_one: "{{count}} linha corrigida", + rowsCorrected_many: "{{count}} de linhas corrigidas", + rowsCorrected_other: "{{count}} linhas corrigidas", clickToFix: "— clique em uma célula destacada para editar em linha.", showingRows: "Mostrando {{shown}} de {{total}} linhas", importing: "Importando… {{progress}}%", @@ -622,11 +668,17 @@ const pt = { imported: "{{count}} importado(s)", skippedCount: "{{count}} pulado(s)", moreErrors: "…e {{count}} erro(s) a mais", + moreErrors_one: "…e {{count}} erro a mais", + moreErrors_many: "…e {{count}} de erros a mais", + moreErrors_other: "…e {{count}} erros a mais", cancel: "Cancelar", back: "Voltar", next: "Próximo", close: "Fechar", importNRows: "Importar {{count}} linhas", + importNRows_one: "Importar {{count}} linha", + importNRows_many: "Importar {{count}} de linhas", + importNRows_other: "Importar {{count}} linhas", importingProgress: "Importando…", requiredMark: "*", required: "Obrigatório", @@ -712,7 +764,9 @@ const pt = { refresh: "Atualizar", loading: "Carregando registros…", recordCount: "{{count}} registros", - recordCountOne: "{{count}} registro", + recordCount_one: "{{count}} registro", + recordCount_many: "{{count}} de registros", + recordCount_other: "{{count}} registros", addRecord: "Adicionar registro", tabs: "Abas", allRecords: "Todos os registros", @@ -887,13 +941,22 @@ const pt = { conflict: { title: "Conflito de agendamento", body: "Esta movimentação conflita com as restrições de dependência. Reagendar automaticamente {{count}} tarefa(s) afetada(s)?", + body_one: "Esta movimentação conflita com as restrições de dependência. Reagendar automaticamente {{count}} tarefa afetada?", + body_many: "Esta movimentação conflita com as restrições de dependência. Reagendar automaticamente {{count}} de tarefas afetadas?", + body_other: "Esta movimentação conflita com as restrições de dependência. Reagendar automaticamente {{count}} tarefas afetadas?", confirm: "Reagendar automaticamente", cancel: "Manter como está", }, autoScheduleDlg: { title: "Agendamento automático", body: "Adiar {{count}} tarefa(s) para atender aos vínculos de dependência?", + body_one: "Adiar {{count}} tarefa para atender aos vínculos de dependência?", + body_many: "Adiar {{count}} de tarefas para atender aos vínculos de dependência?", + body_other: "Adiar {{count}} tarefas para atender aos vínculos de dependência?", skipped: "{{count}} tarefa(s) bloqueada(s) também violam os vínculos e foram ignoradas.", + skipped_one: "{{count}} tarefa bloqueada também viola os vínculos e foi ignorada.", + skipped_many: "{{count}} de tarefas bloqueadas também violam os vínculos e foram ignoradas.", + skipped_other: "{{count}} tarefas bloqueadas também violam os vínculos e foram ignoradas.", confirm: "Aplicar", cancel: "Cancelar", none: "Todas as dependências foram atendidas — nada a reagendar.", @@ -1000,7 +1063,9 @@ const pt = { pageHeaderActions: "Ações do cabeçalho da página", emojiPicker: "Seletor de emojis", reactionCount: "{{emoji}} {{count}} reações", - reactionCountOne: "{{emoji}} {{count}} reação", + reactionCount_one: "{{emoji}} {{count}} reação", + reactionCount_many: "{{emoji}} {{count}} de reações", + reactionCount_other: "{{emoji}} {{count}} reações", recordDetail: "Detalhe do registro", recordDetailWithLabel: "Detalhe de {{label}}", openAsFullPage: "Abrir como página inteira", @@ -1037,11 +1102,22 @@ const pt = { unpin: "Desfixar", justNow: "agora mesmo", minutesAgo: "há {{count}} min", + minutesAgo_one: "há {{count}} min", + minutesAgo_many: "há {{count}} min", + minutesAgo_other: "há {{count}} min", hoursAgo: "há {{count}} h", + hoursAgo_one: "há {{count}} h", + hoursAgo_many: "há {{count}} h", + hoursAgo_other: "há {{count}} h", daysAgo: "há {{count}} d", + daysAgo_one: "há {{count}} d", + daysAgo_many: "há {{count}} d", + daysAgo_other: "há {{count}} d", dropFilesToUpload: "Arraste arquivos aqui ou clique para enviar", attachmentCount: "{{count}} anexo", - attachmentCountPlural: "{{count}} anexos", + attachmentCount_one: "{{count}} anexo", + attachmentCount_many: "{{count}} de anexos", + attachmentCount_other: "{{count}} anexos", removeAttachment: "Remover anexo", // Record Attachments panel (enable.files, objectstack#4358) attachments: "Anexos", @@ -1100,7 +1176,9 @@ const pt = { via: "via {{source}}", viewSource: "Ver origem", replyCount: "{{count}} resposta", - replyCountPlural: "{{count}} respostas", + replyCount_one: "{{count}} resposta", + replyCount_many: "{{count}} de respostas", + replyCount_other: "{{count}} respostas", replyPlaceholder: "Responder…", filterActivity: "Filtrar atividade", openDiscussion: "Abrir painel de discussão", @@ -1182,6 +1260,9 @@ const pt = { copyPhone: "Copiar telefone", copyRecordId: "Copiar ID do registro", showEmptyFields: "Mostrar {{count}} campos vazios", + showEmptyFields_one: "Mostrar {{count}} campo vazio", + showEmptyFields_many: "Mostrar {{count}} de campos vazios", + showEmptyFields_other: "Mostrar {{count}} campos vazios", hideEmptyFields: "Ocultar campos vazios", noValue: "Sem valor", unresolvedReference: "Referência não resolvida: {{value}} não foi resolvida como um usuário", @@ -1553,7 +1634,13 @@ const pt = { backToHome: "Voltar ao início", }, saveAdvisoryTitle: "Salvo — a verificação de criação gerou {{count}} recomendação(ões)", + saveAdvisoryTitle_one: "Salvo — a verificação de criação gerou {{count}} recomendação", + saveAdvisoryTitle_many: "Salvo — a verificação de criação gerou {{count}} de recomendações", + saveAdvisoryTitle_other: "Salvo — a verificação de criação gerou {{count}} recomendações", publishAdvisoryTitle: "Publicado — a verificação de criação gerou {{count}} recomendação(ões)", + publishAdvisoryTitle_one: "Publicado — a verificação de criação gerou {{count}} recomendação", + publishAdvisoryTitle_many: "Publicado — a verificação de criação gerou {{count}} de recomendações", + publishAdvisoryTitle_other: "Publicado — a verificação de criação gerou {{count}} recomendações", importMappingsUnavailable: "Não foi possível carregar os mapeamentos de importação salvos de {{object}}", importMappingsRefused: "O servidor recusou esta solicitação, portanto esta lista está vazia porque não pôde ser lida, não porque nada esteja registrado. Entre novamente ou peça acesso a um administrador.", importMappingsUnreadable: "Esta lista está vazia porque não pôde ser lida, não porque nada esteja registrado. Tente novamente e relate o problema se ele persistir.", @@ -2033,7 +2120,9 @@ const pt = { title: "Título", viewType: "Tipo de visualização", recordCount: "{{count}} registros", - recordCountOne: "{{count}} registro", + recordCount_one: "{{count}} registro", + recordCount_many: "{{count}} de registros", + recordCount_other: "{{count}} registros", save: "Salvar", discard: "Descartar", createView: "Criar visualização", @@ -2049,6 +2138,9 @@ const pt = { import: "Importar", importTitle: "Importar do CSV", importedToast: "{{count}} linha(s) importada(s).", + importedToast_one: "{{count}} linha importada.", + importedToast_many: "{{count}} de linhas importadas.", + importedToast_other: "{{count}} linhas importadas.", importedWithSkipped: "{{ok}} linha(s) importada(s); {{skipped}} pulada(s).", createViewDesc: "Escolha um tipo de exibição e dê um nome a ela. Todas as configurações podem ser alteradas depois.", cancel: "Cancelar", @@ -2057,6 +2149,9 @@ const pt = { deleteViewTitle: "Excluir exibição", deleteViewConfirm: "Excluir a exibição \"{{name}}\"? Isso não pode ser desfeito.", bulkDeleteConfirm: "Excluir {{count}} registros selecionados? Isso não pode ser desfeito.", + bulkDeleteConfirm_one: "Excluir {{count}} registro selecionado? Isso não pode ser desfeito.", + bulkDeleteConfirm_many: "Excluir {{count}} de registros selecionados? Isso não pode ser desfeito.", + bulkDeleteConfirm_other: "Excluir {{count}} registros selecionados? Isso não pode ser desfeito.", duplicateViewName: "Já existe uma exibição com esse nome.", viewTypeGrid: "Grade", viewTypeGridDesc: "Tabela de registros no estilo planilha.", @@ -2514,6 +2609,9 @@ const pt = { }, pendingDrafts: { message: "Você tem {{count}} alteração(ões) não publicada(s) — publique para ativá-las.", + message_one: "Você tem {{count}} alteração não publicada — publique para ativá-la.", + message_many: "Você tem {{count}} de alterações não publicadas — publique para ativá-las.", + message_other: "Você tem {{count}} alterações não publicadas — publique para ativá-las.", cta: "Publicar", publishing: "Publicando…", published: "Publicado! Suas alterações estão ativas.", @@ -2637,9 +2735,13 @@ const pt = { placeholder: "Pesquisar objetos, painéis, páginas, relatórios…", inputAriaLabel: "Pesquisar objetos, painéis, páginas, relatórios", resultsCount: "{{count}} resultado para \"{{query}}\"", - resultsCountPlural: "{{count}} resultados para \"{{query}}\"", + resultsCount_one: "{{count}} resultado para \"{{query}}\"", + resultsCount_many: "{{count}} de resultados para \"{{query}}\"", + resultsCount_other: "{{count}} resultados para \"{{query}}\"", itemsAvailable: "{{count}} itens disponíveis", - itemsAvailableOne: "{{count}} item disponível", + itemsAvailable_one: "{{count}} item disponível", + itemsAvailable_many: "{{count}} de itens disponíveis", + itemsAvailable_other: "{{count}} itens disponíveis", noResults: "Nenhum resultado encontrado", noResultsHint: "Tente ajustar seus termos de pesquisa", typeObjects: "Objetos", @@ -2716,6 +2818,9 @@ const pt = { noRecordId: "Nenhum ID de registro fornecido", deleteConfirm: "Excluir este registro?", bulkDeleteSuccess: "{{count}} registros de {{label}} excluídos", + bulkDeleteSuccess_one: "{{count}} registro de {{label}} excluído", + bulkDeleteSuccess_many: "{{count}} de registros de {{label}} excluídos", + bulkDeleteSuccess_other: "{{count}} registros de {{label}} excluídos", bulkDeletePartial: "{{succeeded}} excluídos, {{failed}} com falha", }, objectViewActions: { @@ -2916,12 +3021,18 @@ const pt = { regionLabel: "Notificações", groupCount: "{{count}} notificações", groupUnread: "{{count}} não lidas", + groupUnread_one: "{{count}} não lida", + groupUnread_many: "{{count}} não lidas", + groupUnread_other: "{{count}} não lidas", groupMarkRead: "Marcar como lidas", groupMarkReadTitle: "Marcar todas deste tipo como lidas", empty: "Sem notificações", markAllRead: "Marcar tudo como lido", viewAll: "Ver todas as notificações", approvalsPending: "{{count}} aprovações pendentes", + approvalsPending_one: "{{count}} aprovação pendente", + approvalsPending_many: "{{count}} de aprovações pendentes", + approvalsPending_other: "{{count}} aprovações pendentes", viewApprovals: "Ver aprovações", noPendingApprovals: "Sem aprovações pendentes", openApprovalsInbox: "Abrir caixa de aprovações", @@ -2929,7 +3040,13 @@ const pt = { badgeNotifications: "{{unread}} notificações", badgeApprovals: "{{approvals}} aprovações pendentes", arrivalMany: "{{count}} novas mensagens", + arrivalMany_one: "{{count}} nova mensagem", + arrivalMany_many: "{{count}} de novas mensagens", + arrivalMany_other: "{{count}} novas mensagens", arrivalRepeats: "{{count}} novas mensagens sobre este tópico", + arrivalRepeats_one: "{{count}} nova mensagem sobre este tópico", + arrivalRepeats_many: "{{count}} de novas mensagens sobre este tópico", + arrivalRepeats_other: "{{count}} novas mensagens sobre este tópico", arrivalOpen: "Ver", toastEnabled: "Avisos no aplicativo", desktopEnabled: "Notificações da área de trabalho", @@ -3006,6 +3123,9 @@ const pt = { noImportableFields: "Esse objeto não tem campos importáveis.", schemaReadFailed: "Não foi possível ler o esquema do objeto.", imported: "{{count}} linha(s) importada(s) para {{object}}.", + imported_one: "{{count}} linha importada para {{object}}.", + imported_many: "{{count}} de linhas importadas para {{object}}.", + imported_other: "{{count}} linhas importadas para {{object}}.", importFrom: "Importar as linhas reais de", into: "para", opening: "Abrindo…", @@ -3035,11 +3155,15 @@ const pt = { formAssist: { title: "Sugestões de IA", suggestionCount: "{{count}} sugestões", - suggestionCountOne: "{{count}} sugestão", + suggestionCount_one: "{{count}} sugestão", + suggestionCount_many: "{{count}} de sugestões", + suggestionCount_other: "{{count}} sugestões", applyAll: "Aplicar tudo", confidence: "Confiança: {{percent}}", appliedCount: "{{count}} sugestões aplicadas", - appliedCountOne: "{{count}} sugestão aplicada", + appliedCount_one: "{{count}} sugestão aplicada", + appliedCount_many: "{{count}} de sugestões aplicadas", + appliedCount_other: "{{count}} sugestões aplicadas", }, recommendations: { title: "Recomendações", @@ -3340,10 +3464,16 @@ const pt = { purgeSampleData: 'Remover dados de exemplo', purgeConfirm: 'Excluir todos os registros de exemplo criados por este pacote? Os registros adicionados pelo usuário NÃO serão afetados.', purgeSuccess: '{{count}} registro(s) de exemplo removido(s).', + purgeSuccess_one: '{{count}} registro de exemplo removido.', + purgeSuccess_many: '{{count}} de registros de exemplo removidos.', + purgeSuccess_other: '{{count}} registros de exemplo removidos.', purgeNoData: 'Nenhum registro de exemplo encontrado para remover.', reseedQueued: 'Os dados de exemplo serão recarregados no próximo acesso ao ambiente.', reseedLocalSuccess: 'Dados de exemplo recarregados: {{inserted}} inseridos, {{updated}} atualizados.', reseedPartialErrors: '({{count}} registro(s) não puderam ser gravados)', + reseedPartialErrors_one: '({{count}} registro não pôde ser gravado)', + reseedPartialErrors_many: '({{count}} de registros não puderam ser gravados)', + reseedPartialErrors_other: '({{count}} registros não puderam ser gravados)', sampleDataKernelUnavailable: 'Este plano de controle não tem um kernel de ambiente, portanto os dados de exemplo não podem ser recarregados nem removidos aqui. Faça isso no runtime do próprio ambiente.', updateAvailable: 'Atualização disponível', }, @@ -3441,7 +3571,13 @@ const pt = { today: "hoje", daysAgo: "há {{count}}d", monthsAgo: "há {{count}} meses", + monthsAgo_one: "há {{count}} mês", + monthsAgo_many: "há {{count}} meses", + monthsAgo_other: "há {{count}} meses", yearsAgo: "há {{count}} anos", + yearsAgo_one: "há {{count}} ano", + yearsAgo_many: "há {{count}} anos", + yearsAgo_other: "há {{count}} anos", }, }, approvalsInbox: { @@ -3464,6 +3600,9 @@ const pt = { requestInfoSent: 'Devolvida ao solicitante para complemento', remindBtn: 'Lembrar', remindSuccess: 'Lembrete enviado a {{count}} aprovador(es)', + remindSuccess_one: 'Lembrete enviado a {{count}} aprovador', + remindSuccess_many: 'Lembrete enviado a {{count}} de aprovadores', + remindSuccess_other: 'Lembrete enviado a {{count}} aprovadores', remindThrottled: 'Um lembrete já foi enviado há pouco — tente mais tarde.', replyPlaceholder: 'Responder nesta solicitação…', slaRemaining: 'SLA: faltam {{dur}}', @@ -3480,6 +3619,9 @@ const pt = { progressApprovals: 'Aprovações — {{got}} de {{need}}', progressGroups: 'Progresso de assinaturas — {{got}} de {{need}} grupos', progressEligible: '{{count}} aprovador(es) elegível(eis)', + progressEligible_one: '{{count}} aprovador elegível', + progressEligible_many: '{{count}} de aprovadores elegíveis', + progressEligible_other: '{{count}} aprovadores elegíveis', progressBar: 'Progresso da decisão', declaredActions: 'Ações', attachmentChip: 'Anexo', @@ -3511,6 +3653,9 @@ const pt = { filterCount: '{{shown}} de {{total}}', selected: 'selecionadas', actionableCount: '({{count}} acionáveis)', + actionableCount_one: '({{count}} acionável)', + actionableCount_many: '({{count}} acionáveis)', + actionableCount_other: '({{count}} acionáveis)', selectAll: 'Selecionar tudo', selectRow: 'Selecionar solicitação', colRequest: 'Solicitação', @@ -3584,11 +3729,23 @@ const pt = { approveN: 'Aprovar {{count}}', rejectN: 'Rejeitar {{count}}', bulkApproveTitle: 'Aprovar {{count}} solicitações?', + bulkApproveTitle_one: 'Aprovar {{count}} solicitação?', + bulkApproveTitle_many: 'Aprovar {{count}} de solicitações?', + bulkApproveTitle_other: 'Aprovar {{count}} solicitações?', bulkApproveBody: 'Cada solicitação é aprovada com sua identidade e o fluxo segue pelo ramo de aprovação.', bulkRejectTitle: 'Rejeitar {{count}} solicitações?', + bulkRejectTitle_one: 'Rejeitar {{count}} solicitação?', + bulkRejectTitle_many: 'Rejeitar {{count}} de solicitações?', + bulkRejectTitle_other: 'Rejeitar {{count}} solicitações?', bulkRejectBody: 'As solicitações selecionadas serão rejeitadas e os solicitantes notificados.', bulkApproved: '{{count}} solicitações aprovadas', + bulkApproved_one: '{{count}} solicitação aprovada', + bulkApproved_many: '{{count}} de solicitações aprovadas', + bulkApproved_other: '{{count}} solicitações aprovadas', bulkRejected: '{{count}} solicitações rejeitadas', + bulkRejected_one: '{{count}} solicitação rejeitada', + bulkRejected_many: '{{count}} de solicitações rejeitadas', + bulkRejected_other: '{{count}} solicitações rejeitadas', bulkPartial: '{{ok}} com sucesso, {{fail}} falharam: {{which}}', rejectOneTitle: 'Rejeitar "{{title}}"?', rejectOneBody: 'A solicitação será rejeitada e o solicitante notificado.', @@ -3613,8 +3770,17 @@ const pt = { alreadyDecided: 'Esta solicitação já foi decidida. Atualize a lista.', justNow: 'agora mesmo', minutesAgo: 'há {{count}} min', + minutesAgo_one: 'há {{count}} min', + minutesAgo_many: 'há {{count}} min', + minutesAgo_other: 'há {{count}} min', hoursAgo: 'há {{count}} h', + hoursAgo_one: 'há {{count}} h', + hoursAgo_many: 'há {{count}} h', + hoursAgo_other: 'há {{count}} h', daysAgo: 'há {{count}} dias', + daysAgo_one: 'há {{count}} dia', + daysAgo_many: 'há {{count}} dias', + daysAgo_other: 'há {{count}} dias', }, preview: { empty: { @@ -3649,11 +3815,17 @@ const pt = { detailNone: 'Nenhuma diferença detectada — o rascunho corresponde à versão publicada.', detailChangedKeys: 'Também alterado:', confirmNote: 'Publicar libera atomicamente todos os {{count}} rascunhos pendentes deste pacote.', + confirmNote_one: 'Publicar libera atomicamente {{count}} rascunho pendente deste pacote.', + confirmNote_many: 'Publicar libera atomicamente todos os {{count}} de rascunhos pendentes deste pacote.', + confirmNote_other: 'Publicar libera atomicamente todos os {{count}} rascunhos pendentes deste pacote.', publishConfirm: 'Publicar tudo', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'A publicação será recusada — {{count}} item(ns) precisam de uma decisão primeiro', + securityBlockTitle_one: 'A publicação será recusada — {{count}} item precisa de uma decisão primeiro', + securityBlockTitle_many: 'A publicação será recusada — {{count}} de itens precisam de uma decisão primeiro', + securityBlockTitle_other: 'A publicação será recusada — {{count}} itens precisam de uma decisão primeiro', securityBlockWhere: 'Corrija no objeto em Configurações → Compartilhamento de registros e publique novamente.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3743,7 +3915,9 @@ const pt = { }, collaboration: { commentCount: "{{count}} comentários", - commentCountOne: "{{count}} comentário", + commentCount_one: "{{count}} comentário", + commentCount_many: "{{count}} de comentários", + commentCount_other: "{{count}} comentários", resolvedSuffix: " · Resolvido", sortComments: "Ordenar comentários", sortOldest: "Mais antigos", @@ -3752,11 +3926,22 @@ const pt = { reopen: "Reabrir", justNow: "agora mesmo", minutesAgo: "há {{count}} min", + minutesAgo_one: "há {{count}} min", + minutesAgo_many: "há {{count}} min", + minutesAgo_other: "há {{count}} min", hoursAgo: "há {{count}} h", + hoursAgo_one: "há {{count}} h", + hoursAgo_many: "há {{count}} h", + hoursAgo_other: "há {{count}} h", daysAgo: "há {{count}} d", + daysAgo_one: "há {{count}} d", + daysAgo_many: "há {{count}} d", + daysAgo_other: "há {{count}} d", edited: "(editado)", reactionCount: "{{count}} reações", - reactionCountOne: "{{count}} reação", + reactionCount_one: "{{count}} reação", + reactionCount_many: "{{count}} de reações", + reactionCount_other: "{{count}} reações", addThumbsUp: "Adicionar curtida", reply: "Responder", reactThumbsUp: "Reagir com curtida", @@ -3767,9 +3952,13 @@ const pt = { commentPlaceholder: "Adicionar um comentário… (use @ para mencionar)", send: "Enviar", presentUserCount: "{{count}} usuários presentes", - presentUserCountOne: "{{count}} usuário presente", + presentUserCount_one: "{{count}} usuário presente", + presentUserCount_many: "{{count}} de usuários presentes", + presentUserCount_other: "{{count}} usuários presentes", moreUserCount: "mais {{count}} usuários", - moreUserCountOne: "mais {{count}} usuário", + moreUserCount_one: "mais {{count}} usuário", + moreUserCount_many: "mais {{count}} de usuários", + moreUserCount_other: "mais {{count}} usuários", userStatusTitle: "{{name}} ({{status}})", statusActive: "ativo", statusIdle: "inativo", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 28ebb84c30..c5a96f677d 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -82,7 +82,10 @@ const ru = { selectFirst: 'Сначала выберите {{fields}}', selectRecord: 'Выбрать запись', recordCount: 'Записей: {{count}}', - recordCountOne: '1 запись', + recordCount_one: '{{count}} запись', + recordCount_few: '{{count}} записи', + recordCount_many: '{{count}} записей', + recordCount_other: '{{count}} записи', pageOf: 'Страница {{current}} из {{total}}', filters: 'Фильтры', clear: 'Очистить', @@ -134,12 +137,14 @@ const ru = { closeChat: "Закрыть чат", closePanel: "Закрыть панель", resizeDrawer: "Изменить ширину панели", - // objectui#10242 — the tab-count badge switches keys only at exactly 1, so - // this half serves one (21, 31…), few (2-4, 22-24…) AND many (5-20…). No - // single `{{count}} ` form agrees with all three; a count label does, - // the same device as `detail.repeaterItemCount`. + // objectui#11445 — a count family: each slot agrees the noun with the + // number (one 1/21, few 2-4, many 5-20, other a fraction). The base key, a + // count-invariant form, answers a call made without a count. itemCount: "Элементов: {{count}}", - itemCountOne: "{{count}} элемент", + itemCount_one: "{{count}} элемент", + itemCount_few: "{{count}} элемента", + itemCount_many: "{{count}} элементов", + itemCount_other: "{{count}} элемента", toggleSidebar: "Переключить боковую панель", package: "Пакет", done: "Готово", @@ -279,6 +284,10 @@ const ru = { }, relativeDate: { overdue: "Просрочено на {{count}} дн.", + overdue_one: "Просрочено на {{count}} дн.", + overdue_few: "Просрочено на {{count}} дн.", + overdue_many: "Просрочено на {{count}} дн.", + overdue_other: "Просрочено на {{count}} дн.", }, file: { dragDropHere: 'Перетащите файлы сюда', @@ -415,23 +424,47 @@ const ru = { open: "Открыть", search: "Поиск…", modified: "{{count}} строк изменено", + modified_one: "Изменена {{count}} строка", + modified_few: "Изменено {{count}} строки", + modified_many: "Изменено {{count}} строк", + modified_other: "Изменено {{count}} строки", saveFailed: 'Не удалось сохранить', selected: "{{count}} выбрано", + selected_one: "Выбрано: {{count}}", + selected_few: "Выбрано: {{count}}", + selected_many: "Выбрано: {{count}}", + selected_other: "Выбрано: {{count}}", edit: "Редактировать", delete: "Удалить", }, grid: { bulkSelected: "Выбрано: {{count}}", + bulkSelected_one: "Выбрано: {{count}}", + bulkSelected_few: "Выбрано: {{count}}", + bulkSelected_many: "Выбрано: {{count}}", + bulkSelected_other: "Выбрано: {{count}}", bulkSelectedAllMatches: "Выбрано: {{count}} (все совпадения)", bulkClear: "Снять выделение", bulkAllOnPage: "Выбраны все {{count}} на этой странице.", + bulkAllOnPage_one: "Выбраны все записи на этой странице ({{count}}).", + bulkAllOnPage_few: "Выбраны все записи на этой странице ({{count}}).", + bulkAllOnPage_many: "Выбраны все записи на этой странице ({{count}}).", + bulkAllOnPage_other: "Выбраны все записи на этой странице ({{count}}).", bulkSelectAllMatching: "Выбрать все совпадения ({{count}})", bulkAllMatchingSelected: "Выбраны все подходящие записи ({{count}}).", bulk: { confirmDefault: "Будет применено к записям: {{count}}.", + confirmDefault_one: "Будет применено к записям: {{count}}.", + confirmDefault_few: "Будет применено к записям: {{count}}.", + confirmDefault_many: "Будет применено к записям: {{count}}.", + confirmDefault_other: "Будет применено к записям: {{count}}.", overLimit: "Выбрано ({{count}}) больше лимита действия ({{limit}}). Уменьшите выбор, чтобы продолжить.", affectedRecords: "Затрагиваемые записи ({{count}}):", skippedIneligible: '{{count}} из выбранных записей не подходят для этого действия и будут пропущены.', + skippedIneligible_one: '{{count}} из выбранных записей не подходит для этого действия и будет пропущена.', + skippedIneligible_few: '{{count}} из выбранных записей не подходят для этого действия и будут пропущены.', + skippedIneligible_many: '{{count}} из выбранных записей не подходят для этого действия и будут пропущены.', + skippedIneligible_other: '{{count}} из выбранных записей не подходят для этого действия и будут пропущены.', rowFallback: "Строка {{index}}", andMore: "… и ещё {{count}}", processed: "Обработано {{count}} / {{total}}", @@ -507,6 +540,10 @@ const ru = { templateFileName: "{{object}}-import-template", autoMatched: "Сопоставлено автоматически", autoMatchedSummary: "Автоматически сопоставлено столбцов: {{count}} — проверьте и скорректируйте ниже.", + autoMatchedSummary_one: "Автоматически сопоставлено столбцов: {{count}} — проверьте и скорректируйте ниже.", + autoMatchedSummary_few: "Автоматически сопоставлено столбцов: {{count}} — проверьте и скорректируйте ниже.", + autoMatchedSummary_many: "Автоматически сопоставлено столбцов: {{count}} — проверьте и скорректируйте ниже.", + autoMatchedSummary_other: "Автоматически сопоставлено столбцов: {{count}} — проверьте и скорректируйте ниже.", confidence: { high: "Высокая точность", medium: "Средняя точность", @@ -519,8 +556,20 @@ const ru = { cancelImport: "Отменить импорт", importCancelled: "Импорт отменён", resultsTruncated: "Показаны результаты первых {{count}} строк (из {{total}}).", + resultsTruncated_one: "Показаны результаты первых строк: {{count}} (из {{total}}).", + resultsTruncated_few: "Показаны результаты первых строк: {{count}} (из {{total}}).", + resultsTruncated_many: "Показаны результаты первых строк: {{count}} (из {{total}}).", + resultsTruncated_other: "Показаны результаты первых строк: {{count}} (из {{total}}).", createdCount: "Создано: {{count}}", + createdCount_one: "Создано: {{count}}", + createdCount_few: "Создано: {{count}}", + createdCount_many: "Создано: {{count}}", + createdCount_other: "Создано: {{count}}", updatedCount: "Обновлено: {{count}}", + updatedCount_one: "Обновлено: {{count}}", + updatedCount_few: "Обновлено: {{count}}", + updatedCount_many: "Обновлено: {{count}}", + updatedCount_other: "Обновлено: {{count}}", downloadFailed: "Скачать строки с ошибками", options: "Параметры импорта", writeMode: "Если строка совпадает с существующей записью", @@ -568,6 +617,10 @@ const ru = { historyColResult: "Результат", historyColTime: "Когда", errorCount: "Ошибок: {{count}}", + errorCount_one: "Ошибок: {{count}}", + errorCount_few: "Ошибок: {{count}}", + errorCount_many: "Ошибок: {{count}}", + errorCount_other: "Ошибок: {{count}}", undoImport: "Отменить импорт", undoing: "Отмена…", undoConfirm: "Отменить этот импорт? Созданные им записи будут удалены, а обновлённые — восстановлены к прежним значениям.", @@ -628,7 +681,15 @@ const ru = { mapped: "Сопоставлено", skipped: "Пропущено", rowsWithErrors: "{{count}} строк с ошибками", + rowsWithErrors_one: "{{count}} строка с ошибками", + rowsWithErrors_few: "{{count}} строки с ошибками", + rowsWithErrors_many: "{{count}} строк с ошибками", + rowsWithErrors_other: "{{count}} строки с ошибками", rowsCorrected: "{{count}} строк исправлено", + rowsCorrected_one: "Исправлена {{count}} строка", + rowsCorrected_few: "Исправлено {{count}} строки", + rowsCorrected_many: "Исправлено {{count}} строк", + rowsCorrected_other: "Исправлено {{count}} строки", clickToFix: "— нажмите на выделенную ячейку для редактирования.", showingRows: "Отображено {{shown}} из {{total}} строк", importing: "Импорт… {{progress}}%", @@ -636,11 +697,19 @@ const ru = { imported: "{{count}} импортировано", skippedCount: "{{count}} пропущено", moreErrors: "…ещё {{count}} ошибок", + moreErrors_one: "…ещё {{count}} ошибка", + moreErrors_few: "…ещё {{count}} ошибки", + moreErrors_many: "…ещё {{count}} ошибок", + moreErrors_other: "…ещё {{count}} ошибки", cancel: "Отмена", back: "Назад", next: "Далее", close: "Закрыть", importNRows: "Импортировать {{count}} строк", + importNRows_one: "Импортировать {{count}} строку", + importNRows_few: "Импортировать {{count}} строки", + importNRows_many: "Импортировать {{count}} строк", + importNRows_other: "Импортировать {{count}} строки", importingProgress: "Импортируется…", requiredMark: "*", required: "Обязательно", @@ -726,11 +795,14 @@ const ru = { }, refresh: "Обновить", loading: "Загрузка записей…", - // objectui#10425 — the record-count bar picks this half at every count but - // 1, which spans one (21), few (2-4) and many (5-20); a count label reads - // right at all three, the same form as `lookup.recordCount`. + // objectui#11445 — a count family: each slot agrees the noun with the + // number (one 1/21, few 2-4, many 5-20, other a fraction). The base key, a + // count-invariant form, answers a call made without a count. recordCount: "Записей: {{count}}", - recordCountOne: "{{count}} запись", + recordCount_one: "{{count}} запись", + recordCount_few: "{{count}} записи", + recordCount_many: "{{count}} записей", + recordCount_other: "{{count}} записи", addRecord: "Добавить запись", tabs: "Вкладки", allRecords: "Все записи", @@ -905,13 +977,25 @@ const ru = { conflict: { title: "Конфликт расписания", body: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", + body_one: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", + body_few: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", + body_many: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", + body_other: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", confirm: "Перепланировать автоматически", cancel: "Оставить как есть", }, autoScheduleDlg: { title: "Автопланирование", body: "Сдвинуть {{count}} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?", + body_one: "Сдвинуть {{count}} задачу на более поздний срок, чтобы соблюсти связи зависимостей?", + body_few: "Сдвинуть {{count}} задачи на более поздний срок, чтобы соблюсти связи зависимостей?", + body_many: "Сдвинуть {{count}} задач на более поздний срок, чтобы соблюсти связи зависимостей?", + body_other: "Сдвинуть {{count}} задачи на более поздний срок, чтобы соблюсти связи зависимостей?", skipped: "{{count}} заблокированных задач(и) также нарушают связи и были пропущены.", + skipped_one: "{{count}} заблокированная задача также нарушает связи и была пропущена.", + skipped_few: "{{count}} заблокированные задачи также нарушают связи и были пропущены.", + skipped_many: "{{count}} заблокированных задач также нарушают связи и были пропущены.", + skipped_other: "{{count}} заблокированной задачи также нарушают связи и были пропущены.", confirm: "Применить", cancel: "Отмена", none: "Все зависимости соблюдены — перепланировать нечего.", @@ -1017,11 +1101,14 @@ const ru = { addReaction: "Добавить реакцию", pageHeaderActions: "Действия в заголовке страницы", emojiPicker: "Выбор эмодзи", - // objectui#10242 — the chip picks this half at every count but 1, which - // spans one, few and many; a count label reads right at all three, the same - // device as `collaboration.reactionCount`. + // objectui#11445 — a count family: each slot agrees the noun with the + // number (one 1/21, few 2-4, many 5-20, other a fraction). The base key, a + // count-invariant form, answers a call made without a count. reactionCount: "{{emoji}} Реакций: {{count}}", - reactionCountOne: "{{emoji}} {{count}} реакция", + reactionCount_one: "{{emoji}} {{count}} реакция", + reactionCount_few: "{{emoji}} {{count}} реакции", + reactionCount_many: "{{emoji}} {{count}} реакций", + reactionCount_other: "{{emoji}} {{count}} реакции", recordDetail: "Сведения о записи", recordDetailWithLabel: "Сведения: {{label}}", openAsFullPage: "Открыть на всю страницу", @@ -1070,11 +1157,26 @@ const ru = { unpin: "Открепить", justNow: "только что", minutesAgo: "{{count}} мин. назад", + minutesAgo_one: "{{count}} мин. назад", + minutesAgo_few: "{{count}} мин. назад", + minutesAgo_many: "{{count}} мин. назад", + minutesAgo_other: "{{count}} мин. назад", hoursAgo: "{{count}} ч. назад", + hoursAgo_one: "{{count}} ч. назад", + hoursAgo_few: "{{count}} ч. назад", + hoursAgo_many: "{{count}} ч. назад", + hoursAgo_other: "{{count}} ч. назад", daysAgo: "{{count}} дн. назад", + daysAgo_one: "{{count}} дн. назад", + daysAgo_few: "{{count}} дн. назад", + daysAgo_many: "{{count}} дн. назад", + daysAgo_other: "{{count}} дн. назад", dropFilesToUpload: "Перетащите файлы сюда или нажмите для загрузки", attachmentCount: "{{count}} вложение", - attachmentCountPlural: "{{count}} вложений", + attachmentCount_one: "{{count}} вложение", + attachmentCount_few: "{{count}} вложения", + attachmentCount_many: "{{count}} вложений", + attachmentCount_other: "{{count}} вложения", removeAttachment: "Удалить вложение", // Record Attachments panel (enable.files, objectstack#4358) attachments: "Вложения", @@ -1133,7 +1235,10 @@ const ru = { via: "через {{source}}", viewSource: "Показать источник", replyCount: "{{count}} ответ", - replyCountPlural: "{{count}} ответов", + replyCount_one: "{{count}} ответ", + replyCount_few: "{{count}} ответа", + replyCount_many: "{{count}} ответов", + replyCount_other: "{{count}} ответа", replyPlaceholder: "Ответить…", filterActivity: "Фильтр активности", openDiscussion: "Открыть панель обсуждения", @@ -1206,6 +1311,10 @@ const ru = { copyPhone: "Копировать телефон", copyRecordId: "Копировать ID записи", showEmptyFields: "Показать {{count}} пустых полей", + showEmptyFields_one: "Показать {{count}} пустое поле", + showEmptyFields_few: "Показать {{count}} пустых поля", + showEmptyFields_many: "Показать {{count}} пустых полей", + showEmptyFields_other: "Показать {{count}} пустого поля", hideEmptyFields: "Скрыть пустые поля", noValue: "Нет значения", unresolvedReference: "Неразрешённая ссылка: {{value}} не удалось сопоставить с пользователем", @@ -1579,7 +1688,15 @@ const ru = { backToHome: "На главную", }, saveAdvisoryTitle: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", + saveAdvisoryTitle_one: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", + saveAdvisoryTitle_few: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", + saveAdvisoryTitle_many: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", + saveAdvisoryTitle_other: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", publishAdvisoryTitle: "Опубликовано — проверка авторинга выдала рекомендаций: {{count}}", + publishAdvisoryTitle_one: "Опубликовано — проверка авторинга выдала рекомендаций: {{count}}", + publishAdvisoryTitle_few: "Опубликовано — проверка авторинга выдала рекомендаций: {{count}}", + publishAdvisoryTitle_many: "Опубликовано — проверка авторинга выдала рекомендаций: {{count}}", + publishAdvisoryTitle_other: "Опубликовано — проверка авторинга выдала рекомендаций: {{count}}", importMappingsUnavailable: "Не удалось загрузить сохранённые сопоставления импорта для {{object}}", importMappingsRefused: "Сервер отклонил этот запрос, поэтому список пуст из-за того, что его не удалось прочитать, а не потому, что ничего не зарегистрировано. Войдите заново или запросите доступ у администратора.", importMappingsUnreadable: "Список пуст из-за того, что его не удалось прочитать, а не потому, что ничего не зарегистрировано. Повторите попытку и сообщите о проблеме, если она повторяется.", @@ -2063,11 +2180,14 @@ const ru = { searchFields: "Поиск полей…", title: "Заголовок", viewType: "Тип представления", - // objectui#10636 — the footer picks this half at every count but 1, which spans - // one (21), few (2-4) and many (5-20); a count label reads right at all three, - // the same form as `list.recordCount`. + // objectui#11445 — a count family: each slot agrees the noun with the + // number (one 1/21, few 2-4, many 5-20, other a fraction). The base key, a + // count-invariant form, answers a call made without a count. recordCount: "Записей: {{count}}", - recordCountOne: "{{count}} запись", + recordCount_one: "{{count}} запись", + recordCount_few: "{{count}} записи", + recordCount_many: "{{count}} записей", + recordCount_other: "{{count}} записи", save: "Сохранить", discard: "Отменить", createView: "Создать представление", @@ -2083,6 +2203,10 @@ const ru = { import: "Импорт", importTitle: "Импорт из CSV", importedToast: "{{count}} строк импортировано.", + importedToast_one: "Импортирована {{count}} строка.", + importedToast_few: "Импортировано {{count}} строки.", + importedToast_many: "Импортировано {{count}} строк.", + importedToast_other: "Импортировано {{count}} строки.", importedWithSkipped: "{{ok}} строк импортировано; {{skipped}} пропущено.", createViewDesc: "Выберите тип представления и задайте ему имя. Все настройки можно изменить позже.", cancel: "Отмена", @@ -2091,6 +2215,10 @@ const ru = { deleteViewTitle: "Удалить представление", deleteViewConfirm: "Удалить представление \"{{name}}\"? Это действие нельзя отменить.", bulkDeleteConfirm: "Удалить {{count}} выбранных записей? Это действие нельзя отменить.", + bulkDeleteConfirm_one: "Удалить {{count}} выбранную запись? Это действие нельзя отменить.", + bulkDeleteConfirm_few: "Удалить {{count}} выбранные записи? Это действие нельзя отменить.", + bulkDeleteConfirm_many: "Удалить {{count}} выбранных записей? Это действие нельзя отменить.", + bulkDeleteConfirm_other: "Удалить {{count}} выбранной записи? Это действие нельзя отменить.", duplicateViewName: "Представление с таким именем уже существует.", viewTypeGrid: "Таблица", viewTypeGridDesc: "Таблица записей в стиле электронной таблицы.", @@ -2548,6 +2676,10 @@ const ru = { }, pendingDrafts: { message: "У вас {{count}} неопубликованных изменений — опубликуйте их, чтобы они вступили в силу.", + message_one: "У вас {{count}} неопубликованное изменение — опубликуйте его, чтобы оно вступило в силу.", + message_few: "У вас {{count}} неопубликованных изменения — опубликуйте их, чтобы они вступили в силу.", + message_many: "У вас {{count}} неопубликованных изменений — опубликуйте их, чтобы они вступили в силу.", + message_other: "У вас {{count}} неопубликованного изменения — опубликуйте их, чтобы они вступили в силу.", cta: "Опубликовать", publishing: "Публикация…", published: "Опубликовано! Изменения вступили в силу.", @@ -2672,13 +2804,18 @@ const ru = { placeholder: "Поиск объектов, панелей, страниц, отчётов…", inputAriaLabel: "Поиск объектов, панелей, страниц, отчётов", resultsCount: "{{count}} результат для \"{{query}}\"", - // objectui#10024 — the page switches keys only at exactly 1, so this half - // serves one (21, 31…), few (2-4, 22-24…) AND many (0, 5-20…). No single - // `{{count}} ` form agrees with all three; «Существительное: - // {{count}}» does, the same device as `collaboration.commentCount`. - resultsCountPlural: "Результатов по запросу \"{{query}}\": {{count}}", + resultsCount_one: "{{count}} результат по запросу \"{{query}}\"", + resultsCount_few: "{{count}} результата по запросу \"{{query}}\"", + resultsCount_many: "{{count}} результатов по запросу \"{{query}}\"", + resultsCount_other: "{{count}} результата по запросу \"{{query}}\"", + // objectui#11445 — a count family: each slot agrees the noun with the + // number (one 1/21, few 2-4, many 5-20, other a fraction). The base key, a + // count-invariant form, answers a call made without a count. itemsAvailable: "{{count}} доступно", - itemsAvailableOne: "{{count}} доступно", + itemsAvailable_one: "{{count}} доступно", + itemsAvailable_few: "{{count}} доступно", + itemsAvailable_many: "{{count}} доступно", + itemsAvailable_other: "{{count}} доступно", noResults: "Результатов не найдено", noResultsHint: "Попробуйте изменить поисковые запросы", typeObjects: "Объекты", @@ -2755,6 +2892,10 @@ const ru = { noRecordId: "ID записи не указан", deleteConfirm: "Удалить эту запись?", bulkDeleteSuccess: "{{count}} записей {{label}} удалено", + bulkDeleteSuccess_one: "Удалена {{count}} запись {{label}}", + bulkDeleteSuccess_few: "Удалено {{count}} записи {{label}}", + bulkDeleteSuccess_many: "Удалено {{count}} записей {{label}}", + bulkDeleteSuccess_other: "Удалено {{count}} записи {{label}}", bulkDeletePartial: "{{succeeded}} удалено, {{failed}} не удалось", }, objectViewActions: { @@ -2955,12 +3096,20 @@ const ru = { regionLabel: "Уведомления", groupCount: "Уведомлений: {{count}}", groupUnread: "Непрочитанных: {{count}}", + groupUnread_one: "Непрочитанных: {{count}}", + groupUnread_few: "Непрочитанных: {{count}}", + groupUnread_many: "Непрочитанных: {{count}}", + groupUnread_other: "Непрочитанных: {{count}}", groupMarkRead: "Отметить прочитанными", groupMarkReadTitle: "Отметить прочитанными все этого типа", empty: "Нет уведомлений", markAllRead: "Отметить все как прочитанные", viewAll: "Показать все уведомления", approvalsPending: "{{count}} ожидающих утверждений", + approvalsPending_one: "{{count}} ожидающее утверждение", + approvalsPending_few: "{{count}} ожидающих утверждения", + approvalsPending_many: "{{count}} ожидающих утверждений", + approvalsPending_other: "{{count}} ожидающего утверждения", viewApprovals: "Показать утверждения", noPendingApprovals: "Нет ожидающих утверждений", openApprovalsInbox: "Открыть входящие утверждений", @@ -2968,7 +3117,15 @@ const ru = { badgeNotifications: "{{unread}} уведомлений", badgeApprovals: "{{approvals}} ожидающих утверждений", arrivalMany: "Новых сообщений: {{count}}", + arrivalMany_one: "Новых сообщений: {{count}}", + arrivalMany_few: "Новых сообщений: {{count}}", + arrivalMany_many: "Новых сообщений: {{count}}", + arrivalMany_other: "Новых сообщений: {{count}}", arrivalRepeats: "Новых сообщений по этой теме: {{count}}", + arrivalRepeats_one: "Новых сообщений по этой теме: {{count}}", + arrivalRepeats_few: "Новых сообщений по этой теме: {{count}}", + arrivalRepeats_many: "Новых сообщений по этой теме: {{count}}", + arrivalRepeats_other: "Новых сообщений по этой теме: {{count}}", arrivalOpen: "Открыть", toastEnabled: "Оповещения в приложении", desktopEnabled: "Уведомления на рабочем столе", @@ -3045,6 +3202,10 @@ const ru = { noImportableFields: "У этого объекта нет полей, доступных для импорта.", schemaReadFailed: "Не удалось прочитать схему объекта.", imported: "Импортировано строк: {{count}} в {{object}}.", + imported_one: "Импортировано строк: {{count}} в {{object}}.", + imported_few: "Импортировано строк: {{count}} в {{object}}.", + imported_many: "Импортировано строк: {{count}} в {{object}}.", + imported_other: "Импортировано строк: {{count}} в {{object}}.", importFrom: "Импортировать реальные строки из", into: "в", opening: "Открытие…", @@ -3074,11 +3235,17 @@ const ru = { formAssist: { title: "Предложения ИИ", suggestionCount: "Предложений: {{count}}", - suggestionCountOne: "{{count}} предложение", + suggestionCount_one: "{{count}} предложение", + suggestionCount_few: "{{count}} предложения", + suggestionCount_many: "{{count}} предложений", + suggestionCount_other: "{{count}} предложения", applyAll: "Применить все", confidence: "Уверенность: {{percent}}", appliedCount: "Применено предложений: {{count}}", - appliedCountOne: "Применено {{count}} предложение", + appliedCount_one: "Применено {{count}} предложение", + appliedCount_few: "Применено {{count}} предложения", + appliedCount_many: "Применено {{count}} предложений", + appliedCount_other: "Применено {{count}} предложения", }, recommendations: { title: "Рекомендации", @@ -3382,10 +3549,18 @@ const ru = { purgeSampleData: 'Удалить демоданные', purgeConfirm: 'Удалить все демонстрационные записи, созданные этим пакетом? Записи, добавленные пользователем, НЕ будут затронуты.', purgeSuccess: 'Удалено демонстрационных записей: {{count}}.', + purgeSuccess_one: 'Удалено демонстрационных записей: {{count}}.', + purgeSuccess_few: 'Удалено демонстрационных записей: {{count}}.', + purgeSuccess_many: 'Удалено демонстрационных записей: {{count}}.', + purgeSuccess_other: 'Удалено демонстрационных записей: {{count}}.', purgeNoData: 'Демонстрационные записи для удаления не найдены.', reseedQueued: 'Демоданные будут перезагружены при следующем доступе к среде.', reseedLocalSuccess: 'Демоданные перезагружены: добавлено {{inserted}}, обновлено {{updated}}.', reseedPartialErrors: '(не удалось записать записей: {{count}})', + reseedPartialErrors_one: '(не удалось записать записей: {{count}})', + reseedPartialErrors_few: '(не удалось записать записей: {{count}})', + reseedPartialErrors_many: '(не удалось записать записей: {{count}})', + reseedPartialErrors_other: '(не удалось записать записей: {{count}})', sampleDataKernelUnavailable: 'У этой control plane нет ядра среды, поэтому здесь нельзя перезагрузить или удалить демоданные. Выполните это действие в собственном рантайме среды.', updateAvailable: 'Доступно обновление', }, @@ -3483,7 +3658,15 @@ const ru = { today: "сегодня", daysAgo: "{{count}} д назад", monthsAgo: "{{count}} мес назад", + monthsAgo_one: "{{count}} мес назад", + monthsAgo_few: "{{count}} мес назад", + monthsAgo_many: "{{count}} мес назад", + monthsAgo_other: "{{count}} мес назад", yearsAgo: "{{count}} лет назад", + yearsAgo_one: "{{count}} год назад", + yearsAgo_few: "{{count}} года назад", + yearsAgo_many: "{{count}} лет назад", + yearsAgo_other: "{{count}} года назад", }, }, approvalsInbox: { @@ -3506,6 +3689,10 @@ const ru = { requestInfoSent: 'Возвращено заявителю за дополнениями', remindBtn: 'Напомнить', remindSuccess: 'Напоминание отправлено: {{count}}', + remindSuccess_one: 'Напоминание отправлено: {{count}}', + remindSuccess_few: 'Напоминание отправлено: {{count}}', + remindSuccess_many: 'Напоминание отправлено: {{count}}', + remindSuccess_other: 'Напоминание отправлено: {{count}}', remindThrottled: 'Напоминание уже отправлялось недавно — попробуйте позже.', replyPlaceholder: 'Ответить по этому запросу…', slaRemaining: 'SLA: осталось {{dur}}', @@ -3522,6 +3709,10 @@ const ru = { progressApprovals: 'Согласования — {{got}} из {{need}}', progressGroups: 'Ход визирования — {{got}} из {{need}} групп', progressEligible: 'Доступных согласующих: {{count}}', + progressEligible_one: 'Доступных согласующих: {{count}}', + progressEligible_few: 'Доступных согласующих: {{count}}', + progressEligible_many: 'Доступных согласующих: {{count}}', + progressEligible_other: 'Доступных согласующих: {{count}}', progressBar: 'Ход решения', declaredActions: 'Действия', attachmentChip: 'Вложение', @@ -3553,6 +3744,10 @@ const ru = { filterCount: '{{shown}} из {{total}}', selected: 'выбрано', actionableCount: '({{count}} доступно)', + actionableCount_one: '({{count}} доступно)', + actionableCount_few: '({{count}} доступно)', + actionableCount_many: '({{count}} доступно)', + actionableCount_other: '({{count}} доступно)', selectAll: 'Выбрать все', selectRow: 'Выбрать запрос', colRequest: 'Запрос', @@ -3626,11 +3821,27 @@ const ru = { approveN: 'Согласовать {{count}}', rejectN: 'Отклонить {{count}}', bulkApproveTitle: 'Согласовать {{count}} запросов?', + bulkApproveTitle_one: 'Согласовать {{count}} запрос?', + bulkApproveTitle_few: 'Согласовать {{count}} запроса?', + bulkApproveTitle_many: 'Согласовать {{count}} запросов?', + bulkApproveTitle_other: 'Согласовать {{count}} запроса?', bulkApproveBody: 'Каждый запрос согласуется от вашего имени, и поток продолжится по ветке согласования.', bulkRejectTitle: 'Отклонить {{count}} запросов?', + bulkRejectTitle_one: 'Отклонить {{count}} запрос?', + bulkRejectTitle_few: 'Отклонить {{count}} запроса?', + bulkRejectTitle_many: 'Отклонить {{count}} запросов?', + bulkRejectTitle_other: 'Отклонить {{count}} запроса?', bulkRejectBody: 'Выбранные запросы будут отклонены, заявители получат уведомления.', bulkApproved: 'Согласовано запросов: {{count}}', + bulkApproved_one: 'Согласовано запросов: {{count}}', + bulkApproved_few: 'Согласовано запросов: {{count}}', + bulkApproved_many: 'Согласовано запросов: {{count}}', + bulkApproved_other: 'Согласовано запросов: {{count}}', bulkRejected: 'Отклонено запросов: {{count}}', + bulkRejected_one: 'Отклонено запросов: {{count}}', + bulkRejected_few: 'Отклонено запросов: {{count}}', + bulkRejected_many: 'Отклонено запросов: {{count}}', + bulkRejected_other: 'Отклонено запросов: {{count}}', bulkPartial: 'Успешно: {{ok}}, с ошибкой: {{fail}}: {{which}}', rejectOneTitle: 'Отклонить «{{title}}»?', rejectOneBody: 'Запрос будет отклонён, заявитель получит уведомление.', @@ -3655,8 +3866,20 @@ const ru = { alreadyDecided: 'Этот запрос уже обработан. Обновите список.', justNow: 'только что', minutesAgo: '{{count}} мин назад', + minutesAgo_one: '{{count}} мин назад', + minutesAgo_few: '{{count}} мин назад', + minutesAgo_many: '{{count}} мин назад', + minutesAgo_other: '{{count}} мин назад', hoursAgo: '{{count}} ч назад', + hoursAgo_one: '{{count}} ч назад', + hoursAgo_few: '{{count}} ч назад', + hoursAgo_many: '{{count}} ч назад', + hoursAgo_other: '{{count}} ч назад', daysAgo: '{{count}} дн назад', + daysAgo_one: '{{count}} дн назад', + daysAgo_few: '{{count}} дн назад', + daysAgo_many: '{{count}} дн назад', + daysAgo_other: '{{count}} дн назад', }, preview: { empty: { @@ -3691,11 +3914,19 @@ const ru = { detailNone: 'Различий не обнаружено — черновик совпадает с опубликованной версией.', detailChangedKeys: 'Также изменено:', confirmNote: 'Публикация атомарно выпускает все {{count}} ожидающих черновиков этого пакета.', + confirmNote_one: 'Публикация атомарно выпускает {{count}} ожидающий черновик этого пакета.', + confirmNote_few: 'Публикация атомарно выпускает все {{count}} ожидающих черновика этого пакета.', + confirmNote_many: 'Публикация атомарно выпускает все {{count}} ожидающих черновиков этого пакета.', + confirmNote_other: 'Публикация атомарно выпускает {{count}} ожидающего черновика этого пакета.', publishConfirm: 'Опубликовать всё', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: 'Публикация будет отклонена — {{count}} элемент(ов) требуют решения', + securityBlockTitle_one: 'Публикация будет отклонена — {{count}} элемент требует решения', + securityBlockTitle_few: 'Публикация будет отклонена — {{count}} элемента требуют решения', + securityBlockTitle_many: 'Публикация будет отклонена — {{count}} элементов требуют решения', + securityBlockTitle_other: 'Публикация будет отклонена — {{count}} элемента требуют решения', securityBlockWhere: 'Исправьте это в объекте в разделе «Настройки → Общий доступ к записям» и опубликуйте снова.', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -3785,7 +4016,10 @@ const ru = { }, collaboration: { commentCount: "Комментариев: {{count}}", - commentCountOne: "{{count}} комментарий", + commentCount_one: "{{count}} комментарий", + commentCount_few: "{{count}} комментария", + commentCount_many: "{{count}} комментариев", + commentCount_other: "{{count}} комментария", resolvedSuffix: " · Решено", sortComments: "Сортировка комментариев", sortOldest: "Сначала старые", @@ -3794,11 +4028,26 @@ const ru = { reopen: "Открыть заново", justNow: "только что", minutesAgo: "{{count}} мин назад", + minutesAgo_one: "{{count}} мин назад", + minutesAgo_few: "{{count}} мин назад", + minutesAgo_many: "{{count}} мин назад", + minutesAgo_other: "{{count}} мин назад", hoursAgo: "{{count}} ч назад", + hoursAgo_one: "{{count}} ч назад", + hoursAgo_few: "{{count}} ч назад", + hoursAgo_many: "{{count}} ч назад", + hoursAgo_other: "{{count}} ч назад", daysAgo: "{{count}} д назад", + daysAgo_one: "{{count}} д назад", + daysAgo_few: "{{count}} д назад", + daysAgo_many: "{{count}} д назад", + daysAgo_other: "{{count}} д назад", edited: "(изменено)", reactionCount: "Реакций: {{count}}", - reactionCountOne: "{{count}} реакция", + reactionCount_one: "{{count}} реакция", + reactionCount_few: "{{count}} реакции", + reactionCount_many: "{{count}} реакций", + reactionCount_other: "{{count}} реакции", addThumbsUp: "Поставить лайк", reply: "Ответить", reactThumbsUp: "Отреагировать лайком", @@ -3809,9 +4058,15 @@ const ru = { commentPlaceholder: "Добавьте комментарий… (@ — упоминание)", send: "Отправить", presentUserCount: "Присутствует пользователей: {{count}}", - presentUserCountOne: "Присутствует {{count}} пользователь", + presentUserCount_one: "Присутствует {{count}} пользователь", + presentUserCount_few: "Присутствуют {{count}} пользователя", + presentUserCount_many: "Присутствует {{count}} пользователей", + presentUserCount_other: "Присутствует {{count}} пользователя", moreUserCount: "Ещё пользователей: {{count}}", - moreUserCountOne: "Ещё {{count}} пользователь", + moreUserCount_one: "Ещё {{count}} пользователь", + moreUserCount_few: "Ещё {{count}} пользователя", + moreUserCount_many: "Ещё {{count}} пользователей", + moreUserCount_other: "Ещё {{count}} пользователя", userStatusTitle: "{{name}} ({{status}})", statusActive: "активен", statusIdle: "бездействует", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9d1f40886c..fe80871fe2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -79,7 +79,8 @@ const zh = { selectFirst: '请先选择{{fields}}', selectRecord: '选择记录', recordCount: '{{count}} 条记录', - recordCountOne: '1 条记录', + recordCount_one: '{{count}} 条记录', + recordCount_other: '{{count}} 条记录', pageOf: '第 {{current}}/{{total}} 页', filters: '筛选', clear: '清除', @@ -129,7 +130,8 @@ const zh = { closePanel: '关闭面板', resizeDrawer: '调整面板宽度', itemCount: '{{count}} 项', - itemCountOne: '{{count}} 项', + itemCount_one: '{{count}} 项', + itemCount_other: '{{count}} 项', toggleSidebar: '切换侧边栏', package: '软件包', done: '完成', @@ -257,6 +259,8 @@ const zh = { fields: { relativeDate: { overdue: '逾期 {{count}} 天', + overdue_one: '逾期 {{count}} 天', + overdue_other: '逾期 {{count}} 天', }, file: { dragDropHere: '拖拽文件到此处', @@ -409,8 +413,12 @@ const zh = { open: '打开', search: '搜索…', modified: '{{count}} 行已修改', + modified_one: '{{count}} 行已修改', + modified_other: '{{count}} 行已修改', saveFailed: '保存失败', selected: '已选择 {{count}} 项', + selected_one: '已选择 {{count}} 项', + selected_other: '已选择 {{count}} 项', edit: '编辑', delete: '删除', }, @@ -427,9 +435,13 @@ const zh = { openRecord: '打开记录', openMenu: '更多操作', bulkSelected: '已选择 {{count}} 项', + bulkSelected_one: '已选择 {{count}} 项', + bulkSelected_other: '已选择 {{count}} 项', bulkSelectedAllMatches: '已选择全部 {{count}} 项(全部匹配)', bulkClear: '清除', bulkAllOnPage: '本页 {{count}} 项已全选。', + bulkAllOnPage_one: '本页 {{count}} 项已全选。', + bulkAllOnPage_other: '本页 {{count}} 项已全选。', bulkSelectAllMatching: '选择全部 {{count}} 项匹配记录', bulkAllMatchingSelected: '已选择全部 {{count}} 项匹配记录。', rowHeight: '行高: {{mode}}', @@ -518,7 +530,11 @@ const zh = { mapped: '已映射', skipped: '已跳过', rowsWithErrors: '{{count}} 行有错误', + rowsWithErrors_one: '{{count}} 行有错误', + rowsWithErrors_other: '{{count}} 行有错误', rowsCorrected: '已修正 {{count}} 行', + rowsCorrected_one: '已修正 {{count}} 行', + rowsCorrected_other: '已修正 {{count}} 行', clickToFix: '— 点击高亮单元格可直接修改。', showingRows: '显示第 {{shown}} 行,共 {{total}} 行', importing: '导入中… {{progress}}%', @@ -526,11 +542,15 @@ const zh = { imported: '已导入 {{count}} 条', skippedCount: '已跳过 {{count}} 条', moreErrors: '…还有 {{count}} 个错误', + moreErrors_one: '…还有 {{count}} 个错误', + moreErrors_other: '…还有 {{count}} 个错误', cancel: '取消', back: '上一步', next: '下一步', close: '关闭', importNRows: '导入 {{count}} 行', + importNRows_one: '导入 {{count}} 行', + importNRows_other: '导入 {{count}} 行', importingProgress: '导入中…', requiredMark: '*', optTreatHistorical: '作为历史数据导入', @@ -543,6 +563,8 @@ const zh = { // 自动匹配(Airtable 风格) autoMatched: '自动匹配', autoMatchedSummary: '已自动匹配 {{count}} 列 — 请在下方检查并调整。', + autoMatchedSummary_one: '已自动匹配 {{count}} 列 — 请在下方检查并调整。', + autoMatchedSummary_other: '已自动匹配 {{count}} 列 — 请在下方检查并调整。', confidence: { high: '高置信度', medium: '中置信度', @@ -559,6 +581,8 @@ const zh = { validatePassed: '全部 {{ok}} 行均有效。', validateFailed: '{{ok}} 条有效,{{errors}} 条有错误。', errorCount: '{{count}} 个错误', + errorCount_one: '{{count}} 个错误', + errorCount_other: '{{count}} 个错误', errorRowPrefix: '第 {{row}} 行:', // 服务端结构化导入错误的友好文案 referenceNotFound: '找不到匹配 "{{value}}" 的记录', @@ -589,8 +613,14 @@ const zh = { optBackgroundHint: '(作为可撤销任务运行)', // 结果 createdCount: '新建 {{count}} 条', + createdCount_one: '新建 {{count}} 条', + createdCount_other: '新建 {{count}} 条', updatedCount: '更新 {{count}} 条', + updatedCount_one: '更新 {{count}} 条', + updatedCount_other: '更新 {{count}} 条', resultsTruncated: '显示前 {{count}} 行结果(共 {{total}} 行)。', + resultsTruncated_one: '显示前 {{count}} 行结果(共 {{total}} 行)。', + resultsTruncated_other: '显示前 {{count}} 行结果(共 {{total}} 行)。', downloadFailed: '下载失败行', cancelImport: '取消导入', importCancelled: '导入已取消', @@ -627,9 +657,13 @@ const zh = { }, bulk: { confirmDefault: '此操作将应用于 {{count}} 条记录。', + confirmDefault_one: '此操作将应用于 {{count}} 条记录。', + confirmDefault_other: '此操作将应用于 {{count}} 条记录。', overLimit: '所选记录({{count}})超过该动作的上限({{limit}})。请减少选择后再继续。', affectedRecords: '受影响的记录({{count}}):', skippedIneligible: '{{count}} 条所选记录不符合该动作的条件,将被跳过。', + skippedIneligible_one: '{{count}} 条所选记录不符合该动作的条件,将被跳过。', + skippedIneligible_other: '{{count}} 条所选记录不符合该动作的条件,将被跳过。', rowFallback: '第 {{index}} 行', andMore: '……以及另外 {{count}} 条', processed: '已处理 {{count}} / {{total}}', @@ -690,7 +724,8 @@ const zh = { list: { loading: '正在加载记录…', recordCount: '{{count}} 条记录', - recordCountOne: '{{count}} 条记录', + recordCount_one: '{{count}} 条记录', + recordCount_other: '{{count}} 条记录', addRecord: '添加记录', tabs: '标签页', allRecords: '全部记录', @@ -936,13 +971,19 @@ const zh = { conflict: { title: '排程冲突', body: '此次移动与依赖约束冲突。是否自动重新排程 {{count}} 个受影响的任务?', + body_one: '此次移动与依赖约束冲突。是否自动重新排程 {{count}} 个受影响的任务?', + body_other: '此次移动与依赖约束冲突。是否自动重新排程 {{count}} 个受影响的任务?', confirm: '自动重新排程', cancel: '保持不变', }, autoScheduleDlg: { title: '自动排程', body: '将顺延 {{count}} 个任务以满足依赖约束,是否执行?', + body_one: '将顺延 {{count}} 个任务以满足依赖约束,是否执行?', + body_other: '将顺延 {{count}} 个任务以满足依赖约束,是否执行?', skipped: '另有 {{count}} 项因锁定/无权限跳过。', + skipped_one: '另有 {{count}} 项因锁定/无权限跳过。', + skipped_other: '另有 {{count}} 项因锁定/无权限跳过。', confirm: '执行', cancel: '取消', none: '依赖均满足,无需排程', @@ -1064,7 +1105,8 @@ const zh = { pageHeaderActions: '页面标题栏操作', emojiPicker: '表情选择器', reactionCount: '{{emoji}} {{count}} 个回应', - reactionCountOne: '{{emoji}} {{count}} 个回应', + reactionCount_one: '{{emoji}} {{count}} 个回应', + reactionCount_other: '{{emoji}} {{count}} 个回应', recordDetail: '记录详情', recordDetailWithLabel: '{{label}}详情', openAsFullPage: '以完整页面打开', @@ -1117,8 +1159,14 @@ const zh = { unpin: '取消置顶', justNow: '刚刚', minutesAgo: '{{count}}分钟前', + minutesAgo_one: '{{count}}分钟前', + minutesAgo_other: '{{count}}分钟前', hoursAgo: '{{count}}小时前', + hoursAgo_one: '{{count}}小时前', + hoursAgo_other: '{{count}}小时前', daysAgo: '{{count}}天前', + daysAgo_one: '{{count}}天前', + daysAgo_other: '{{count}}天前', // Activity feed actors systemActor: '系统', unknownUser: '未知用户', @@ -1132,7 +1180,8 @@ const zh = { // Attachments dropFilesToUpload: '拖拽文件到此处或点击上传', attachmentCount: '{{count}} 个附件', - attachmentCountPlural: '{{count}} 个附件', + attachmentCount_one: '{{count}} 个附件', + attachmentCount_other: '{{count}} 个附件', removeAttachment: '移除附件', // Record Attachments panel (enable.files, objectstack#4358) attachments: '附件', @@ -1203,7 +1252,8 @@ const zh = { viewSource: '查看来源', // Replies replyCount: '{{count}} 条回复', - replyCountPlural: '{{count}} 条回复', + replyCount_one: '{{count}} 条回复', + replyCount_other: '{{count}} 条回复', replyPlaceholder: '回复…', // 无障碍标签 filterActivity: '筛选活动', @@ -1216,6 +1266,8 @@ const zh = { copyPhone: '复制电话号码', copyRecordId: '复制记录 ID', showEmptyFields: '显示 {{count}} 个空字段', + showEmptyFields_one: '显示 {{count}} 个空字段', + showEmptyFields_other: '显示 {{count}} 个空字段', hideEmptyFields: '隐藏空字段', noValue: '无', unresolvedReference: '未解析的引用:{{value}} 未能解析为用户', @@ -1611,7 +1663,11 @@ const zh = { backToHome: '返回首页', }, saveAdvisoryTitle: '已保存 — 编辑检查提出了 {{count}} 条建议', + saveAdvisoryTitle_one: '已保存 — 编辑检查提出了 {{count}} 条建议', + saveAdvisoryTitle_other: '已保存 — 编辑检查提出了 {{count}} 条建议', publishAdvisoryTitle: '已发布 — 编辑检查提出了 {{count}} 条建议', + publishAdvisoryTitle_one: '已发布 — 编辑检查提出了 {{count}} 条建议', + publishAdvisoryTitle_other: '已发布 — 编辑检查提出了 {{count}} 条建议', importMappingsUnavailable: "无法加载 {{object}} 的已保存导入映射", importMappingsRefused: "服务器拒绝了此请求,因此该列表为空是因为读取失败,而不是因为没有注册任何映射。请重新登录,或联系管理员申请访问权限。", importMappingsUnreadable: "该列表为空是因为读取失败,而不是因为没有注册任何映射。请重试;如果反复出现,请反馈此问题。", @@ -2112,6 +2168,8 @@ const zh = { import: '导入', importTitle: '从 CSV 文件导入', importedToast: '成功导入 {{count}} 行。', + importedToast_one: '成功导入 {{count}} 行。', + importedToast_other: '成功导入 {{count}} 行。', importedWithSkipped: '已导入 {{ok}} 行;跳过 {{skipped}} 行。', configureView: '配置视图', toolbarEnabledCount: '已启用 {{count}}/{{total}} 项', @@ -2119,7 +2177,8 @@ const zh = { title: '标题', viewType: '视图类型', recordCount: '{{count}} 条记录', - recordCountOne: '{{count}} 条记录', + recordCount_one: '{{count}} 条记录', + recordCount_other: '{{count}} 条记录', save: '保存', discard: '丢弃', createView: '创建视图', @@ -2130,6 +2189,8 @@ const zh = { deleteViewTitle: '删除视图', deleteViewConfirm: '确定要删除视图 "{{name}}" 吗?此操作无法撤销。', bulkDeleteConfirm: '确定要删除选中的 {{count}} 条记录吗?此操作无法撤销。', + bulkDeleteConfirm_one: '确定要删除选中的 {{count}} 条记录吗?此操作无法撤销。', + bulkDeleteConfirm_other: '确定要删除选中的 {{count}} 条记录吗?此操作无法撤销。', duplicateViewName: '已存在同名视图。', viewTypeGrid: '表格', viewTypeGridDesc: '电子表格风格的记录表格。', @@ -2614,6 +2675,8 @@ const zh = { }, pendingDrafts: { message: '你有 {{count}} 项未发布的更改 —— 点击发布即可生效。', + message_one: '你有 {{count}} 项未发布的更改 —— 点击发布即可生效。', + message_other: '你有 {{count}} 项未发布的更改 —— 点击发布即可生效。', cta: '发布', publishing: '发布中…', published: '已发布!更改已生效。', @@ -2716,9 +2779,11 @@ const zh = { placeholder: '搜索对象、仪表板、页面、报表…', inputAriaLabel: '搜索对象、仪表板、页面、报表', resultsCount: '找到 {{count}} 条与“{{query}}”相关的结果', - resultsCountPlural: '找到 {{count}} 条与“{{query}}”相关的结果', + resultsCount_one: '找到 {{count}} 条与“{{query}}”相关的结果', + resultsCount_other: '找到 {{count}} 条与“{{query}}”相关的结果', itemsAvailable: '共 {{count}} 项可搜索', - itemsAvailableOne: '共 {{count}} 项可搜索', + itemsAvailable_one: '共 {{count}} 项可搜索', + itemsAvailable_other: '共 {{count}} 项可搜索', noResults: '未找到结果', noResultsHint: '请尝试调整搜索关键字', typeObjects: '对象', @@ -2792,11 +2857,15 @@ const zh = { detailNone: '未检测到差异 —— 草稿与已发布版本一致。', detailChangedKeys: '其他变更:', confirmNote: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', + confirmNote_one: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', + confirmNote_other: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', publishConfirm: '全部发布', // [`7a90afdf9`] Pre-publish security-posture findings, shown next to // the confirm button so a refusal the door would issue is read BEFORE // the click rather than as a toast after the batch rolled back. securityBlockTitle: '发布会被拒绝 —— 有 {{count}} 项需要先做出决定', + securityBlockTitle_one: '发布会被拒绝 —— 有 {{count}} 项需要先做出决定', + securityBlockTitle_other: '发布会被拒绝 —— 有 {{count}} 项需要先做出决定', securityBlockWhere: '请在对象的“设置 → 记录共享模型”中修改后重新发布。', }, // ADR-0045 — the materialized-but-unlisted app banner @@ -2869,6 +2938,8 @@ const zh = { '该权限集随已安装的软件包发布,无法删除。删除操作将丢弃环境定制并把它重置为发布基线。是否继续?', resetPackageSetSuccess: '权限集已重置为发布基线', bulkDeleteSuccess: '已删除 {{count}} 条 {{label}} 记录', + bulkDeleteSuccess_one: '已删除 {{count}} 条 {{label}} 记录', + bulkDeleteSuccess_other: '已删除 {{count}} 条 {{label}} 记录', bulkDeletePartial: '成功 {{succeeded}} 条,失败 {{failed}} 条', }, objectViewActions: { @@ -3049,9 +3120,13 @@ const zh = { viewAll: '查看全部通知', groupCount: '{{count}} 条通知', groupUnread: '{{count}} 条未读', + groupUnread_one: '{{count}} 条未读', + groupUnread_other: '{{count}} 条未读', groupMarkRead: '标记已读', groupMarkReadTitle: '将此类通知全部标记为已读', approvalsPending: '{{count}} 条待审批', + approvalsPending_one: '{{count}} 条待审批', + approvalsPending_other: '{{count}} 条待审批', viewApprovals: '查看审批', noPendingApprovals: '暂无待审批事项', openApprovalsInbox: '打开审批中心', @@ -3059,7 +3134,11 @@ const zh = { badgeNotifications: '{{unread}} 条通知', badgeApprovals: '{{approvals}} 条待审批', arrivalMany: '{{count}} 条新消息', + arrivalMany_one: '{{count}} 条新消息', + arrivalMany_other: '{{count}} 条新消息', arrivalRepeats: '该主题有 {{count}} 条新消息', + arrivalRepeats_one: '该主题有 {{count}} 条新消息', + arrivalRepeats_other: '该主题有 {{count}} 条新消息', arrivalOpen: '查看', toastEnabled: '站内提醒', desktopEnabled: '桌面通知', @@ -3133,6 +3212,8 @@ const zh = { noImportableFields: '该对象没有可导入的字段。', schemaReadFailed: '无法读取对象结构。', imported: '已把 {{count}} 行真实数据导入「{{object}}」。', + imported_one: '已把 {{count}} 行真实数据导入「{{object}}」。', + imported_other: '已把 {{count}} 行真实数据导入「{{object}}」。', importFrom: '把真实数据导入自', into: '到', opening: '打开中…', @@ -3162,11 +3243,13 @@ const zh = { formAssist: { title: 'AI 建议', suggestionCount: '{{count}} 条建议', - suggestionCountOne: '{{count}} 条建议', + suggestionCount_one: '{{count}} 条建议', + suggestionCount_other: '{{count}} 条建议', applyAll: '全部应用', confidence: '置信度 {{percent}}', appliedCount: '已应用 {{count}} 条建议', - appliedCountOne: '已应用 {{count}} 条建议', + appliedCount_one: '已应用 {{count}} 条建议', + appliedCount_other: '已应用 {{count}} 条建议', }, recommendations: { title: '推荐', @@ -3457,10 +3540,14 @@ const zh = { purgeSampleData: '清除示例数据', purgeConfirm: '删除此应用预置的所有示例记录?用户自行添加的记录不会被删除。', purgeSuccess: '已删除 {{count}} 条示例记录。', + purgeSuccess_one: '已删除 {{count}} 条示例记录。', + purgeSuccess_other: '已删除 {{count}} 条示例记录。', purgeNoData: '没有可清除的示例记录。', reseedQueued: '示例数据将在下次访问该环境时重新加载。', reseedLocalSuccess: '已重新加载示例数据:新增 {{inserted}} 条,更新 {{updated}} 条。', reseedPartialErrors: '(有 {{count}} 条记录写入失败)', + reseedPartialErrors_one: '(有 {{count}} 条记录写入失败)', + reseedPartialErrors_other: '(有 {{count}} 条记录写入失败)', sampleDataKernelUnavailable: '此控制面没有环境内核,因此无法在这里重新加载或清除示例数据。请在该环境自己的运行时中操作。', updateAvailable: '有可用更新', }, @@ -3569,7 +3656,11 @@ const zh = { today: '今天', daysAgo: '{{count}} 天前', monthsAgo: '{{count}} 个月前', + monthsAgo_one: '{{count}} 个月前', + monthsAgo_other: '{{count}} 个月前', yearsAgo: '{{count}} 年前', + yearsAgo_one: '{{count}} 年前', + yearsAgo_other: '{{count}} 年前', }, }, approvalsInbox: { @@ -3592,6 +3683,8 @@ const zh = { requestInfoSent: '已退回给申请人补充材料', remindBtn: '催办', remindSuccess: '已提醒 {{count}} 位审批人', + remindSuccess_one: '已提醒 {{count}} 位审批人', + remindSuccess_other: '已提醒 {{count}} 位审批人', remindThrottled: '刚刚已发送过催办,请稍后再试。', replyPlaceholder: '在此回复…', slaRemaining: 'SLA 剩余 {{dur}}', @@ -3608,6 +3701,8 @@ const zh = { progressApprovals: '审批进度 — {{got}} / {{need}}', progressGroups: '会签进度 — {{got}} / {{need}} 组', progressEligible: '{{count}} 位可审批人', + progressEligible_one: '{{count}} 位可审批人', + progressEligible_other: '{{count}} 位可审批人', progressBar: '决策进度', declaredActions: '操作', attachmentChip: '附件', @@ -3639,6 +3734,8 @@ const zh = { filterCount: '{{shown}} / {{total}} 条', selected: '已选择', actionableCount: '({{count}} 条可操作)', + actionableCount_one: '({{count}} 条可操作)', + actionableCount_other: '({{count}} 条可操作)', selectAll: '全选', selectRow: '选择该请求', colRequest: '审批事项', @@ -3712,11 +3809,19 @@ const zh = { approveN: '通过 {{count}} 条', rejectN: '拒绝 {{count}} 条', bulkApproveTitle: '通过这 {{count}} 条请求?', + bulkApproveTitle_one: '通过这 {{count}} 条请求?', + bulkApproveTitle_other: '通过这 {{count}} 条请求?', bulkApproveBody: '每条请求都将以你的身份通过,对应流程沿“通过”分支继续。', bulkRejectTitle: '拒绝这 {{count}} 条请求?', + bulkRejectTitle_one: '拒绝这 {{count}} 条请求?', + bulkRejectTitle_other: '拒绝这 {{count}} 条请求?', bulkRejectBody: '将拒绝所选请求并通知各申请人。', bulkApproved: '已通过 {{count}} 条请求', + bulkApproved_one: '已通过 {{count}} 条请求', + bulkApproved_other: '已通过 {{count}} 条请求', bulkRejected: '已拒绝 {{count}} 条请求', + bulkRejected_one: '已拒绝 {{count}} 条请求', + bulkRejected_other: '已拒绝 {{count}} 条请求', bulkPartial: '{{ok}} 条成功,{{fail}} 条失败:{{which}}', rejectOneTitle: '拒绝“{{title}}”?', rejectOneBody: '将拒绝该请求并通知申请人。', @@ -3741,8 +3846,14 @@ const zh = { alreadyDecided: '该请求已被处理,请刷新列表。', justNow: '刚刚', minutesAgo: '{{count}} 分钟前', + minutesAgo_one: '{{count}} 分钟前', + minutesAgo_other: '{{count}} 分钟前', hoursAgo: '{{count}} 小时前', + hoursAgo_one: '{{count}} 小时前', + hoursAgo_other: '{{count}} 小时前', daysAgo: '{{count}} 天前', + daysAgo_one: '{{count}} 天前', + daysAgo_other: '{{count}} 天前', }, filterBuilder: { where: '其中', @@ -3800,7 +3911,8 @@ const zh = { }, collaboration: { commentCount: '{{count}} 条评论', - commentCountOne: '{{count}} 条评论', + commentCount_one: '{{count}} 条评论', + commentCount_other: '{{count}} 条评论', resolvedSuffix: ' · 已解决', sortComments: '评论排序', sortOldest: '最早优先', @@ -3809,11 +3921,18 @@ const zh = { reopen: '重新打开', justNow: '刚刚', minutesAgo: '{{count}} 分钟前', + minutesAgo_one: '{{count}} 分钟前', + minutesAgo_other: '{{count}} 分钟前', hoursAgo: '{{count}} 小时前', + hoursAgo_one: '{{count}} 小时前', + hoursAgo_other: '{{count}} 小时前', daysAgo: '{{count}} 天前', + daysAgo_one: '{{count}} 天前', + daysAgo_other: '{{count}} 天前', edited: '(已编辑)', reactionCount: '{{count}} 个回应', - reactionCountOne: '{{count}} 个回应', + reactionCount_one: '{{count}} 个回应', + reactionCount_other: '{{count}} 个回应', addThumbsUp: '点赞', reply: '回复', reactThumbsUp: '以点赞回应', @@ -3824,9 +3943,11 @@ const zh = { commentPlaceholder: '添加评论…(输入 @ 提及他人)', send: '发送', presentUserCount: '{{count}} 人在线', - presentUserCountOne: '{{count}} 人在线', + presentUserCount_one: '{{count}} 人在线', + presentUserCount_other: '{{count}} 人在线', moreUserCount: '另有 {{count}} 人', - moreUserCountOne: '另有 {{count}} 人', + moreUserCount_one: '另有 {{count}} 人', + moreUserCount_other: '另有 {{count}} 人', userStatusTitle: '{{name}}({{status}})', statusActive: '活跃', statusIdle: '空闲', diff --git a/packages/i18n/src/useSafeTranslation.ts b/packages/i18n/src/useSafeTranslation.ts index 788aa09c06..a542619180 100644 --- a/packages/i18n/src/useSafeTranslation.ts +++ b/packages/i18n/src/useSafeTranslation.ts @@ -6,7 +6,8 @@ * returning raw i18n keys — and, for a key the `defaults` map does not carry, * to the call site's own inline `t(key, { defaultValue })` (objectui#3865). * The lookup order is i18next's, so a provider-less host and a provider-mounted - * one render the same string: `defaults[key]` (the pack value's stand-in here) + * one render the same string: `defaults[key_]` for a numeric `count` + * (objectui#11445), then `defaults[key]` (the pack value's stand-in here) * -> `defaultValue` -> the key. * * @param defaults - Fallback English translations keyed by i18n key @@ -15,6 +16,33 @@ import { useObjectTranslation } from './provider.js'; import { DEFAULT_VALUE_OPTION, interpolateFallback } from './fallbackInterpolation.js'; +/** + * The plural rules the defaults tables are written in. Every table is English + * (the `en` pack's stand-in), so English selects the slot. + */ +const DEFAULTS_PLURAL_RULES = new Intl.PluralRules('en'); + +/** + * The `defaults` row a count family answers from, the way i18next walks a pack + * (objectui#11445): `key_` first, then the base `key`. + * + * i18next plural-selects only for a NUMERIC `count` (`needsPluralHandling` in + * i18next 26 is `count !== undefined && !isString(count)`), so a string count + * reads the base row on both paths. `_zero` is not looked up: no English row + * spells one, because `en` selects no `zero` category. + */ +function defaultsRowFor( + defaults: Record, + key: string, + options: Record | undefined, +): string | undefined { + const count = options?.count; + if (typeof count === 'number' && Number.isFinite(count)) { + const slot = defaults[`${key}_${DEFAULTS_PLURAL_RULES.select(count)}`]; + if (slot) return slot; + } + return defaults[key]; +} export function createSafeTranslation( defaults: Record, @@ -45,9 +73,18 @@ export function createSafeTranslation( // `string`, and i18next itself declines a `null` default. `||` (not `??`) // keeps the whole chain consistent with the pre-existing `defaults[key] ||` // step: an empty string at any position falls through to the next. + // + // A count family's suffixed rows are reached here too (objectui#11445): + // `t('detail.replyCount', { count: 3 })` reads `detail.replyCount_other` + // before the base row, exactly as i18next reads the `en` pack. Before that + // card a family's `_one` / `_other` rows were unreachable on this path, so + // the code-selected `xxxCountOne` pairs were the only way a provider-less + // host rendered "1 reply" — the card retired those pairs. const inlineDefault = options?.[DEFAULT_VALUE_OPTION]; let value = - defaults[key] || (typeof inlineDefault === 'string' ? inlineDefault : '') || key; + defaultsRowFor(defaults, key, options) || + (typeof inlineDefault === 'string' ? inlineDefault : '') || + key; // The interpolation itself lives in `fallbackInterpolation.ts` — ONE // function, shared with `useObjectTranslation`'s not-ready path // (objectui#6219). Both provider-less renderers therefore fill exactly diff --git a/packages/plugin-ai/src/AIFormAssist.tsx b/packages/plugin-ai/src/AIFormAssist.tsx index a2700f4949..9f49e18497 100644 --- a/packages/plugin-ai/src/AIFormAssist.tsx +++ b/packages/plugin-ai/src/AIFormAssist.tsx @@ -97,10 +97,7 @@ export const AIFormAssist: React.FC = ({ schema, onApply, onR {t('ai.formAssist.title')} - {t( - pendingSuggestions.length === 1 ? 'ai.formAssist.suggestionCountOne' : 'ai.formAssist.suggestionCount', - { count: pendingSuggestions.length }, - )} + {t('ai.formAssist.suggestionCount', { count: pendingSuggestions.length })}
@@ -187,10 +184,7 @@ export const AIFormAssist: React.FC = ({ schema, onApply, onR {appliedFields.size > 0 && (
- {t( - appliedFields.size === 1 ? 'ai.formAssist.appliedCountOne' : 'ai.formAssist.appliedCount', - { count: appliedFields.size }, - )} + {t('ai.formAssist.appliedCount', { count: appliedFields.size })}
)} diff --git a/packages/plugin-ai/src/useAiTranslation.ts b/packages/plugin-ai/src/useAiTranslation.ts index 283a5c0213..4049809f62 100644 --- a/packages/plugin-ai/src/useAiTranslation.ts +++ b/packages/plugin-ai/src/useAiTranslation.ts @@ -29,10 +29,9 @@ import { createSafeTranslation } from '@object-ui/i18n'; * shows English rather than raw keys. Every row is byte-identical to its `en` * pack value — `aiComponents.i18n-10232.test.tsx` holds it there. * - * The count rows are this repo's two-key plural convention (`*One` picked at - * the call site at exactly one), not an i18next `_one`/`_other` family: the - * provider-less path resolves `defaults[key]` literally and never appends a - * plural suffix. + * The count rows are i18next count families (objectui#11445): the components + * pass `count`, and the provider-less path reads the `_one` / `_other` row for + * a numeric count, in the order i18next reads the `en` pack. */ export const AI_DEFAULT_TRANSLATIONS: Record = { 'ai.nlQuery.placeholder': 'Ask a question about your data…', @@ -44,11 +43,13 @@ export const AI_DEFAULT_TRANSLATIONS: Record = { 'ai.nlQuery.recentQueries': 'Recent Queries', 'ai.formAssist.title': 'AI Suggestions', 'ai.formAssist.suggestionCount': '{{count}} suggestions', - 'ai.formAssist.suggestionCountOne': '{{count}} suggestion', + 'ai.formAssist.suggestionCount_one': '{{count}} suggestion', + 'ai.formAssist.suggestionCount_other': '{{count}} suggestions', 'ai.formAssist.applyAll': 'Apply All', 'ai.formAssist.confidence': '{{percent}} confidence', 'ai.formAssist.appliedCount': '{{count}} suggestions applied', - 'ai.formAssist.appliedCountOne': '{{count}} suggestion applied', + 'ai.formAssist.appliedCount_one': '{{count}} suggestion applied', + 'ai.formAssist.appliedCount_other': '{{count}} suggestions applied', 'ai.recommendations.title': 'Recommendations', 'ai.recommendations.generating': 'Generating recommendations…', 'ai.recommendations.empty': 'No recommendations available', diff --git a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.i18n.test.tsx b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.i18n.test.tsx index c5b0496f9e..6aa9ba5a1a 100644 --- a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.i18n.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.i18n.test.tsx @@ -168,10 +168,14 @@ const AR = { reject: 'رفض', emptyTitle: 'لا توجد إجراءات في الانتظار', justNow: 'الآن', + // `detail.*Ago` are count families since objectui#11445, so each count reads + // its own `ar` category: 1 `one`, 2 `two` (the dual), 3 `few` (the plural), + // 20 `many` (accusative singular). The single forms they replaced read + // «منذ 2 دقيقة», «منذ 3 ساعة» and «منذ 20 يوم». oneMinute: 'منذ 1 دقيقة', - twoMinutes: 'منذ 2 دقيقة', - threeHours: 'منذ 3 ساعة', - twentyDays: 'منذ 20 يوم', + twoMinutes: 'منذ دقيقتين (2)', + threeHours: 'منذ 3 ساعات', + twentyDays: 'منذ 20 يومًا', }; describe('AiPendingActionsInbox — relative times resolve from the packs (objectui#7173)', () => { diff --git a/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx b/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx index 231535f31d..3e3176085a 100644 --- a/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx +++ b/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx @@ -111,7 +111,10 @@ describe('objectui#7149 — relative timestamps resolve from the packs', () => { cleanup(); renderIn('ar', [entry({ timestamp: ago(MINUTES * 60_000) })]); - expect(screen.getByText(`منذ ${MINUTES} دقيقة`)).toBeTruthy(); + // `detail.minutesAgo` is a count family since objectui#11445: 5 is `ar` + // `few` (3-10), which takes the plural noun — the single form it replaced + // read «منذ 5 دقيقة». + expect(screen.getByText(`منذ ${MINUTES} دقائق`)).toBeTruthy(); }); it('still reads English with no provider mounted — the defaults map, not a raw key', () => { diff --git a/packages/plugin-detail/src/CommentAttachment.tsx b/packages/plugin-detail/src/CommentAttachment.tsx index 65ab8c1ad8..178571e821 100644 --- a/packages/plugin-detail/src/CommentAttachment.tsx +++ b/packages/plugin-detail/src/CommentAttachment.tsx @@ -137,7 +137,9 @@ export const CommentAttachment: React.FC = ({
- {attachments.length !== 1 ? t('detail.attachmentCountPlural', { count: attachments.length }) : t('detail.attachmentCount', { count: attachments.length })} + {/* One count family (objectui#11445): i18next picks the CLDR slot, + so `ru` 3 reads «3 вложения» and `ar` 2 reads the dual. */} + {t('detail.attachmentCount', { count: attachments.length })}
diff --git a/packages/plugin-detail/src/ReactionPicker.tsx b/packages/plugin-detail/src/ReactionPicker.tsx index c07e0f61c5..dd758129b2 100644 --- a/packages/plugin-detail/src/ReactionPicker.tsx +++ b/packages/plugin-detail/src/ReactionPicker.tsx @@ -50,16 +50,10 @@ export const ReactionPicker: React.FC = ({
{/* Existing reactions */} {reactions.map((reaction) => { - // Two keys, NOT an i18next `_one`/`_other` pair — this repo's own - // plural convention (`detail.relatedRecords`/`relatedRecordOne`, - // `lookup.recordCount`/`recordCountOne`). A `_one` suffix would break - // `all-locales-key-parity`: zh/ja/ko have no separate singular form, - // so those packs would legitimately omit the `_one` half and the gate - // would read that as a missing key (objectstack#5430). - const countLabel = t( - reaction.count === 1 ? 'detail.reactionCountOne' : 'detail.reactionCount', - { emoji: reaction.emoji, count: reaction.count }, - ); + // One i18next count family (objectui#11445): i18next picks the CLDR + // slot from the numeric `count`, so `ru` reads «👍 3 реакции» and `ar` + // its dual at 2 — a `=== 1` key switch gave each pack only two slots. + const countLabel = t('detail.reactionCount', { emoji: reaction.emoji, count: reaction.count }); return ( )} diff --git a/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx index ba69a9161c..fc8c4bdda9 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx @@ -290,11 +290,13 @@ describe('DetailSection — one definition of emptiness, and it trims (#8376)', // 4 fields with exactly ONE empty row (`close_date`), so auto-hide fires and // hides it: `0` and `false` are on the FILLED side of the count. - const toggle = screen.getByRole('button', { name: /empty fields/i }); + // `detail.showEmptyFields` is a count family since objectui#11445, so ONE + // empty row reads the singular — the `$` anchor keeps the plural out. + const toggle = screen.getByRole('button', { name: /empty fields?$/i }); expect( toggle.textContent, '`0` and `false` are values: exactly one field (`close_date`) is empty', - ).toContain('Show 1 empty fields'); + ).toMatch(/Show 1 empty field$/); expect(affordances(), 'no visible row draws the affordance').toHaveLength(0); expect(shown('amount'), '`0` keeps its row and is not hidden as empty').toBe(true); expect(shown('stage'), '`false` keeps its row and is not hidden as empty').toBe(true); diff --git a/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx index 30e4854443..3fb60b12a0 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx @@ -293,11 +293,13 @@ describe('DetailSection — an empty array is not a cell value (#8474)', () => { // 4 fields with exactly ONE empty row (`close_date`), so auto-hide fires and // hides it: `0` and `false` are on the FILLED side of the count. - const toggle = screen.getByRole('button', { name: /empty fields/i }); + // `detail.showEmptyFields` is a count family since objectui#11445, so ONE + // empty row reads the singular — the `$` anchor keeps the plural out. + const toggle = screen.getByRole('button', { name: /empty fields?$/i }); expect( toggle.textContent, '`0` and `false` are values: exactly one field (`close_date`) is empty', - ).toContain('Show 1 empty fields'); + ).toMatch(/Show 1 empty field$/); expect(affordances(), 'no visible row draws the affordance').toHaveLength(0); expect(shown('amount'), '`0` keeps its row and is not hidden as empty').toBe(true); expect(shown('stage'), '`false` keeps its row and is not hidden as empty').toBe(true); diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index 438e073bb3..384875bc34 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -73,11 +73,12 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { // objectstack#5430 — the rest of the reaction chrome. The picker popup is // `role="listbox"` with no visible label, and each reaction chip is an // emoji + a bare number, so these labels ARE those controls to a screen - // reader. The count pair follows the repo's two-key plural convention - // (`relatedRecords`/`relatedRecordOne`), never an i18next `_one` suffix. + // reader. The count label is an i18next count family (objectui#11445): + // `fallbackT` reads its `_one` / `_other` row for a numeric `count`. 'detail.emojiPicker': 'Emoji picker', 'detail.reactionCount': '{{emoji}} {{count}} reactions', - 'detail.reactionCountOne': '{{emoji}} {{count}} reaction', + 'detail.reactionCount_one': '{{emoji}} {{count}} reaction', + 'detail.reactionCount_other': '{{emoji}} {{count}} reactions', 'detail.addToFavorites': 'Add to favorites', 'detail.removeFromFavorites': 'Remove from favorites', 'detail.previousRecord': 'Previous record', @@ -101,6 +102,8 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.activity': 'Activity', 'detail.copyRecordId': 'Copy record ID', 'detail.showEmptyFields': 'Show {{count}} empty fields', + 'detail.showEmptyFields_one': 'Show {{count}} empty field', + 'detail.showEmptyFields_other': 'Show {{count}} empty fields', 'detail.hideEmptyFields': 'Hide empty fields', 'detail.editRow': 'Edit', 'detail.deleteRow': 'Delete', @@ -140,7 +143,8 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { // Attachments 'detail.dropFilesToUpload': 'Drop files here or click to upload', 'detail.attachmentCount': '{{count}} attachment', - 'detail.attachmentCountPlural': '{{count}} attachments', + 'detail.attachmentCount_one': '{{count}} attachment', + 'detail.attachmentCount_other': '{{count}} attachments', 'detail.removeAttachment': 'Remove attachment', // Discussion 'detail.discussion': 'Discussion', @@ -161,12 +165,10 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.unsubscribedTooltip': 'Subscribe to notifications', // The reference rail's empty related-record list 'detail.noRecords': 'No records', - // objectui#3863 — the packs grew a BASE key for this family, and this map has to - // mirror it for a reason of its own: `fallbackT` (createSafeTranslation) resolves - // `defaults[key]` LITERALLY and never appends a plural suffix, so with only the two - // suffixed rows below the provider-less path answered `t('detail.showEmptyRelated', - // { count })` with the raw key. The base row is the only one that path can reach; - // the suffixed rows are kept so the map's key set still mirrors the packs'. + // objectui#3863 — the packs grew a BASE key for this family, and this map mirrors + // it: `fallbackT` (createSafeTranslation) reads the `_one` / `_other` row for a + // numeric `count` (objectui#11445) and the base row for a call made without one, + // the order i18next reads the `en` pack in. 'detail.showEmptyRelated': '+ {{count}} empty', 'detail.showEmptyRelated_one': '+ {{count}} empty', 'detail.showEmptyRelated_other': '+ {{count}} empty', @@ -202,7 +204,8 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.viewSource': 'View source', // Replies 'detail.replyCount': '{{count}} reply', - 'detail.replyCountPlural': '{{count}} replies', + 'detail.replyCount_one': '{{count}} reply', + 'detail.replyCount_other': '{{count}} replies', 'detail.replyPlaceholder': 'Reply…', // Aria labels 'detail.filterActivity': 'Filter activity', diff --git a/packages/plugin-gantt/src/useGanttTranslation.ts b/packages/plugin-gantt/src/useGanttTranslation.ts index fb1b4f4cd4..0c84c5b040 100644 --- a/packages/plugin-gantt/src/useGanttTranslation.ts +++ b/packages/plugin-gantt/src/useGanttTranslation.ts @@ -80,11 +80,19 @@ export const GANTT_DEFAULT_TRANSLATIONS: Record = { 'gantt.link.rejected.cycle': 'That link would create a circular dependency.', 'gantt.conflict.title': 'Schedule conflict', 'gantt.conflict.body': 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?', + // Count families (objectui#11445): `fallback` reads the `_one` / `_other` row + // for a numeric `count`, the order i18next reads the `en` pack in. + 'gantt.conflict.body_one': 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task?', + 'gantt.conflict.body_other': 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected tasks?', 'gantt.conflict.confirm': 'Auto-reschedule', 'gantt.conflict.cancel': 'Keep as is', 'gantt.autoScheduleDlg.title': 'Auto-schedule', 'gantt.autoScheduleDlg.body': 'Shift {{count}} task(s) later to satisfy dependency links?', + 'gantt.autoScheduleDlg.body_one': 'Shift {{count}} task later to satisfy dependency links?', + 'gantt.autoScheduleDlg.body_other': 'Shift {{count}} tasks later to satisfy dependency links?', 'gantt.autoScheduleDlg.skipped': '{{count}} locked task(s) also violate links and were skipped.', + 'gantt.autoScheduleDlg.skipped_one': '{{count}} locked task also violates links and was skipped.', + 'gantt.autoScheduleDlg.skipped_other': '{{count}} locked tasks also violate links and were skipped.', 'gantt.autoScheduleDlg.confirm': 'Apply', 'gantt.autoScheduleDlg.cancel': 'Cancel', 'gantt.autoScheduleDlg.none': 'All dependencies satisfied — nothing to reschedule.', @@ -110,8 +118,20 @@ export const GANTT_DEFAULT_TRANSLATIONS: Record = { 'gantt.writeFailed': 'Save failed — the change was rolled back', }; +/** The defaults are English, so English plural rules pick a family's row. */ +const EN_PLURAL_RULES = new Intl.PluralRules('en'); + function fallback(key: string, options?: Record): string { - let v = GANTT_DEFAULT_TRANSLATIONS[key] || key; + // A count family's `_one` / `_other` row first, then the base row — the + // order i18next reads the `en` pack in, for a NUMERIC count only (i18next + // plural-selects nothing else). The hand-rolled copy of `createSafeTranslation`'s + // `defaultsRowFor` (objectui#11445), as `interpolate` below is of its interpolator. + const count = options?.count; + const slot = + typeof count === 'number' && Number.isFinite(count) + ? GANTT_DEFAULT_TRANSLATIONS[`${key}_${EN_PLURAL_RULES.select(count)}`] + : undefined; + let v = slot || GANTT_DEFAULT_TRANSLATIONS[key] || key; if (options) { for (const [k, val] of Object.entries(options)) { // `split(needle).join(value)` — deliberately not `replace`, and not diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index 394be530f1..987a8fd37c 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -83,6 +83,10 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.typeMismatch': 'Looks like {{type}}', 'grid.import.autoMatched': 'Auto-matched', 'grid.import.autoMatchedSummary': 'Auto-matched {{count}} column(s) — review and adjust below.', + // The `_one` / `_other` rows below are count families (objectui#11445): + // `useImportTranslation`'s fallback reads them for a numeric `count`. + 'grid.import.autoMatchedSummary_one': 'Auto-matched {{count}} column — review and adjust below.', + 'grid.import.autoMatchedSummary_other': 'Auto-matched {{count}} columns — review and adjust below.', 'grid.import.confidence.high': 'High confidence', 'grid.import.confidence.medium': 'Medium confidence', 'grid.import.confidence.low': 'Low confidence', @@ -97,7 +101,11 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.mapped': 'Mapped', 'grid.import.skipped': 'Skipped', 'grid.import.rowsWithErrors': '{{count}} row(s) with errors', + 'grid.import.rowsWithErrors_one': '{{count}} row with errors', + 'grid.import.rowsWithErrors_other': '{{count}} rows with errors', 'grid.import.rowsCorrected': '{{count}} row(s) corrected', + 'grid.import.rowsCorrected_one': '{{count}} row corrected', + 'grid.import.rowsCorrected_other': '{{count}} rows corrected', 'grid.import.clickToFix': '— click a highlighted cell to fix it inline.', 'grid.import.showingRows': 'Showing {{shown}} of {{total}} rows', 'grid.import.importing': 'Importing… {{progress}}%', @@ -109,12 +117,20 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.cancelImport': 'Cancel import', 'grid.import.importCancelled': 'Import cancelled', 'grid.import.resultsTruncated': 'Showing the first {{count}} row results (of {{total}}).', + 'grid.import.resultsTruncated_one': 'Showing the first {{count}} row result (of {{total}}).', + 'grid.import.resultsTruncated_other': 'Showing the first {{count}} row results (of {{total}}).', 'grid.import.importComplete': 'Import Complete', 'grid.import.imported': '{{count}} imported', 'grid.import.createdCount': '{{count}} created', + 'grid.import.createdCount_one': '{{count}} created', + 'grid.import.createdCount_other': '{{count}} created', 'grid.import.updatedCount': '{{count}} updated', + 'grid.import.updatedCount_one': '{{count}} updated', + 'grid.import.updatedCount_other': '{{count}} updated', 'grid.import.skippedCount': '{{count}} skipped', 'grid.import.moreErrors': '…and {{count}} more errors', + 'grid.import.moreErrors_one': '…and {{count}} more error', + 'grid.import.moreErrors_other': '…and {{count}} more errors', 'grid.import.downloadFailed': 'Download failed rows', // Write-mode / options (preview step) 'grid.import.options': 'Import options', @@ -166,6 +182,8 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.historyColResult': 'Result', 'grid.import.historyColTime': 'When', 'grid.import.errorCount': '{{count}} errors', + 'grid.import.errorCount_one': '{{count}} error', + 'grid.import.errorCount_other': '{{count}} errors', // Undo / logical rollback 'grid.import.undoImport': 'Undo import', 'grid.import.undoing': 'Undoing…', @@ -181,6 +199,8 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.next': 'Next', 'grid.import.close': 'Close', 'grid.import.importNRows': 'Import {{count}} Rows', + 'grid.import.importNRows_one': 'Import {{count}} Row', + 'grid.import.importNRows_other': 'Import {{count}} Rows', 'grid.import.importingProgress': 'Importing…', 'grid.import.required': 'Required', 'grid.import.invalidType': 'Invalid {{type}}', @@ -228,9 +248,27 @@ function interpolate(template: string, vars?: Record): string { /** Translation hook with safe English fallback for standalone usage. * When no I18nProvider is mounted (e.g. unit tests) the hook still resolves * `grid.import.*` keys via the embedded defaults so the wizard stays usable. */ +/** The defaults are English, so English plural rules pick a family's row. */ +const EN_PLURAL_RULES = new Intl.PluralRules('en'); + +/** + * A count family's `_one` / `_other` row for a NUMERIC count, else the base row + * — the order i18next reads the `en` pack in (i18next plural-selects nothing + * but a number). The hand-rolled copy of `createSafeTranslation`'s + * `defaultsRowFor` (objectui#11445), as `interpolate` above is of its interpolator. + */ +function defaultsRow(key: string, vars?: Record): string | undefined { + const count = vars?.count; + if (typeof count === 'number' && Number.isFinite(count)) { + const slot = IMPORT_DEFAULT_TRANSLATIONS[`${key}_${EN_PLURAL_RULES.select(count)}`]; + if (slot !== undefined) return slot; + } + return IMPORT_DEFAULT_TRANSLATIONS[key]; +} + function useImportTranslation(): { t: (key: string, vars?: Record) => string } { const fallback = (key: string, vars?: Record) => - interpolate(IMPORT_DEFAULT_TRANSLATIONS[key] ?? key, vars); + interpolate(defaultsRow(key, vars) ?? key, vars); // Deliberately NOT `createSafeTranslation`: that probes one testKey and then // serves defaults for everything, whereas the wizard falls back per key so a // host dictionary that covers the common keys but lags on newer ones still @@ -1110,7 +1148,8 @@ const StepMapping: React.FC<{ {autoMatchedCount > 0 && (

- {t('grid.import.autoMatchedSummary', { count: String(autoMatchedCount) })} + {/* A NUMBER count: a count family (objectui#11445) plural-selects only on one. */} + {t('grid.import.autoMatchedSummary', { count: autoMatchedCount })}

)}
diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 03244ec848..7bcdeea4c9 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -807,7 +807,8 @@ export function evaluateConditionalFormatting( // reads this map from downstream instead of parsing this file's text. export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.recordCount': '{{count}} records', - 'list.recordCountOne': '{{count}} record', + 'list.recordCount_one': '{{count}} record', + 'list.recordCount_other': '{{count}} records', 'list.noItems': 'No items found', 'list.noItemsMessage': 'There are no records to display. Try adjusting your filters or adding new data.', // First-run (truly empty, no filter/search) vs filtered-to-empty. Showing @@ -5397,12 +5398,8 @@ export const ListView = React.forwardRef(({ honest record count is the server's grand total (#586). When the whole result set is in memory, serverTotal is null and data.length already IS the total. */} - {(() => { - const totalCount = serverTotal ?? data.length; - return totalCount === 1 - ? t('list.recordCountOne', { count: totalCount }) - : t('list.recordCount', { count: totalCount }); - })()} + {/* One count family (objectui#11445): i18next picks the CLDR slot. */} + {t('list.recordCount', { count: serverTotal ?? data.length })} {/* The cap warning is about rows the user CANNOT REACH. A paged grid with a known total can reach them all through its pager, so the diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 5287d4a60f..ef2d00c1e8 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -547,9 +547,15 @@ const VIEW_DEFAULT_TRANSLATIONS: Record = { 'actionConfirm.cancel': 'Cancel', 'objectActions.deleteConfirm': 'Are you sure you want to delete this record?', 'console.objectView.bulkDeleteConfirm': 'Delete {{count}} selected records? This cannot be undone.', + // Count families (objectui#11445): `fallbackT` reads a family's `_one` / + // `_other` row for a numeric `count`, as i18next reads the `en` pack. + 'console.objectView.bulkDeleteConfirm_one': 'Delete {{count}} selected record? This cannot be undone.', + 'console.objectView.bulkDeleteConfirm_other': 'Delete {{count}} selected records? This cannot be undone.', 'objectActions.deleteSuccess': '{{label}} deleted successfully', 'objectActions.deleteFailed': 'Failed to delete {{label}}', 'objectActions.bulkDeleteSuccess': 'Deleted {{count}} {{label}} records', + 'objectActions.bulkDeleteSuccess_one': 'Deleted {{count}} {{label}} record', + 'objectActions.bulkDeleteSuccess_other': 'Deleted {{count}} {{label}} records', 'objectActions.bulkDeletePartial': '{{succeeded}} deleted, {{failed}} failed', };