From e9e5896e02433539209518c8bd99b56a2674c3b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:45:40 +0000 Subject: [PATCH] fix(i18n): ru/ar count labels on seven more `=== 1` two-key values (objectui#10425) The count-not-one half of seven values behind the `count === 1` switch held one noun form, wrong at some of the counts it serves (ru 2-4 and 21; ar 2 and 11+). Each becomes a count label, the objectui#10024 / #10242 device: ru list.recordCount; ar list.recordCount, lookup.recordCount, detail.reactionCount, collaboration.presentUserCount / moreUserCount and search.itemsAvailable. Key sets and call sites are unchanged. The card's eighth value, ar detail.revisionFieldsChanged, left the tree with its only consumer (PointInTimeRestore) in #10453. Pins: a real-render pin beside the objectui#10242 one, the 9664 value pin flipped to the new ar value, and the pending 9664 changeset's last sentence reworded to the shape the ar value now has. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy --- .changeset/10425-ru-ar-count-labels.md | 31 ++ .../9664-search-items-available-plural.md | 5 +- .../__tests__/countLabels.ruAr-10425.test.tsx | 480 ++++++++++++++++++ .../searchItemsAvailable-plural-9664.test.ts | 16 +- packages/i18n/src/locales/ar.ts | 26 +- packages/i18n/src/locales/ru.ts | 5 +- 6 files changed, 549 insertions(+), 14 deletions(-) create mode 100644 .changeset/10425-ru-ar-count-labels.md create mode 100644 packages/app-shell/src/views/__tests__/countLabels.ruAr-10425.test.tsx diff --git a/.changeset/10425-ru-ar-count-labels.md b/.changeset/10425-ru-ar-count-labels.md new file mode 100644 index 0000000000..54caee000b --- /dev/null +++ b/.changeset/10425-ru-ar-count-labels.md @@ -0,0 +1,31 @@ +--- +'@object-ui/i18n': patch +--- + +Russian now reads correctly at every count on the list view's record-count bar, +and Arabic on that bar, the record picker's count, the activity-feed reaction chip, +the presence avatars' accessible name and overflow tooltip, and the full-page +search count while browsing (objectui#10425). + +Each of these surfaces picks its `…One` key at exactly one and the plain key at +every other count. Where the plain key held a single noun form, it was wrong at +some of the counts it serves. The Russian record-count bar read `2 записей` at two +(the language needs `2 записи`) and `21 записей` at twenty-one (it needs +`21 запись`). The Arabic values each held one noun form (the plural for three to +ten, or in `lookup.recordCount` the singular), while Arabic needs different forms +at two, at three to ten and from eleven up. + +The count-not-one half of these seven values is now a count label that reads +correctly at any number: + +- `ru` `list.recordCount`: `Записей: N` +- `ar` `list.recordCount`: `عدد السجلات: N` +- `ar` `lookup.recordCount`: `عدد السجلات: N` +- `ar` `detail.reactionCount`: the emoji, then `عدد التفاعلات: N` +- `ar` `collaboration.presentUserCount`: `عدد المستخدمين المتواجدين: N` +- `ar` `collaboration.moreUserCount`: `عدد المستخدمين الآخرين: N` +- `ar` `search.itemsAvailable`: `عدد العناصر المتاحة: N` + +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. diff --git a/.changeset/9664-search-items-available-plural.md b/.changeset/9664-search-items-available-plural.md index 5c893e515f..c7727f9957 100644 --- a/.changeset/9664-search-items-available-plural.md +++ b/.changeset/9664-search-items-available-plural.md @@ -33,5 +33,6 @@ its phrasing states no noun to agree with. One translation changed rather than being added: `ar` wrote both numbers into one string as a parenthesised marker. Its key can no longer be reached at one item, so the singular half of that marker was dead weight while the parentheses still -rendered at every count the key does serve. It now uses the same noun pair that -`ar`'s `common.itemCount`/`itemCountOne` already uses. +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). 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 new file mode 100644 index 0000000000..7f946dcaf2 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/countLabels.ruAr-10425.test.tsx @@ -0,0 +1,480 @@ +/** + * 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#10425 — seven more values behind the `count === 1` two-key switch of + * objectui#10024 and objectui#10242. Six surfaces pick their `…One` key at + * exactly one and the plain key at every other count: + * + * - the list view's record-count bar (`list.recordCount`, `ListView`); + * - the record picker's count (`lookup.recordCount`, `RecordPickerDialog`); + * - the activity-feed reaction chip (`detail.reactionCount`, `ReactionPicker`); + * - the presence stack's accessible name and its overflow badge's tooltip + * (`collaboration.presentUserCount` / `moreUserCount`, `PresenceAvatars`); + * - the full-page search count while browsing (`search.itemsAvailable`, + * `SearchResultsPage`). + * + * Two slots fit `en`. They do not fit `ru` (one: 1, 21, 31…; few: 2-4…; + * many: 0, 5-20…) or `ar` (zero, one, two, few 3-10, many 11-99, other 100+). + * + * What shipped before this card: the count-not-one half held one noun form. In + * `ru` `list.recordCount` that was the `many` form, so the bar read + * `2 записей` at two and `21 записей` at twenty-one. In `ar` it was the 3-10 + * plural (or, on `lookup.recordCount`, the singular), wrong at 2 and from 11 up. + * + * ## The repair: the objectui#10024 device, key by key + * + * The count-not-one half becomes a count label — a label, a colon, then the + * 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. + * + * ## Why this file sits beside the objectui#10242 pin + * + * Same defect, same shape (`countLabels.ruAr-10242.test.tsx`), and + * `@object-ui/app-shell` is the one package that depends on every consumer + * here, so one file renders each of them for real. + * + * ## What this file measures + * + * The strings a user reads, from a real render inside a real i18next instance + * booted in the session's language. `t` is not mocked. `en` and the `ru` values + * this card does not change are the controls. + */ + +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '@object-ui/plugin-list'; +import { RecordPickerDialog } from '@object-ui/fields'; +import { ReactionPicker } from '@object-ui/plugin-detail'; +import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration'; + +/** Mutable across cases: the app's nav items, which browse mode counts. */ +const app = vi.hoisted(() => ({ navigation: [] as unknown[] })); + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ appName: 'crm' }), + // No `?q=`: the page is in its BROWSE branch, the one `itemsAvailable` serves. + useSearchParams: () => [new URLSearchParams(''), vi.fn()], + Link: ({ + to, + children, + ...rest + }: { to: unknown; children?: React.ReactNode } & Record) => ( + + {children} + + ), +})); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + // Browse mode queries nothing, so the count on screen is the nav count. + useRecordSearch: () => ({ results: [], isSearching: false, error: undefined }), + }; +}); + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ + apps: [{ name: 'crm', label: 'CRM', navigation: app.navigation }], + objects: [], + }), +})); + +vi.mock('../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ find: vi.fn(), searchAll: vi.fn() }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }), +})); + +import { SearchResultsPage } from '../SearchResultsPage'; + +type Lang = 'en' | 'ru' | 'ar'; + +afterEach(() => cleanup()); + +// ListView draws its rows through `object-grid`, which lives in a package this +// file does not load. A stub keeps the grid out of the measurement: only the +// record-count bar below it is read. +let prevObjectGrid: unknown; +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', () =>
); +}); +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid as never); + else ComponentRegistry.unregister('object-grid'); +}); + +function boot(lang: Lang) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + // The instance really booted in the language under test; otherwise every + // string below would be `en` read through `fallbackLng`. + expect(i18n.language).toBe(lang); + return i18n; +} + +const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `r${i}`, name: `Row ${i}` })); + +/** The list view's record-count bar at `count` fetched records. */ +async function listBar(lang: Lang, count: number): Promise { + const dataSource = { + find: vi.fn(async () => rows(count)), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as never; + const { findByTestId } = render( + + + + + , + ); + const bar = await findByTestId('record-count-bar'); + // The count is the bar's first span; the data-limit warning, when present, + // is a second one. + const text = bar.querySelector('span')?.textContent ?? ''; + cleanup(); + return text; +} + +/** The record picker's count line at `count` matching records. */ +async function pickerCount(lang: Lang, count: number): Promise { + const dataSource = { find: vi.fn(async () => ({ data: rows(count), total: count })) } as never; + render( + + {}} + dataSource={dataSource} + objectName="contacts" + columns={[{ field: 'name', label: 'Name', type: 'text' }]} + // One page holds every count below, so no `pageOf` suffix joins the line. + pageSize={50} + onSelect={() => {}} + /> + , + ); + // The dialog renders into a portal on `document.body`. + const line = await waitFor(() => { + const span = document.querySelector('[data-testid="record-picker-pagination"] > span'); + expect(span, `${lang} at ${count}: picker count line not found`).not.toBeNull(); + return span as Element; + }); + const text = line.textContent ?? ''; + cleanup(); + return text; +} + +/** The activity-feed reaction chip's accessible name at `count`. */ +function reactionChip(lang: Lang, count: number): string { + const { container } = render( + + + , + ); + // No `onToggleReaction`, so no add-reaction button: the chip is the only one. + const chips = container.querySelectorAll('button[aria-label]'); + expect(chips, `${lang} at ${count}: reaction chip not found exactly once`).toHaveLength(1); + const name = chips[0].getAttribute('aria-label') ?? ''; + cleanup(); + return name; +} + +const presentUsers = (n: number): PresenceUser[] => + Array.from({ length: n }, (_, i) => ({ + userId: `u${i}`, + userName: `User ${i}`, + color: '#888888', + status: 'active', + lastActivity: new Date().toISOString(), + })); + +/** The presence stack's accessible name with `count` users present. */ +function presenceName(lang: Lang, count: number): string { + const { container } = render( + + + , + ); + const groups = container.querySelectorAll('[role="group"][aria-label]'); + expect(groups, `${lang} at ${count}: presence group not found exactly once`).toHaveLength(1); + const name = groups[0].getAttribute('aria-label') ?? ''; + cleanup(); + return name; +} + +/** The overflow badge's tooltip with `count` users past the visible one. */ +function overflowTitle(lang: Lang, count: number): string { + const { container } = render( + + + , + ); + const badges = Array.from(container.querySelectorAll('[title]')).filter( + (node) => node.textContent === `+${count}`, + ); + expect(badges, `${lang} at ${count}: overflow badge not found exactly once`).toHaveLength(1); + const title = badges[0].getAttribute('title') ?? ''; + cleanup(); + return title; +} + +const navItems = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `n${i}`, + type: 'object', + objectName: `crm_object_${i}`, + label: `Object ${i}`, + })); + +/** The full-page search count while browsing `count` searchable nav items. */ +function browseCount(lang: Lang, count: number): string { + app.navigation = navItems(count); + const { container } = render( + + + , + ); + // The count line is the one leaf span that carries the number inside words: + // nav hits read `Object N`, and a group badge holds the bare number. + const lines = Array.from(container.querySelectorAll('span')).filter((span) => { + const text = span.textContent ?? ''; + return ( + span.children.length === 0 && + text.includes(String(count)) && + text !== String(count) && + !text.includes('Object') + ); + }); + expect(lines, `${lang} at ${count}: count line not found exactly once`).toHaveLength(1); + const text = lines[0].textContent ?? ''; + cleanup(); + return text; +} + +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). + */ +const LIST_BAR: Rows = { + en: [ + [1, '1 record'], + [2, '2 records'], + ], + ru: [ + [1, '1 запись'], + [2, 'Записей: 2'], + [5, 'Записей: 5'], + [21, 'Записей: 21'], + ], + ar: [ + [1, '1 سجل'], + [2, 'عدد السجلات: 2'], + [3, 'عدد السجلات: 3'], + [11, 'عدد السجلات: 11'], + ], +}; + +const PICKER: Rows = { + en: [ + [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'], + ], + ar: [ + [1, 'سجل واحد'], + [2, 'عدد السجلات: 2'], + [3, 'عدد السجلات: 3'], + [11, 'عدد السجلات: 11'], + ], +}; + +const CHIP: Rows = { + en: [ + [1, '👍 1 reaction'], + [2, '👍 2 reactions'], + ], + ar: [ + [1, '👍 1 تفاعل'], + [2, '👍 عدد التفاعلات: 2'], + [3, '👍 عدد التفاعلات: 3'], + [11, '👍 عدد التفاعلات: 11'], + ], +}; + +const PRESENCE: Rows = { + en: [ + [1, '1 user present'], + [2, '2 users present'], + ], + // The control: `ru` already read this way. + ru: [ + [1, 'Присутствует 1 пользователь'], + [2, 'Присутствует пользователей: 2'], + [21, 'Присутствует пользователей: 21'], + ], + ar: [ + [1, '1 مستخدم متواجد'], + [2, 'عدد المستخدمين المتواجدين: 2'], + [3, 'عدد المستخدمين المتواجدين: 3'], + [11, 'عدد المستخدمين المتواجدين: 11'], + ], +}; + +const OVERFLOW: Rows = { + en: [ + [1, '1 more user'], + [2, '2 more users'], + ], + // The control: `ru` already read this way. + ru: [ + [1, 'Ещё 1 пользователь'], + [2, 'Ещё пользователей: 2'], + [21, 'Ещё пользователей: 21'], + ], + ar: [ + [1, '1 مستخدم آخر'], + [2, 'عدد المستخدمين الآخرين: 2'], + [3, 'عدد المستخدمين الآخرين: 3'], + [11, 'عدد المستخدمين الآخرين: 11'], + ], +}; + +const BROWSE: Rows = { + en: [ + [1, '1 item available'], + [2, '2 items available'], + ], + ar: [ + [1, '1 عنصر متاح'], + [2, 'عدد العناصر المتاحة: 2'], + [3, 'عدد العناصر المتاحة: 3'], + [11, 'عدد العناصر المتاحة: 11'], + ], +}; + +const langs = (table: Rows) => Object.keys(table) as Lang[]; + +describe('count labels read correctly at every CLDR category in ru and ar (objectui#10425)', () => { + it('the representative counts reach the categories the count-not-one half serves', () => { + const ru = new Intl.PluralRules('ru'); + const ar = new Intl.PluralRules('ar'); + expect([2, 5, 21].map((n) => ru.select(n))).toEqual(['few', 'many', 'one']); + expect([2, 3, 11].map((n) => ar.select(n))).toEqual(['two', 'few', 'many']); + }); + + describe.each(langs(LIST_BAR))('list view record-count bar, %s', (lang) => { + it.each(LIST_BAR[lang]!)('at %i', async (count, line) => { + expect(await listBar(lang, count)).toBe(line); + }); + }); + + describe.each(langs(PICKER))('record picker count, %s', (lang) => { + it.each(PICKER[lang]!)('at %i', async (count, line) => { + expect(await pickerCount(lang, count)).toBe(line); + }); + }); + + describe.each(langs(CHIP))('reaction chip, %s', (lang) => { + it.each(CHIP[lang]!)('at %i', (count, chip) => { + expect(reactionChip(lang, count)).toBe(chip); + }); + }); + + describe.each(langs(PRESENCE))('presence stack name, %s', (lang) => { + it.each(PRESENCE[lang]!)('at %i', (count, name) => { + expect(presenceName(lang, count)).toBe(name); + }); + }); + + describe.each(langs(OVERFLOW))('presence overflow tooltip, %s', (lang) => { + it.each(OVERFLOW[lang]!)('at %i', (count, title) => { + expect(overflowTitle(lang, count)).toBe(title); + }); + }); + + describe.each(langs(BROWSE))('search browse count, %s', (lang) => { + it.each(BROWSE[lang]!)('at %i', (count, line) => { + expect(browseCount(lang, count)).toBe(line); + }); + }); + + it('ru no longer binds the many-form noun to a few or one count', async () => { + // The shipped defect, named as the strings a Russian user read. + expect(await listBar('ru', 2)).not.toBe('2 записей'); + expect(await listBar('ru', 21)).not.toBe('21 записей'); + }); + + it('ar no longer binds one noun form to a dual or an 11+ count', async () => { + // The shipped defect, named as the strings an Arabic user read. + expect(await listBar('ar', 2)).not.toBe('2 سجلات'); + expect(await listBar('ar', 11)).not.toBe('11 سجلات'); + expect(await pickerCount('ar', 2)).not.toBe('2 سجل'); + expect(reactionChip('ar', 2)).not.toBe('👍 2 تفاعلات'); + expect(reactionChip('ar', 11)).not.toBe('👍 11 تفاعلات'); + expect(presenceName('ar', 2)).not.toBe('2 مستخدمين متواجدين'); + expect(overflowTitle('ar', 11)).not.toBe('11 مستخدمين آخرين'); + 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. + 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', + ], + }; + 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`)); + } + } + }); +}); diff --git a/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts b/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts index 9410c5983c..85d8213d70 100644 --- a/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts +++ b/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts @@ -54,7 +54,12 @@ const at = (pack: unknown, dotted: string) => const KEY = 'search.itemsAvailable'; const KEY_ONE = 'search.itemsAvailableOne'; -/** The count ≠ 1 half, unchanged by this card except in `ar` (see below). */ +/** + * 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}} 项可搜索', @@ -65,7 +70,7 @@ const PLURAL: Record = { es: '{{count}} elementos disponibles', pt: '{{count}} itens disponíveis', ru: '{{count}} доступно', - ar: '{{count}} عناصر متاحة', + ar: 'عدد العناصر المتاحة: {{count}}', }; /** The count === 1 half, added by this card. */ @@ -147,9 +152,10 @@ describe('search.itemsAvailable carries a singular half in every pack (objectui# // (`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. The noun pair - // it was replaced with is the one `ar.common.itemCount`/`itemCountOne` - // already uses. + // 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. // // 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 diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index cbe06ce9e7..6aec997ddb 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -68,7 +68,10 @@ const ar = { remove: 'إزالة {{label}}', selectFirst: 'اختر {{fields}} أولاً', selectRecord: 'اختيار سجل', - recordCount: '{{count}} سجل', + // 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. + recordCount: 'عدد السجلات: {{count}}', recordCountOne: 'سجل واحد', pageOf: 'صفحة {{current}} من {{total}}', filters: 'عوامل التصفية', @@ -651,7 +654,10 @@ const ar = { }, refresh: "تحديث", loading: "جارٍ تحميل السجلات…", - recordCount: "{{count}} سجلات", + // 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. + recordCount: "عدد السجلات: {{count}}", recordCountOne: "{{count}} سجل", addRecord: "إضافة سجل", tabs: "علامات التبويب", @@ -938,7 +944,10 @@ const ar = { addReaction: "إضافة تفاعل", pageHeaderActions: "إجراءات رأس الصفحة", emojiPicker: "منتقي الرموز التعبيرية", - reactionCount: "{{emoji}} {{count}} تفاعلات", + // 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`. + reactionCount: "{{emoji}} عدد التفاعلات: {{count}}", reactionCountOne: "{{emoji}} {{count}} تفاعل", recordDetail: "تفاصيل السجل", recordDetailWithLabel: "تفاصيل {{label}}", @@ -2525,7 +2534,9 @@ const ar = { // byte; a count label («عدد …: {{count}}», as `calendar.a11y.dayCell` does) // reads correctly at every one of them. resultsCountPlural: "عدد نتائج البحث عن \"{{query}}\": {{count}}", - itemsAvailable: "{{count}} عناصر متاحة", + // objectui#10425 — the browse branch of the same line switches at exactly + // 1 too, so this half needs the same count label. + itemsAvailable: "عدد العناصر المتاحة: {{count}}", itemsAvailableOne: "{{count}} عنصر متاح", noResults: "لم يتم العثور على نتائج", noResultsHint: "جرب تعديل مصطلحات البحث", @@ -3644,9 +3655,12 @@ const ar = { replyingToComment: "الرد على التعليق…", commentPlaceholder: "أضف تعليقًا… (استخدم @ للإشارة)", send: "إرسال", - presentUserCount: "{{count}} مستخدمين متواجدين", + // 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. + presentUserCount: "عدد المستخدمين المتواجدين: {{count}}", presentUserCountOne: "{{count}} مستخدم متواجد", - moreUserCount: "{{count}} مستخدمين آخرين", + moreUserCount: "عدد المستخدمين الآخرين: {{count}}", moreUserCountOne: "{{count}} مستخدم آخر", userStatusTitle: "{{name}} ({{status}})", statusActive: "نشط", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 2db07986c1..bc8793e9b3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -653,7 +653,10 @@ const ru = { }, refresh: "Обновить", loading: "Загрузка записей…", - recordCount: "{{count}} записей", + // 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`. + recordCount: "Записей: {{count}}", recordCountOne: "{{count}} запись", addRecord: "Добавить запись", tabs: "Вкладки",