diff --git a/.changeset/residue-locale-keys-3546-slice7.md b/.changeset/residue-locale-keys-3546-slice7.md new file mode 100644 index 000000000..be9bcf7b5 --- /dev/null +++ b/.changeset/residue-locale-keys-3546-slice7.md @@ -0,0 +1,63 @@ +--- +"@object-ui/i18n": patch +--- + +Backfill the last 17 missing locale keys and both remaining template-key families, emptying the call-site key ratchet (objectui#3546, slice seven — final) + +`scripts/check-i18n-call-site-keys.mjs` (objectui#3530) opened this backlog with +**258 keys and 4 template-key families** that a `t()` call site asks for and that +**no locale pack defined**. Seven slices later the last of it is paid: this change +takes the ratchet from 17 keys to **zero** and from 2 prefix families to **zero**, +and the gate now reports every one of the **2320** literal call-site keys +resolving against `en`. + +The residue was the long tail — nine namespaces across `app-shell`, +`plugin-detail`, `plugin-dashboard`, `plugin-kanban` and `plugin-gantt`, none of +them big enough to have been its own slice. 17 distinct keys at **23** call sites +(five keys are used at more than one site) plus **3** call sites behind the two +families. + +What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the "App not +available" empty state a user lands on when an app is still publishing, including +its whole explanation and its Retry button; the interface page's "source is not +available" message; the system navigation's **Administration** group header, +**Datasources** and **Documentation** entries; the "creating new organizations is +disabled on this instance" guard in the workspace dialog; the invitation list's +five status labels (All / Pending / Accepted / Rejected / Canceled) on both the +filter tabs and every invitation badge; the Gantt dependency-drag hint that names +which endpoint the drop will link (`start` / `end`); the record detail's Add, +"Record deleted", "No history yet" and the concurrent-update dialog's "this +record"; the kanban empty board's column count; the dashboard widget's screen +reader "Loading…"; and the page editor's "Edit in studio" tooltip and accessible +name. All of it rendered English, in every one of the ten languages. + +Nothing here rendered a raw key — slice one (PR #3583) held those sites, and the +three keys the issue body named as unprotected (`detail.viewSource`, +`wizard.missingRequired`, `gantt.toolbar.refresh`) have resolved in `en` since. + +Both families are repaired as **enumerations, not wildcards**, and the assertion +that used to live in the ratchet's `missingPrefixes` moves into a test that fails +if either union grows a member without a key: + +- `gantt.linkEnd.` — the closed union `'start' | 'end'`, declared by GanttView's + own `linkDrag` state. +- `organization.invitations.status.` — `StatusFilter` + (`all | pending | accepted | rejected | canceled`), declared by InvitationsPage. + +Every `en` value is byte-identical to the English the call site rendered before, +so no string a user sees today changes: 16 keys match an inline +`t(key, { defaultValue: … })`; `dashboard.loading` matches `useSafeTranslate`'s +positional fallback `tt(key, 'Loading…')`; `gantt.linkEnd.*` match +`useGanttTranslation`'s per-key fallback map; and the five status labels match the +CSS-capitalised wire value each badge and tab showed. The nine translations follow +each pack's own neighbourhood and reuse an existing neighbour's row wherever the +`en` string already existed verbatim **and** that row is grammatical here — the +four invitation adjectives are deliberately not reused from the approvals family, +because those agree with each pack's word for "request" (`ru` masculine `Отклонён`) +while an invitation needs its own agreement (`ru` neuter `Отклонено`). + +`scripts/i18n-call-site-key-baseline.json` is kept rather than deleted: empty is +its terminal, load-bearing state — against an empty baseline any NEW unresolved +call-site key is unexpected and fails the build. + +No component changed. diff --git a/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx b/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx index 42790061e..3dedfd476 100644 --- a/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx @@ -256,20 +256,16 @@ describe('objectui#3546 slice three — the auth / oauth / acceptInvitation name ); expect(stillBaselined).toEqual([]); // 163 before this slice, 54 removed — then slice four (console, 41 keys) took - // it to 68, slice five (marketplace + preview, 37 keys) to 31 and slice six - // (perm + home, 14 keys) to 17. The other namespaces' debt is not this - // slice's to spend, and this number is what catches a slice that overreaches; - // it moves once per slice, and only downwards. - expect(Object.keys(baseline.missingKeys).length).toBe(17); + // it to 68, slice five (marketplace + preview, 37 keys) to 31, slice six + // (perm + home, 14 keys) to 17 and slice seven (the 17-key residue) to ZERO. + // The counter moved once per slice and only downwards; at zero it stops being + // "how much is left" and becomes "nothing may be added back". + expect(Object.keys(baseline.missingKeys).length).toBe(0); // None of the template-key FAMILIES belonged to the auth family, so this slice // left all four. Slice four then took `console.ai.group.` (it is a `console` - // key) and slice five `marketplace.disclosure.runtime.`, leaving two. This - // assertion is what stops a later slice from thinking one of the remaining - // two was already handled. - expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([ - 'gantt.linkEnd.', - 'organization.invitations.status.', - ]); + // key), slice five `marketplace.disclosure.runtime.`, and slice seven the last + // two (`gantt.linkEnd.`, `organization.invitations.status.`). + expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([]); }); describe('through the real binding — bare useObjectTranslation, provider mounted', () => { diff --git a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx index c61ec8004..430bddf69 100644 --- a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx @@ -322,18 +322,19 @@ describe('objectui#3546 slice four — the console namespace', () => { }; expect(Object.keys(baseline.missingKeys).filter((k) => k.startsWith('console.'))).toEqual([]); // 109 before this slice, 41 removed — then slice five (marketplace + preview, - // 37 keys) took it to 31 and slice six (perm + home, 14 keys) to 17. The other - // namespaces' debt is not this slice's to spend; this number moves once per - // slice, and only downwards. - expect(Object.keys(baseline.missingKeys).length).toBe(17); - // The prefix family this slice handled is GONE from the ratchet, and the ones - // that remain are untouched — none of them belongs to `console`. Slice five - // then took `marketplace.disclosure.runtime.`, leaving two. - expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([ - 'gantt.linkEnd.', - 'organization.invitations.status.', - ]); - expect(Object.keys(baseline.missingPrefixes)).not.toContain('console.ai.group.'); + // 37 keys) took it to 31, slice six (perm + home, 14 keys) to 17 and slice + // seven (the 17-key residue) to ZERO. The counter moved once per slice and + // only downwards; at zero it stops being "how much is left" and becomes + // "nothing may be added back". + expect(Object.keys(baseline.missingKeys).length).toBe(0); + // The prefix family this slice handled is GONE from the ratchet. Slice five + // then took `marketplace.disclosure.runtime.` and slice seven the last two + // (`gantt.linkEnd.`, `organization.invitations.status.`), so the list is empty. + // The `not.toContain('console.ai.group.')` that used to sit here was dropped + // rather than kept: against an empty list it passes because nothing is + // produced, not because the logic holds. The set equality above is the + // stronger statement and it is not vacuous. + expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([]); }); describe('through the real binding — bare useObjectTranslation, provider mounted', () => { diff --git a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx index 799494848..f3fa89e5a 100644 --- a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx @@ -576,16 +576,17 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', ), ).toEqual([]); // 68 before this slice, 37 removed — then slice six (perm + home, 14 keys) - // took it to 17. The other namespaces' debt is not this slice's to spend; - // this number moves once per slice, and only downwards. - expect(Object.keys(baseline.missingKeys).length).toBe(17); - // The prefix family this slice handled is GONE from the ratchet, and the two - // that remain are untouched — neither belongs to these namespaces. - expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([ - 'gantt.linkEnd.', - 'organization.invitations.status.', - ]); - expect(Object.keys(baseline.missingPrefixes)).not.toContain('marketplace.disclosure.runtime.'); + // took it to 17 and slice seven (the 17-key residue) to ZERO. The counter + // moved once per slice and only downwards; at zero it stops being "how much + // is left" and becomes "nothing may be added back". + expect(Object.keys(baseline.missingKeys).length).toBe(0); + // The prefix family this slice handled is GONE from the ratchet, and slice + // seven took the last two (`gantt.linkEnd.`, + // `organization.invitations.status.`). The `not.toContain(…)` that used to sit + // below this line was dropped rather than kept: against an empty list it + // passes because nothing is produced, not because the logic holds. The set + // equality is the stronger statement and it is not vacuous. + expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([]); }); describe('through the real binding — bare useObjectTranslation, provider mounted', () => { diff --git a/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx b/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx index f3c0e42fa..30aa8db83 100644 --- a/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx @@ -257,17 +257,21 @@ describe('objectui#3546 slice two — the organization namespace', () => { missingPrefixes: Record; }; expect(Object.keys(baseline.missingKeys).filter((k) => k.startsWith('organization.'))).toEqual([]); - // Untouched on purpose: `organization.invitations.status.*` is a template - // key FAMILY, a different repair (enumerate the status values) than the 90 - // literal keys, and it is still missing. Deliberately left for the - // prefix-family slice — this assertion is what stops it being forgotten. - expect(Object.keys(baseline.missingPrefixes)).toContain('organization.invitations.status.'); + // `organization.invitations.status.*` was left untouched by THIS slice — a + // template key FAMILY needs a different repair (enumerate the status values) + // than the 90 literal keys — and the assertion here used to be + // `toContain(…)`, whose whole job was to stop the family being forgotten. + // Slice seven enumerated it (`StatusFilter` = all/pending/accepted/rejected/ + // canceled) and emptied `missingPrefixes`, so the assertion inverts: it now + // states the family is gone, which is the fact a reverting change breaks. + expect(Object.keys(baseline.missingPrefixes)).not.toContain('organization.invitations.status.'); + expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([]); // The other namespaces' debt is not this slice's to spend. Slice three // (auth/oauth/acceptInvitation, 54 keys) took it from 163 to 109, slice four - // (console, 41 keys) to 68, slice five (marketplace + preview, 37 keys) to 31 - // and slice six (perm + home, 14 keys) to 17; this number moves once per - // slice, and only downwards. - expect(Object.keys(baseline.missingKeys).length).toBe(17); + // (console, 41 keys) to 68, slice five (marketplace + preview, 37 keys) to 31, + // slice six (perm + home, 14 keys) to 17 and slice seven (the 17-key residue) + // to ZERO; this number moved once per slice, and only downwards. + expect(Object.keys(baseline.missingKeys).length).toBe(0); }); describe('through the real binding — bare useObjectTranslation, provider mounted', () => { diff --git a/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx b/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx index acdc260b3..5d34d66f8 100644 --- a/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx @@ -556,15 +556,14 @@ describe('objectui#3546 slice six — the perm and home namespaces', () => { expect( Object.keys(baseline.missingKeys).filter((k) => k.startsWith('perm.') || k.startsWith('home.')), ).toEqual([]); - // 31 before this slice, 14 removed. The other namespaces' debt is not this - // slice's to spend; this number moves once per slice, and only downwards. - expect(Object.keys(baseline.missingKeys).length).toBe(17); - // This slice owns NO prefix family — the two left belong to later slices, and - // neither is touched. - expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([ - 'gantt.linkEnd.', - 'organization.invitations.status.', - ]); + // 31 before this slice, 14 removed, leaving 17 — which slice seven (the + // residue: 17 keys plus both remaining prefix families) took to ZERO. The + // counter moved once per slice and only downwards; at zero it stops being + // "how much is left" and becomes "nothing may be added back". + expect(Object.keys(baseline.missingKeys).length).toBe(0); + // This slice owned NO prefix family — the two left belonged to slice seven, + // which enumerated both, so the list is now empty. + expect(Object.keys(baseline.missingPrefixes).sort()).toEqual([]); }); describe('through the real binding — provider mounted', () => { diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx new file mode 100644 index 000000000..ef82dabd7 --- /dev/null +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -0,0 +1,870 @@ +/** + * The last of objectui#3546 — slice seven, the **residue**: every entry that was + * still in `scripts/i18n-call-site-key-baseline.json`. After this slice both of + * the ratchet's lists are empty and `check-i18n-call-site-keys.mjs` reports + * **2320/2320** literal call-site keys resolving against `en`. + * + * ## What was left, precisely (measured, never hand-counted) + * + * `analyze()` from #3530's gate, run on the branch point: + * + * - **17 distinct keys at 23 call sites** — the first slice in this series + * where the two numbers differ by more than a rounding error. Five keys are + * shared: `common.retry` (3 sites), `common.record`, `common.editInStudio`, + * `detail.add` and `layout.systemNav.datasources` (2 each). Slice two + * predicted 90 and the truth was 93; the lesson is applied here by taking + * the call-site list from the script, not from the ratchet's line count. + * - **both remaining `missing-prefix` families**, 3 call sites: + * `gantt.linkEnd.` (1) and `organization.invitations.status.` (2). + * + * Nine namespaces, ten owning surfaces, spread across `app-shell`, + * `plugin-detail`, `plugin-dashboard`, `plugin-kanban` and `plugin-gantt` — the + * long tail that was never big enough to be its own slice. + * + * ## Severity: all English-visible, zero raw keys — and three different ways + * + * The 8 raw-key sites the issue's body named were slice one's (PR #3583), and + * `detail.viewSource` / `wizard.missingRequired` / `gantt.toolbar.refresh` have + * resolved in `en` ever since. Everything in THIS slice is the milder + * objectui#3517 class, but the inline English default takes three shapes, which + * is why the byte-equality test below is split three ways: + * + * 1. **22 sites / 16 keys** pass `t(key, { defaultValue: 'English' })`. + * 2. **1 site** (`dashboard.loading`) uses `useSafeTranslate`'s positional + * form, `tt(key, 'Loading…')` — a fallback, not a `defaultValue` property. + * A test that greps only for `defaultValue:` would call this site + * unprotected and be wrong about it. + * 3. **the two families' defaults are not literals at all**: + * `organization.invitations.status.` passes `{ defaultValue: tab }` — the + * enum member itself, CSS-capitalised by the element — and + * `gantt.linkEnd.` passes no default, relying on + * `useGanttTranslation`'s PER-KEY fallback map, which does contain both + * members. So a byte compare is structurally impossible for all seven, and + * what is pinned instead is the equivalence each one actually needs. + * + * Consequence for test design, as in slices two through six: **`en` output was + * already correct before the change**, so an `en` assertion cannot discriminate + * before from after. Every assertion that pins the fix is a non-`en` one; the + * `en` ones prove reachability and prove the wording did not move. + * + * ## The provider-less path is a different, filed defect + * + * Four of the owning files bind `createSafeTranslation` hooks + * (`useDetailTranslation`, `useKanbanT`) whose defaults maps do NOT list these + * keys, and `useSafeTranslate`/`useGanttTranslation` are per-call/per-key. With + * a provider mounted — the console — i18next answers, which is the path these + * assertions describe. Without one, `createSafeTranslation`'s `fallbackT` reads + * `defaults[key] || key` and never looks at the call site's inline + * `defaultValue`, so it renders the raw key: that is objectui#3865, filed by + * slice six, and it is not what this slice repairs. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '../provider'; +import { builtInLocales } from '../locales/index'; + +/** The 17 keys the guard measured as missing, in the ratchet's own order. */ +const MEASURED_KEYS = [ + 'common.done', + 'common.editInStudio', + 'common.record', + 'common.retry', + 'dashboard.loading', + 'detail.add', + 'detail.concurrentUpdateRecordLabel', + 'detail.deleted', + 'detail.historyEmpty', + 'empty.appNotAvailable', + 'empty.appNotAvailableDescription', + 'empty.interfacePageSourceMissing', + 'kanban.columns', + 'layout.systemNav.administration', + 'layout.systemNav.datasources', + 'layout.systemNav.documentation', + 'workspace.multiOrgDisabled', +] as const; + +/** + * `gantt.linkEnd.` — the value domain is the closed union `'start' | 'end'`, + * declared in GanttView itself (the `linkDrag` state type and the `endLabel` + * parameter). Same situation as slice four's `console.ai.group.` + * (`ConversationGroupKey` in the same file), and unlike slice five's + * `marketplace.disclosure.runtime.`, whose authority lives in the sibling repo's + * `packages/spec`. + */ +const LINK_END_MEMBERS = ['start', 'end'] as const; + +/** + * `organization.invitations.status.` — the value domain is `StatusFilter`, + * declared at the top of InvitationsPage. `all` is the filter tab's extra + * member; the other four are also `AuthInvitation.status` values. + */ +const STATUS_MEMBERS = ['all', 'pending', 'accepted', 'rejected', 'canceled'] as const; + +/** Every path this slice adds: 17 leaves + 2 + 5 family members = 24. */ +const KEYS = [ + ...MEASURED_KEYS, + ...LINK_END_MEMBERS.map((m) => `gantt.linkEnd.${m}`), + ...STATUS_MEMBERS.map((m) => `organization.invitations.status.${m}`), +]; + +const LANGS = Object.keys(builtInLocales); + +// ── the owning surfaces ────────────────────────────────────────────────────── +const INVITE_DIALOG = 'packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx'; +const PAGE_VIEW = 'packages/app-shell/src/views/PageView.tsx'; +const APP_CONTENT = 'packages/app-shell/src/console/AppContent.tsx'; +const INVITATIONS = 'packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx'; +const MEMBERS = 'packages/app-shell/src/console/organizations/manage/MembersPage.tsx'; +const DATASET_WIDGET = 'packages/plugin-dashboard/src/DatasetWidget.tsx'; +const RELATED_LIST = 'packages/plugin-detail/src/RelatedList.tsx'; +const SAVE_BAR = 'packages/plugin-detail/src/InlineEditSaveBar.tsx'; +const RECORD_DETAIL = 'packages/app-shell/src/views/RecordDetailView.tsx'; +const DETAIL_VIEW = 'packages/plugin-detail/src/DetailView.tsx'; +const INTERFACE_LIST = 'packages/app-shell/src/views/InterfaceListPage.tsx'; +const KANBAN = 'packages/plugin-kanban/src/KanbanImpl.tsx'; +const UNIFIED_SIDEBAR = 'packages/app-shell/src/layout/UnifiedSidebar.tsx'; +const APP_SIDEBAR = 'packages/app-shell/src/layout/AppSidebar.tsx'; +const CREATE_WORKSPACE = 'packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx'; +const GANTT_VIEW = 'packages/plugin-gantt/src/GanttView.tsx'; +const GANTT_HOOK = 'packages/plugin-gantt/src/useGanttTranslation.ts'; +const OCC_DIALOG = 'packages/plugin-detail/src/ConcurrentUpdateDialog.tsx'; + +const at = (pack: unknown, path: string): unknown => + path.split('.').reduce((n, k) => (n as Record | undefined)?.[k], pack); + +const wrapperFor = (lang: string) => + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; + +/** + * Read a component's source. `import.meta.url` is not a file: URL in the dom + * project, so resolve from the vitest root (which the invocation guard pins to + * the repo root) — and prove the read landed, or every assertion on it is + * vacuous. + */ +function sourceOf(rel: string): string { + const path = join(process.cwd(), rel); + expect(existsSync(path), `source not found at ${path}`).toBe(true); + return readFileSync(path, 'utf8'); +} + +/** What CSS `text-transform: capitalize` does to a single lowercase word. */ +const cssCapitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); + +beforeEach(() => { + // The provider persists the last language (objectstack#5406); without this a + // stale locale leaks into the `en` cases. + window.localStorage.clear(); +}); + +describe('objectui#3546 slice seven — the ratchet residue', () => { + it('covers all ten packs and all twenty-four paths (guards the loops from emptying)', () => { + expect(LANGS).toHaveLength(10); + expect(MEASURED_KEYS).toHaveLength(17); + expect(LINK_END_MEMBERS).toHaveLength(2); + expect(STATUS_MEMBERS).toHaveLength(5); + expect(KEYS).toHaveLength(24); + expect(new Set(KEYS).size).toBe(24); + // 17 measured leaves + 7 family members. Keeping the two counts apart here + // is what stops a later reader reading "24" off the ratchet, which held 17 + // key lines and 2 prefix lines. + const perNamespace = MEASURED_KEYS.reduce>((acc, k) => { + const ns = k.split('.')[0]; + acc[ns] = (acc[ns] ?? 0) + 1; + return acc; + }, {}); + expect(perNamespace).toEqual({ + common: 4, + dashboard: 1, + detail: 4, + empty: 3, + kanban: 1, + layout: 3, + workspace: 1, + }); + }); + + it.each(LANGS)('%s defines every path in this slice as a non-empty string', (lang) => { + for (const key of KEYS) { + const value = at(builtInLocales[lang], key); + expect(typeof value, `${lang}.${key}`).toBe('string'); + expect((value as string).trim().length, `${lang}.${key} is empty`).toBeGreaterThan(0); + } + }); + + it('the nine non-en packs carry real translations, not the English strings', () => { + // The failure this catches is a backfill that copy-pastes `en` into the other + // nine packs: full key parity, ten packs green, nine languages still reading + // English. `all-locales-key-parity.test.ts` cannot see it — it compares key + // sets and placeholder shape, never what a value SAYS. + // + // Exact set, not "few enough": 2 of 216 pairs. Both are `fr`, both are words + // French and English genuinely spell the same, and both sit in the system + // navigation next to `layout.systemNav.configuration`, which `fr` has spelled + // "Configuration" since before this slice. A third entry fails here and must + // be justified on this list the way slices two (12), three (18) and four (1) + // justified theirs. + const identical: string[] = []; + for (const lang of LANGS.filter((l) => l !== 'en')) { + for (const key of KEYS) { + if (at(builtInLocales[lang], key) === at(builtInLocales.en, key)) identical.push(`${lang} :: ${key}`); + } + } + expect(identical.sort()).toEqual([ + 'fr :: layout.systemNav.administration', + 'fr :: layout.systemNav.documentation', + ]); + // …and the neighbour that makes them ordinary rather than a slip. + expect(at(builtInLocales.fr, 'layout.systemNav.configuration')).toBe('Configuration'); + expect(at(builtInLocales.en, 'layout.systemNav.configuration')).toBe('Configuration'); + }); + + it('the two-entry cognate set is not vacuous — other packs did translate those two', () => { + // A set-equality assertion is also green when the packs are EMPTY (slice + // two's B1 lesson). The presence assertion above covers emptiness; this one + // covers the other way the set could be right for the wrong reason: if the + // two `fr` rows were a copy-paste of `en`, the same copy-paste would show up + // in the packs that CAN translate them, and it does not. + expect(at(builtInLocales.de, 'layout.systemNav.administration')).toBe('Verwaltung'); + expect(at(builtInLocales.ru, 'layout.systemNav.administration')).toBe('Администрирование'); + expect(at(builtInLocales.de, 'layout.systemNav.documentation')).toBe('Dokumentation'); + expect(at(builtInLocales.ar, 'layout.systemNav.documentation')).toBe('الوثائق'); + // Loanwords that are carried through and still differ from `en`, so "no more + // cognates" is a real zero rather than a pack dodging every English-looking + // token: `Studio` and `Datenquellen`/`データソース` all survive. + expect(at(builtInLocales.ja, 'common.editInStudio')).toBe('Studio で編集'); + expect(at(builtInLocales.ko, 'common.editInStudio')).toBe('Studio에서 편집'); + expect(at(builtInLocales.de, 'layout.systemNav.datasources')).toBe('Datenquellen'); + for (const [lang, key] of [ + ['ja', 'common.editInStudio'], + ['ko', 'common.editInStudio'], + ['de', 'layout.systemNav.datasources'], + ] as const) { + expect(at(builtInLocales[lang], key)).not.toBe(at(builtInLocales.en, key)); + } + }); + + it('exactly one path interpolates, and every pack carries the same hole', () => { + // A translator who drops `{{name}}` renders a sentence with the source name + // missing; one who invents a second hole renders braces verbatim. + // `all-locales-key-parity` compares placeholder shape too — this states the + // intended shape BY NAME so a wrong one is legible here. + // Deliberately two regexes: a `/g` one is stateful, and reusing it for + // `.test()` inside a `filter` silently skips every other match through + // `lastIndex` (slice five's own bug). + const HOLES = /\{\{\w+\}\}/g; + const HAS_HOLE = /\{\{\w+\}\}/; + const INTERPOLATED = ['empty.interfacePageSourceMissing']; + expect(KEYS.filter((k) => HAS_HOLE.test(at(builtInLocales.en, k) as string)).sort()).toEqual(INTERPOLATED); + for (const lang of LANGS) { + for (const key of KEYS) { + const holes = ((at(builtInLocales[lang], key) as string).match(HOLES) ?? []).join(','); + expect(holes, `${lang}.${key}`).toBe(INTERPOLATED.includes(key) ? '{{name}}' : ''); + } + } + }); + + it('the sixteen literal en values are byte-identical to their inline defaultValue', () => { + // Two paths must not diverge: with the pack present i18next answers, and + // before this slice the inline default did — a user must not be able to tell + // which ran. 16 keys here; `dashboard.loading` and the two families are the + // three shapes a byte compare cannot reach, each pinned in its own case. + const EXPECTED: Array<[key: string, source: string, value: string]> = [ + ['common.done', INVITE_DIALOG, 'Done'], + ['common.editInStudio', PAGE_VIEW, 'Edit in studio'], + ['common.record', APP_CONTENT, 'Record'], + ['common.retry', APP_CONTENT, 'Retry'], + ['detail.add', RELATED_LIST, 'Add'], + ['detail.concurrentUpdateRecordLabel', SAVE_BAR, 'this record'], + ['detail.deleted', RECORD_DETAIL, 'Record deleted'], + ['detail.historyEmpty', DETAIL_VIEW, 'No history yet'], + ['empty.appNotAvailable', APP_CONTENT, 'App not available'], + [ + 'empty.appNotAvailableDescription', + APP_CONTENT, + 'This app is not available yet — it may still be publishing. Try again in a moment.', + ], + [ + 'empty.interfacePageSourceMissing', + INTERFACE_LIST, + 'This interface page references "{{name}}", which is not available.', + ], + ['kanban.columns', KANBAN, 'columns'], + ['layout.systemNav.administration', UNIFIED_SIDEBAR, 'Administration'], + ['layout.systemNav.datasources', APP_SIDEBAR, 'Datasources'], + ['layout.systemNav.documentation', UNIFIED_SIDEBAR, 'Documentation'], + ['workspace.multiOrgDisabled', CREATE_WORKSPACE, 'Creating new organizations is disabled on this instance.'], + ]; + expect(EXPECTED).toHaveLength(16); + const cache = new Map(); + for (const [key, rel, value] of EXPECTED) { + if (!cache.has(rel)) cache.set(rel, sourceOf(rel)); + expect(at(builtInLocales.en, key), `en ${key}`).toBe(value); + // the premise: the call site still passes exactly this defaultValue + expect(cache.get(rel), `${key}'s defaultValue moved`).toContain(`defaultValue: '${value}'`); + } + }); + + it('the five multi-site keys really are one string at every one of their sites', () => { + // 17 keys, 23 call sites. A key used twice with two different inline defaults + // would make the byte-equality above true at one site and false at the other, + // and no single-site check could see it. Both spellings are asserted at both + // files, so a divergence introduced later fails here. + const MULTI: Array<[key: string, value: string, files: string[]]> = [ + ['common.editInStudio', 'Edit in studio', [PAGE_VIEW]], + ['common.record', 'Record', [APP_CONTENT]], + ['common.retry', 'Retry', [APP_CONTENT, INVITATIONS, MEMBERS]], + ['detail.add', 'Add', [RELATED_LIST]], + ['layout.systemNav.datasources', 'Datasources', [APP_SIDEBAR, UNIFIED_SIDEBAR]], + ]; + for (const [key, value, files] of MULTI) { + for (const rel of files) { + const src = sourceOf(rel); + const occurrences = src.split(`t('${key}', { defaultValue: '${value}' })`).length - 1; + expect(occurrences, `${key} in ${rel}`).toBeGreaterThan(0); + } + } + // PageView uses it twice on the same button (title + aria-label) and + // RelatedList twice in two different affordances — the counts are pinned so a + // silent drop of one of them is visible. + expect(sourceOf(PAGE_VIEW).split("t('common.editInStudio', { defaultValue: 'Edit in studio' })").length - 1).toBe(2); + expect(sourceOf(RELATED_LIST).split("t('detail.add', { defaultValue: 'Add' })").length - 1).toBe(2); + expect(sourceOf(APP_CONTENT).split("t('common.record', { defaultValue: 'Record' })").length - 1).toBe(2); + }); + + it('dashboard.loading matches useSafeTranslate positional fallback, not a defaultValue', () => { + // Shape two. `useSafeTranslate()` returns `t(keyOrKeys, fallback)` — the + // English default is the SECOND POSITIONAL ARGUMENT, so a sweep that greps + // for `defaultValue:` would report this site as having no fallback and be + // wrong. The equivalence is the same one: pack value === the fallback the + // call site would otherwise have rendered. + const src = sourceOf(DATASET_WIDGET); + expect(src, 'the sr-only loading announcement moved').toContain("tt('dashboard.loading', 'Loading…')"); + expect(src).toContain('const tt = useSafeTranslate();'); + expect(at(builtInLocales.en, 'dashboard.loading')).toBe('Loading…'); + // …and the hook really does return the fallback per key, not per provider. + const hook = sourceOf('packages/i18n/src/useSafeTranslation.ts'); + expect(hook).toContain('export function useSafeTranslate()'); + expect(hook).toContain('if (v && v !== key) return v;'); + // U+2026, not three ASCII dots. `common.loading` is the older `'Loading...'` + // spelling and is a DIFFERENT string, so it is deliberately not reused here. + expect(at(builtInLocales.en, 'dashboard.loading')).not.toBe(at(builtInLocales.en, 'common.loading')); + expect(at(builtInLocales.en, 'common.loading')).toBe('Loading...'); + }); + + describe('gantt.linkEnd. — the first prefix family', () => { + it('the key surface is exactly the closed union declared in GanttView', () => { + // Key reachability, asserted on the KEY set — not on a value verdict. A + // fourth endpoint kind added to `linkDrag` fails here, which is exactly the + // job the ratchet's prefix entry used to do, moved into a test. + const family = at(builtInLocales.en, 'gantt.linkEnd') as Record; + expect(Object.keys(family).sort()).toEqual([...LINK_END_MEMBERS].sort()); + const src = sourceOf(GANTT_VIEW); + // the premise: both the state type and the label helper still say start|end + expect(src, 'the linkDrag endpoint union moved').toContain("sourceEnd: 'start' | 'end';"); + expect(src).toContain("targetEnd: 'start' | 'end' | null;"); + expect(src, 'the template call moved').toContain( + "const endLabel = (e: 'start' | 'end') => t(`gantt.linkEnd.${e}`);", + ); + // `targetEnd` is nullable and the call site collapses null to 'start', so + // the union really is two members at the call site too. + expect(src).toContain("endLabel(linkDrag.targetEnd ?? 'start')"); + for (const lang of LANGS) { + expect(Object.keys(at(builtInLocales[lang], 'gantt.linkEnd') as object).sort(), `${lang}`).toEqual([ + ...LINK_END_MEMBERS, + ].sort()); + } + }); + + it('the en values are byte-identical to the hook per-key fallback map', () => { + // Value verdict, asserted separately from the key surface (PR #3546 slice + // five wrote this distinction down). This call site passes NO + // `defaultValue`: `useGanttTranslation` asks the host first and falls back + // to its own map PER KEY, so the map is what rendered before this slice and + // i18next is what renders now. If the two disagreed, the drag hint would + // change wording for English users, which this slice must not do. + const hook = sourceOf(GANTT_HOOK); + expect(hook).toContain("'gantt.linkEnd.start': 'start',"); + expect(hook).toContain("'gantt.linkEnd.end': 'end',"); + expect(at(builtInLocales.en, 'gantt.linkEnd.start')).toBe('start'); + expect(at(builtInLocales.en, 'gantt.linkEnd.end')).toBe('end'); + // the premise for "the map is what rendered": per-key, host-first. + expect(hook).toContain('const hostValue = result.t(key, options as never) as unknown;'); + expect(hook).toContain("if (typeof hostValue === 'string' && hostValue !== key) return hostValue;"); + }); + + it('the nine packs take their endpoint words from gantt own link vocabulary', () => { + // Concept adjacency: `gantt.linkType.*` names the same two endpoints + // ("Finish → Start"), and `de` deliberately says Anfang there rather than + // the `gantt.column.start` label "Start" — so the link family, not the + // column family, is the neighbour. `en` is lowercase because the words land + // inside parentheses mid-hint; German capitalises nouns regardless, which + // is correct German and not a copy of `en`. + expect(at(builtInLocales.de, 'gantt.linkType.fs')).toBe('Ende → Anfang'); + expect(at(builtInLocales.de, 'gantt.linkEnd.start')).toBe('Anfang'); + expect(at(builtInLocales.de, 'gantt.linkEnd.end')).toBe('Ende'); + expect(at(builtInLocales.zh, 'gantt.linkType.fs')).toBe('完成 → 开始'); + expect(at(builtInLocales.zh, 'gantt.linkEnd.start')).toBe('开始'); + expect(at(builtInLocales.ar, 'gantt.linkType.fs')).toBe('نهاية → بداية'); + expect(at(builtInLocales.ar, 'gantt.linkEnd.start')).toBe('بداية'); + expect(at(builtInLocales.ar, 'gantt.linkEnd.end')).toBe('نهاية'); + }); + }); + + describe('organization.invitations.status. — the second prefix family', () => { + it('the key surface is exactly StatusFilter', () => { + const family = at(builtInLocales.en, 'organization.invitations.status') as Record; + expect(Object.keys(family).sort()).toEqual([...STATUS_MEMBERS].sort()); + const src = sourceOf(INVITATIONS); + // the premise: the union and the tab list still spell these five + expect(src, 'StatusFilter moved').toContain( + "type StatusFilter = 'all' | 'pending' | 'accepted' | 'rejected' | 'canceled';", + ); + expect(src).toContain( + "const tabs: StatusFilter[] = ['all', 'pending', 'accepted', 'rejected', 'canceled'];", + ); + expect(src, 'the tab template call moved').toContain( + "t(`organization.invitations.status.${tab}`, { defaultValue: tab })", + ); + expect(src, 'the badge template call moved').toContain( + "t(`organization.invitations.status.${inv.status}`, { defaultValue: inv.status })", + ); + for (const lang of LANGS) { + expect( + Object.keys(at(builtInLocales[lang], 'organization.invitations.status') as object).sort(), + `${lang}`, + ).toEqual([...STATUS_MEMBERS].sort()); + } + }); + + it('en renders exactly what the CSS-capitalised wire value rendered', () => { + // The equivalence that replaces a byte compare for shape three. The + // `defaultValue` here is the enum member itself, and BOTH call sites put + // `capitalize` on the element, so what a user saw was `Pending`, not + // `pending`. `en` therefore has to be the capitalised member, exactly — one + // letter of drift and this slice would have changed the English. + for (const member of STATUS_MEMBERS) { + expect( + at(builtInLocales.en, `organization.invitations.status.${member}`), + `en status.${member}`, + ).toBe(cssCapitalize(member)); + } + // the premise: both elements still carry `capitalize` + const src = sourceOf(INVITATIONS); + expect(src, 'the filter tab lost its capitalize class').toContain('transition-colors capitalize'); + expect(src, 'the status badge lost its capitalize class').toContain('className="shrink-0 capitalize"'); + // Single words, so `text-transform: capitalize` is exactly "upcase the + // first letter" — pinned, because the CSS rule capitalises EVERY word and a + // future two-word status would not round-trip through this model. + for (const member of STATUS_MEMBERS) expect(member).not.toContain(' '); + }); + + it('the four invitation adjectives agree with each pack own word for "invitation"', () => { + // Deliberately NOT reused from the `approvals*` family even though `en` has + // byte-identical rows there, and the reason is grammatical rather than + // stylistic: those rows agree with each pack's word for "request", and this + // family describes an INVITATION. `ru` is the clearest case — + // `approvalsInbox.statusRejected` is the masculine `Отклонён` (запрос), + // while приглашение is neuter and needs `Отклонено`. Reusing would have + // shipped a grammar error that key parity and the cognate set both pass. + expect(at(builtInLocales.en, 'approvalsInbox.statusRejected')).toBe('Rejected'); + expect(at(builtInLocales.ru, 'approvalsInbox.statusRejected')).toBe('Отклонён'); + expect(at(builtInLocales.ru, 'organization.invitations.status.rejected')).toBe('Отклонено'); + expect(at(builtInLocales.ru, 'organization.accept.declined')).toBe('Приглашение отклонено'); + // fr/es inflect feminine (invitation / invitación), pt masculine (convite) — + // taken from the same-namespace toasts, which is where each pack already + // committed to a gender. + expect(at(builtInLocales.fr, 'organization.accept.accepted')).toBe('Invitation acceptée'); + expect(at(builtInLocales.fr, 'organization.invitations.status.accepted')).toBe('Acceptée'); + expect(at(builtInLocales.es, 'organization.accept.accepted')).toBe('Invitación aceptada'); + expect(at(builtInLocales.es, 'organization.invitations.status.accepted')).toBe('Aceptada'); + expect(at(builtInLocales.pt, 'organization.accept.accepted')).toBe('Convite aceito'); + expect(at(builtInLocales.pt, 'organization.invitations.status.accepted')).toBe('Aceito'); + // …and `all`, the filter tab's own member, follows the same gender: the + // noun it quantifies is "invitations". + expect(at(builtInLocales.fr, 'organization.invitations.status.all')).toBe('Toutes'); + expect(at(builtInLocales.es, 'organization.invitations.status.all')).toBe('Todas'); + expect(at(builtInLocales.pt, 'organization.invitations.status.all')).toBe('Todos'); + // `canceled` is the ORG withdrawing the invitation, not the invitee + // declining it, so de takes `zurückgezogen` from its own cancel toast + // rather than the generic `Abgebrochen`. + expect(at(builtInLocales.de, 'organization.invitations.canceled')).toBe('Einladung zurückgezogen'); + expect(at(builtInLocales.de, 'organization.invitations.status.canceled')).toBe('Zurückgezogen'); + }); + }); + + it('reused strings are the neighbours own translations, not second renderings', () => { + // Where this slice's `en` string already existed verbatim elsewhere AND the + // neighbour's translation is grammatically usable here, the existing row is + // reused rather than re-translated, so one English string never renders as + // two different sentences in the same language. A drift on either side fails + // here. + const REUSED: Array<[newKey: string, neighbour: string]> = [ + ['common.done', 'view.done'], + ['common.record', 'home.recentApps.itemType.record'], + ['common.retry', 'lookup.retry'], + ['dashboard.loading', 'lookup.loading'], + ['detail.add', 'report.editor.fieldPickerAdd'], + // The one family member that IS reused: `Pending` is rendered by a verb or + // an invariant adjective in every pack (`ru` "Ожидает"), so unlike the + // other four it carries no gender to disagree with. + ['organization.invitations.status.pending', 'grid.import.jobStatus.pending'], + ]; + for (const [newKey, neighbour] of REUSED) { + // the premise: the two really are the same English string + expect(at(builtInLocales.en, newKey), `en ${newKey} vs ${neighbour}`).toBe( + at(builtInLocales.en, neighbour), + ); + for (const lang of LANGS) { + expect(at(builtInLocales[lang], newKey), `${lang} ${newKey} diverged from ${neighbour}`).toBe( + at(builtInLocales[lang], neighbour), + ); + } + } + // Recorded because it looks like an omission and is not: two other rows also + // say `Done`/`Pending` in `en` and already disagree with the chosen neighbour + // in one pack each — `grid.bulk.done` is es "Hecho" against "Listo", and + // `approvalsInbox.statusPending` is zh 待审批 ("awaiting approval"), which is + // wrong for an invitation. So the repo ALREADY renders these English strings + // more than one way, on purpose, and picking a neighbour is a choice that has + // to be made rather than derived. + expect(at(builtInLocales.en, 'grid.bulk.done')).toBe('Done'); + expect(at(builtInLocales.es, 'grid.bulk.done')).toBe('Hecho'); + expect(at(builtInLocales.es, 'view.done')).toBe('Listo'); + expect(at(builtInLocales.zh, 'approvalsInbox.statusPending')).toBe('待审批'); + expect(at(builtInLocales.zh, 'organization.invitations.status.pending')).toBe('等待中'); + }); + + it('the concept neighbours are the ones this slice claims, and they still say so', () => { + // "Find the neighbour by CONCEPT, not by bytes" (slice five's blind spot, + // slice six's rule). Each premise is asserted next to the value it justified, + // so a neighbour that moves takes this with it. + + // `Administration` — the strongest neighbour in the whole slice: a sibling + // key NAMES this very menu, per pack, in a sentence. + expect(at(builtInLocales.en, 'home.welcomeAdminDescriptionNoAi')).toBe( + 'Set up your first application from the Administration menu on the left.', + ); + expect(at(builtInLocales.zh, 'home.welcomeAdminDescriptionNoAi')).toContain('「管理」菜单'); + expect(at(builtInLocales.zh, 'layout.systemNav.administration')).toBe('管理'); + expect(at(builtInLocales.de, 'home.welcomeAdminDescriptionNoAi')).toContain('Verwaltungsmenü'); + expect(at(builtInLocales.de, 'layout.systemNav.administration')).toBe('Verwaltung'); + expect(at(builtInLocales.ru, 'home.welcomeAdminDescriptionNoAi')).toContain('«Администрирование»'); + expect(at(builtInLocales.ru, 'layout.systemNav.administration')).toBe('Администрирование'); + + // `Datasources` — the plural nav label; the singular concept is already + // translated as a field label, and the sibling nav entries are plural. + expect(at(builtInLocales.en, 'report.editor.objectName')).toBe('Data source'); + expect(at(builtInLocales.ru, 'report.editor.objectName')).toBe('Источник данных'); + expect(at(builtInLocales.ru, 'layout.systemNav.datasources')).toBe('Источники данных'); + expect(at(builtInLocales.ru, 'layout.systemNav.organizations')).toBe('Организации'); + + // `Documentation` — the help menu already has it, twice. + expect(at(builtInLocales.en, 'help.onlineDocs')).toBe('Online documentation'); + expect(at(builtInLocales.ja, 'help.onlineDocs')).toBe('オンラインドキュメント'); + expect(at(builtInLocales.ja, 'layout.systemNav.documentation')).toBe('ドキュメント'); + + // `Record deleted` — structural twin: " deleted" as a success toast. + expect(at(builtInLocales.en, 'organization.settings.deleted')).toBe('Organization deleted'); + expect(at(builtInLocales.ja, 'organization.settings.deleted')).toBe('組織を削除しました'); + expect(at(builtInLocales.ja, 'detail.deleted')).toBe('レコードを削除しました'); + expect(at(builtInLocales.ru, 'organization.settings.deleted')).toBe('Организация удалена'); + expect(at(builtInLocales.ru, 'detail.deleted')).toBe('Запись удалена'); + + // `No history yet` — the "No X yet" empty-state family, plus each pack's own + // word for History (ko says 기록, not 히스토리). + expect(at(builtInLocales.en, 'detail.noCommentsYet')).toBe('No comments yet'); + expect(at(builtInLocales.ko, 'detail.noCommentsYet')).toBe('아직 댓글이 없습니다'); + expect(at(builtInLocales.ko, 'detail.history')).toBe('기록'); + expect(at(builtInLocales.ko, 'detail.historyEmpty')).toBe('아직 기록이 없습니다'); + + // `Edit in studio` — " in Studio", with each pack's own Edit verb. The + // product name stays Latin in all ten, which is why `Studio` shows up in the + // loanword check above. + expect(at(builtInLocales.en, 'topbar.designInStudio')).toBe('Design in Studio'); + expect(at(builtInLocales.zh, 'topbar.designInStudio')).toBe('在 Studio 中设计'); + expect(at(builtInLocales.zh, 'common.edit')).toBe('编辑'); + expect(at(builtInLocales.zh, 'common.editInStudio')).toBe('在 Studio 中编辑'); + + // `on this instance` — ru and ar both render instance/deployment with their + // own environment word, and have done so consistently. + expect(at(builtInLocales.en, 'auth.login.devAdminHint.title')).toBe('Development instance'); + expect(at(builtInLocales.ru, 'approvalsInbox.recallUnavailable')).toBe('Отзыв недоступен в этой среде.'); + expect(at(builtInLocales.ru, 'workspace.multiOrgDisabled')).toContain('в этой среде'); + expect(at(builtInLocales.ar, 'connectAgent.disabled.title')).toBe('MCP معطّل في هذا النشر'); + expect(at(builtInLocales.ar, 'workspace.multiOrgDisabled')).toContain('في هذا النشر'); + // …and the "X ist disabled" sentence shape comes from the pack's own + // `gantt.readOnlyHint`, not from a fresh construction. + expect(at(builtInLocales.de, 'gantt.readOnlyHint')).toBe('Die Bearbeitung ist in dieser Ansicht deaktiviert.'); + expect(at(builtInLocales.de, 'workspace.multiOrgDisabled')).toContain('ist auf dieser Instanz deaktiviert'); + }); + + it('kanban.columns is a bare unit word and follows the repo one precedent for that', () => { + // The call site concatenates: `` `${boardColumns.length} ${t('kanban.columns')}` ``, so + // the pack supplies a UNIT, not a sentence — the same structure as + // `preview.history.items` (slice five, which had to be corrected once for + // exactly this reason). `en` is plural-only and that is safe here: the empty + // state only renders when `boardColumns.length > 1`, so the count is never 1 + // and no plural family is needed. + const src = sourceOf(KANBAN); + expect(src, 'the columns count label moved').toContain( + "description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`}", + ); + expect(src, 'the >1 guard moved — a plural family would now be required').toContain( + 'const isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1;', + ); + // The precedent's shape, per pack: unit word only, no counter particle, since + // the call site already inserts the space and the number. + expect(at(builtInLocales.en, 'preview.history.items')).toBe('item(s)'); + expect(at(builtInLocales.ko, 'preview.history.items')).toBe('항목'); + expect(at(builtInLocales.ru, 'preview.history.items')).toBe('элементов'); + // …and the WORD comes from kanban's own column vocabulary, which is not the + // table's: ja says カラム here and 列 in `table.columns`, ru колонка against + // столбец. + expect(at(builtInLocales.ja, 'kanban.addColumn')).toBe('カラムを追加'); + expect(at(builtInLocales.ja, 'table.columns')).toBe('列'); + expect(at(builtInLocales.ja, 'kanban.columns')).toBe('カラム'); + expect(at(builtInLocales.ru, 'kanban.addColumn')).toBe('Добавить колонку'); + expect(at(builtInLocales.ru, 'kanban.columns')).toBe('колонок'); + expect(at(builtInLocales.ko, 'kanban.columns')).toBe('열'); + }); + + it('detail.concurrentUpdateRecordLabel is grammatical in the sentence that embeds it', () => { + // This value is not a label on its own: `ConcurrentUpdateDialog` splits + // `detail.concurrentUpdateDescription` on `{{field}}` and renders it bolded in + // the gap, so the pack value has to fit whatever case/preposition precedes + // the hole. `de` needs the DATIVE (…Version **von** {{field}}) where its + // sibling `detail.deleteConfirmation` uses the accusative, and `ru` needs the + // GENITIVE (…версию {{field}}). This is why the value is not simply the + // pack's word for "record". + const dialog = sourceOf(OCC_DIALOG); + expect(dialog).toContain("const descriptionTemplate = t('detail.concurrentUpdateDescription', { field: '{{field}}' });"); + expect(dialog).toContain("const [beforeField, afterField] = descriptionTemplate.split('{{field}}');"); + expect(dialog).toContain("const fieldLabel = conflict?.label || conflict?.field || '';"); + expect(sourceOf(SAVE_BAR)).toContain("label: t('detail.concurrentUpdateRecordLabel', { defaultValue: 'this record' }),"); + + const composed = (lang: string) => + (at(builtInLocales[lang], 'detail.concurrentUpdateDescription') as string).replace( + '{{field}}', + at(builtInLocales[lang], 'detail.concurrentUpdateRecordLabel') as string, + ); + expect(composed('de')).toContain('eine neuere Version von diesem Datensatz gespeichert'); + expect(at(builtInLocales.de, 'detail.deleteConfirmation')).toContain('diesen Datensatz'); + expect(composed('ru')).toContain('более новую версию этой записи'); + expect(composed('fr')).toContain("une version plus récente de cet enregistrement"); + // `pt` is the one pack where the embedded phrase is NOT idiomatic and cannot + // be fixed from this leaf: Portuguese contracts de + este into "deste", but + // the "de " lives in the surrounding sentence, which is an EXISTING pack value + // this slice does not touch. Pinned as the current truth rather than left for + // the next reader to mistake for an oversight; filed separately, and the fix + // is to rephrase pt's `concurrentUpdateDescription` so the hole is not + // preceded by a bare preposition. + expect(composed('pt')).toContain('mais recente de este registro'); + expect(at(builtInLocales.pt, 'detail.concurrentUpdateDescription')).toContain('mais recente de {{field}}'); + }); + + it('each pack keeps its own typography in the one long sentence of this slice', () => { + // The habits a copy-paste from `en` or a machine translation breaks first, + // checked on the only multi-clause value here plus across all 24 paths where + // the rule is pack-wide. + const KEY = 'empty.appNotAvailableDescription'; + // en's em dash is U+2014; zh writes the doubled `——` its own pack prefers + // (45 values against 25 single), everyone else mirrors en. + for (const lang of LANGS) { + const value = at(builtInLocales[lang], KEY) as string; + expect(value.includes(lang === 'zh' ? '——' : '—'), `${lang} ${KEY} dash`).toBe(true); + } + // fr writes the straight apostrophe U+0027 (502 values against 21 curly) — + // across every path, not just this one. + for (const key of KEYS) { + expect((at(builtInLocales.fr, key) as string).includes('’'), `fr ${key} used a curly apostrophe`).toBe(false); + } + expect(at(builtInLocales.fr, KEY)).toContain("n'est pas encore disponible"); + // ru writes ё (163 values in the pack). + expect(at(builtInLocales.ru, KEY)).toContain('ещё'); + // The quote style around `{{name}}` follows the sibling empty-state value in + // the SAME pack: zh curly, ja corner brackets, the rest ASCII. + expect(at(builtInLocales.zh, 'empty.objectNotFoundDescription')).toContain('“{{name}}”'); + expect(at(builtInLocales.zh, 'empty.interfacePageSourceMissing')).toContain('“{{name}}”'); + expect(at(builtInLocales.ja, 'empty.objectNotFoundDescription')).toContain('「{{name}}」'); + expect(at(builtInLocales.ja, 'empty.interfacePageSourceMissing')).toContain('「{{name}}」'); + for (const lang of ['ko', 'fr', 'es', 'pt', 'ru', 'ar'] as const) { + expect(at(builtInLocales[lang], 'empty.interfacePageSourceMissing'), `${lang} quotes`).toContain('"{{name}}"'); + } + // de is the deliberate divergence: its sibling values pair the German opening + // low quote with an ASCII straight quote (20 values do this, measured), which + // is a typo rather than a convention, so this slice writes the correctly + // paired „…“ and the mismatch is filed instead of copied. + expect(at(builtInLocales.de, 'empty.objectNotFoundDescription')).toContain('„{{name}}"'); + expect(at(builtInLocales.de, 'empty.interfacePageSourceMissing')).toContain('„{{name}}“'); + }); + + it('the ratchet is empty — this is the terminal state, not a partial one', () => { + // `scripts/i18n-call-site-key-baseline.json` fails the build both ways: an + // unfixed key missing from it, AND a fixed key still listed. With both lists + // empty, any NEW unresolved call-site key is `unexpected` and fails — which + // is what makes the empty file load-bearing rather than dead weight. + const baselinePath = join(process.cwd(), 'scripts/i18n-call-site-key-baseline.json'); + expect(existsSync(baselinePath), `baseline not found at ${baselinePath}`).toBe(true); + const raw = readFileSync(baselinePath, 'utf8'); + const baseline = JSON.parse(raw) as { + note: string[]; + missingKeys: Record; + missingPrefixes: Record; + }; + // 258 keys and 4 prefix families at the start (main@a2c8f2a29), across seven + // slices: 253 → 163 → 109 → 68 → 31 → 17 → 0, and 4 → 4 → 3 → 2 → 2 → 0. + expect(Object.keys(baseline.missingKeys)).toEqual([]); + expect(Object.keys(baseline.missingPrefixes)).toEqual([]); + // Not deleted, and the file says why — a reader who finds two empty objects + // must not conclude the ratchet is obsolete. + expect(baseline.note.join(' ')).toContain('BOTH LISTS ARE NOW EMPTY'); + expect(baseline.note.join(' ')).toContain('fails the build'); + // Every key this slice removed now resolves, which is the other half of the + // same statement: the entries went because the defect went. + for (const key of KEYS) expect(typeof at(builtInLocales.en, key), `en ${key}`).toBe('string'); + }); + + describe('through the real binding — provider mounted', () => { + /** One key per owning surface. */ + const SAMPLE: Array<[key: string, owner: string]> = [ + ['common.done', 'InviteMemberDialog (invitation created footer)'], + ['common.editInStudio', 'PageView (edit affordance title/aria-label)'], + ['empty.appNotAvailable', 'AppContent (requested app missing)'], + ['detail.historyEmpty', 'DetailView (history tab)'], + ['kanban.columns', 'KanbanImpl (empty board)'], + ['layout.systemNav.administration', 'UnifiedSidebar (admin cluster)'], + ['workspace.multiOrgDisabled', 'CreateWorkspaceDialog (submit guard)'], + ['gantt.linkEnd.start', 'GanttView (link drag hint)'], + ['organization.invitations.status.pending', 'InvitationsPage (filter tab + badge)'], + ]; + + it('every owning file still binds t the way this suite assumes', () => { + // The premise of mounting a provider, per family of binding — asserted, not + // assumed. Nine files take i18next directly; four go through a + // `createSafeTranslation` hook that hands i18next's `t` over once its probe + // key resolves (which it does, since the probe keys are in the packs); one + // uses the per-call `useSafeTranslate`; one uses gantt's per-key wrapper. + for (const rel of [ + INVITE_DIALOG, + PAGE_VIEW, + APP_CONTENT, + INVITATIONS, + MEMBERS, + INTERFACE_LIST, + UNIFIED_SIDEBAR, + APP_SIDEBAR, + CREATE_WORKSPACE, + ]) { + expect(sourceOf(rel), `${rel} no longer binds the pack hook`).toContain('useObjectTranslation'); + expect(sourceOf(rel), `${rel} stopped destructuring t`).toContain('const { t } = useObjectTranslation();'); + } + // RecordDetailView takes it from @object-ui/react's re-export, with language. + expect(sourceOf(RECORD_DETAIL)).toContain('const { t, language } = useObjectTranslation();'); + // plugin-detail's three go through useDetailTranslation… + for (const rel of [RELATED_LIST, SAVE_BAR, DETAIL_VIEW]) { + expect(sourceOf(rel), `${rel}`).toContain("import { useDetailTranslation } from './useDetailTranslation'"); + expect(sourceOf(rel), `${rel}`).toContain('const { t } = useDetailTranslation();'); + } + // …and kanban through its own createSafeTranslation, whose probe key IS in + // the packs, so the provider path wins. Its defaults map does not list + // `kanban.columns`, which is the provider-LESS defect objectui#3865 owns. + const kanban = sourceOf(KANBAN); + expect(kanban).toContain('const useKanbanT = createSafeTranslation('); + expect(kanban).toContain("'kanban.noCards',"); + expect(kanban).not.toContain("'kanban.columns':"); + expect(typeof at(builtInLocales.en, 'kanban.noCards')).toBe('string'); + // gantt's wrapper is per-key rather than probe-based (see its own header). + expect(sourceOf(GANTT_VIEW)).toContain('const { t, language } = useGanttTranslation();'); + }); + + it.each(['en', 'zh'])('%s resolves every sampled key from the pack', (lang) => { + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + for (const [key, owner] of SAMPLE) { + const value = result.current.t(key); + expect(value, `${lang} ${owner} rendered the raw key for ${key}`).not.toBe(key); + expect(value, `${lang}.${key}`).toBe(at(builtInLocales[lang], key)); + } + }); + + it('zh is Chinese — the half that was red before the backfill', () => { + // Pre-fix each of these returned the inline English default in a zh session. + // That is the whole defect, and only a non-en assertion sees it. + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('zh') }); + const { t } = result.current; + expect(t('common.editInStudio')).toBe('在 Studio 中编辑'); + expect(t('empty.appNotAvailable')).toBe('应用不可用'); + expect(t('empty.appNotAvailableDescription')).toBe('此应用尚不可用 —— 可能仍在发布中。请稍后重试。'); + expect(t('empty.interfacePageSourceMissing', { name: 'crm_lead' })).toBe( + '此界面页引用了 “crm_lead”,但该来源不可用。', + ); + expect(t('layout.systemNav.administration')).toBe('管理'); + expect(t('workspace.multiOrgDisabled')).toBe('此实例已禁用创建新组织。'); + expect(t('kanban.columns')).toBe('列'); + expect(t('detail.deleted')).toBe('记录已删除'); + }); + + it('both template families render every member, in en and in zh', () => { + // Exercised the way the call sites build the key, so a family that resolves + // in the abstract but not through the template would still fail. + for (const lang of ['en', 'zh'] as const) { + window.localStorage.clear(); + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + for (const member of LINK_END_MEMBERS) { + const key = `gantt.linkEnd.${member}`; + expect(result.current.t(key), `${lang} ${key}`).toBe(at(builtInLocales[lang], key)); + expect(result.current.t(key)).not.toBe(key); + } + for (const member of STATUS_MEMBERS) { + const key = `organization.invitations.status.${member}`; + expect(result.current.t(key), `${lang} ${key}`).toBe(at(builtInLocales[lang], key)); + expect(result.current.t(key)).not.toBe(key); + } + } + // The gantt drag hint, composed the way GanttView composes it. + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('zh') }); + const endLabel = (e: 'start' | 'end') => result.current.t(`gantt.linkEnd.${e}`); + expect(`设计评审 (${endLabel('end')}) → 上线 (${endLabel('start')})`).toBe('设计评审 (结束) → 上线 (开始)'); + }); + + it.each([ + ['de', 'workspace.multiOrgDisabled', 'Das Erstellen neuer Organisationen ist auf dieser Instanz deaktiviert.'], + ['fr', 'detail.historyEmpty', 'Aucun historique pour le moment'], + ['es', 'organization.invitations.status.canceled', 'Cancelada'], + ['pt', 'empty.appNotAvailable', 'Aplicativo indisponível'], + ['ru', 'layout.systemNav.datasources', 'Источники данных'], + ['ja', 'detail.deleted', 'レコードを削除しました'], + ['ko', 'common.editInStudio', 'Studio에서 편집'], + ['ar', 'layout.systemNav.documentation', 'الوثائق'], + ])('%s renders a user-visible string from the pack', (lang, key, expected) => { + // One pinned surface per remaining pack, across four writing systems, so a + // pack that silently reverts to English is caught by name and not only by + // the aggregate above. + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); + expect(result.current.t(key)).toBe(expected); + }); + + it('the ar pack does not open an RTL string with a Latin token', () => { + // Same rule slices three through six applied. `MCP`/`Studio`-style Latin + // runs are allowed inside a string, never at its head, and the one + // interpolated path is checked after substitution too — a hole at position + // zero would put the caller's Latin value first. + const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('ar') }); + for (const key of KEYS) { + const value = result.current.t(key, { name: 'crm_lead' }); + expect(/^[A-Za-z]/.test(value), `${key} starts with a Latin token: ${value}`).toBe(false); + } + expect(result.current.t('common.editInStudio')).toBe('التعديل في Studio'); + expect(result.current.t('empty.interfacePageSourceMissing', { name: 'crm_lead' })).toContain('"crm_lead"'); + }); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 612f23ec7..f49f8cd00 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -109,6 +109,10 @@ const ar = { itemCountOne: "{{count}} عنصر", toggleSidebar: "تبديل الشريط الجانبي", package: "الحزمة", + done: "تم", + editInStudio: "التعديل في Studio", + record: "سجل", + retry: "إعادة المحاولة", }, actions: { decisionOutput: { @@ -602,6 +606,7 @@ const ar = { }, kanban: { uncategorized: "غير مصنّف", + columns: "أعمدة", addCard: "إضافة بطاقة", addColumn: "إضافة عمود", moveCard: "نقل بطاقة", @@ -667,6 +672,10 @@ const ar = { ff: "نهاية → نهاية", sf: "بداية → نهاية", }, + linkEnd: { + start: "بداية", + end: "نهاية", + }, conflict: { title: "تعارض في الجدولة", body: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", @@ -804,9 +813,11 @@ const ar = { copyToClipboard: "نسخ إلى الحافظة", copied: "تم النسخ!", deleteConfirmation: "هل أنت متأكد أنك تريد حذف هذا السجل؟", + deleted: "تم حذف السجل", editRecord: "تحرير السجل", viewAll: "عرض الكل", new: "جديد", + add: "إضافة", emptyValue: "—", comments: "التعليقات", searchComments: "البحث في التعليقات…", @@ -913,9 +924,11 @@ const ar = { concurrentUpdateReload: "تحميل النسخة الحالية", concurrentUpdateOverwrite: "الكتابة فوقه على أي حال", concurrentUpdateCancel: "إلغاء", + concurrentUpdateRecordLabel: "هذا السجل", openInNewTab: "فتح في علامة تبويب جديدة", activity: "النشاط", history: "السجل", + historyEmpty: "لا يوجد سجل بعد", editRow: "تعديل", deleteRow: "حذف", deleteRowConfirmation: "حذف هذا السجل؟", @@ -1011,6 +1024,7 @@ const ar = { }, dashboard: { noRows: "لا توجد صفوف", + loading: "جارٍ التحميل…", pickMeasures: "اختر المقاييس (القيم) لأداة مجموعة البيانات هذه.", datasetUnsupported: "مصدر البيانات هذا لا يدعم استعلامات مجموعات البيانات.", details: "التفاصيل", @@ -2155,6 +2169,7 @@ const ar = { invite: "دعوة عضو", members: "الأعضاء", settings: "إعدادات مساحة العمل", + multiOrgDisabled: "إنشاء مؤسسات جديدة معطّل في هذا النشر.", }, help: { onThisPage: "في هذه الصفحة", @@ -2311,6 +2326,9 @@ const ar = { roles: "الأدوار", configuration: "التهيئة", createApp: "إنشاء تطبيق", + administration: "الإدارة", + datasources: "مصادر البيانات", + documentation: "الوثائق", }, appSwitcher: { switchApplication: "تبديل التطبيق", @@ -2369,6 +2387,7 @@ const ar = { empty: { objectNotFound: "الكائن غير موجود", objectNotFoundDescription: "تعريف الكائن \"{{name}}\" مفقود. تحقق من الإعداد أو ارجع للخلف.", + interfacePageSourceMissing: "تشير صفحة الواجهة هذه إلى \"{{name}}\"، وهو غير متاح.", pageNotFound: "الصفحة غير موجودة", pageNotFoundDescription: "الصفحة \"{{name}}\" غير موجودة. ربما تم إزالتها أو إعادة تسميتها.", dashboardNotFound: "لوحة التحكم غير موجودة", @@ -2377,6 +2396,8 @@ const ar = { reportNotFoundDescription: "التقرير \"{{name}}\" غير موجود. ربما تم إزالته أو إعادة تسميته.", noAppsConfigured: "لا تطبيقات مُهيأة", noAppsConfiguredDescription: "لا تطبيقات مسجلة. أنشئ تطبيقك الأول أو زر إعدادات النظام.", + appNotAvailable: "التطبيق غير متاح", + appNotAvailableDescription: "هذا التطبيق غير متاح بعد — قد يكون النشر ما زال جارياً. أعد المحاولة بعد لحظات.", createFirstApp: "إنشاء أول تطبيق", systemSettings: "إعدادات النظام", back: "رجوع", @@ -2659,6 +2680,13 @@ const ar = { sentDescription: "شارك الرابط أدناه مع المدعوّ. سيحتاج إلى تسجيل الدخول للقبول.", linkLabel: "رابط القبول", invitedAs: "تمت دعوة {{email}} بصفة {{role}}", + status: { + all: "الكل", + pending: "قيد الانتظار", + accepted: "مقبولة", + rejected: "مرفوضة", + canceled: "ملغاة", + }, }, settings: { generalTitle: "عام", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 8294796e4..7c16de212 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -105,6 +105,10 @@ const de = { itemCountOne: "{{count}} Element", toggleSidebar: "Seitenleiste umschalten", package: "Paket", + done: "Fertig", + editInStudio: "Im Studio bearbeiten", + record: "Datensatz", + retry: "Erneut versuchen", }, actions: { decisionOutput: { @@ -598,6 +602,7 @@ const de = { }, kanban: { uncategorized: "Nicht kategorisiert", + columns: "Spalten", addCard: "Karte hinzufügen", addColumn: "Spalte hinzufügen", moveCard: "Karte verschieben", @@ -663,6 +668,10 @@ const de = { ff: "Ende → Ende", sf: "Anfang → Ende", }, + linkEnd: { + start: "Anfang", + end: "Ende", + }, conflict: { title: "Terminkonflikt", body: "Diese Verschiebung verstößt gegen Abhängigkeitsbedingungen. {{count}} betroffene Vorgänge automatisch neu planen?", @@ -798,9 +807,11 @@ const de = { copyToClipboard: "In Zwischenablage kopieren", copied: "Kopiert!", deleteConfirmation: "Sind Sie sicher, dass Sie diesen Datensatz löschen möchten?", + deleted: "Datensatz gelöscht", editRecord: "Datensatz bearbeiten", viewAll: "Alle anzeigen", new: "Neu", + add: "Hinzufügen", emptyValue: "—", comments: "Kommentare", searchComments: "Kommentare suchen…", @@ -907,9 +918,11 @@ const de = { concurrentUpdateReload: "Aktuelle Version laden", concurrentUpdateOverwrite: "Trotzdem überschreiben", concurrentUpdateCancel: "Abbrechen", + concurrentUpdateRecordLabel: "diesem Datensatz", openInNewTab: "In neuem Tab öffnen", activity: "Aktivität", history: "Verlauf", + historyEmpty: "Noch kein Verlauf", editRow: "Bearbeiten", deleteRow: "Löschen", deleteRowConfirmation: "Möchten Sie diesen Datensatz wirklich löschen?", @@ -1007,6 +1020,7 @@ const de = { }, dashboard: { noRows: "Keine Zeilen", + loading: "Wird geladen…", pickMeasures: "Wählen Sie Kennzahlen (Werte) für dieses Dataset-Widget.", datasetUnsupported: "Diese Datenquelle unterstützt keine Dataset-Abfragen.", details: "Details", @@ -2151,6 +2165,7 @@ const de = { invite: "Mitglied einladen", members: "Mitglieder", settings: "Arbeitsbereichseinstellungen", + multiOrgDisabled: "Das Erstellen neuer Organisationen ist auf dieser Instanz deaktiviert.", }, help: { onThisPage: "Auf dieser Seite", @@ -2307,6 +2322,9 @@ const de = { roles: "Rollen", configuration: "Konfiguration", createApp: "App erstellen", + administration: "Verwaltung", + datasources: "Datenquellen", + documentation: "Dokumentation", }, appSwitcher: { switchApplication: "Anwendung wechseln", @@ -2365,6 +2383,7 @@ const de = { empty: { objectNotFound: "Objekt nicht gefunden", objectNotFoundDescription: "Definition des Objekts „{{name}}\" fehlt. Überprüfen Sie Ihre Konfiguration oder navigieren Sie zurück.", + interfacePageSourceMissing: "Diese Interface-Seite verweist auf „{{name}}“, das nicht verfügbar ist.", pageNotFound: "Seite nicht gefunden", pageNotFoundDescription: "Die Seite „{{name}}\" wurde nicht gefunden. Sie wurde möglicherweise entfernt oder umbenannt.", dashboardNotFound: "Dashboard nicht gefunden", @@ -2373,6 +2392,8 @@ const de = { reportNotFoundDescription: "Der Bericht „{{name}}\" wurde nicht gefunden. Er wurde möglicherweise entfernt oder umbenannt.", noAppsConfigured: "Keine Apps konfiguriert", noAppsConfiguredDescription: "Es sind keine Anwendungen registriert. Erstellen Sie Ihre erste App oder besuchen Sie die Systemeinstellungen.", + appNotAvailable: "App nicht verfügbar", + appNotAvailableDescription: "Diese App ist noch nicht verfügbar — sie wird möglicherweise noch veröffentlicht. Versuchen Sie es in einem Moment erneut.", createFirstApp: "Erste App erstellen", systemSettings: "Systemeinstellungen", back: "Zurück", @@ -2655,6 +2676,13 @@ const de = { sentDescription: "Teilen Sie den folgenden Link mit der eingeladenen Person. Sie muss sich anmelden, um anzunehmen.", linkLabel: "Annahme-Link", invitedAs: "{{email}} als {{role}} eingeladen", + status: { + all: "Alle", + pending: "Ausstehend", + accepted: "Angenommen", + rejected: "Abgelehnt", + canceled: "Zurückgezogen", + }, }, settings: { generalTitle: "Allgemein", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 19dfbf512..2fe731195 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -121,6 +121,10 @@ const en = { itemCountOne: '{{count}} item', toggleSidebar: 'Toggle sidebar', package: 'Package', + done: 'Done', + editInStudio: 'Edit in studio', + record: 'Record', + retry: 'Retry', }, actions: { decisionOutput: { @@ -654,6 +658,7 @@ const en = { noCards: 'No cards', cardTitlePlaceholder: 'Enter card title...', uncategorized: 'Uncategorized', + columns: 'columns', }, timeline: { bucket: { @@ -740,6 +745,10 @@ const en = { ff: 'Finish → Finish', sf: 'Start → Finish', }, + linkEnd: { + start: 'start', + end: 'end', + }, conflict: { title: 'Schedule conflict', body: 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?', @@ -845,6 +854,7 @@ const en = { concurrentUpdateReload: 'Reload latest', concurrentUpdateOverwrite: 'Overwrite anyway', concurrentUpdateCancel: 'Cancel', + concurrentUpdateRecordLabel: 'this record', openInNewTab: 'Open in new tab', share: 'Share', duplicate: 'Duplicate', @@ -891,12 +901,15 @@ const en = { copyToClipboard: 'Copy to clipboard', copied: 'Copied!', deleteConfirmation: 'Are you sure you want to delete this record?', + deleted: 'Record deleted', editRecord: 'Edit record', viewAll: 'View All', new: 'New', + add: 'Add', emptyValue: '—', activity: 'Activity', history: 'History', + historyEmpty: 'No history yet', editRow: 'Edit', deleteRow: 'Delete', deleteRowConfirmation: 'Are you sure you want to delete this record?', @@ -1225,6 +1238,7 @@ const en = { noDataAvailable: 'No data available', noDataSourceFor: 'No data source available for', noRows: 'No rows', + loading: 'Loading…', pickMeasures: 'Pick measures (values) for this dataset widget.', datasetUnsupported: 'This data source does not support dataset queries.', details: 'Details', @@ -2410,6 +2424,7 @@ const en = { invite: 'Invite member', members: 'Members', settings: 'Workspace settings', + multiOrgDisabled: 'Creating new organizations is disabled on this instance.', }, help: { onThisPage: 'On this page', @@ -2580,6 +2595,9 @@ const en = { roles: 'Roles', configuration: 'Configuration', createApp: 'Create App', + administration: 'Administration', + datasources: 'Datasources', + documentation: 'Documentation', }, activityFeed: { title: 'Recent Activity', @@ -2627,6 +2645,7 @@ const en = { empty: { objectNotFound: 'Object Not Found', objectNotFoundDescription: 'Object "{{name}}" definition missing. Check your configuration or navigate back to select a valid object.', + interfacePageSourceMissing: 'This interface page references "{{name}}", which is not available.', recordNotFound: 'Record not found', recordNotFoundDescription: 'The record you are looking for does not exist or may have been deleted.', pageNotFound: 'Page Not Found', @@ -2637,6 +2656,8 @@ const en = { reportNotFoundDescription: 'The report "{{name}}" could not be found. It may have been removed or renamed.', noAppsConfigured: 'No Apps Configured', noAppsConfiguredDescription: 'No applications have been registered. Create your first app or visit System Settings to configure your environment.', + appNotAvailable: 'App not available', + appNotAvailableDescription: 'This app is not available yet — it may still be publishing. Try again in a moment.', createFirstApp: 'Create Your First App', systemSettings: 'System Settings', back: 'Back', @@ -2862,6 +2883,13 @@ const en = { sentDescription: 'Share the link below with the invitee. They will need to sign in to accept.', linkLabel: 'Accept link', invitedAs: '{{email}} invited as {{role}}', + status: { + all: 'All', + pending: 'Pending', + accepted: 'Accepted', + rejected: 'Rejected', + canceled: 'Canceled', + }, }, settings: { generalTitle: 'General', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 95f0ff8a2..fc3dfef5d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -104,6 +104,10 @@ const es = { itemCountOne: "{{count}} elemento", toggleSidebar: "Alternar barra lateral", package: "Paquete", + done: "Listo", + editInStudio: "Editar en Studio", + record: "Registro", + retry: "Reintentar", }, actions: { decisionOutput: { @@ -602,6 +606,7 @@ const es = { }, kanban: { uncategorized: "Sin categoría", + columns: "columnas", addCard: "Añadir tarjeta", addColumn: "Añadir columna", moveCard: "Mover tarjeta", @@ -667,6 +672,10 @@ const es = { ff: "Fin → Fin", sf: "Inicio → Fin", }, + linkEnd: { + start: "Inicio", + end: "Fin", + }, 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)?", @@ -802,9 +811,11 @@ const es = { copyToClipboard: "Copiar al portapapeles", copied: "¡Copiado!", deleteConfirmation: "¿Está seguro de que desea eliminar este registro?", + deleted: "Registro eliminado", editRecord: "Editar registro", viewAll: "Ver todo", new: "Nuevo", + add: "Agregar", emptyValue: "—", comments: "Comentarios", searchComments: "Buscar comentarios…", @@ -911,9 +922,11 @@ const es = { concurrentUpdateReload: "Cargar versión actual", concurrentUpdateOverwrite: "Sobrescribir de todos modos", concurrentUpdateCancel: "Cancelar", + concurrentUpdateRecordLabel: "este registro", openInNewTab: "Abrir en nueva pestaña", activity: "Actividad", history: "Historial", + historyEmpty: "Aún no hay historial", editRow: "Editar", deleteRow: "Eliminar", deleteRowConfirmation: "¿Eliminar este registro?", @@ -1011,6 +1024,7 @@ const es = { }, dashboard: { noRows: "Sin filas", + loading: "Cargando…", pickMeasures: "Elija medidas (valores) para este widget de dataset.", datasetUnsupported: "Esta fuente de datos no admite consultas de dataset.", details: "Detalles", @@ -2155,6 +2169,7 @@ const es = { invite: "Invitar miembro", members: "Miembros", settings: "Configuración del espacio de trabajo", + multiOrgDisabled: "La creación de nuevas organizaciones está deshabilitada en esta instancia.", }, help: { onThisPage: "En esta página", @@ -2311,6 +2326,9 @@ const es = { roles: "Roles", configuration: "Configuración", createApp: "Crear aplicación", + administration: "Administración", + datasources: "Fuentes de datos", + documentation: "Documentación", }, appSwitcher: { switchApplication: "Cambiar de aplicación", @@ -2369,6 +2387,7 @@ const es = { empty: { objectNotFound: "Objeto no encontrado", objectNotFoundDescription: "Falta la definición del objeto \"{{name}}\". Verifique su configuración o navegue hacia atrás.", + interfacePageSourceMissing: "Esta página de interfaz hace referencia a \"{{name}}\", que no está disponible.", pageNotFound: "Página no encontrada", pageNotFoundDescription: "La página \"{{name}}\" no fue encontrada. Puede haber sido eliminada o renombrada.", dashboardNotFound: "Panel no encontrado", @@ -2377,6 +2396,8 @@ const es = { reportNotFoundDescription: "El informe \"{{name}}\" no fue encontrado. Puede haber sido eliminado o renombrado.", noAppsConfigured: "Sin aplicaciones configuradas", noAppsConfiguredDescription: "No hay aplicaciones registradas. Cree su primera aplicación o visite la configuración del sistema.", + appNotAvailable: "Aplicación no disponible", + appNotAvailableDescription: "Esta aplicación aún no está disponible — puede que todavía se esté publicando. Vuelva a intentarlo en un momento.", createFirstApp: "Crear primera aplicación", systemSettings: "Configuración del sistema", back: "Atrás", @@ -2659,6 +2680,13 @@ const es = { sentDescription: "Comparte el enlace de abajo con la persona invitada. Tendrá que iniciar sesión para aceptarla.", linkLabel: "Enlace de aceptación", invitedAs: "{{email}} invitado como {{role}}", + status: { + all: "Todas", + pending: "Pendiente", + accepted: "Aceptada", + rejected: "Rechazada", + canceled: "Cancelada", + }, }, settings: { generalTitle: "General", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index dd65651ca..87649e282 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -105,6 +105,10 @@ const fr = { itemCountOne: "{{count}} élément", toggleSidebar: "Basculer la barre latérale", package: "Package", + done: "Terminé", + editInStudio: "Modifier dans Studio", + record: "Enregistrement", + retry: "Réessayer", }, actions: { decisionOutput: { @@ -598,6 +602,7 @@ const fr = { }, kanban: { uncategorized: "Non catégorisé", + columns: "colonnes", addCard: "Ajouter une carte", addColumn: "Ajouter une colonne", moveCard: "Déplacer la carte", @@ -663,6 +668,10 @@ const fr = { ff: "Fin → Fin", sf: "Début → Fin", }, + linkEnd: { + start: "Début", + end: "Fin", + }, 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) ?", @@ -800,9 +809,11 @@ const fr = { copyToClipboard: "Copier dans le presse-papiers", copied: "Copié !", deleteConfirmation: "Êtes-vous sûr de vouloir supprimer cet enregistrement ?", + deleted: "Enregistrement supprimé", editRecord: "Modifier l'enregistrement", viewAll: "Tout afficher", new: "Nouveau", + add: "Ajouter", emptyValue: "—", comments: "Commentaires", searchComments: "Rechercher des commentaires…", @@ -909,9 +920,11 @@ const fr = { concurrentUpdateReload: "Charger la version actuelle", concurrentUpdateOverwrite: "Écraser quand même", concurrentUpdateCancel: "Annuler", + concurrentUpdateRecordLabel: "cet enregistrement", openInNewTab: "Ouvrir dans un nouvel onglet", activity: "Activité", history: "Historique", + historyEmpty: "Aucun historique pour le moment", editRow: "Modifier", deleteRow: "Supprimer", deleteRowConfirmation: "Supprimer cet enregistrement ?", @@ -1007,6 +1020,7 @@ const fr = { }, dashboard: { noRows: "Aucune ligne", + loading: "Chargement…", pickMeasures: "Choisissez des mesures (valeurs) pour ce widget de dataset.", datasetUnsupported: "Cette source de données ne prend pas en charge les requêtes de dataset.", details: "Détails", @@ -2151,6 +2165,7 @@ const fr = { invite: "Inviter un membre", members: "Membres", settings: "Paramètres de l'espace de travail", + multiOrgDisabled: "La création de nouvelles organisations est désactivée sur cette instance.", }, help: { onThisPage: "Sur cette page", @@ -2307,6 +2322,9 @@ const fr = { roles: "Rôles", configuration: "Configuration", createApp: "Créer une application", + administration: "Administration", + datasources: "Sources de données", + documentation: "Documentation", }, appSwitcher: { switchApplication: "Changer d'application", @@ -2365,6 +2383,7 @@ const fr = { empty: { objectNotFound: "Objet introuvable", objectNotFoundDescription: "La définition de l'objet \"{{name}}\" est manquante. Vérifiez votre configuration ou revenez en arrière.", + interfacePageSourceMissing: "Cette page d'interface référence \"{{name}}\", qui n'est pas disponible.", pageNotFound: "Page introuvable", pageNotFoundDescription: "La page \"{{name}}\" est introuvable. Elle a peut-être été supprimée ou renommée.", dashboardNotFound: "Tableau de bord introuvable", @@ -2373,6 +2392,8 @@ const fr = { reportNotFoundDescription: "Le rapport \"{{name}}\" est introuvable. Il a peut-être été supprimé ou renommé.", noAppsConfigured: "Aucune application configurée", noAppsConfiguredDescription: "Aucune application n'est enregistrée. Créez votre première application ou visitez les paramètres système.", + appNotAvailable: "Application non disponible", + appNotAvailableDescription: "Cette application n'est pas encore disponible — sa publication est peut-être en cours. Réessayez dans un instant.", createFirstApp: "Créer la première application", systemSettings: "Paramètres système", back: "Retour", @@ -2655,6 +2676,13 @@ const fr = { sentDescription: "Partagez le lien ci-dessous avec la personne invitée. Elle devra se connecter pour accepter.", linkLabel: "Lien d'acceptation", invitedAs: "{{email}} invité en tant que {{role}}", + status: { + all: "Toutes", + pending: "En attente", + accepted: "Acceptée", + rejected: "Refusée", + canceled: "Annulée", + }, }, settings: { generalTitle: "Général", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 27a685025..ae1db4b8f 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -105,6 +105,10 @@ const ja = { itemCountOne: "{{count}} 件", toggleSidebar: "サイドバーを切り替え", package: "パッケージ", + done: "完了", + editInStudio: "Studio で編集", + record: "レコード", + retry: "再試行", }, actions: { decisionOutput: { @@ -598,6 +602,7 @@ const ja = { }, kanban: { uncategorized: "未分類", + columns: "カラム", addCard: "カードを追加", addColumn: "カラムを追加", moveCard: "カードを移動", @@ -663,6 +668,10 @@ const ja = { ff: "終了 → 終了", sf: "開始 → 終了", }, + linkEnd: { + start: "開始", + end: "終了", + }, conflict: { title: "スケジュールの競合", body: "この移動は依存関係の制約と競合します。影響を受ける {{count}} 件のタスクを自動で再スケジュールしますか?", @@ -798,9 +807,11 @@ const ja = { copyToClipboard: "クリップボードにコピー", copied: "コピーしました!", deleteConfirmation: "このレコードを削除してもよろしいですか?", + deleted: "レコードを削除しました", editRecord: "レコードを編集", viewAll: "すべて表示", new: "新規", + add: "追加", emptyValue: "—", activity: "アクティビティ", editRow: "編集", @@ -918,8 +929,10 @@ const ja = { concurrentUpdateReload: "最新を読み込む", concurrentUpdateOverwrite: "上書きする", concurrentUpdateCancel: "キャンセル", + concurrentUpdateRecordLabel: "このレコード", openInNewTab: "新しいタブで開く", history: "履歴", + historyEmpty: "履歴はまだありません", deleteRowTitle: "レコードを削除", createdBy: "作成者", updatedBy: "更新者", @@ -1007,6 +1020,7 @@ const ja = { }, dashboard: { noRows: "行がありません", + loading: "読み込み中…", pickMeasures: "このデータセットウィジェットの指標(値)を選択してください。", datasetUnsupported: "このデータソースはデータセットクエリに対応していません。", details: "詳細", @@ -2151,6 +2165,7 @@ const ja = { invite: "メンバーを招待", members: "メンバー", settings: "ワークスペース設定", + multiOrgDisabled: "このインスタンスでは新しい組織の作成が無効です。", }, help: { onThisPage: "このページの内容", @@ -2307,6 +2322,9 @@ const ja = { roles: "ロール", configuration: "構成", createApp: "アプリを作成", + administration: "管理", + datasources: "データソース", + documentation: "ドキュメント", }, appSwitcher: { switchApplication: "アプリケーションを切り替え", @@ -2365,6 +2383,7 @@ const ja = { empty: { objectNotFound: "オブジェクトが見つかりません", objectNotFoundDescription: "オブジェクト「{{name}}」の定義が見つかりません。設定を確認するか、有効なオブジェクトを選択してください。", + interfacePageSourceMissing: "このインターフェースページは「{{name}}」を参照していますが、利用できません。", pageNotFound: "ページが見つかりません", pageNotFoundDescription: "ページ「{{name}}」が見つかりません。削除または名前変更された可能性があります。", dashboardNotFound: "ダッシュボードが見つかりません", @@ -2373,6 +2392,8 @@ const ja = { reportNotFoundDescription: "レポート「{{name}}」が見つかりません。削除または名前変更された可能性があります。", noAppsConfigured: "アプリが設定されていません", noAppsConfiguredDescription: "登録されているアプリケーションがありません。最初のアプリを作成するか、システム設定を参照してください。", + appNotAvailable: "アプリを利用できません", + appNotAvailableDescription: "このアプリはまだ利用できません — まだ公開処理中の可能性があります。しばらくしてからもう一度お試しください。", createFirstApp: "最初のアプリを作成", systemSettings: "システム設定", back: "戻る", @@ -2655,6 +2676,13 @@ const ja = { sentDescription: "以下のリンクを招待相手に共有してください。承諾にはサインインが必要です。", linkLabel: "承諾リンク", invitedAs: "{{email}} を {{role}} として招待しました", + status: { + all: "すべて", + pending: "待機中", + accepted: "承諾済み", + rejected: "辞退済み", + canceled: "取消済み", + }, }, settings: { generalTitle: "一般", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4413b6974..a250e57a7 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -105,6 +105,10 @@ const ko = { itemCountOne: "{{count}}개 항목", toggleSidebar: "사이드바 전환", package: "패키지", + done: "완료", + editInStudio: "Studio에서 편집", + record: "레코드", + retry: "다시 시도", }, actions: { decisionOutput: { @@ -598,6 +602,7 @@ const ko = { }, kanban: { uncategorized: "미분류", + columns: "열", addCard: "카드 추가", addColumn: "열 추가", moveCard: "카드 이동", @@ -663,6 +668,10 @@ const ko = { ff: "종료 → 종료", sf: "시작 → 종료", }, + linkEnd: { + start: "시작", + end: "종료", + }, conflict: { title: "일정 충돌", body: "이 이동은 종속성 제약과 충돌합니다. 영향을 받는 작업 {{count}}건의 일정을 자동으로 조정할까요?", @@ -798,9 +807,11 @@ const ko = { copyToClipboard: "클립보드에 복사", copied: "복사됨!", deleteConfirmation: "이 레코드를 삭제하시겠습니까?", + deleted: "레코드가 삭제됨", editRecord: "레코드 편집", viewAll: "모두 보기", new: "새로 만들기", + add: "추가", emptyValue: "—", comments: "댓글", searchComments: "댓글 검색…", @@ -907,9 +918,11 @@ const ko = { concurrentUpdateReload: "현재 버전 로드", concurrentUpdateOverwrite: "그래도 덮어쓰기", concurrentUpdateCancel: "취소", + concurrentUpdateRecordLabel: "이 레코드", openInNewTab: "새 탭에서 열기", activity: "활동", history: "기록", + historyEmpty: "아직 기록이 없습니다", editRow: "편집", deleteRow: "삭제", deleteRowConfirmation: "이 레코드를 삭제하시겠습니까?", @@ -1007,6 +1020,7 @@ const ko = { }, dashboard: { noRows: "행 없음", + loading: "로딩 중…", pickMeasures: "이 데이터셋 위젯의 측정값(값)을 선택하세요.", datasetUnsupported: "이 데이터 소스는 데이터셋 쿼리를 지원하지 않습니다.", details: "세부 정보", @@ -2151,6 +2165,7 @@ const ko = { invite: "구성원 초대", members: "구성원", settings: "워크스페이스 설정", + multiOrgDisabled: "이 인스턴스에서는 새 조직을 만들 수 없습니다.", }, help: { onThisPage: "이 페이지에서", @@ -2306,6 +2321,9 @@ const ko = { roles: "역할", configuration: "구성", createApp: "앱 만들기", + administration: "관리", + datasources: "데이터 소스", + documentation: "문서", }, appSwitcher: { switchApplication: "애플리케이션 전환", @@ -2364,6 +2382,7 @@ const ko = { empty: { objectNotFound: "오브젝트를 찾을 수 없습니다", objectNotFoundDescription: "\"{{name}}\" 오브젝트 정의가 없습니다. 구성을 확인하거나 뒤로 이동하세요.", + interfacePageSourceMissing: "이 인터페이스 페이지는 \"{{name}}\"을(를) 참조하지만 사용할 수 없습니다.", pageNotFound: "페이지를 찾을 수 없습니다", pageNotFoundDescription: "\"{{name}}\" 페이지를 찾을 수 없습니다. 삭제되거나 이름이 변경되었을 수 있습니다.", dashboardNotFound: "대시보드를 찾을 수 없습니다", @@ -2372,6 +2391,8 @@ const ko = { reportNotFoundDescription: "\"{{name}}\" 보고서를 찾을 수 없습니다. 삭제되거나 이름이 변경되었을 수 있습니다.", noAppsConfigured: "구성된 앱 없음", noAppsConfiguredDescription: "등록된 앱이 없습니다. 첫 번째 앱을 만들거나 시스템 설정을 방문하세요.", + appNotAvailable: "앱을 사용할 수 없습니다", + appNotAvailableDescription: "이 앱을 아직 사용할 수 없습니다 — 아직 게시 중일 수 있습니다. 잠시 후 다시 시도하세요.", createFirstApp: "첫 번째 앱 만들기", systemSettings: "시스템 설정", back: "뒤로", @@ -2654,6 +2675,13 @@ const ko = { sentDescription: "아래 링크를 초대 대상자와 공유하세요. 수락하려면 로그인해야 합니다.", linkLabel: "수락 링크", invitedAs: "{{email}}을(를) {{role}}(으)로 초대함", + status: { + all: "전체", + pending: "대기 중", + accepted: "수락됨", + rejected: "거절됨", + canceled: "취소됨", + }, }, settings: { generalTitle: "일반", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index bd2c8301a..b89b1ac89 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -104,6 +104,10 @@ const pt = { itemCountOne: "{{count}} item", toggleSidebar: "Alternar barra lateral", package: "Pacote", + done: "Concluído", + editInStudio: "Editar no Studio", + record: "Registro", + retry: "Tentar novamente", }, actions: { decisionOutput: { @@ -597,6 +601,7 @@ const pt = { }, kanban: { uncategorized: "Sem categoria", + columns: "colunas", addCard: "Adicionar cartão", addColumn: "Adicionar coluna", moveCard: "Mover cartão", @@ -662,6 +667,10 @@ const pt = { ff: "Término → Término", sf: "Início → Término", }, + linkEnd: { + start: "Início", + end: "Fim", + }, conflict: { title: "Conflito de agendamento", body: "Esta movimentação conflita com as restrições de dependência. Reagendar automaticamente {{count}} tarefa(s) afetada(s)?", @@ -799,9 +808,11 @@ const pt = { copyToClipboard: "Copiar para área de transferência", copied: "Copiado!", deleteConfirmation: "Tem certeza de que deseja excluir este registro?", + deleted: "Registro excluído", editRecord: "Editar registro", viewAll: "Ver tudo", new: "Novo", + add: "Adicionar", emptyValue: "—", comments: "Comentários", searchComments: "Pesquisar comentários…", @@ -908,9 +919,11 @@ const pt = { concurrentUpdateReload: "Carregar versão atual", concurrentUpdateOverwrite: "Sobrescrever mesmo assim", concurrentUpdateCancel: "Cancelar", + concurrentUpdateRecordLabel: "este registro", openInNewTab: "Abrir em nova aba", activity: "Atividade", history: "Histórico", + historyEmpty: "Nenhum histórico ainda", editRow: "Editar", deleteRow: "Excluir", deleteRowConfirmation: "Excluir este registro?", @@ -1006,6 +1019,7 @@ const pt = { }, dashboard: { noRows: "Sem linhas", + loading: "Carregando…", pickMeasures: "Escolha medidas (valores) para este widget de dataset.", datasetUnsupported: "Esta fonte de dados não oferece suporte a consultas de dataset.", details: "Detalhes", @@ -2150,6 +2164,7 @@ const pt = { invite: "Convidar membro", members: "Membros", settings: "Configurações do espaço de trabalho", + multiOrgDisabled: "A criação de novas organizações está desativada nesta instância.", }, help: { onThisPage: "Nesta página", @@ -2306,6 +2321,9 @@ const pt = { roles: "Perfis", configuration: "Configuração", createApp: "Criar aplicativo", + administration: "Administração", + datasources: "Fontes de dados", + documentation: "Documentação", }, appSwitcher: { switchApplication: "Trocar de aplicativo", @@ -2364,6 +2382,7 @@ const pt = { empty: { objectNotFound: "Objeto não encontrado", objectNotFoundDescription: "A definição do objeto \"{{name}}\" está ausente. Verifique sua configuração ou navegue de volta.", + interfacePageSourceMissing: "Esta página de interface faz referência a \"{{name}}\", que não está disponível.", pageNotFound: "Página não encontrada", pageNotFoundDescription: "A página \"{{name}}\" não foi encontrada. Ela pode ter sido removida ou renomeada.", dashboardNotFound: "Painel não encontrado", @@ -2372,6 +2391,8 @@ const pt = { reportNotFoundDescription: "O relatório \"{{name}}\" não foi encontrado. Ele pode ter sido removido ou renomeado.", noAppsConfigured: "Nenhum aplicativo configurado", noAppsConfiguredDescription: "Nenhum aplicativo está registrado. Crie seu primeiro aplicativo ou visite as configurações do sistema.", + appNotAvailable: "Aplicativo indisponível", + appNotAvailableDescription: "Este aplicativo ainda não está disponível — a publicação pode estar em andamento. Tente novamente em instantes.", createFirstApp: "Criar primeiro aplicativo", systemSettings: "Configurações do sistema", back: "Voltar", @@ -2654,6 +2675,13 @@ const pt = { sentDescription: "Compartilhe o link abaixo com a pessoa convidada. Ela precisará entrar para aceitar.", linkLabel: "Link de aceitação", invitedAs: "{{email}} convidado como {{role}}", + status: { + all: "Todos", + pending: "Pendente", + accepted: "Aceito", + rejected: "Recusado", + canceled: "Cancelado", + }, }, settings: { generalTitle: "Geral", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index aca3c333d..265799a24 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -111,6 +111,10 @@ const ru = { itemCountOne: "{{count}} элемент", toggleSidebar: "Переключить боковую панель", package: "Пакет", + done: "Готово", + editInStudio: "Редактировать в Studio", + record: "Запись", + retry: "Повторить", }, actions: { decisionOutput: { @@ -604,6 +608,7 @@ const ru = { }, kanban: { uncategorized: "Без категории", + columns: "колонок", addCard: "Добавить карточку", addColumn: "Добавить колонку", moveCard: "Переместить карточку", @@ -669,6 +674,10 @@ const ru = { ff: "Окончание → Окончание", sf: "Начало → Окончание", }, + linkEnd: { + start: "Начало", + end: "Конец", + }, conflict: { title: "Конфликт расписания", body: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", @@ -806,9 +815,11 @@ const ru = { copyToClipboard: "Копировать в буфер обмена", copied: "Скопировано!", deleteConfirmation: "Вы уверены, что хотите удалить эту запись?", + deleted: "Запись удалена", editRecord: "Редактировать запись", viewAll: "Показать все", new: "Создать", + add: "Добавить", emptyValue: "—", activity: "Активность", editRow: "Редактировать", @@ -926,8 +937,10 @@ const ru = { concurrentUpdateReload: "Загрузить текущую версию", concurrentUpdateOverwrite: "Всё равно перезаписать", concurrentUpdateCancel: "Отмена", + concurrentUpdateRecordLabel: "этой записи", openInNewTab: "Открыть в новой вкладке", history: "История", + historyEmpty: "Истории пока нет", deleteRowTitle: "Удалить запись", createdBy: "Создано", updatedBy: "Обновлено", @@ -1013,6 +1026,7 @@ const ru = { }, dashboard: { noRows: "Нет строк", + loading: "Загрузка…", pickMeasures: "Выберите меры (значения) для этого виджета набора данных.", datasetUnsupported: "Этот источник данных не поддерживает запросы к наборам данных.", details: "Подробности", @@ -2157,6 +2171,7 @@ const ru = { invite: "Пригласить участника", members: "Участники", settings: "Настройки рабочего пространства", + multiOrgDisabled: "Создание новых организаций отключено в этой среде.", }, help: { onThisPage: "На этой странице", @@ -2314,6 +2329,9 @@ const ru = { roles: "Роли", configuration: "Конфигурация", createApp: "Создать приложение", + administration: "Администрирование", + datasources: "Источники данных", + documentation: "Документация", }, appSwitcher: { switchApplication: "Сменить приложение", @@ -2372,6 +2390,7 @@ const ru = { empty: { objectNotFound: "Объект не найден", objectNotFoundDescription: "Определение объекта \"{{name}}\" отсутствует. Проверьте конфигурацию или вернитесь назад.", + interfacePageSourceMissing: "Эта интерфейсная страница ссылается на \"{{name}}\", который недоступен.", pageNotFound: "Страница не найдена", pageNotFoundDescription: "Страница \"{{name}}\" не найдена. Возможно, она была удалена или переименована.", dashboardNotFound: "Панель не найдена", @@ -2380,6 +2399,8 @@ const ru = { reportNotFoundDescription: "Отчёт \"{{name}}\" не найден. Возможно, он был удалён или переименован.", noAppsConfigured: "Нет приложений", noAppsConfiguredDescription: "Нет зарегистрированных приложений. Создайте первое приложение или посетите системные настройки.", + appNotAvailable: "Приложение недоступно", + appNotAvailableDescription: "Это приложение пока недоступно — возможно, публикация ещё идёт. Повторите попытку через мгновение.", createFirstApp: "Создать приложение", systemSettings: "Системные настройки", back: "Назад", @@ -2662,6 +2683,13 @@ const ru = { sentDescription: "Отправьте ссылку ниже приглашённому. Чтобы принять приглашение, ему нужно войти в систему.", linkLabel: "Ссылка для принятия", invitedAs: "{{email}} приглашён как {{role}}", + status: { + all: "Все", + pending: "Ожидает", + accepted: "Принято", + rejected: "Отклонено", + canceled: "Отменено", + }, }, settings: { generalTitle: "Общие", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ec2238b99..dd15947e1 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -112,6 +112,10 @@ const zh = { itemCountOne: '{{count}} 项', toggleSidebar: '切换侧边栏', package: '软件包', + done: '完成', + editInStudio: '在 Studio 中编辑', + record: '记录', + retry: '重试', }, actions: { decisionOutput: { @@ -626,6 +630,7 @@ const zh = { noCards: '暂无卡片', cardTitlePlaceholder: '输入卡片标题...', uncategorized: '未分类', + columns: '列', }, timeline: { bucket: { @@ -712,6 +717,10 @@ const zh = { ff: '完成 → 完成', sf: '开始 → 完成', }, + linkEnd: { + start: '开始', + end: '结束', + }, conflict: { title: '排程冲突', body: '此次移动与依赖约束冲突。是否自动重新排程 {{count}} 个受影响的任务?', @@ -818,6 +827,7 @@ const zh = { concurrentUpdateReload: '加载最新', concurrentUpdateOverwrite: '仍然覆盖', concurrentUpdateCancel: '取消', + concurrentUpdateRecordLabel: '此记录', openInNewTab: '在新标签页打开', share: '分享', duplicate: '复制', @@ -851,12 +861,15 @@ const zh = { copyToClipboard: '复制到剪贴板', copied: '已复制!', deleteConfirmation: '确定要删除此记录吗?', + deleted: '记录已删除', editRecord: '编辑记录', viewAll: '查看全部', new: '新建', + add: '添加', emptyValue: '—', activity: '活动', history: '历史', + historyEmpty: '暂无历史记录', editRow: '编辑', deleteRow: '删除', deleteRowConfirmation: '确定要删除此记录吗?', @@ -1181,6 +1194,7 @@ const zh = { noDataAvailable: '暂无数据', noDataSourceFor: '没有可用的数据源:', noRows: '暂无数据行', + loading: '加载中…', pickMeasures: '请为该数据集组件选择度量(值)。', datasetUnsupported: '当前数据源不支持数据集查询。', details: '明细', @@ -2329,6 +2343,7 @@ const zh = { invite: '邀请成员', members: '成员', settings: '工作区设置', + multiOrgDisabled: '此实例已禁用创建新组织。', }, help: { keyboardShortcuts: '键盘快捷键', @@ -2496,6 +2511,9 @@ const zh = { roles: '角色', configuration: '配置', createApp: '创建应用', + administration: '管理', + datasources: '数据源', + documentation: '文档', }, activityFeed: { title: '最近动态', @@ -2543,6 +2561,7 @@ const zh = { empty: { objectNotFound: '未找到对象', objectNotFoundDescription: '对象 “{{name}}” 的定义不存在。请检查配置或返回选择有效的对象。', + interfacePageSourceMissing: '此界面页引用了 “{{name}}”,但该来源不可用。', recordNotFound: '未找到记录', recordNotFoundDescription: '您查找的记录不存在或已被删除。', pageNotFound: '未找到页面', @@ -2553,6 +2572,8 @@ const zh = { reportNotFoundDescription: '未找到报表 “{{name}}”,可能已被删除或重命名。', noAppsConfigured: '尚未配置应用', noAppsConfiguredDescription: '当前没有任何已注册的应用。请创建您的第一个应用,或前往系统设置进行配置。', + appNotAvailable: '应用不可用', + appNotAvailableDescription: '此应用尚不可用 —— 可能仍在发布中。请稍后重试。', createFirstApp: '创建您的第一个应用', systemSettings: '系统设置', back: '返回', @@ -2768,6 +2789,13 @@ const zh = { sentDescription: '请将下方链接发送给受邀人。对方需要登录后才能接受。', linkLabel: '接受链接', invitedAs: '{{email}} 已以 {{role}} 身份受邀', + status: { + all: '全部', + pending: '等待中', + accepted: '已接受', + rejected: '已拒绝', + canceled: '已取消', + }, }, settings: { generalTitle: '常规', diff --git a/scripts/i18n-call-site-key-baseline.json b/scripts/i18n-call-site-key-baseline.json index 3fdd30401..2b6c13570 100644 --- a/scripts/i18n-call-site-key-baseline.json +++ b/scripts/i18n-call-site-key-baseline.json @@ -6,32 +6,15 @@ "Fix one by adding the key to packages/i18n/src/locales/en.ts and deleting its line", "here; all-locales-key-parity.test.ts then demands the same key in the other nine", "packs. Adding an inline defaultValue is NOT a fix -- that is the mechanism that hid", - "these for months (objectui#3517)." + "these for months (objectui#3517).", + "BOTH LISTS ARE NOW EMPTY -- the 258-key stock objectui#3546 opened with was paid off", + "across seven slices, the last of them the two template families below. Keep the file:", + "empty is its terminal, load-bearing state. Any NEW unresolved call-site key is", + "`unexpected` against an empty baseline and fails the build, which is the point." ], - "missingKeys": { - "common.done": { "issue": "objectui#3546" }, - "common.editInStudio": { "issue": "objectui#3546" }, - "common.record": { "issue": "objectui#3546" }, - "common.retry": { "issue": "objectui#3546" }, - "dashboard.loading": { "issue": "objectui#3546" }, - "detail.add": { "issue": "objectui#3546" }, - "detail.concurrentUpdateRecordLabel": { "issue": "objectui#3546" }, - "detail.deleted": { "issue": "objectui#3546" }, - "detail.historyEmpty": { "issue": "objectui#3546" }, - "empty.appNotAvailable": { "issue": "objectui#3546" }, - "empty.appNotAvailableDescription": { "issue": "objectui#3546" }, - "empty.interfacePageSourceMissing": { "issue": "objectui#3546" }, - "kanban.columns": { "issue": "objectui#3546" }, - "layout.systemNav.administration": { "issue": "objectui#3546" }, - "layout.systemNav.datasources": { "issue": "objectui#3546" }, - "layout.systemNav.documentation": { "issue": "objectui#3546" }, - "workspace.multiOrgDisabled": { "issue": "objectui#3546" } - }, + "missingKeys": {}, "//": "Template keys whose static head matches no en key at all, so every expansion misses.", - "missingPrefixes": { - "gantt.linkEnd.": { "issue": "objectui#3546" }, - "organization.invitations.status.": { "issue": "objectui#3546" } - } + "missingPrefixes": {} }