diff --git a/config/scripts/locale-cross-locale-key-overrides.mjs b/config/scripts/locale-cross-locale-key-overrides.mjs index 124fb8f32e55..85b937b3244d 100644 --- a/config/scripts/locale-cross-locale-key-overrides.mjs +++ b/config/scripts/locale-cross-locale-key-overrides.mjs @@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = { 'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': { zh: '测试版', ja: 'ベータ' + }, + // Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state. + 'auto.components.PullRequestPage.7b8f6bf6d8': { + zh: '开放' + }, + // This "Open" is the button that opens the config file, not an issue state. + 'auto.components.settings.McpConfigFileRow.e720c139cd': { + zh: '打开' + }, + // Terminal cursor-color group: the on-screen cursor, not the Cursor editor. + 'auto.components.settings.TerminalWindowSection.c9e1fdf42f': { + ko: '커서', + zh: '光标' + }, + 'auto.components.onboarding.ThemeStep.ab2a583a97': { + ko: '커서', + zh: '光标' + }, + // Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors. + 'auto.components.sidebar.workspace.status.6437a8c253': { + zh: '天蓝', + ja: '空色' + }, + 'auto.components.sidebar.workspace.status.caabd5ca85': { + zh: '锌灰' } } diff --git a/config/scripts/locale-generic-ui-terms.mjs b/config/scripts/locale-generic-ui-terms.mjs new file mode 100644 index 000000000000..2b41a324ce64 --- /dev/null +++ b/config/scripts/locale-generic-ui-terms.mjs @@ -0,0 +1,102 @@ +// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate +// these, so the policy must neither pin the whole value to English nor revert the translation +// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin +// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word. + +// Renderings a locale is expected to use. They are translations, never machine-translation +// errors, so a brand revert must leave them alone. Nonsense forms that share the same English +// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse") +// stay in BRAND_MISTRANSLATIONS and keep getting reverted. +const GENERIC_TERM_FAMILIES = [ + { + terms: ['Agent', 'Agents', 'agent', 'agents'], + renderings: { + ko: ['에이전트'], + ja: ['エージェント'], + zh: ['代理', '智能体'], + es: ['Agente', 'agente', 'Agentes', 'agentes'] + } + }, + { + terms: ['Commit', 'Commits', 'commit', 'commits'], + renderings: { + ko: ['커밋'], + ja: ['コミット'], + zh: ['提交'], + es: [ + 'Confirmación', + 'confirmación', + 'Confirmaciones', + 'confirmaciones', + 'Confirmar', + 'confirmar' + ] + } + }, + { + terms: ['Continue'], + renderings: { + ko: ['계속하다', '계속'], + ja: ['続ける', '続行'], + zh: ['继续'], + es: ['Continuar', 'continuar'] + } + }, + { + terms: ['Repo', 'Repos', 'repo', 'repos'], + renderings: { + ko: ['저장소', '레포'], + ja: ['リポジトリ', 'リポ'], + zh: ['存储库', '仓库'], + es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios'] + } + }, + { + terms: ['Terminal', 'Terminals', 'terminal', 'terminals'], + renderings: { + ko: ['터미널'], + ja: ['ターミナル'], + zh: ['终端'], + es: ['Terminales', 'terminales'] + } + } +] + +export const LOCALIZABLE_GENERIC_TERMS = new Set( + GENERIC_TERM_FAMILIES.flatMap((family) => family.terms) +) + +const RENDERINGS_BY_TERM = new Map( + GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings])) +) + +export function isLocalizableGenericTerm(term) { + return LOCALIZABLE_GENERIC_TERMS.has(term) +} + +export function isCanonicalGenericRendering(term, locale, form) { + return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false +} + +export function canonicalGenericRenderings(locale) { + return GENERIC_TERM_FAMILIES.flatMap((family) => + (family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms })) + ) +} + +function spansOf(value, needle) { + const spans = [] + for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) { + spans.push([at, at + needle.length]) + } + return spans +} + +// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the +// electrical-connector mistranslation would cut a valid word in half. +export function overlapsCanonicalRendering(term, locale, value, start, end) { + const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? [] + return renderings.some((form) => + spansOf(value, form).some(([from, to]) => start < to && from < end) + ) +} diff --git a/config/scripts/locale-generic-ui-terms.test.mjs b/config/scripts/locale-generic-ui-terms.test.mjs new file mode 100644 index 000000000000..42a7d63d0b3b --- /dev/null +++ b/config/scripts/locale-generic-ui-terms.test.mjs @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' + +import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs' +import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs' + +// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because +// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin +// the boundary: generic words stay translated, real brand names stay Latin. +describe('locale generic UI terms', () => { + it('keeps a translated generic term when the whole value is that term', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.settings.AppearancePane.terminalTitle', + enValue: 'Terminal', + localeValue: '터미널', + locale: 'ko' + }) + ).toBe('터미널') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.QuickCommandsPane.4ccc63da87', + enValue: 'Agent', + localeValue: '에이전트', + locale: 'ko' + }) + ).toBe('에이전트') + expect( + repairTranslatedValue({ + key: 'auto.components.mobile.MobileHero.a8fb43cf1c', + enValue: 'Continue', + localeValue: '继续', + locale: 'zh' + }) + ).toBe('继续') + }) + + it('keeps a translated generic term inside a sentence', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.settings.EditorFontSection.matchTerminal', + enValue: 'Match terminal font', + localeValue: '터미널 글꼴과 동일', + locale: 'ko' + }) + ).toBe('터미널 글꼴과 동일') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel', + enValue: 'Toggle Agent Dashboard', + localeValue: '에이전트 대시보드 전환', + locale: 'ko' + }) + ).toBe('에이전트 대시보드 전환') + expect( + repairTranslatedValue({ + key: 'components.agentSessionContinuation.dialogTitle', + enValue: 'Continue in New Session', + localeValue: '新しいセッションで続ける', + locale: 'ja' + }) + // 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive. + ).toBe('新規セッションで続ける') + }) + + it('still reverts real brand names that machine translation localized', () => { + expect( + repairTranslatedValue({ + key: 'auto.stats.StatsPane.7d26110cea', + enValue: 'Codex', + localeValue: '사본', + locale: 'ko' + }) + ).toBe('Codex') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.appearance.search.9ae151b26b', + enValue: 'linear', + localeValue: '线性', + locale: 'zh' + }) + ).toBe('Linear') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.NetworkPane.tailscale', + enValue: 'Connect over Tailscale', + localeValue: '通过尾鳞连接', + locale: 'zh' + }) + ).toBe('通过 Tailscale 连接') + }) + + it('still reverts nonsense renderings of a generic term', () => { + // 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge". + expect( + repairTranslatedValue({ + key: 'auto.components.agent.AgentCombobox.986f946354', + enValue: 'Blank Terminal', + localeValue: '空白端子', + locale: 'zh' + }) + ).toBe('空白 Terminal') + expect( + repairTranslatedValue({ + key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', + enValue: 'Commit', + localeValue: 'Comprometerse', + locale: 'es' + }) + ).toBe('Commit') + expect( + repairTranslatedValue({ + key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', + enValue: 'Markdown', + localeValue: '가격 인하', + locale: 'ko' + }) + ).toBe('Markdown') + }) + + it('does not cut a valid word that contains a nonsense rendering', () => { + // 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子. + expect( + repairTranslatedValue({ + key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1', + enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.', + localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。', + locale: 'zh' + }) + ).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。') + }) + + it('still fills in a translation when the catalog holds English', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', + enValue: 'Terminal 1', + localeValue: 'Terminal 1', + locale: 'ko' + }) + ).toBe('터미널 1') + expect( + repairTranslatedValue({ + key: 'auto.store.slices.worktrees.889487d8bb', + enValue: 'Dismiss', + localeValue: '해고하다', + locale: 'ko' + }) + ).toBe('닫기') + }) + + it('keeps agent catalog product names in English by key, not by word', () => { + expect( + repairTranslatedValue({ + key: 'auto.lib.agent.catalog.9e2a9bb87b', + enValue: 'Continue', + localeValue: '继续', + locale: 'zh' + }) + ).toBe('Continue') + expect( + repairTranslatedValue({ + key: 'auto.lib.agent.catalog.760bc6883d', + enValue: 'Codex', + localeValue: '사본', + locale: 'ko' + }) + ).toBe('Codex') + }) + + it('does not list a generic term as never-translatable', () => { + const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term)) + expect(pinned).toEqual([]) + }) +}) diff --git a/config/scripts/locale-ja-value-overrides.mjs b/config/scripts/locale-ja-value-overrides.mjs index 7f9eeb60af73..30954473186b 100644 --- a/config/scripts/locale-ja-value-overrides.mjs +++ b/config/scripts/locale-ja-value-overrides.mjs @@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = { 'Launch plan': '起動プラン', 'Launch agent': 'エージェントを起動', 'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動', - Play: 'Play', + Play: '再生', Action: '操作', Actions: '操作', action: '操作', @@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = { 'Pick a base branch below': '以下のベースブランチを選択', 'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択', 'Local project, Git repo, or folder with many repos': - 'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー', + 'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', 'Enter passphrase': 'パスフレーズを入力', 'Enter password': 'パスワードを入力', 'Enter the passphrase for': 'のパスフレーズを入力', diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index d0577e80babd..d1b4b853bd75 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = { ja: 'クローズ' }, 'auto.components.GitHubItemDialog.dc1ca081a8': { - ko: '진행 중', - zh: '进行中', + ko: '열림', + zh: '开放', ja: 'オープン' }, 'auto.components.tab.bar.TabBarCreateEntry.b27864279e': { @@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = { zh: '打开 Linear 任务', ja: 'Linear タスクを開く' }, + // Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action. 'auto.components.sidebar.SidebarNav.c86d83b5c3': { - ko: '새로 만들기', - zh: '新建', - ja: '新規' + ko: '신규', + zh: '新功能', + ja: '新機能' }, 'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': { ko: 'Discord', diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 9b40ad61f503..e8d13941ffaa 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -752,6 +752,21 @@ "auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": { "ko": "{{value1}}단계 중 {{value0}}단계" }, + "auto.components.contextual.tours.ContextualTourOverlaySurface.complete": { + "ko": "완료" + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": { + "ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요." + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": { + "ko": "자동화란 무엇인가요?" + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": { + "ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다." + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": { + "ko": "결과 확인" + }, "auto.components.crash.report.CrashReportDialog.88fea8e84e": { "ko": "보내지 않음" }, @@ -1868,6 +1883,9 @@ "auto.components.right.sidebar.SourceControl.3a231c845b": { "ko": "알 수 없음" }, + "auto.components.right.sidebar.SourceControl.6d7f2a47e5": { + "ko": "폴더의 변경 사항 취소" + }, "auto.components.right.sidebar.SourceControl.812cb992ee": { "ko": "{{value0}}에서 열기" }, @@ -1880,6 +1898,9 @@ "auto.components.right.sidebar.SourceControl.9bb062a886": { "ko": "commit 되지 않음" }, + "auto.components.right.sidebar.SourceControl.a5e5a11090": { + "ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다." + }, "auto.components.right.sidebar.SourceControl.aaf1451654": { "ko": "초안 {{value0}} 만들기" }, @@ -1994,6 +2015,9 @@ "auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": { "ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?" }, + "auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": { + "ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다." + }, "auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": { "ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?" }, @@ -2015,6 +2039,9 @@ "auto.components.right.sidebar.source.control.primary.action.1d47e850cf": { "ko": "연결된 리뷰 브랜치에 업데이트 푸시" }, + "auto.components.right.sidebar.source.control.primary.action.5a477d80cb": { + "ko": "모든 변경 사항 스테이징" + }, "auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": { "ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요." }, @@ -2162,6 +2189,9 @@ "auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": { "ko": "{assistantMessage}" }, + "auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": { + "ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다." + }, "auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": { "ko": "fix-login-flow" }, @@ -2228,6 +2258,9 @@ "auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": { "ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다." }, + "auto.components.settings.DevToolsPane.orcaCloudDescription": { + "ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다." + }, "auto.components.settings.DeveloperPermissionsPane.16381e040a": { "ko": "마이크" }, @@ -2255,6 +2288,12 @@ "auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": { "ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다." }, + "auto.components.settings.EphemeralVmsPane.recipes": { + "ko": "레시피" + }, + "auto.components.settings.EphemeralVmsPane.whatTitle": { + "ko": "이 스킬로 함께 하는 작업" + }, "auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": { "ko": "편집기" }, @@ -2727,7 +2766,7 @@ "ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다." }, "auto.components.settings.SshTargetForm.137e88ce8d": { - "ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:" + "ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다." }, "auto.components.settings.SshTargetForm.2ee9bcd2e8": { "ko": "server, deploy@server:2222, ssh://server" @@ -3710,6 +3749,9 @@ "auto.components.settings.ssh.search.f9493b80c0": { "ko": "서버" }, + "auto.components.settings.source.control.action.recipe.options.commitMessage": { + "ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다." + }, "auto.components.settings.task.tracker.integration.cards.1a12e33fe5": { "ko": "Linear 액세스 추가" }, @@ -4683,7 +4725,7 @@ "ko": "중지 중" }, "auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": { - "ko": "Space" + "ko": "저장 공간" }, "auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": { "ko": "확보 가능" @@ -4953,7 +4995,7 @@ "ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다." }, "auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": { - "ko": "Space" + "ko": "저장 공간" }, "auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": { "ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간." diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 1d128a67897c..19db7c753ec5 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = { 'Install the Orca skill so agents know to use the Orca CLI.': '에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.', 'Local project, Git repo, or folder with many repos': - '로컬 프로젝트, Git repo 또는 repos가 많은 폴더', + '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.': 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.', 'changed since you last approved. Re-review before it runs': diff --git a/config/scripts/locale-phrase-fixes.mjs b/config/scripts/locale-phrase-fixes.mjs index fec68ef8b5d7..b3c8026cea55 100644 --- a/config/scripts/locale-phrase-fixes.mjs +++ b/config/scripts/locale-phrase-fixes.mjs @@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = { { pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' }, { pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' }, { pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' }, - { pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' }, - { pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' }, - { pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' }, + // Swatch labels want the bare color noun, not the adjectival 的 form — and not English. + { pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' }, + { pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' }, + { pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' }, { pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' }, { pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' }, ...ZH_PHRASE_FIXES_ROUND5 diff --git a/config/scripts/locale-prose-term-exemptions.mjs b/config/scripts/locale-prose-term-exemptions.mjs deleted file mode 100644 index fbe2d483fa1f..000000000000 --- a/config/scripts/locale-prose-term-exemptions.mjs +++ /dev/null @@ -1,19 +0,0 @@ -const LOCALIZED_PROSE_TERM_KEYS = new Set([ - 'auto.hooks.useMacosTccPromptNotice.description', - 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8' -]) - -const LOCALIZABLE_PROSE_TERMS = new Set([ - 'Agent', - 'Agents', - 'agent', - 'agents', - 'Terminal', - 'Terminals', - 'terminal', - 'terminals' -]) - -export function isLocalizedProseTermContext(term, key) { - return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term) -} diff --git a/config/scripts/locale-repair-catalog-missing-leaves.test.mjs b/config/scripts/locale-repair-catalog-missing-leaves.test.mjs new file mode 100644 index 000000000000..65e142e5149a --- /dev/null +++ b/config/scripts/locale-repair-catalog-missing-leaves.test.mjs @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { repairCatalog } from './locale-translation-policy.mjs' + +// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet +// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work. +describe('repairCatalog with un-bootstrapped keys', () => { + const enCatalog = { + auto: { + lib: { agent: { catalog: { '760bc6883d': 'Codex' } } }, + components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } } + } + } + + const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } }) + + it('skips leaves the locale catalog is missing instead of throwing', () => { + for (const locale of ['ko', 'ja', 'zh', 'es']) { + const localeCatalog = translatedOnly() + expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow() + expect(localeCatalog.auto.components, locale).toBeUndefined() + } + }) + + it('still repairs the leaves that are present', () => { + const localeCatalog = translatedOnly() + expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1) + expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex') + }) +}) diff --git a/config/scripts/locale-translation-policy-ko-round5.test.mjs b/config/scripts/locale-translation-policy-ko-round5.test.mjs index b3f88e4e1414..e03157e0bc60 100644 --- a/config/scripts/locale-translation-policy-ko-round5.test.mjs +++ b/config/scripts/locale-translation-policy-ko-round5.test.mjs @@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.', locale: 'ko' }) - ).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') + ).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') expect( repairTranslatedValue({ key: 'auto.components.editor.MarkdownPreview.322afab6ff', @@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => { ).toBe('파이프라인') }) - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', @@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '터미널 1', locale: 'ko' }) - ).toBe('Terminal 1') + ).toBe('터미널 1') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '에이전트 스킬', locale: 'ko' }) - ).toBe('Agent 스킬') + ).toBe('에이전트 스킬') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '푸시되지 않은 커밋', locale: 'ko' }) - ).toBe('푸시되지 않은 commits') + ).toBe('푸시되지 않은 커밋') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', @@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '레포', locale: 'ko' }) - ).toBe('Repo') + ).toBe('레포') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', locale: 'ko' }) - ).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더') + ).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더') }) it('re-glues Korean particles after Latin terms without gluing content words', () => { diff --git a/config/scripts/locale-translation-policy.es-round5.test.mjs b/config/scripts/locale-translation-policy.es-round5.test.mjs index b52afceb02d3..3bf5a77fb3a0 100644 --- a/config/scripts/locale-translation-policy.es-round5.test.mjs +++ b/config/scripts/locale-translation-policy.es-round5.test.mjs @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest' import { repairTranslatedValue } from './locale-translation-policy.mjs' describe('locale-translation-policy es round 5', () => { - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'mensaje de confirmación', locale: 'es' }) - ).toBe('mensaje de Commit') + ).toBe('mensaje de confirmación') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', @@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'repositorio', locale: 'es' }) - ).toBe('Repo') + ).toBe('repositorio') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios', locale: 'es' }) - ).toBe('Proyecto local, repo de Git o carpeta con muchos repos') + ).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios') }) }) diff --git a/config/scripts/locale-translation-policy.ja-round5.test.mjs b/config/scripts/locale-translation-policy.ja-round5.test.mjs index 3100efc3abea..219bc33113b4 100644 --- a/config/scripts/locale-translation-policy.ja-round5.test.mjs +++ b/config/scripts/locale-translation-policy.ja-round5.test.mjs @@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: '遊ぶ', locale: 'ja' }) - ).toBe('Play') + ).toBe('再生') expect( repairTranslatedValue({ key: 'auto.components.TaskPage.8396825a14', @@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => { ).toBe('まずプロジェクトを追加') }) - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3', @@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'エージェントのスキル', locale: 'ja' }) - ).toBe('Agent のスキル') + ).toBe('エージェントのスキル') expect( repairTranslatedValue({ key: 'auto.components.tab.bar.TabBar.3d5d6c960d', @@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'コミット', locale: 'ja' }) - ).toBe('commits') + ).toBe('コミット') expect( repairTranslatedValue({ key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df', @@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'リポ', locale: 'ja' }) - ).toBe('Repo') + ).toBe('リポ') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => { 'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', locale: 'ja' }) - ).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー') + ).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー') }) }) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index a3feb5f59dfd..50a490dc184a 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -1,5 +1,8 @@ import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs' -import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs' +import { + isCanonicalGenericRendering, + overlapsCanonicalRendering +} from './locale-generic-ui-terms.mjs' import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs' import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs' import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs' @@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.' // Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac). export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX] +// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to +// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs +// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead. export const NEVER_TRANSLATE_VALUES = new Set([ - 'Agent', - 'Agents', 'Aider', 'Amp', 'Android', @@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Codebuff', 'Codex', 'Command Code', - 'Continue', 'Cursor', 'Droid', 'Devin', @@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Pi', 'PostHog', 'Qwen Code', - 'Repo', - 'Repos', 'Rovo Dev', - 'Commit', - 'Commits', 'Markdown', - 'Terminal', - 'Terminals', 'VS Code', 'Warp', 'Zed', - 'agent', - 'agents', 'android', 'codex', - 'commit', - 'commits', 'gemini', 'claude', 'markdown', - 'repo', - 'repos', - 'terminal', - 'terminals', 'gh', 'idle', 'anthropic', @@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) { return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value) } +function replaceMistranslatedForm(value, wrong, brand, locale) { + let result = '' + let cursor = 0 + for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) { + const end = at + wrong.length + result += value.slice(cursor, at) + result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand + cursor = end + } + return result + value.slice(cursor) +} + function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { let result = localeValue const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {} @@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (isScreenCursorContext(brand, enValue, key)) { continue } - if (isLocalizedProseTermContext(brand, key)) { - continue - } if (includesPreservedLatinTerm(result, brand)) { continue } @@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (!result.includes(wrong)) { continue } + // Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it + // rewrote ~2000 translated values back to English on every repair run. + if (isCanonicalGenericRendering(brand, locale, wrong)) { + continue + } // Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand. if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) { continue } - result = result.replaceAll(wrong, brand) + result = replaceMistranslatedForm(result, wrong, brand, locale) } } @@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) { for (const leaf of leaves) { const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog) + // Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only + // rewrites values that already exist, so skip instead of crashing on undefined. + if (typeof current !== 'string') { + continue + } const next = repairTranslatedValue({ key: leaf.key, enValue: leaf.value, diff --git a/config/scripts/locale-translation-policy.test.mjs b/config/scripts/locale-translation-policy.test.mjs index 1722f8f946ee..d919de96cff3 100644 --- a/config/scripts/locale-translation-policy.test.mjs +++ b/config/scripts/locale-translation-policy.test.mjs @@ -80,7 +80,7 @@ describe('locale-translation-policy', () => { localeValue: '壊れた小切手に対して AI エージェントを開始しました。', locale: 'ja' }) - ).toBe('失敗したチェックに対して AI agent を開始しました。') + ).toBe('失敗したチェックに対して AI エージェントを開始しました。') expect( repairTranslatedValue({ key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94', @@ -88,7 +88,7 @@ describe('locale-translation-policy', () => { localeValue: '電話機からターミナルとエージェントを制御します。', locale: 'ja' }) - ).toBe('スマートフォンから terminals と agents を操作') + ).toBe('スマートフォンからターミナルとエージェントを操作') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.934add88b6', @@ -267,7 +267,7 @@ describe('locale-translation-policy', () => { localeValue: '未已检测代理', locale: 'zh' }) - ).toBe('未检测到 agents') + ).toBe('未检测到代理') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -275,7 +275,7 @@ describe('locale-translation-policy', () => { localeValue: '代理技巧', locale: 'zh' }) - ).toBe('Agent 技能') + ).toBe('代理技能') expect( repairTranslatedValue({ key: 'auto.components.settings.appearance.search.9ae151b26b', @@ -358,7 +358,7 @@ describe('locale-translation-policy', () => { localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。', locale: 'ja' }) - ).toBe('commit するものはありません。PR はすでにマージされています。') + ).toBe('コミットするものはありません。PR はすでにマージされています。') expect( repairTranslatedValue({ key: 'auto.components.settings.integrations.search.581844769a', @@ -546,7 +546,7 @@ describe('locale-translation-policy', () => { localeValue: '玩', locale: 'zh' }) - ).toBe('Play') + ).toBe('播放') expect( repairTranslatedValue({ key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', diff --git a/config/scripts/locale-translation-policy.zh-round5.test.mjs b/config/scripts/locale-translation-policy.zh-round5.test.mjs index 6e451c6d89b5..28901fba8d45 100644 --- a/config/scripts/locale-translation-policy.zh-round5.test.mjs +++ b/config/scripts/locale-translation-policy.zh-round5.test.mjs @@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => { ).toBe('集成') }) - it('keeps Terminal as a product surface term', () => { + // Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical + // connector") mistranslation still reverts to Latin. + it('keeps terminal translated but reverts the 端子 mistranslation', () => { expect( repairTranslatedValue({ key: 'auto.components.settings.Settings.3de4bbb841', @@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端', locale: 'zh' }) - ).toBe('Terminal') + ).toBe('终端') expect( repairTranslatedValue({ key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', @@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端 1', locale: 'zh' }) - ).toBe('Terminal 1') + ).toBe('终端 1') expect( repairTranslatedValue({ key: 'auto.components.agent.AgentCombobox.986f946354', @@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '分体式端子右', locale: 'zh' }) - ).toBe('向右拆分 Terminal') + ).toBe('向右拆分终端') expect( repairTranslatedValue({ key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019', @@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端Cursor', locale: 'zh' }) - ).toBe('Terminal Cursor') + ).toBe('终端 Cursor') expect( repairTranslatedValue({ key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9', @@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => { ).toBe('新 Terminal') }) - it('keeps workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.sidebar.SidebarNav.9c95e1ce91', @@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '代理', locale: 'zh' }) - ).toBe('Agents') + ).toBe('代理') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.28986b3747', @@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '已启动 AI 代理处理失败的检查。', locale: 'zh' }) - ).toBe('已启动 AI agent 处理失败的检查。') + ).toBe('已启动 AI 代理处理失败的检查。') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '次提交', locale: 'zh' }) - ).toBe('commits') + ).toBe('次提交') expect( repairTranslatedValue({ key: 'auto.store.slices.worktrees.d1d78a7baa', @@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => { 'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。', locale: 'zh' }) - ).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。') + ).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。') }) it('does not confuse proxy copy with Agent terminology', () => { @@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹', locale: 'zh' }) - ).toBe('本地项目、Git repo 或包含多个 repos 的文件夹') + ).toBe('本地项目、Git 仓库或包含多个仓库的文件夹') }) it('keeps product, provider, code, and shell tokens untranslated', () => { diff --git a/config/scripts/locale-value-overrides.mjs b/config/scripts/locale-value-overrides.mjs index e237bca76b09..ea49d748250f 100644 --- a/config/scripts/locale-value-overrides.mjs +++ b/config/scripts/locale-value-overrides.mjs @@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'OpenCode Go': 'OpenCode Go', 'Open in Cursor': 'Abrir en Cursor', 'Local project, Git repo, or folder with many repos': - 'Proyecto local, repo de Git o carpeta con muchos repos' + 'Proyecto local, repositorio de Git o carpeta con muchos repositorios' }, ko: { Save: '저장', @@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = { teams: '团队', passing: '通过', 'opened this issue': '创建了此议题', - Open: '进行中', + // No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放), + // so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide. 'Join Discord': '加入 Discord', 'Pull request merged': '拉取请求已合并', 'Agent Skills': '代理技能', diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index d11c747c1df0..087091a3f3ef 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = { Inline: '内联', thread: '帖子串', destructive: 'destructive', - sheet: 'sheet', - page: 'page', + sheet: '面板', + page: '页面', Annotation: '批注', 'check #': '检查 #', by: '由', @@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = { '仓库操作方案。在此仓库自定义之前,将使用全局设置。', 'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.': '为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。', - Play: 'Play', - Flag: 'Flag', - Zinc: 'Zinc', - Rose: 'Rose', - Emerald: 'Emerald', - Amber: 'Amber', - Violet: 'Violet', - Sky: 'Sky', - Blue: 'Blue', - Neutral: 'Neutral', + // Workspace status icon and color swatch labels; siblings below are translated, so English + // pins here would leave the picker half-Chinese on the next catalog regeneration. + Play: '播放', + Flag: '标记', + Zinc: '锌灰', + Rose: '玫瑰', + Emerald: '翡翠', + Amber: '琥珀', + Violet: '紫罗兰', + Sky: '天蓝', + Blue: '蓝色', + Neutral: '中性', Dashed: '虚线', Dot: '圆点', Circle: '圆形', @@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = { '每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。', 'Show Linear in the Tasks source picker and sidebar shortcuts.': '在任务源选择器和侧边栏快捷方式中显示 Linear。', - 'Local project, Git repo, or folder with many repos': - '本地项目、Git repo 或包含多个 repos 的文件夹', + 'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹', 'Staged Changes': '已暂存的更改', Changes: '更改', 'Untracked Files': '未跟踪文件', @@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = { 'Split Down': '向下拆分', 'Split Left': '向左拆分', 'Split Right': '向右拆分', - 'Split Terminal Down': '向下拆分 Terminal', - 'Split Terminal Right': '向右拆分 Terminal', + 'Split Terminal Down': '向下拆分终端', + 'Split Terminal Right': '向右拆分终端', 'Optional account switching for Claude while preserving shared chat context.': 'Claude 的可选账户切换,同时保留共享聊天上下文。', 'Countdown timer showing time until prompt cache expires (Claude agents).': diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 901c651798d0..d3e25d3fd2ca 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -6,6 +6,9 @@ import process from 'node:process' // TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. import ts from 'typescript-api' +import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs' +import { repairTranslatedValue } from './locale-translation-policy.mjs' + const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain']) @@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) { return changed } +// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms +// back to English, so drift only surfaces when someone regenerates the catalog. +export function collectGenericTermRegressions(enEntries, localeEntries, localeName) { + const renderings = canonicalGenericRenderings(localeName) + if (renderings.length === 0) { + return [] + } + + const regressions = [] + for (const [key, enValue] of enEntries) { + const localeValue = localeEntries.get(key) + if (typeof enValue !== 'string' || typeof localeValue !== 'string') { + continue + } + const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName }) + if (repaired === localeValue) { + continue + } + // Why: {{agent}} is an interpolation name, not English copy the reader sees. + const repairedCopy = repaired.replace(PLACEHOLDER_RE, '') + for (const { form, terms } of renderings) { + if (!localeValue.includes(form) || repaired.includes(form)) { + continue + } + if ( + terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy)) + ) { + regressions.push({ key, form, localeValue, repaired }) + break + } + } + } + return regressions +} + +function formatGenericTermRegressions(regressions) { + return regressions + .map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`) + .join('\n') +} + function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } = collectLocaleParityIssues(enCatalog, localeCatalog) + const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName) // Why: feature PRs own English declarations; absent target leaves deliberately // use i18next's existing English fallback until a localization PR supplies them. @@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { `${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.` ) - if (extraInLocale.length > 0 || interpolationMismatches.length > 0) { + if ( + extraInLocale.length > 0 || + interpolationMismatches.length > 0 || + genericTermRegressions.length > 0 + ) { console.error(`Locale catalog validation failed for ${localeName}.json.`) if (extraInLocale.length > 0) { console.error('') @@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`) } } + if (genericTermRegressions.length > 0) { + console.error('') + console.error( + 'repair-locale-catalog would rewrite these translated terms back to English.', + 'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs', + 'instead of listing its translation as a mistranslation.' + ) + console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20))) + if (genericTermRegressions.length > 20) { + console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`) + } + } return 1 } diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index 89e24bc0c0dd..4bf3d7d7ebad 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -4,7 +4,10 @@ import path from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs' +import { + collectGenericTermRegressions, + main as verifyLocalizationCatalog +} from './verify-localization-catalog.mjs' function writeJson(filePath, value) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') @@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => { report.mockRestore() } }) + + // Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English. + it('flags catalog values the repair policy would rewrite back to English', () => { + const enEntries = new Map([['auto.example.commitLabel', 'Commit message']]) + + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de confirmación']]), + 'es' + ) + ).toEqual([]) + + // A locale whose committed value is the English term is stable, not a regression. + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de Commit']]), + 'es' + ) + ).toEqual([]) + + // 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected. + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]), + 'es' + ) + ).toEqual([]) + }) + + it('ignores interpolation names when looking for English rewrites', () => { + expect( + collectGenericTermRegressions( + new Map([ + ['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}'] + ]), + new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]), + 'zh' + ) + ).toEqual([]) + }) }) diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index dd1ccb4f2d79..969765c4dcaa 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -48,6 +48,7 @@ import type { } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' type JiraIssueWorkspaceProps = { issue: JiraIssue | null @@ -56,22 +57,8 @@ type JiraIssueWorkspaceProps = { sourceContext?: TaskSourceContext | null } -const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeFormatter.format(diffHours, 'hour') - } - return relativeFormatter.format(Math.round(diffHours / 24), 'day') + return formatUiRelativeTimeFromDate(input) } function buildJiraBranchName(issue: JiraIssue): string { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 25ee1ffef163..719aecbf4b18 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -50,6 +50,7 @@ import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( @@ -101,22 +102,7 @@ function LinearEditChipAdornment({ } function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } type LinearItemDrawerProps = { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index b430b0c33a3e..0f2fa5c12fb2 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -359,6 +359,7 @@ import { resolveVisibleTaskProvider } from '../../../shared/task-providers' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' import { getGitHubModeButtons, getGitHubTaskKindPresets, @@ -585,29 +586,8 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` } -// Why: Intl.RelativeTimeFormat allocation is non-trivial; hoist to module scope so all rows share one instance instead of allocating per render. -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 4ee1e7c8be6a..2c3f6b28bb19 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -73,6 +73,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -168,24 +169,12 @@ const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { minute: '2-digit' }) -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatAbsoluteDate(timestamp: number): string { return absoluteDateFormatter.format(new Date(timestamp)) } function formatRelativeTime(timestamp: number): string { - const diffMs = timestamp - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTime(timestamp - Date.now()) } function findActivityTerminalPane( diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx new file mode 100644 index 000000000000..b9923d0fc112 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import type { ReactElement, RefObject } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setRendererUiLanguage } from '@/i18n/i18n' +import { + ContextualTourOverlaySurface, + handleContextualTourOverlayKeyDown, + type ActiveTourRenderState +} from './ContextualTourOverlaySurface' + +afterEach(async () => { + await setRendererUiLanguage('en') +}) + +function renderSurface(isLastStep: boolean): ReactElement { + const panelRef: RefObject = { current: null } + const renderState: ActiveTourRenderState = { + rect: new DOMRect(0, 0, 20, 20), + targetElement: document.createElement('button'), + progress: { current: isLastStep ? 2 : 1, total: 2 }, + title: 'Tour title', + body: 'Tour body', + isLastStep, + isFirstStep: !isLastStep, + panelHost: null + } + return ( + + ) +} + +describe('ContextualTourOverlaySurface localization', () => { + it('renders default tour actions in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + + const firstStep = renderToStaticMarkup(renderSurface(false)) + const finalStep = renderToStaticMarkup(renderSurface(true)) + + expect(firstStep).toContain('다음') + expect(firstStep).not.toContain('>Next<') + expect(finalStep).toContain('완료') + expect(finalStep).not.toContain('>Done<') + }) +}) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index a692a6cdaf19..5b6284ae6483 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -107,7 +107,12 @@ export function ContextualTourOverlaySurface({ const stepKey = `${activeTourId}-${renderState.progress.current}` const defaultPrimaryAction = { kind: renderState.isLastStep ? 'complete' : 'next', - label: renderState.isLastStep ? 'Done' : 'Next' + label: renderState.isLastStep + ? translate('auto.components.contextual.tours.ContextualTourOverlaySurface.complete', 'Done') + : translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.38b3155418', + 'Next' + ) } satisfies ContextualTourStepAction const primaryAction = renderState.primaryAction ?? (renderState.hidePrimaryAction ? null : defaultPrimaryAction) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts index d83fafd3fc17..4b2ce9631ca1 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts @@ -1,12 +1,104 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' import { getContextualTour } from '../../../../shared/contextual-tours' +import { setRendererUiLanguage } from '@/i18n/i18n' import { getContextualTourDisplayProgress, getContextualTourMeasurementAction, + measureContextualTourOverlayRenderState, isContextualTourLastDisplayStep } from './contextual-tour-overlay-measurement' +afterEach(async () => { + document.body.replaceChildren() + await setRendererUiLanguage('en') +}) + describe('contextual tour overlay measurement', () => { + it('renders the automation tour copy in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('button') + target.setAttribute('data-contextual-tour-target', 'automations-create') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const result = measureContextualTourOverlayRenderState({ + tour: getContextualTour('automations'), + activeStepIndex: 0, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('자동화란 무엇인가요?') + expect(result.renderState.body).toBe( + '자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요.' + ) + }) + + it('renders the automation results step in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('div') + target.setAttribute('data-contextual-tour-target', 'automations-runs') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const result = measureContextualTourOverlayRenderState({ + tour: getContextualTour('automations'), + activeStepIndex: 1, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('결과 확인') + expect(result.renderState.body).toBe( + '실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다.' + ) + }) + + it('keeps localized copy on its own step when a step is inserted before it', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('button') + target.setAttribute('data-contextual-tour-target', 'automations-create') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const automations = getContextualTour('automations') + const result = measureContextualTourOverlayRenderState({ + tour: { + ...automations, + steps: [ + { + title: 'Inserted step', + body: 'Added ahead of the localized steps.', + targetSelector: '[data-contextual-tour-target="automations-create"]' + }, + ...automations.steps + ] + }, + activeStepIndex: 1, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('자동화란 무엇인가요?') + }) + it('shows all defined browser steps in progress even when step 3 is hidden', () => { const tour = getContextualTour('browser') diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts index a807ade9fe41..1fe945a42cbf 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts @@ -28,6 +28,36 @@ export type ContextualTourOverlayMeasurementResult = telemetryTotalSteps: number } +// Why: keyed by the step's stable id, not its position — inserting a step must +// not shift localized copy onto a neighbour. Thunks keep translate() out of +// module scope so the lookup resolves in the language active at render time. +const LOCALIZED_STEP_COPY: Record string; body: () => string }> = { + 'automations-intro': { + title: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title', + 'What is an automation?' + ), + body: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body', + 'Automations run agent work on a schedule. Add an automation by clicking this button.' + ) + }, + 'automations-results': { + title: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title', + 'Find the results' + ), + body: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body', + 'Runs show when automations ran, what happened, and where to inspect their output.' + ) + } +} + export function getContextualTourDisplayProgress(args: { tour: ContextualTour visibleStepIndexes: readonly number[] @@ -89,6 +119,13 @@ export function measureContextualTourOverlayRenderState(args: { ) const activeStep = args.tour.steps[args.activeStepIndex] const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null + const localizedCopy = activeStep?.id ? LOCALIZED_STEP_COPY[activeStep.id] : undefined + const localizedTitle = localizedCopy ? localizedCopy.title() : activeStep?.title + const localizedBody = localizedCopy + ? localizedCopy.body() + : activeStep + ? getContextualTourStepCopy(activeStep) + : undefined const progress = getContextualTourDisplayProgress({ tour: args.tour, visibleStepIndexes, @@ -135,8 +172,11 @@ export function measureContextualTourOverlayRenderState(args: { rect: target.rect, targetElement: target.element, progress, - title: activeStep.title, - body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), args.keybindings), + title: localizedTitle ?? activeStep.title, + body: formatContextualTourStepCopy( + localizedBody ?? getContextualTourStepCopy(activeStep), + args.keybindings + ), control: activeStep.control, primaryAction, secondaryAction, diff --git a/src/renderer/src/components/github/work-item-state-presentation.tsx b/src/renderer/src/components/github/work-item-state-presentation.tsx index 7e14804fbafa..fadc502fb917 100644 --- a/src/renderer/src/components/github/work-item-state-presentation.tsx +++ b/src/renderer/src/components/github/work-item-state-presentation.tsx @@ -1,24 +1,10 @@ import React from 'react' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function getStateLabel(item: GitHubWorkItem): string { diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index b4e88121bb89..e047495d40b0 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,25 +1,10 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' - -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatLinearIssueRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function buildLinearIssueBranchName(issue: LinearIssue): string { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 7578112895a6..d9db52dd9c6e 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -81,6 +81,7 @@ import { type ProviderAccountRuntimeView } from './provider-account-visibility' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { cn } from '@/lib/utils' import { isWebClientLocation } from '@/lib/web-client-location' import { @@ -104,16 +105,7 @@ function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { if (diffMs < 60_000) { return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now') } - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - const minutes = Math.round(diffMs / 60_000) - if (minutes < 60) { - return formatter.format(-minutes, 'minute') - } - const hours = Math.round(minutes / 60) - if (hours < 24) { - return formatter.format(-hours, 'hour') - } - return formatter.format(-Math.round(hours / 24), 'day') + return formatUiRelativeTime(-diffMs) } function MiniMaxCookieHelpPopover(): React.JSX.Element { diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx index aeb8f4151d8d..b0c55e87204d 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx @@ -4,7 +4,7 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { getLinkRoutingModifierDescription, getLinkRoutingModifierTitle -} from './browser-link-routing-modifier-copy' +} from './browser-link-routing-copy' type BrowserLinkRoutingModifierSettingProps = { settings: Pick diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index d67aaf7ef70e..5c8088cc9bbf 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObjec import type { GlobalSettings } from '../../../../shared/types' import { useAppStore } from '../../store' import { matchesSettingsSearch } from './settings-search' -import { getBrowserPaneSearchEntries, getBrowserLinkRoutingDescription } from './browser-search' +import { getBrowserPaneSearchEntries } from './browser-search' +import { getBrowserLinkRoutingDescription } from './browser-link-routing-copy' import { getBrowserUsePaneSearchEntries } from './browser-use-search' import { getBrowserPaneCombinedSearchEntries } from './browser-pane-search' import { BrowserHomePageSetting } from './BrowserHomePageSetting' diff --git a/src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts b/src/renderer/src/components/settings/browser-link-routing-copy.ts similarity index 58% rename from src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts rename to src/renderer/src/components/settings/browser-link-routing-copy.ts index 1151d3e70e13..5a4d5bac2d65 100644 --- a/src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts +++ b/src/renderer/src/components/settings/browser-link-routing-copy.ts @@ -1,5 +1,30 @@ import { translate } from '@/i18n/i18n' +export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean }): string { + return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click' +} + +// Why: "always" stops being true once inverting is on, so only then does the nested +// row take over the chord sentence — with it off this reads exactly as it always has. +// Each variant is a complete catalog entry: stitched suffixes break in locales whose +// sentence joining differs from English. +export function getBrowserLinkRoutingDescription( + platform: { isMac: boolean }, + modifierInverts = false +): string { + if (modifierInverts) { + return translate( + 'auto.components.settings.BrowserLinkRoutingSetting.descriptionBase', + "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor." + ) + } + return translate( + 'auto.components.settings.BrowserLinkRoutingSetting.description', + "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. {{shortcut}} always uses your system browser.", + { shortcut: getBrowserLinkRoutingShortcutLabel(platform) } + ) +} + /** * Title and description both name the destination the modifier reaches, which is * the opposite of wherever Link Routing points. Kept out of the component so the diff --git a/src/renderer/src/components/settings/browser-link-routing-localization.test.ts b/src/renderer/src/components/settings/browser-link-routing-localization.test.ts new file mode 100644 index 000000000000..613f8e0de1cf --- /dev/null +++ b/src/renderer/src/components/settings/browser-link-routing-localization.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { i18n } from '@/i18n/i18n' +import { getBrowserLinkRoutingDescription } from './browser-link-routing-copy' + +// The Link Routing description was a bare template literal, so it stayed English in every +// locale while its own title and the rest of the pane translated. It carries the platform +// shortcut label, so each locale must interpolate it rather than embed a hardcoded modifier. +const NON_ENGLISH_LOCALES = ['es', 'ja', 'ko', 'zh'] as const + +const SHORTCUT_BY_PLATFORM = { + mac: '⇧⌘-click', + other: 'Shift+Ctrl+click' +} as const + +describe('Link Routing description localization', () => { + afterEach(async () => { + await i18n.changeLanguage('en') + }) + + it('translates the description in every non-English locale on both platforms', async () => { + const english = { + mac: getBrowserLinkRoutingDescription({ isMac: true }), + other: getBrowserLinkRoutingDescription({ isMac: false }) + } + + for (const locale of NON_ENGLISH_LOCALES) { + await i18n.changeLanguage(locale) + + for (const [platform, shortcut] of Object.entries(SHORTCUT_BY_PLATFORM)) { + const description = getBrowserLinkRoutingDescription({ isMac: platform === 'mac' }) + + expect(description, `${locale}/${platform} fell back to English`).not.toBe( + english[platform as keyof typeof english] + ) + // The label is a key symbol, not prose: it stays literal in every locale. + expect(description, `${locale}/${platform} lost the shortcut label`).toContain(shortcut) + expect(description, `${locale}/${platform} leaked a placeholder`).not.toMatch(/\{\{.+?\}\}/) + } + } + }) + + it('keeps each platform label out of the other platform copy', async () => { + for (const locale of ['en', ...NON_ENGLISH_LOCALES]) { + await i18n.changeLanguage(locale) + + expect(getBrowserLinkRoutingDescription({ isMac: true })).not.toContain( + SHORTCUT_BY_PLATFORM.other + ) + expect(getBrowserLinkRoutingDescription({ isMac: false })).not.toContain( + SHORTCUT_BY_PLATFORM.mac + ) + } + }) +}) diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts index 2ac1b67239e5..0df9173b28d3 100644 --- a/src/renderer/src/components/settings/browser-search.test.ts +++ b/src/renderer/src/components/settings/browser-search.test.ts @@ -1,12 +1,25 @@ -import { describe, expect, it } from 'vitest' -import { getBrowserLinkRoutingDescription, getBrowserPaneSearchEntries } from './browser-search' +import { beforeEach, describe, expect, it } from 'vitest' + +import ko from '@/i18n/locales/ko.json' +import { i18n } from '@/i18n/i18n' +import { getBrowserPaneSearchEntries } from './browser-search' import { + getBrowserLinkRoutingDescription, + getBrowserLinkRoutingShortcutLabel, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle -} from './browser-link-routing-modifier-copy' +} from './browser-link-routing-copy' describe('browser settings search copy', () => { - it('uses macOS shortcut keywords for Link Routing search metadata', () => { + it('uses macOS shortcut symbols for Link Routing copy and search metadata', () => { + expect(getBrowserLinkRoutingShortcutLabel({ isMac: true })).toBe('⇧⌘-click') + + const description = getBrowserLinkRoutingDescription({ isMac: true }) + expect(description).toContain('⇧⌘-click') + expect(description).not.toContain('Cmd/Ctrl') + // The copy is translated: a leaked `{{...}}` means the interpolation name drifted from the catalog. + expect(description).not.toMatch(/\{\{.+?\}\}/) + const linkRoutingEntry = getBrowserPaneSearchEntries({ isMac: true }).find( (entry) => entry.title === 'Link Routing' ) @@ -20,7 +33,14 @@ describe('browser settings search copy', () => { expect(defaultZoomEntry?.keywords).toContain('zoom') }) - it('uses Ctrl shortcut keywords for Link Routing search metadata off macOS', () => { + it('uses Ctrl shortcut text for Link Routing copy and search metadata off macOS', () => { + expect(getBrowserLinkRoutingShortcutLabel({ isMac: false })).toBe('Shift+Ctrl+click') + + const description = getBrowserLinkRoutingDescription({ isMac: false }) + expect(description).toContain('Shift+Ctrl+click') + expect(description).not.toContain('Cmd/Ctrl') + expect(description).not.toMatch(/\{\{.+?\}\}/) + const linkRoutingEntry = getBrowserPaneSearchEntries({ isMac: false }).find( (entry) => entry.title === 'Link Routing' ) @@ -102,3 +122,61 @@ describe('browser link routing modifier copy', () => { expect(entry?.keywords).toContain(getLinkRoutingModifierTitle(true)) }) }) + +// The bug this file guards: the Link Routing description was a bare template +// literal, so it stayed English in every locale. Asserting only "no {{...}} leaked" +// cannot catch that — the English literal has no placeholder either. +describe('Link Routing description localization', () => { + const KEY = 'auto.components.settings.BrowserLinkRoutingSetting.description' + const BASE_KEY = 'auto.components.settings.BrowserLinkRoutingSetting.descriptionBase' + + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + it('renders the Korean copy with the shortcut interpolated', async () => { + const koCopy = ( + ko.auto.components.settings.BrowserLinkRoutingSetting as unknown as Record + )['description'] + expect(koCopy).toBeTruthy() + expect(koCopy).toContain('{{shortcut}}') + + i18n.addResourceBundle('ko', 'translation', ko, true, true) + await i18n.changeLanguage('ko') + + const description = getBrowserLinkRoutingDescription({ isMac: true }) + expect(description).toBe(koCopy.replace('{{shortcut}}', '⇧⌘-click')) + expect(description).not.toMatch(/\{\{.+?\}\}/) + // Fails when the copy is a hardcoded English literal. + expect(description).not.toContain("Orca's built-in browser") + + // The entry title is localized too, so match on the description instead. + const entry = getBrowserPaneSearchEntries({ isMac: true }).find( + (item) => item.description === description + ) + expect(entry).toBeDefined() + + await i18n.changeLanguage('en') + expect(getBrowserLinkRoutingDescription({ isMac: true })).toContain("Orca's built-in browser") + }) + + it('renders the Korean copy for the invert-on variant', async () => { + const koBase = ( + ko.auto.components.settings.BrowserLinkRoutingSetting as unknown as Record + )['descriptionBase'] + expect(koBase).toBeTruthy() + + i18n.addResourceBundle('ko', 'translation', ko, true, true) + await i18n.changeLanguage('ko') + + const description = getBrowserLinkRoutingDescription({ isMac: true }, true) + expect(description).toBe(koBase) + // Fails when the invert-on branch regresses to a hardcoded English literal. + expect(description).not.toContain("Orca's built-in browser") + }) + + it('uses the catalog key rather than an inline literal', () => { + expect(i18n.exists(KEY)).toBe(true) + expect(i18n.exists(BASE_KEY)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index a9b204441429..67f5506de42c 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -2,9 +2,10 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { + getBrowserLinkRoutingDescription, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle -} from './browser-link-routing-modifier-copy' +} from './browser-link-routing-copy' type BrowserShortcutPlatform = { isMac: boolean @@ -16,19 +17,6 @@ function getDefaultBrowserShortcutPlatform(): BrowserShortcutPlatform { } } -// Why: "always" stops being true once inverting is on, so only then does the nested -// row take over the chord sentence — with it off this reads exactly as it always has. -export function getBrowserLinkRoutingDescription( - platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform(), - modifierInverts = false -): string { - const base = - "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor." - return modifierInverts - ? base - : `${base} ${platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click'} always uses your system browser.` -} - export function getBrowserPaneSearchEntries( platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform() ): SettingsSearchEntry[] { diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index e1889c2f073f..e9f510628c62 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -99,7 +99,14 @@ export function JiraIntegrationCard(): React.JSX.Element { } checking={checking} statusTone={connected ? 'connected' : 'attention'} - statusLabel={connected ? 'Connected' : 'Not connected'} + statusLabel={ + connected + ? translate('auto.components.settings.jira.integration.card.statusConnected', 'Connected') + : translate( + 'auto.components.settings.jira.integration.card.statusNotConnected', + 'Not connected' + ) + } actions={ !checking ? (