Skip to content

Commit 9b4b952

Browse files
authored
fix(i18n): make en the complete source of truth for grid import and set-password (#2872 b/c) (#2877)
`en` and `zh` had drifted in both directions, silently: `fallbackLng: 'en'` degrades a missing key into English rather than an error, and the missing-key handler only fires in dev. - 74 keys existed only in `zh` (`grid.import.*`, `auth.setPassword.*`). They were never in `en` — the English came from call-site `defaultValue:` args and a private map inside `ImportWizard` — so no other locale could translate them. - 4 `en` keys were missing from `zh`, so Chinese users saw English. `grid.import` had three disagreeing sources (en 62, zh 130, wizard map 133, union 134, no two the same set); all three now agree on 134. `en` and `zh` are at exact parity, 2448 keys each. `IMPORT_DEFAULT_TRANSLATIONS` is kept, not deleted — it is what lets the wizard render with no `I18nProvider` mounted — and is instead pinned to the `en` pack by a test. `SetPasswordPage` drops its now-redundant inline `defaultValue:` args; rendered text is byte-identical. Two mutation-verified guards: en↔zh full key parity (both directions), and wizard-map ↔ `en.grid.import` same keys and same text. The other eight packs are ~357 keys behind and are deliberately not asserted — that backfill needs a translation-strategy decision (#2872 part a).
1 parent 75f1cdf commit 9b4b952

7 files changed

Lines changed: 271 additions & 23 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/plugin-grid": patch
4+
"@object-ui/console": patch
5+
---
6+
7+
fix(i18n): make `en` the complete source of truth for grid import and set-password (objectui#2872 b/c)
8+
9+
The `en` and `zh` packs had drifted in both directions, silently, because
10+
`fallbackLng: 'en'` degrades a missing key into English rather than an error and
11+
the missing-key handler only fires in dev.
12+
13+
- **74 keys existed only in `zh`.** `grid.import.*` and `auth.setPassword.*` had
14+
never been added to `en`, so no other locale could translate them: the English
15+
text came from call-site `defaultValue:` args and a private map inside
16+
`ImportWizard`. They now live in `en`, which is what translators and
17+
`os i18n extract` read.
18+
- **4 `en` keys were missing from `zh`** (`console.commandPalette.title`,
19+
two `console.ai.suggestions.metadataAssistant.*`, `help.keyboardShortcuts`),
20+
so Chinese users saw English.
21+
22+
`grid.import` in particular had three disagreeing sources — the `en` pack (62
23+
keys), `zh` (130) and `ImportWizard`'s own fallback map (133), union 134, no two
24+
the same set. All three are now aligned on 134.
25+
26+
The wizard's fallback map is kept, not deleted: it is what lets the wizard render
27+
with no `I18nProvider` mounted (standalone embedding, unit tests). It is instead
28+
pinned to the `en` pack by a new test, so the two can no longer drift.
29+
30+
`SetPasswordPage` drops its now-redundant inline `defaultValue:` args; the text
31+
is byte-identical, it just comes from the pack now.
32+
33+
Adds two guards, both mutation-verified:
34+
- `en``zh` full key parity, asserted in both directions. The other eight
35+
packs are still ~357 keys behind and are tracked separately (objectui#2872
36+
part a), so they are deliberately not asserted yet.
37+
- `IMPORT_DEFAULT_TRANSLATIONS``en.grid.import`, same keys and same text.

apps/console/src/pages/auth/SetPasswordPage.tsx

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,20 @@ export function SetPasswordPage() {
5252
e.preventDefault();
5353
if (newPassword !== confirmPassword) {
5454
toast.error(
55-
t('auth.setPassword.passwordsMismatch', { defaultValue: 'Passwords do not match' }),
55+
t('auth.setPassword.passwordsMismatch'),
5656
);
5757
return;
5858
}
5959
setSubmitting(true);
6060
try {
6161
await setInitialPassword(newPassword);
6262
toast.success(
63-
t('auth.setPassword.success', { defaultValue: 'Local password set' }),
63+
t('auth.setPassword.success'),
6464
);
6565
navigate(next);
6666
} catch (err) {
6767
toast.error(
68-
t('auth.setPassword.failed', { defaultValue: 'Could not set password' }),
68+
t('auth.setPassword.failed'),
6969
{ description: (err as Error).message },
7070
);
7171
} finally {
@@ -78,34 +78,29 @@ export function SetPasswordPage() {
7878
<Card className="border-border/60 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
7979
<CardHeader className="text-center">
8080
<CardTitle className="text-xl tracking-tight">
81-
{t('auth.setPassword.title', { defaultValue: 'Set a recovery password' })}
81+
{t('auth.setPassword.title')}
8282
</CardTitle>
8383
<CardDescription>
84-
{t('auth.setPassword.description', {
85-
defaultValue:
86-
'You signed in via single sign-on. Set a local password so you can still sign in to this environment directly if SSO ever becomes unavailable.',
87-
})}
84+
{t('auth.setPassword.description')}
8885
</CardDescription>
8986
</CardHeader>
9087
<CardContent>
9188
{!isLoading && !user ? (
9289
<p className="text-center text-sm text-muted-foreground">
93-
{t('auth.setPassword.noSession', {
94-
defaultValue: 'Your session has expired.',
95-
})}{' '}
90+
{t('auth.setPassword.noSession')}{' '}
9691
<Link
9792
to="/login"
9893
className="font-medium text-primary underline-offset-4 hover:underline"
9994
>
100-
{t('auth.setPassword.backToSignIn', { defaultValue: 'Sign in again' })}
95+
{t('auth.setPassword.backToSignIn')}
10196
</Link>
10297
</p>
10398
) : (
10499
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
105100
{user?.email ? (
106101
<div className="flex flex-col gap-2">
107102
<Label htmlFor="set-password-email">
108-
{t('auth.setPassword.email', { defaultValue: 'Email' })}
103+
{t('auth.setPassword.email')}
109104
</Label>
110105
<Input
111106
id="set-password-email"
@@ -119,7 +114,7 @@ export function SetPasswordPage() {
119114
) : null}
120115
<div className="flex flex-col gap-2">
121116
<Label htmlFor="new-password">
122-
{t('auth.setPassword.newPassword', { defaultValue: 'New password' })}
117+
{t('auth.setPassword.newPassword')}
123118
</Label>
124119
<Input
125120
id="new-password"
@@ -133,7 +128,7 @@ export function SetPasswordPage() {
133128
</div>
134129
<div className="flex flex-col gap-2">
135130
<Label htmlFor="confirm-password">
136-
{t('auth.setPassword.confirmPassword', { defaultValue: 'Confirm password' })}
131+
{t('auth.setPassword.confirmPassword')}
137132
</Label>
138133
<Input
139134
id="confirm-password"
@@ -147,8 +142,8 @@ export function SetPasswordPage() {
147142
</div>
148143
<Button type="submit" className="w-full" disabled={submitting}>
149144
{submitting
150-
? t('auth.setPassword.submitting', { defaultValue: 'Saving…' })
151-
: t('auth.setPassword.submit', { defaultValue: 'Set password' })}
145+
? t('auth.setPassword.submitting')
146+
: t('auth.setPassword.submit')}
152147
</Button>
153148
</form>
154149
)}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Full `en` ↔ `zh` key parity (objectui#2872 parts b and c).
3+
*
4+
* `fallbackLng: 'en'` means a key missing from a pack degrades silently — the
5+
* UI just renders English and looks "not translated yet" rather than broken.
6+
* The missing-key handler only fires in dev. So the packs had quietly drifted
7+
* in BOTH directions:
8+
*
9+
* - 74 keys existed only in `zh` (`grid.import.*`, `auth.setPassword.*`).
10+
* They were never in `en`, so no other locale could translate them — the
11+
* English text came from call-site `defaultValue:` args and a private map
12+
* inside `ImportWizard`.
13+
* - 4 `en` keys were missing from `zh`, so Chinese users saw English.
14+
*
15+
* `en` and `zh` are the two packs that are actually maintained in full, so
16+
* they are pinned to each other here. The remaining eight packs are tracked
17+
* separately (objectui#2872 part a) — they are missing ~357 keys each and
18+
* cannot be asserted this way until that backfill lands.
19+
*/
20+
import { describe, it, expect } from 'vitest';
21+
import { builtInLocales } from '../locales';
22+
23+
/** Flatten a nested translation node into sorted dot-paths. */
24+
function keyPaths(node: unknown, prefix = ''): string[] {
25+
return node !== null && typeof node === 'object'
26+
? Object.entries(node as Record<string, unknown>)
27+
.flatMap(([k, v]) => keyPaths(v, prefix ? `${prefix}.${k}` : k))
28+
.sort()
29+
: [prefix];
30+
}
31+
32+
describe('en ↔ zh key parity', () => {
33+
const en = keyPaths(builtInLocales.en);
34+
const zh = keyPaths(builtInLocales.zh);
35+
36+
it('flattens a realistic number of keys', () => {
37+
// Guard against the walker silently returning nothing and the two
38+
// set-difference assertions below passing vacuously.
39+
expect(en.length).toBeGreaterThan(2000);
40+
});
41+
42+
it('has no en key missing from zh', () => {
43+
const zhSet = new Set(zh);
44+
expect(en.filter((k) => !zhSet.has(k))).toEqual([]);
45+
});
46+
47+
it('has no zh key absent from en', () => {
48+
// This direction matters just as much: a key only in `zh` cannot be
49+
// translated into any other language, because `en` is what translators
50+
// and the extraction tooling read as the source of truth.
51+
const enSet = new Set(en);
52+
expect(zh.filter((k) => !enSet.has(k))).toEqual([]);
53+
});
54+
});

packages/i18n/src/locales/en.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ const en = {
236236
},
237237
import: {
238238
title: 'Import {{object}}',
239-
notAllowed: 'This object is not open for import.',
240239
stepUpload: 'Upload',
241240
stepMapping: 'Mapping',
242241
stepPreview: 'Preview',
@@ -245,9 +244,12 @@ const en = {
245244
previewDescription: 'Review data before importing.',
246245
dragDrop: 'Drag & drop a CSV or Excel file here, or click to browse',
247246
browseFiles: 'Browse Files',
247+
downloadTemplate: 'Download template',
248+
downloadTemplateHint: 'Get a CSV with the right columns (required fields marked *).',
249+
templateFileName: '{{object}}-import-template',
248250
parsing: 'Parsing…',
249251
pasteHint: 'or paste (Ctrl/⌘+V) rows copied from Excel or Google Sheets',
250-
legacyXls: "Legacy .xls files aren't supported — please re-save as .xlsx.",
252+
legacyXls: 'Legacy .xls files aren\'t supported — please re-save as .xlsx.',
251253
unsupportedFile: 'Unsupported file type. Use CSV, TSV, or Excel (.xlsx).',
252254
parseFailed: 'Could not read this file. Please check the format and try again.',
253255
fileNeedsHeader: 'File must contain a header row and at least one data row.',
@@ -268,6 +270,13 @@ const en = {
268270
csvColumn: 'Column',
269271
mapsTo: 'Maps To',
270272
typeMismatch: 'Looks like {{type}}',
273+
autoMatched: 'Auto-matched',
274+
autoMatchedSummary: 'Auto-matched {{count}} column(s) — review and adjust below.',
275+
confidence: {
276+
high: 'High confidence',
277+
medium: 'Medium confidence',
278+
low: 'Low confidence',
279+
},
271280
type: {
272281
number: 'Number',
273282
boolean: 'Boolean',
@@ -285,20 +294,89 @@ const en = {
285294
clickToFix: '— click a highlighted cell to fix it inline.',
286295
showingRows: 'Showing {{shown}} of {{total}} rows',
287296
importing: 'Importing… {{progress}}%',
297+
asyncQueued: 'Queued — preparing to import…',
298+
asyncProcessing: 'Importing {{processed}} of {{total}} rows… {{progress}}%',
299+
asyncLargeHint: 'This file is large, so it will be imported in the background.',
300+
largeSampleNotice: 'Previewing the first {{shown}} of {{total}} rows.',
301+
cancelImport: 'Cancel import',
302+
importCancelled: 'Import cancelled',
303+
resultsTruncated: 'Showing the first {{count}} row results (of {{total}}).',
288304
importComplete: 'Import Complete',
289305
imported: '{{count}} imported',
306+
createdCount: '{{count}} created',
307+
updatedCount: '{{count}} updated',
290308
skippedCount: '{{count}} skipped',
291309
moreErrors: '…and {{count}} more errors',
310+
downloadFailed: 'Download failed rows',
311+
options: 'Import options',
312+
writeMode: 'When a row matches an existing record',
313+
writeModeOpt: {
314+
insert: 'Always create new',
315+
update: 'Update existing (skip if no match)',
316+
upsert: 'Update if matched, else create',
317+
},
318+
matchFields: 'Match on',
319+
matchFieldsPlaceholder: 'Choose match field(s)…',
320+
matchFieldsHint: 'Rows are matched to existing records by these field(s).',
321+
needMatchFields: 'Select at least one field to match on.',
322+
optCreateOptions: 'Keep unknown option values',
323+
optRunAutomations: 'Run automations & triggers',
324+
optTreatHistorical: 'Import as historical data',
325+
optTreatHistoricalHint: '(import completed records as-is — skip state-machine checks and keep their original timestamps & author instead of stamping now)',
326+
optSkipBlankKey: 'Skip rows with a blank match value',
327+
optBackground: 'Import in the background',
328+
optBackgroundHint: '(runs as an undoable job)',
329+
validate: 'Validate data',
330+
validating: 'Validating…',
331+
validateHint: 'Check every row against the server before importing.',
332+
validatePassed: 'All {{ok}} rows are valid.',
333+
validateFailed: '{{ok}} valid, {{errors}} with errors.',
334+
errorRowPrefix: 'Row {{row}}: ',
335+
referenceNotFound: 'No matching record for "{{value}}"',
336+
referenceAmbiguous: '"{{value}}" matches more than one record — use a unique value or the record id',
337+
invalidBoolean: '"{{value}}" is not a valid true/false value',
338+
invalidNumber: '"{{value}}" is not a valid number',
339+
invalidDate: '"{{value}}" is not a valid date',
340+
invalidOption: '"{{value}}" is not one of the allowed options',
341+
requiredValue: 'This field is required',
342+
matchAmbiguous: 'Matches more than one existing record — use a unique value or the record id',
343+
history: 'History',
344+
historyBack: 'Back to import',
345+
historyDescription: 'Recent imports for this object.',
346+
historyHint: 'Background import jobs, newest first.',
347+
historyRefresh: 'Refresh',
348+
historyLoading: 'Loading…',
349+
historyEmpty: 'No imports yet.',
350+
historyUnsupported: 'Import history isn’t available for this data source.',
351+
historyColStatus: 'Status',
352+
historyColRows: 'Rows',
353+
historyColResult: 'Result',
354+
historyColTime: 'When',
355+
errorCount: '{{count}} errors',
356+
undoImport: 'Undo import',
357+
undoing: 'Undoing…',
358+
undoConfirm: 'Undo this import? Records it created will be deleted and records it updated will be restored to their previous values.',
359+
reverted: 'Undone',
360+
jobStatus: {
361+
pending: 'Pending',
362+
running: 'Running',
363+
succeeded: 'Succeeded',
364+
failed: 'Failed',
365+
cancelled: 'Cancelled',
366+
},
292367
cancel: 'Cancel',
293368
back: 'Back',
294369
next: 'Next',
295370
close: 'Close',
296371
importNRows: 'Import {{count}} Rows',
297372
importingProgress: 'Importing…',
298-
requiredMark: '*',
299373
required: 'Required',
300374
invalidType: 'Invalid {{type}}',
301-
legacyReferenceBlocked: 'Import blocked: {{fields}} are relation fields that need the server import route to resolve names into record IDs, and this connection doesn\u2019t support it. Importing them as plain text would corrupt the data. Upgrade the backend/client, or unmap these columns and import them separately.',
375+
legacyReferenceBlocked: 'Import blocked: {{fields}} are relation fields that need the server import route to resolve names into record IDs, and this connection doesn’t support it. Importing them as plain text would corrupt the data. Upgrade the backend/client, or unmap these columns and import them separately.',
376+
missingRequiredHint: 'Can’t continue — required field(s) not mapped: {{fields}}. Add a matching column to your file, or go back and upload one that includes it.',
377+
legacyFallbackNotice: 'Imported via a compatibility fallback: this connection doesn’t support the server import route, so values were saved as text without server-side type coercion. Upgrade the backend/client for full import support (type coercion and relation lookups).',
378+
notAllowed: 'This object is not open for import.',
379+
requiredMark: '*',
302380
},
303381
bulk: {
304382
confirmDefault: 'This will apply to {{count}} record(s).',
@@ -1847,6 +1925,24 @@ const en = {
18471925
shell: {
18481926
tenantHostHint: 'You are signing in to this workspace',
18491927
},
1928+
// These were previously supplied only as inline `defaultValue:` args at the
1929+
// SetPasswordPage call sites, so `zh` was the only pack that could carry a
1930+
// translation. Owning them here makes the page translatable everywhere.
1931+
setPassword: {
1932+
title: 'Set a recovery password',
1933+
description:
1934+
'You signed in via single sign-on. Set a local password so you can still sign in to this environment directly if SSO ever becomes unavailable.',
1935+
email: 'Email',
1936+
newPassword: 'New password',
1937+
confirmPassword: 'Confirm password',
1938+
submit: 'Set password',
1939+
submitting: 'Saving…',
1940+
success: 'Local password set',
1941+
failed: 'Could not set password',
1942+
passwordsMismatch: 'Passwords do not match',
1943+
noSession: 'Your session has expired.',
1944+
backToSignIn: 'Sign in again',
1945+
},
18501946
layout: {
18511947
headline: 'Build powerful business applications, faster.',
18521948
subhead: 'The universal platform for enterprise data management, workflows, and analytics.',

packages/i18n/src/locales/zh.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ const zh = {
295295
importNRows: '导入 {{count}} 行',
296296
importingProgress: '导入中…',
297297
requiredMark: '*',
298+
optTreatHistorical: '作为历史数据导入',
299+
optTreatHistoricalHint: '(按原样导入已完结的记录——跳过状态机校验,保留原始时间戳与作者,而不是记为当前时间)',
300+
missingRequiredHint: '无法继续——以下必填字段未映射:{{fields}}。请在文件中添加对应的列,或返回上一步重新上传包含该列的文件。',
301+
legacyFallbackNotice: '已通过兼容模式导入:当前连接不支持服务端导入接口,因此所有值均以文本写入,未做服务端类型转换。请升级后端/客户端以获得完整的导入支持(类型转换与关联查找)。',
298302
required: '必填',
299303
invalidType: '{{type}} 格式无效',
300304
legacyReferenceBlocked: '导入已阻止:{{fields}} 是关联字段,需要服务端导入接口把名称解析为记录 ID,而当前连接不支持。按纯文本导入会写坏数据。请升级后端/客户端,或取消这些列的映射后再导入。',
@@ -1361,6 +1365,7 @@ const zh = {
13611365
toggleDarkMode: '切换深色模式',
13621366
},
13631367
commandPalette: {
1368+
title: '命令面板',
13641369
placeholder: '输入命令或搜索...',
13651370
noResults: '未找到结果。',
13661371
searching: '搜索中…',
@@ -1508,6 +1513,8 @@ const zh = {
15081513
buildCrm: '帮我搭建一个 CRM:客户、联系人、商机,并建立它们之间的关系。',
15091514
buildApp: '做一个项目管理应用:项目、任务、成员。',
15101515
buildFlow: '设计一个工单系统:工单、优先级、状态流转。',
1516+
buildInventory: '做一个库存管理应用:商品、库存量、供应商,以及低库存预警。',
1517+
buildRecruiting: '做一个招聘跟踪应用:候选人、在招岗位、面试阶段和面试记录。',
15111518
},
15121519
generic: {
15131520
help: '你可以帮我做什么?',
@@ -2035,6 +2042,7 @@ const zh = {
20352042
settings: '工作区设置',
20362043
},
20372044
help: {
2045+
keyboardShortcuts: '键盘快捷键',
20382046
onThisPage: '本页目录',
20392047
appDocs: '本应用文档',
20402048
allDocs: '全部文档',

0 commit comments

Comments
 (0)