From fc1e03ffa735e0faeecb3f502db44656c72a71fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:42:47 -0700 Subject: [PATCH] fix(mobile): harden localization review gates --- .../mobile-localization-candidate-rules.mjs | 88 +++--- ...bile-localization-candidate-rules.test.mjs | 75 +++++ .../mobile-localization-product-glossary.mjs | 287 ++++++++++++++++++ .../mobile-localization-rendered-variable.mjs | 127 ++++++-- .../mobile-localization-source-bindings.mjs | 11 +- ...bile-localization-translation-bindings.mjs | 185 +++++++---- ...localization-translation-bindings.test.mjs | 50 +++ .../verify-mobile-localization-catalog.mjs | 10 +- ...erify-mobile-localization-catalog.test.mjs | 47 +++ mobile/src/i18n/locales/es.json | 84 ++--- mobile/src/i18n/locales/ja.json | 78 ++--- mobile/src/i18n/locales/ko.json | 78 ++--- mobile/src/i18n/locales/zh.json | 72 ++--- .../terminal-accessory-key-catalog.ts | 34 ++- 14 files changed, 938 insertions(+), 288 deletions(-) create mode 100644 config/scripts/mobile-localization-translation-bindings.test.mjs diff --git a/config/scripts/mobile-localization-candidate-rules.mjs b/config/scripts/mobile-localization-candidate-rules.mjs index c6e25f65f1d..08380394585 100644 --- a/config/scripts/mobile-localization-candidate-rules.mjs +++ b/config/scripts/mobile-localization-candidate-rules.mjs @@ -8,6 +8,8 @@ import { isReturnedByRenderedFunction } from './mobile-localization-rendered-variable.mjs' +const NOTIFICATION_CHANNEL_NAME_NODES = new WeakMap() + const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ 'ariaLabel', 'aria-label', @@ -388,57 +390,80 @@ function isUserVisibleErrorArgument(node, userVisibleErrorSource) { ) } -function resolveObjectLiteral(node, bindings, seen = new Set()) { +function resolveBoundInitializer(node, bindings, seen = new Set()) { if ( ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) ) { - return resolveObjectLiteral(node.expression, bindings, seen) - } - if (ts.isObjectLiteralExpression(node)) { - return node + return resolveBoundInitializer(node.expression, bindings, seen) } if (!bindings || !ts.isIdentifier(node)) { - return undefined + return node } const binding = bindings.resolveBinding(node) if (!binding || seen.has(binding) || !ts.isVariableDeclaration(binding) || !binding.initializer) { - return undefined + return node } seen.add(binding) - return resolveObjectLiteral(binding.initializer, bindings, seen) + return resolveBoundInitializer(binding.initializer, bindings, seen) +} + +function objectPropertyValues(node, propertyName, bindings, seen = new Set()) { + const expression = resolveBoundInitializer(node, bindings, seen) + if (!ts.isObjectLiteralExpression(expression)) { + return [] + } + return expression.properties.flatMap((property) => { + if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propertyName) { + return [property.initializer] + } + if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) { + return [property.name] + } + return ts.isSpreadAssignment(property) + ? objectPropertyValues(property.expression, propertyName, bindings, new Set(seen)) + : [] + }) +} + +function collectBoundStringNodes(expression, bindings, nodes, seen = new Set()) { + const resolved = resolveBoundInitializer(expression, bindings, seen) + if (ts.isStringLiteralLike(resolved) || ts.isTemplateExpression(resolved)) { + nodes.add(resolved) + return + } + ts.forEachChild(resolved, (child) => + collectBoundStringNodes(child, bindings, nodes, new Set(seen)) + ) } function isNotificationChannelName(node, bindings) { - const property = findAncestor(node, ts.isPropertyAssignment) - const object = property?.parent - if ( - !property || - propertyNameText(property.name) !== 'name' || - !object || - !ts.isObjectLiteralExpression(object) - ) { - return false + const sourceFile = node.getSourceFile() + const cached = NOTIFICATION_CHANNEL_NAME_NODES.get(sourceFile) + if (cached) { + return cached.has(node) } - let found = false + const nodes = new Set() function visit(current) { - if (found) { - return - } if ( ts.isCallExpression(current) && current.arguments[1] && - expressionNameText(current.expression)?.endsWith('setNotificationChannelAsync') && - resolveObjectLiteral(current.arguments[1], bindings) === object + expressionNameText(current.expression)?.endsWith('setNotificationChannelAsync') ) { - found = true - return + for (const value of objectPropertyValues(current.arguments[1], 'name', bindings)) { + collectBoundStringNodes(value, bindings, nodes) + } } ts.forEachChild(current, visit) } - visit(node.getSourceFile()) - return found + visit(sourceFile) + NOTIFICATION_CHANNEL_NAME_NODES.set(sourceFile, nodes) + return nodes.has(node) +} + +function isRenderedMobileExpression(expression) { + return isRenderedJsxExpression(expression) || isAssignedToRenderedVariable(expression) } function hasDisqualifyingBinaryAncestor(node) { @@ -581,14 +606,7 @@ export function classifyMobileStringNode(node, userVisibleErrorSource, bindings) ) { return 'user-visible-return' } - if ( - returnFunctionName && - isReturnedByRenderedFunction( - node, - (expression) => - isRenderedJsxExpression(expression) || isAssignedToRenderedVariable(expression) - ) - ) { + if (returnFunctionName && isReturnedByRenderedFunction(node, isRenderedMobileExpression)) { return 'rendered-function-return' } if (isUserVisibleErrorArgument(node, userVisibleErrorSource)) { diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs index 53b750e1783..1ba6f8cd196 100644 --- a/config/scripts/mobile-localization-candidate-rules.test.mjs +++ b/config/scripts/mobile-localization-candidate-rules.test.mjs @@ -191,6 +191,42 @@ export function Example() { expect(candidates.map((candidate) => candidate.text)).toEqual(['Extracting model']) }) + it('finds copy returned through a rendered helper object', () => { + const source = ` +function lookup() { + return 'Extracting object model' +} +export function Example() { + const renderers = { lookup } + return {renderers.lookup()} +} +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/components/Example.tsx', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual(['Extracting object model']) + }) + + it('matches rendered variables by binding instead of identifier text', () => { + const source = ` +const value = 'internal mode name' +export function Example() { + const value = 'Visible nested value' + return {value} +} +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/components/Example.tsx', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual(['Visible nested value']) + }) + it('finds Android notification-channel names', () => { const source = ` const channel = { @@ -208,6 +244,45 @@ Notifications.setNotificationChannelAsync('orca-desktop', channel) expect(candidates.map((candidate) => candidate.text)).toEqual(['Desktop Notifications']) }) + it('finds bound and spread Android notification-channel names', () => { + const source = ` +const channelName = 'Desktop Notifications' +const channel = { name: channelName } +const baseChannel = { name: 'Background Notifications' } +const spreadChannel = { ...baseChannel } +Notifications.setNotificationChannelAsync('orca-desktop', channel) +Notifications.setNotificationChannelAsync('orca-background', spreadChannel) +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/notifications/local-notification-scheduling.ts', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual([ + 'Desktop Notifications', + 'Background Notifications' + ]) + }) + + it('reports raw copy from a namespace-local translator', () => { + const source = ` +import { t } from '@/i18n/mobile-i18n' +namespace Local { + var t = (value) => value + export const label = {t('Namespace raw copy')} +} +export const translated = t('example.translated') +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/components/Example.tsx', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual(['Namespace raw copy']) + }) + it('finds user-visible subject fallbacks in returned rows', () => { const source = ` export function toRow(item) { diff --git a/config/scripts/mobile-localization-product-glossary.mjs b/config/scripts/mobile-localization-product-glossary.mjs index 9de023a5054..0e2117c0cab 100644 --- a/config/scripts/mobile-localization-product-glossary.mjs +++ b/config/scripts/mobile-localization-product-glossary.mjs @@ -4,6 +4,151 @@ export const REQUIRED_TARGET_TRANSLATIONS = new Set([ 'pullRequest.unresolvedComments.other' ]) +export const EXACT_LANGUAGE_NEUTRAL_KEYS = new Set([ + 'mobileRichMarkdownEditor.h2', + 'terminalAccessoryKeyCatalog.del', + 'terminalAccessoryKeyCatalog.esc', + 'terminalAccessoryKeyCatalog.shiftPlus', + 'terminalAccessoryKeys.alt', + 'terminalAccessoryKeys.ctrl', + 'terminalAccessoryKeys.ins', + 'terminalAccessoryKeys.pgDn', + 'terminalAccessoryKeys.pgUp', + 'terminalAccessoryKeys.shift' +]) + +export const REVIEWED_KEY_TRANSLATIONS = new Map([ + [ + 'task.gitHubReportsMergeConflictsOpen', + { es: 'GitHub informa de conflictos de fusión. Abre GitHub para continuar.' } + ], + ['task.viewOrder', { es: 'Orden de vista', ja: '表示順', ko: '보기 순서', zh: '视图顺序' }], + ['workspaceListPickerOptions.server', { ko: '서버 순서' }], + [ + 'task.orderLinearOrder', + { + ja: '順序: {{linearOrderLabel}}', + ko: '순서: {{linearOrderLabel}}', + zh: '顺序:{{linearOrderLabel}}' + } + ], + ['terminalAccessoryKeyCatalog.escape', { ja: 'Escapeキー', ko: 'Esc 키', zh: 'Esc 键' }], + ['terminalAccessoryKeyCatalog.enter', { ja: 'Enterキー', ko: 'Enter 키', zh: 'Enter 键' }], + ['terminalAccessoryKeyCatalog.space', { ja: 'スペース', ko: '스페이스', zh: '空格键' }], + ['customKeyModal.key', { es: 'Tecla', ja: 'キー', zh: '按键' }], + ['customKeyModal.pick', { es: 'Elige una tecla', ja: 'キーを選択してください', zh: '选择按键' }], + [ + 'voiceSettings.togglePress', + { + ko: '토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 길게 누르기: 누르고 있는 동안 받아씁니다.' + } + ], + [ + 'mobileHostedReviewCreateIntent.force', + { + es: 'Haciendo push forzado con lease...', + ja: 'リースを使用して強制的にプッシュします...', + ko: '리스를 사용해 강제 푸시 중...', + zh: '正在使用租约强制推送...' + } + ], + [ + 'mobileHostedReviewCreateIntent.pushing', + { + es: 'Haciendo push de commits...', + ja: 'commits をプッシュしています...', + ko: 'commits 푸시 중...', + zh: '正在推送 commits...' + } + ], + [ + 'mobilePrCreate.push', + { + es: 'Haz push de la rama base antes de crear un {{reviewType}}.', + ja: '{{reviewType}} を作成する前にベース ブランチをプッシュしてください。', + ko: '{{reviewType}}을 생성하기 전에 기본 브랜치를 푸시하세요.', + zh: '在创建 {{reviewType}} 之前推送基础分支。' + } + ], + [ + 'mobileSourceControlPrimaryAction.forcePushLease', + { + es: 'Haz un push forzado con lease para actualizar la rama remota.', + ja: 'リモート ブランチを更新するには、リースを使用して強制的にプッシュします。', + ko: '원격 브랜치를 업데이트하려면 리스를 사용해 강제 푸시하세요.', + zh: '使用租约强制推送以更新远程分支。' + } + ], + [ + 'mobileSourceControlPrimaryAction.forcePushProgress', + { + es: 'Push forzado en curso.', + ja: '強制プッシュが進行中です。', + ko: '강제 푸시가 진행 중입니다.', + zh: '正在进行强制推送。' + } + ], + [ + 'mobileSourceControlPrimaryAction.pullCommitCountCommit', + { + es: 'Haz pull de {{commitCount}} commit.', + ja: '{{commitCount}} commit をプルします。', + ko: '{{commitCount}}개 commit을 풀합니다.', + zh: '拉取 {{commitCount}} commit。' + } + ], + [ + 'mobileSourceControlPrimaryAction.pullCommitCountCommits', + { + es: 'Haz pull de {{commitCount}} commits.', + ja: '{{commitCount}} commits をプルします。', + ko: '{{commitCount}}개 commits을 풀합니다.', + zh: '拉取 {{commitCount}} commits。' + } + ], + [ + 'mobileSourceControlPrimaryAction.pushCommitCountCommit', + { + es: 'Haz push de {{commitCount}} commit.', + ja: '{{commitCount}} commit をプッシュします。', + ko: '{{commitCount}} commit을 푸시합니다.', + zh: '推送 {{commitCount}} commit。' + } + ], + [ + 'mobileSourceControlPrimaryAction.pushCommitCountCommits', + { + es: 'Haz push de {{commitCount}} commits.', + ja: '{{commitCount}} commits をプッシュします。', + ko: '{{commitCount}} commits을 푸시합니다.', + zh: '推送 {{commitCount}} commits。' + } + ], + [ + 'mobilePrComposeForm.pushCreateReview', + { + es: 'Hacer push y crear {{reviewType}}', + ja: 'プッシュして {{reviewType}} を作成', + ko: '푸시하고 {{reviewType}} 생성', + zh: '推送并创建 {{reviewType}}' + } + ], + [ + 'mobilePrComposeForm.pushCreateDraft', + { + es: 'Hacer push y crear borrador de {{reviewType}}', + ja: 'プッシュしてドラフト {{reviewType}} を作成', + ko: '푸시하고 초안 {{reviewType}} 생성', + zh: '推送并创建草稿 {{reviewType}}' + } + ], + [ + 'mobileOnboarding.notification', + { ja: '通知設定を更新できませんでした。もう一度お試しください。' } + ], + ['mobileOnboarding.your', { ja: '選択内容を保存できませんでした。もう一度お試しください。' }] +]) + export const PRODUCT_GLOSSARY = new Map([ [ 'Sparse Checkout', @@ -40,5 +185,147 @@ export const PRODUCT_GLOSSARY = new Map([ ko: '소스 제어 열기', zh: '打开源代码管理' } + ], + [ + '{{pipelineJobCount}} jobs', + { + es: '{{pipelineJobCount}} trabajos del pipeline', + ja: '{{pipelineJobCount}} 件のジョブ', + ko: '파이프라인 작업 {{pipelineJobCount}}개', + zh: '{{pipelineJobCount}} 个作业' + } + ], + [ + 'No checks found.', + { + es: 'No se encontraron comprobaciones.', + ja: 'チェックが見つかりませんでした。', + ko: '검사를 찾을 수 없습니다.', + zh: '未找到检查项。' + } + ], + [ + 'Invalid checks response', + { + es: 'Respuesta de comprobaciones no válida', + ja: 'チェックの応答が無効です', + ko: '잘못된 검사 응답', + zh: '检查响应无效' + } + ], + [ + 'Order Linear Issues', + { + es: 'Ordenar incidencias de Linear', + ja: 'Linear の課題を並べ替える', + ko: 'Linear 이슈 정렬', + zh: '排序 Linear 议题' + } + ], + [ + 'Group Linear Issues', + { + es: 'Agrupar incidencias de Linear', + ja: 'Linear の課題をグループ化', + ko: 'Linear 이슈 그룹화', + zh: '对 Linear 议题分组' + } + ], + [ + 'No states available', + { + es: 'No hay estados disponibles', + ja: '利用可能なステータスがありません', + ko: '사용 가능한 상태가 없습니다', + zh: '没有可用的状态' + } + ], + ['Body', { es: 'Texto normal', ja: '本文', ko: '본문', zh: '正文' }], + ['SPEECH MODEL', { es: 'MODELO DE VOZ', ja: '音声モデル', ko: '음성 모델', zh: '语音模型' }], + ['Open a shell', { es: 'Abrir un shell', ja: 'シェルを開く', ko: '셸 열기', zh: '打开 shell' }], + [ + 'Save terminal artifact', + { + es: 'Guardar artefacto de terminal', + ja: 'ターミナルの成果物を保存', + ko: '터미널 아티팩트 저장', + zh: '保存终端产物' + } + ], + [ + 'Push & Create PR', + { + es: 'Hacer push y crear PR', + ja: 'プッシュして PR を作成', + ko: '푸시 및 PR 생성', + zh: '推送并创建 PR' + } + ], + [ + 'Commit & Push', + { es: 'Commit y push', ja: 'Commit とプッシュ', ko: 'Commit 및 푸시', zh: 'Commit 并推送' } + ], + [ + 'Nothing to pull', + { + es: 'No hay cambios para pull', + ja: 'プルするものはありません', + ko: '풀할 내용이 없습니다', + zh: '没有可拉取的内容' + } + ], + [ + 'Pull ({{behindCommitCount}})', + { + es: 'Hacer pull ({{behindCommitCount}})', + ja: 'プル ({{behindCommitCount}})', + ko: '풀 ({{behindCommitCount}})', + zh: '拉取 ({{behindCommitCount}})' + } + ], + [ + 'Nothing to push', + { + es: 'No hay commits para push', + ja: 'プッシュするものはありません', + ko: '푸시할 내용이 없습니다', + zh: '没有可推送的内容' + } + ], + [ + 'Push ({{aheadCommitCount}})', + { + es: 'Hacer push ({{aheadCommitCount}})', + ja: 'プッシュ ({{aheadCommitCount}})', + ko: '푸시 ({{aheadCommitCount}})', + zh: '推送 ({{aheadCommitCount}})' + } + ], + [ + 'Pull {{behindCommitCount}}, push {{aheadCommitCount}}.', + { + es: 'Pull: {{behindCommitCount}}; push: {{aheadCommitCount}}.', + ja: '{{behindCommitCount}} 件をプルし、{{aheadCommitCount}} 件をプッシュします。', + ko: '{{behindCommitCount}}개를 풀하고 {{aheadCommitCount}}개를 푸시합니다.', + zh: '拉取 {{behindCommitCount}},推送 {{aheadCommitCount}}。' + } + ], + [ + '{{failingCheckCount}} failing check', + { + es: '{{failingCheckCount}} comprobación fallida', + ja: '失敗したチェック {{failingCheckCount}} 件', + ko: '실패한 검사 {{failingCheckCount}}개', + zh: '{{failingCheckCount}} 项检查失败' + } + ], + [ + 'Failed to load check details', + { + es: 'No se pudieron cargar los detalles de la comprobación', + ja: 'チェックの詳細を読み込めませんでした', + ko: '검사 세부 정보를 불러오지 못했습니다.', + zh: '无法加载检查详细信息' + } ] ]) diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs index b346132f118..e19eae7a72f 100644 --- a/config/scripts/mobile-localization-rendered-variable.mjs +++ b/config/scripts/mobile-localization-rendered-variable.mjs @@ -4,6 +4,8 @@ import ts from 'typescript-api' import { collectSourceBindings } from './mobile-localization-source-bindings.mjs' const SOURCE_BINDINGS = new WeakMap() +const SOURCE_IDENTIFIERS = new WeakMap() +const RENDERED_FUNCTION_RESULTS = new WeakMap() function isDirectDisplayExpressionParent(parent, child) { if ( @@ -72,7 +74,7 @@ export function isRenderedJsxExpression(node) { return false } -function assignedVariableName(node) { +function assignedVariableIdentifier(node) { let current = node while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) { current = current.parent @@ -84,14 +86,14 @@ function assignedVariableName(node) { parent.initializer === current && ts.isIdentifier(parent.name) ) { - return parent.name.text + return parent.name } return parent && ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === current && ts.isIdentifier(parent.left) - ? parent.left.text + ? parent.left : undefined } @@ -139,6 +141,24 @@ function bindingsFor(sourceFile) { return bindings } +function identifiersFor(sourceFile, name) { + let byName = SOURCE_IDENTIFIERS.get(sourceFile) + if (!byName) { + byName = new Map() + function visit(node) { + if (ts.isIdentifier(node)) { + const matches = byName.get(node.text) ?? [] + matches.push(node) + byName.set(node.text, matches) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + SOURCE_IDENTIFIERS.set(sourceFile, byName) + } + return byName.get(name) ?? [] +} + function unwrapAliasExpression(node) { if ( ts.isParenthesizedExpression(node) || @@ -150,6 +170,68 @@ function unwrapAliasExpression(node) { return node } +function propertyNameText(name) { + return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : undefined +} + +function propertyValueExpressions(node, propertyName, bindings, seen = new Set()) { + const expression = unwrapAliasExpression(node) + if (ts.isIdentifier(expression)) { + const binding = bindings.resolveBinding(expression) + if ( + !binding || + seen.has(binding) || + !ts.isVariableDeclaration(binding) || + !binding.initializer + ) { + return [] + } + seen.add(binding) + return propertyValueExpressions(binding.initializer, propertyName, bindings, seen) + } + if (!ts.isObjectLiteralExpression(expression)) { + return [] + } + return expression.properties.flatMap((property) => { + if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propertyName) { + return [property.initializer] + } + if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) { + return [property.name] + } + return ts.isSpreadAssignment(property) + ? propertyValueExpressions(property.expression, propertyName, bindings, new Set(seen)) + : [] + }) +} + +function expressionTargetsBinding(node, targets, bindings, seen = new Set()) { + const expression = unwrapAliasExpression(node) + if (ts.isIdentifier(expression)) { + const binding = bindings.resolveBinding(expression) + return Boolean(binding && targets.has(binding)) + } + let object + let propertyName + if (ts.isPropertyAccessExpression(expression)) { + object = expression.expression + propertyName = expression.name.text + } else if ( + ts.isElementAccessExpression(expression) && + expression.argumentExpression && + ts.isStringLiteralLike(expression.argumentExpression) + ) { + object = expression.expression + propertyName = expression.argumentExpression.text + } + if (!object || !propertyName) { + return false + } + return propertyValueExpressions(object, propertyName, bindings, seen).some((value) => + expressionTargetsBinding(value, targets, bindings, seen) + ) +} + function renderedFunctionBindings(owner, sourceFile, bindings) { const targets = new Set() if (owner.name && ts.isIdentifier(owner.name)) { @@ -212,6 +294,10 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend if (!owner) { return false } + const cached = RENDERED_FUNCTION_RESULTS.get(owner)?.get(isRenderedExpression) + if (cached !== undefined) { + return cached + } const sourceFile = node.getSourceFile() const bindings = bindingsFor(sourceFile) const targetBindings = renderedFunctionBindings(owner, sourceFile, bindings) @@ -222,8 +308,7 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend } if ( ts.isCallExpression(current) && - ts.isIdentifier(current.expression) && - targetBindings.has(bindings.resolveBinding(current.expression)) && + expressionTargetsBinding(current.expression, targetBindings, bindings) && isRenderedExpression(current) ) { rendered = true @@ -232,6 +317,9 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend ts.forEachChild(current, visit) } visit(sourceFile) + const results = RENDERED_FUNCTION_RESULTS.get(owner) ?? new Map() + results.set(isRenderedExpression, rendered) + RENDERED_FUNCTION_RESULTS.set(owner, results) return rendered } @@ -239,8 +327,13 @@ export function isAssignedToRenderedVariable(node, isRenderedExpression = isRend if (ts.isTemplateExpression(node)) { return false } - const name = assignedVariableName(node) - if (!name) { + const identifier = assignedVariableIdentifier(node) + if (!identifier) { + return false + } + const bindings = bindingsFor(node.getSourceFile()) + const targetBinding = bindings.resolveBinding(identifier) + if (!targetBinding) { return false } let owner = node.parent @@ -250,17 +343,11 @@ export function isAssignedToRenderedVariable(node, isRenderedExpression = isRend if (!owner) { return false } - let rendered = false - function visit(current) { - if (rendered) { - return - } - if (ts.isIdentifier(current) && current.text === name && isRenderedExpression(current)) { - rendered = true - return - } - ts.forEachChild(current, visit) - } - visit(owner) - return rendered + return identifiersFor(node.getSourceFile(), identifier.text).some( + (current) => + current.pos >= owner.pos && + current.end <= owner.end && + bindings.resolveBinding(current) === targetBinding && + isRenderedExpression(current) + ) } diff --git a/config/scripts/mobile-localization-source-bindings.mjs b/config/scripts/mobile-localization-source-bindings.mjs index 00e43e2e254..36cc074ea62 100644 --- a/config/scripts/mobile-localization-source-bindings.mjs +++ b/config/scripts/mobile-localization-source-bindings.mjs @@ -19,6 +19,7 @@ function isLexicalScope(node) { return ( ts.isSourceFile(node) || ts.isFunctionLike(node) || + ts.isModuleBlock(node) || ts.isBlock(node) || ts.isCaseBlock(node) || ts.isCatchClause(node) || @@ -39,8 +40,12 @@ function nearestScope(node, sourceFile, predicate = isLexicalScope) { return sourceFile } -function functionScope(node, sourceFile) { - return nearestScope(node, sourceFile, ts.isFunctionLike) +function varScope(node, sourceFile) { + return nearestScope( + node, + sourceFile, + (scope) => ts.isFunctionLike(scope) || ts.isModuleBlock(scope) + ) } function collectDeclarations(sourceFile) { @@ -77,7 +82,7 @@ function collectDeclarations(sourceFile) { const scope = ts.isVariableDeclarationList(declarationList) && (declarationList.flags & ts.NodeFlags.BlockScoped) === 0 - ? functionScope(node, sourceFile) + ? varScope(node, sourceFile) : nearestScope(node, sourceFile) for (const [name, declaration] of bindingDeclarations(node.name, node)) { add(scope, name, declaration) diff --git a/config/scripts/mobile-localization-translation-bindings.mjs b/config/scripts/mobile-localization-translation-bindings.mjs index 176dd20cf9d..37fdf49c336 100644 --- a/config/scripts/mobile-localization-translation-bindings.mjs +++ b/config/scripts/mobile-localization-translation-bindings.mjs @@ -5,6 +5,17 @@ import { collectSourceBindings } from './mobile-localization-source-bindings.mjs const MOBILE_I18N_MODULE_RE = /(?:^|\/)mobile-i18n$/ +function unwrapAliasExpression(node) { + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) + ) { + return unwrapAliasExpression(node.expression) + } + return node +} + function resolvesTo(identifier, bindings) { return bindings.resolveBinding(identifier) } @@ -153,79 +164,141 @@ export function collectMobileTranslationBindings(sourceFile) { function addBinding(identifier, bindingSet, nameSet) { const binding = bindings.resolveBinding(identifier) if (!binding) { - return + return false } + const changed = !bindingSet.has(binding) bindingSet.add(binding) nameSet?.add(identifier.text) + return changed } - function collectDestructuredAliases(node) { - if (!ts.isObjectBindingPattern(node.name) || !node.initializer) { - return + function destructuredTargets(name) { + const expression = unwrapAliasExpression(name) + if (ts.isObjectBindingPattern(expression)) { + return expression.elements.flatMap((element) => { + if (!ts.isIdentifier(element.name) || element.dotDotDotToken) { + return [] + } + const propertyName = element.propertyName ?? element.name + return ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName) + ? [{ propertyName: propertyName.text, target: element.name }] + : [] + }) } - const namespaceSource = isMobileI18nNamespace(node.initializer, bindings) - const instanceSource = isMobileI18nInstance(node.initializer, bindings) + if (!ts.isObjectLiteralExpression(expression)) { + return [] + } + return expression.properties.flatMap((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + return [{ propertyName: property.name.text, target: property.name }] + } + if (!ts.isPropertyAssignment(property)) { + return [] + } + const target = unwrapAliasExpression(property.initializer) + const propertyName = property.name + return ts.isIdentifier(target) && + (ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName)) + ? [{ propertyName: propertyName.text, target }] + : [] + }) + } + + function collectDestructuredAliases(name, initializer) { + const namespaceSource = isMobileI18nNamespace(initializer, bindings) + const instanceSource = isMobileI18nInstance(initializer, bindings) if (!namespaceSource && !instanceSource) { - return + return false } - for (const element of node.name.elements) { - if (!ts.isIdentifier(element.name) || element.dotDotDotToken) { - continue - } - const propertyName = element.propertyName ?? element.name - if (!ts.isIdentifier(propertyName) && !ts.isStringLiteralLike(propertyName)) { - continue - } - if (propertyName.text === 't') { - addBinding(element.name, translatorBindings, translatorNames) - } else if (namespaceSource && propertyName.text === 'createMobileTranslator') { - addBinding(element.name, factoryBindings) - } else if (namespaceSource && propertyName.text === 'mobileI18n') { - addBinding(element.name, instanceBindings, instanceNames) - } else if (instanceSource && propertyName.text === 'getFixedT') { - addBinding(element.name, fixedFactoryBindings) + let changed = false + for (const { propertyName, target } of destructuredTargets(name)) { + if (propertyName === 't') { + changed = addBinding(target, translatorBindings, translatorNames) || changed + } else if (namespaceSource && propertyName === 'createMobileTranslator') { + changed = addBinding(target, factoryBindings) || changed + } else if (namespaceSource && propertyName === 'mobileI18n') { + changed = addBinding(target, instanceBindings, instanceNames) || changed + } else if (instanceSource && propertyName === 'getFixedT') { + changed = addBinding(target, fixedFactoryBindings) || changed } } + return changed + } + + function addPrefixedBinding(identifier, prefix) { + const binding = bindings.resolveBinding(identifier) + if (!binding || prefixedTranslatorBindings.has(binding)) { + return false + } + prefixedTranslatorBindings.set(binding, prefix) + prefixedTranslatorNames.set(identifier.text, prefix) + return true + } + + function collectIdentifierAlias(identifier, initializer) { + let changed = false + if (isMobileI18nNamespace(initializer, bindings)) { + changed = addBinding(identifier, namespaceBindings, namespaceNames) || changed + } else if (isTranslatorFactory(initializer, bindings)) { + changed = addBinding(identifier, factoryBindings) || changed + } + if (isFixedTranslatorFactory(initializer, bindings)) { + changed = addBinding(identifier, fixedFactoryBindings) || changed + } + if (isMobileI18nInstance(initializer, bindings)) { + changed = addBinding(identifier, instanceBindings, instanceNames) || changed + } + if (isEnglishFixedTranslator(initializer, bindings)) { + changed = addBinding(identifier, fixedTranslatorBindings, fixedTranslatorNames) || changed + } + const prefix = + mobileTranslatorPrefix(initializer, bindings) ?? directTranslatorPrefix(initializer, bindings) + return prefix === undefined ? changed : addPrefixedBinding(identifier, prefix) || changed } function collectLocalTranslators(node) { - if (ts.isVariableDeclaration(node)) { - collectDestructuredAliases(node) + let name + let initializer + if (ts.isVariableDeclaration(node) && node.initializer) { + name = node.name + initializer = node.initializer + } else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + name = node.left + initializer = node.right } - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { - const binding = bindings.resolveBinding(node.name) - if (!binding) { - ts.forEachChild(node, collectLocalTranslators) - return - } - if (isMobileI18nNamespace(node.initializer, bindings)) { - namespaceBindings.add(binding) - namespaceNames.add(node.name.text) - } else if (isTranslatorFactory(node.initializer, bindings)) { - factoryBindings.add(binding) - } - if (isFixedTranslatorFactory(node.initializer, bindings)) { - fixedFactoryBindings.add(binding) - } - if (isMobileI18nInstance(node.initializer, bindings)) { - instanceBindings.add(binding) - instanceNames.add(node.name.text) - } - if (isEnglishFixedTranslator(node.initializer, bindings)) { - fixedTranslatorBindings.add(binding) - fixedTranslatorNames.add(node.name.text) - } - const prefix = - mobileTranslatorPrefix(node.initializer, bindings) ?? - directTranslatorPrefix(node.initializer, bindings) - if (prefix !== undefined) { - prefixedTranslatorBindings.set(binding, prefix) - prefixedTranslatorNames.set(node.name.text, prefix) + let changed = false + if (name && initializer) { + changed = collectDestructuredAliases(name, initializer) || changed + const target = unwrapAliasExpression(name) + if (ts.isIdentifier(target)) { + changed = collectIdentifierAlias(target, initializer) || changed } } - ts.forEachChild(node, collectLocalTranslators) + ts.forEachChild(node, (child) => { + changed = collectLocalTranslators(child) || changed + }) + return changed } - collectLocalTranslators(sourceFile) + function bindingCount() { + return [ + factoryBindings, + fixedFactoryBindings, + fixedTranslatorBindings, + instanceBindings, + namespaceBindings, + prefixedTranslatorBindings, + translatorBindings + ].reduce((total, collection) => total + collection.size, 0) + } + + let previousCount + do { + previousCount = bindingCount() + collectLocalTranslators(sourceFile) + } while (bindingCount() > previousCount) return bindings } diff --git a/config/scripts/mobile-localization-translation-bindings.test.mjs b/config/scripts/mobile-localization-translation-bindings.test.mjs new file mode 100644 index 00000000000..2e186abab1e --- /dev/null +++ b/config/scripts/mobile-localization-translation-bindings.test.mjs @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { collectMobileTranslationCalls } from './verify-mobile-localization-catalog.mjs' + +describe('mobile-localization-translation-bindings', () => { + it('finds forward and assignment translator aliases to a fixed point', () => { + const sourceText = ` +import { createMobileTranslator, t } from '@/i18n/mobile-i18n' +import * as i18n from '@/i18n/mobile-i18n' +export function forwardLabel() { + const tr = laterAlias + return tr('example.forward') +} +const laterAlias = t +let assigned +assigned = t +let destructured +;({ t: destructured } = i18n) +let prefixed +prefixed = createMobileTranslator('example') +export const labels = [ + assigned('example.assigned'), + destructured('example.destructured'), + prefixed('prefixed') +] +` + const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo') + + expect(calls.map((call) => call.keys)).toEqual([ + ['example.forward'], + ['example.assigned'], + ['example.destructured'], + ['example.prefixed'] + ]) + }) + + it('keeps namespace var translators inside their module scope', () => { + const sourceText = ` +import { t } from '@/i18n/mobile-i18n' +namespace Local { + var t = (value) => value + export const raw = t('not-a-translation-key') +} +export const translated = t('example.translated') +` + const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo') + + expect(calls.map((call) => call.keys)).toEqual([['example.translated']]) + }) +}) diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs index 197a430ed72..f1e992ebaaa 100644 --- a/config/scripts/verify-mobile-localization-catalog.mjs +++ b/config/scripts/verify-mobile-localization-catalog.mjs @@ -12,7 +12,9 @@ import { mobileTranslationCallPrefix } from './mobile-localization-translation-bindings.mjs' import { + EXACT_LANGUAGE_NEUTRAL_KEYS, PRODUCT_GLOSSARY, + REVIEWED_KEY_TRANSLATIONS, REQUIRED_TARGET_TRANSLATIONS } from './mobile-localization-product-glossary.mjs' @@ -294,7 +296,8 @@ function verifyTerminology(englishValue, localeValue, locale, catalogName, key) if (locale === 'zh' && /\bagent/i.test(englishValue) && localeValue.includes('检测剂')) { issues.push(`${catalogName} agent terminology mismatch: ${key} uses 检测剂`) } - const expectedValue = PRODUCT_GLOSSARY.get(englishValue)?.[locale] + const expectedValue = + REVIEWED_KEY_TRANSLATIONS.get(key)?.[locale] ?? PRODUCT_GLOSSARY.get(englishValue)?.[locale] if (expectedValue && localeValue !== expectedValue) { issues.push(`${catalogName} product terminology mismatch: ${key} must use ${expectedValue}`) } @@ -415,7 +418,10 @@ function verifyLocaleEntries( if (!sameValues(englishPlaceholders, localePlaceholders)) { issues.push(`${catalogName} placeholder mismatch: ${key}`) } - if (EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) && localeValue !== englishValue) { + if ( + (EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) || EXACT_LANGUAGE_NEUTRAL_KEYS.has(key)) && + localeValue !== englishValue + ) { issues.push(`${catalogName} must preserve language-neutral value: ${key}`) } issues.push(...verifyProtectedLiterals(englishValue, localeValue, catalogName, key)) diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs index 6bb4417de01..1f32f1079be 100644 --- a/config/scripts/verify-mobile-localization-catalog.test.mjs +++ b/config/scripts/verify-mobile-localization-catalog.test.mjs @@ -486,6 +486,53 @@ export const labels = [ expect(report).toContain('es.json product terminology mismatch: example.openSource') }) + it('protects terminal legends and reviewed domain terminology', async () => { + const root = makeProject({ + sourceText: ` +import { t } from '@/i18n/mobile-i18n' +export const labels = [ + t('terminalAccessoryKeyCatalog.del'), + t('mobileRichMarkdownEditor.h2'), + t('task.pipelineJob', { pipelineJobCount: 2 }), + t('task.openShell'), + t('voiceSettings.togglePress') +] +`, + catalogs: { + en: { + terminalAccessoryKeyCatalog: { del: 'Del' }, + mobileRichMarkdownEditor: { h2: 'H2' }, + task: { pipelineJob: '{{pipelineJobCount}} jobs', openShell: 'Open a shell' }, + voiceSettings: { + togglePress: 'Toggle: press once to start, again to stop. Hold: dictate while held.' + } + }, + es: { + terminalAccessoryKeyCatalog: { del: 'Supr' }, + task: { pipelineJob: '{{pipelineJobCount}} empleos', openShell: 'Abrir una concha' } + }, + ko: { + voiceSettings: { + togglePress: + '토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 보류: 누르고 있는 동안 지시합니다.' + } + }, + zh: { mobileRichMarkdownEditor: { h2: '氢2' } } + } + }) + + const report = await runFailedVerification(root) + expect(report).toContain( + 'es.json must preserve language-neutral value: terminalAccessoryKeyCatalog.del' + ) + expect(report).toContain( + 'zh.json must preserve language-neutral value: mobileRichMarkdownEditor.h2' + ) + expect(report).toContain('es.json product terminology mismatch: task.pipelineJob') + expect(report).toContain('es.json product terminology mismatch: task.openShell') + expect(report).toContain('ko.json product terminology mismatch: voiceSettings.togglePress') + }) + it('validates placeholders and extra keys in present translations', async () => { const en = { m: { greeting: 'Hello {{name}}' } } const root = makeProject({ diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index ffcb2f075bd..ff95bed3c20 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -60,7 +60,7 @@ "subIssues": "Subtemas", "noPipeline": "No hay ejecuciones de pipeline para este MR.", "none": "Ninguno", - "pipelineJob": "{{pipelineJobCount}} empleos", + "pipelineJob": "{{pipelineJobCount}} trabajos del pipeline", "fileContents": "Contenido del archivo no disponible.", "binary": "Archivo binario.", "markViewed": "Marcar visto", @@ -68,7 +68,7 @@ "view": "Vista", "hide": "Ocultar", "changed": "Archivos modificados", - "noChecks": "No se encontraron cheques.", + "noChecks": "No se encontraron comprobaciones.", "rerunAll": "Vuelva a ejecutar todo", "rerunFailed": "Error al volver a ejecutar", "refresh": "Actualizar", @@ -188,8 +188,8 @@ "createIssueSelectedRepository": "Cree un problema en el repositorio seleccionado.", "sortTasks": "Ordenar tareas", "displayProperties": "Propiedades de visualización", - "orderLinearIssues": "Ordenar cuestiones Lineares", - "groupLinearIssues": "Problemas Lineares del grupo", + "orderLinearIssues": "Ordenar incidencias de Linear", + "groupLinearIssues": "Agrupar incidencias de Linear", "linearView": "Vista Linear", "noStates": "No hay estados disponibles", "loadingStates": "Cargando estados...", @@ -266,7 +266,7 @@ "display": "Mostrar", "tasks": "Tareas", "allWorkspaces": "Todos los espacios de trabajo", - "viewOrder": "Ver pedido", + "viewOrder": "Orden de vista", "send": "Enviar comentario", "addComment": "Agregar un comentario", "failedUpdateIssueSource": "No se pudo actualizar la fuente del problema", @@ -276,7 +276,7 @@ "failedAddLinear": "No se pudo agregar el comentario Linear", "failedUpdateLinear": "No se pudo actualizar el problema Linear", "failedMerge": "No se pudo fusionar", - "gitHubReportsMergeConflictsOpen": "GitHub informa conflictos de fusión. Open in GitHub to continue.", + "gitHubReportsMergeConflictsOpen": "GitHub informa de conflictos de fusión. Abre GitHub para continuar.", "failedReply": "No se pudo responder", "failedAddReview": "No se pudo agregar el comentario de revisión", "unableComment": "No se puede comentar sin el SHA del head del PR.", @@ -310,7 +310,7 @@ "failedSave": "No se pudo guardar el ajuste preestablecido disperso.", "failedSearch": "No se pudieron buscar ramas.", "failedLoadSparse": "No se pudieron cargar ajustes preestablecidos escasos.", - "openShell": "abrir un caparazón", + "openShell": "Abrir un shell", "failedLoadIssue": "No se pudieron cargar los tipos de problemas", "failedLoadAssignees": "No se pudieron cargar los asignados", "failedLoadLabels": "No se pudieron cargar las etiquetas", @@ -471,7 +471,7 @@ "failedReopen": "No se pudo reabrir el hilo", "failedResolve": "No se pudo resolver el hilo", "failedSync": "No se pudo sincronizar el estado visto con GitHub.", - "invalid": "Respuesta de cheques no válidos", + "invalid": "Respuesta de comprobaciones no válida", "failedUpdateGitHubStatus": "No se pudo actualizar el estado de GitHub", "failedUpdateGitHubItem": "No se pudo actualizar el elemento de GitHub", "selectedAgent": "El agent seleccionado está deshabilitado. Elige un agent habilitado antes de crear.", @@ -762,7 +762,7 @@ "backspace": "Retroceso", "space": "Espacio", "shiftTab": "Pestaña Mayús", - "shiftPlus": "Mayús+Tab", + "shiftPlus": "Shift+Tab", "escape": "Escapar" }, "terminalWebviewEngineErrorState": { @@ -787,11 +787,11 @@ "arrowDown": "Flecha hacia abajo", "arrowUp": "flecha arriba", "insert": "Insertar", - "pgDn": "AvPág", - "pgUp": "Re Pág", - "shift": "Cambio", - "alt": "Alt.", - "ctrl": "Control" + "pgDn": "PgDn", + "pgUp": "PgUp", + "shift": "Shift", + "alt": "Alt", + "ctrl": "Ctrl" }, "terminalWebviewReadyWatchdog": { "terminal": "El terminal no se inicializó: no hay señal de listo desde la vista del terminal" @@ -961,8 +961,8 @@ "resolveStage": "Resuelva o organice cambios antes de crear una solicitud de extracción.", "resolveConflicts": "Resuelva los conflictos antes de crear una solicitud de extracción.", "creating": "Creando reseña...", - "force": "Fuerza empujando con arrendamiento...", - "pushing": "Impulsando commits...", + "force": "Haciendo push forzado con lease...", + "pushing": "Haciendo push de commits...", "publishing": "Publicando rama...", "committing": "Confirmando cambios...", "generating": "Generando mensaje de commit...", @@ -970,7 +970,7 @@ "failed": "No se han podido realizar cambios en la etapa" }, "mobileHostedReviewService": { - "pushFailed": "El empujón falló. Resuelva el error de inserción y vuelva a intentarlo.", + "pushFailed": "Falló el push. Resuelve el error de push y vuelve a intentarlo.", "pushSucceeded": "El push se realizó correctamente, pero falló la creación de {{reviewType}}: {{errorMessage}}", "failed": "No se pudo crear la solicitud de extracción" }, @@ -978,10 +978,10 @@ "switch": "Cambiar rama", "rebase": "Rebase sobre la base", "publishBranch": "Publicar rama", - "pushCreate": "Impulsar y crear PR", + "pushCreate": "Hacer push y crear PR", "create": "Crear PR", "commitSync": "Commit y sincronizar", - "commitPush": "Commit y presionar", + "commitPush": "Commit y push", "pullRequests": "Las solicitudes de extracción no están disponibles para este repo", "checking": "Comprobando el estado de la rama...", "publishBranchFirst": "Publicar rama primero", @@ -995,10 +995,10 @@ "branchUp": "La rama está actualizada.", "sync": "Sincronizar", "syncBehind": "Sincronización (↓{{behindCommitCount}} ↑{{aheadCommitCount}})", - "nothingPull": "Nada que tirar", - "pullBehind": "Tirar ({{behindCommitCount}})", - "nothingPush": "Nada que empujar", - "pushAhead": "Empujar ({{aheadCommitCount}})" + "nothingPull": "No hay cambios para pull", + "pullBehind": "Hacer pull ({{behindCommitCount}})", + "nothingPush": "No hay commits para push", + "pushAhead": "Hacer push ({{aheadCommitCount}})" }, "useMobileCommitFailureRecovery": { "failed": "No se pudo iniciar el agent", @@ -1048,7 +1048,7 @@ }, "mobilePrCreate": { "branch": "Esta rama aún no está lista para un {{reviewType}}.", - "push": "Empuje la rama base antes de crear un {{reviewType}}.", + "push": "Haz push de la rama base antes de crear un {{reviewType}}.", "creatingReviewTypeFork": "No se admite la creación de un {{reviewType}} desde esta bifurcación.", "reviewTypeAlreadyExists": "Ya existe un {{reviewType}} para esta rama.", "creatingReviewTypeS": "La creación de {{reviewType}} no es compatible con este repo.", @@ -1065,7 +1065,7 @@ }, "mobileSourceControlPrimaryAction": { "nothing": "No hay nada para hacer commit. La rama está actualizada.", - "forcePushLease": "Fuerce el envío con arrendamiento para actualizar la rama remota.", + "forcePushLease": "Haz un push forzado con lease para actualizar la rama remota.", "publishBranchOrigin": "Publicar esta rama en origen.", "check": "Haz checkout de una rama antes de publicar commits.", "stageLeast": "Prepare al menos un archivo para commit.", @@ -1075,16 +1075,16 @@ "resolve": "Resolver conflictos antes de comprometerse.", "try": "Inténtelo nuevamente una vez que finalice la operación remota.", "remote": "Operación remota en progreso.", - "forcePushProgress": "Empuje forzado en curso.", + "forcePushProgress": "Push forzado en curso.", "commitProgress": "Compromiso en progreso.", "publishBranch": "Publicar rama", "sync": "Sincronizar", "stageAll": "Hacer stage de todo", - "pullBehind": "Tire de {{behindCommitCount}}, presione {{aheadCommitCount}}.", - "pullCommitCountCommit": "Tire del commit {{commitCount}}.", - "pullCommitCountCommits": "Extraiga las commits de {{commitCount}}.", - "pushCommitCountCommit": "Empuje el commit {{commitCount}}.", - "pushCommitCountCommits": "Empuje {{commitCount}} commits." + "pullBehind": "Pull: {{behindCommitCount}}; push: {{aheadCommitCount}}.", + "pullCommitCountCommit": "Haz pull de {{commitCount}} commit.", + "pullCommitCountCommits": "Haz pull de {{commitCount}} commits.", + "pushCommitCountCommit": "Haz push de {{commitCount}} commit.", + "pushCommitCountCommits": "Haz push de {{commitCount}} commits." }, "mobileHostedReviewGitPreparation": { "commit": "La Commit falló", @@ -1496,7 +1496,7 @@ "label": "Etiqueta", "add": "Agregar", "more": "Más teclas: tabulador, flechas, F1–F12…", - "key": "Clave", + "key": "Tecla", "modifiers": "Modificadores", "show": "Mostrar, ocultar o reordenar las teclas de método abreviado", "manage": "Administrar atajos", @@ -1509,8 +1509,8 @@ "navigation": "Navegación", "editing": "Edición", "eGPnpm": "p. ej. pnpm build", - "eGBuild": "p.ej. Construir", - "pick": "Elige una llave" + "eGBuild": "p. ej., Compilar", + "pick": "Elige una tecla" }, "dragReorderList": { "moveDown": "Bajar", @@ -1670,7 +1670,7 @@ "strike": "Tachado", "italic": "Cursiva", "bold": "Negrita", - "body": "Cuerpo" + "body": "Texto normal" }, "mobileWorkspaceNameInput": { "workspace": "Nombre del espacio de trabajo" @@ -1833,8 +1833,8 @@ "mobilePrComposeForm": { "createReview": "Crear {{reviewType}}", "createDraftReview": "Crear borrador {{reviewType}}", - "pushCreateReview": "Empujar y crear {{reviewType}}", - "pushCreateDraft": "Enviar y crear borrador {{reviewType}}", + "pushCreateReview": "Hacer push y crear {{reviewType}}", + "pushCreateDraft": "Hacer push y crear borrador de {{reviewType}}", "createDraft": "Crear como borrador", "generatingTitle": "Generando título y descripción…", "reviewTypeDescription": "{{reviewType}} descripción", @@ -1883,9 +1883,9 @@ "fixFailing": "Solucionar controles fallidos con IA", "inspect": "Inspecciona los detalles o inicia una pasada de corrección con IA.", "rerun": "Volver a ejecutar comprobaciones fallidas", - "failingCheckCountFailingCheck": "{{failingCheckCount}} cheque fallido", - "failingCheckCountFailingChecks": "{{failingCheckCount}} controles fallidos", - "failed": "No se pudieron cargar los detalles del cheque" + "failingCheckCountFailingCheck": "{{failingCheckCount}} comprobación fallida", + "failingCheckCountFailingChecks": "{{failingCheckCount}} comprobaciones fallidas", + "failed": "No se pudieron cargar los detalles de la comprobación" }, "prcommentCard": { "permanently": "Esto elimina permanentemente el comentario en GitHub.", @@ -2059,7 +2059,7 @@ }, "mobileFilePreviewScreen": { "unableLoad": "No se puede cargar la vista previa", - "save": "Guardar artefacto terminal", + "save": "Guardar artefacto de terminal", "back": "Volver a archivos", "unsaved": "Las ediciones no guardadas se perderán.", "discardChanges": "¿Descartar cambios?", @@ -2386,7 +2386,7 @@ }, "voiceSettings": { "speechModelMessage": "Modelo de voz", - "speechModel": "MODELO DE DISCURSO", + "speechModel": "MODELO DE VOZ", "togglePress": "Alternar: presione una vez para comenzar y nuevamente para detener. Mantener: dictar mientras se mantiene presionado.", "dictationMode": "Modo de dictado", "dictate": "Dicta texto en cualquier panel enfocado de tu escritorio.", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 500c120d992..132d3ae8614 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -61,7 +61,7 @@ "subIssues": "サブ Issue", "noPipeline": "この MR ではパイプラインは実行されません。", "none": "なし", - "pipelineJob": "{{pipelineJobCount}} の求人", + "pipelineJob": "{{pipelineJobCount}} 件のジョブ", "pipeline": "パイプライン", "fileContents": "ファイルの内容は利用できません。", "binary": "バイナリファイル。", @@ -192,10 +192,10 @@ "createIssueSelectedRepository": "選択したリポジトリに Issue を作成します。", "sortTasks": "タスクの並べ替え", "displayProperties": "表示プロパティ", - "orderLinearIssues": "順序 Linear Issue", - "groupLinearIssues": "グループの Linear Issue", + "orderLinearIssues": "Linear の課題を並べ替える", + "groupLinearIssues": "Linear の課題をグループ化", "linearView": "リニアビュー", - "noStates": "利用可能な州がありません", + "noStates": "利用可能なステータスがありません", "loadingStates": "状態を読み込み中...", "changeStatus": "ステータスの変更", "allTeams": "全チーム", @@ -272,7 +272,7 @@ "display": "画面", "tasks": "タスク", "allWorkspaces": "すべてのワークスペース", - "viewOrder": "注文を見る", + "viewOrder": "表示順", "send": "コメントを送信", "addComment": "コメントを追加", "failedUpdateIssueSource": "Issue ソースの更新に失敗しました", @@ -383,7 +383,7 @@ "sortGithub": "並べ替え: {{githubProjectSortLabel}}", "fieldsGithub": "フィールド: {{githubProjectFieldsLabel}}", "groupLinearGroup": "グループ: {{linearGroupLabel}}", - "orderLinearOrder": "注文: {{linearOrderLabel}}", + "orderLinearOrder": "順序: {{linearOrderLabel}}", "preferred": "{{repoName}} では、アップストリームの優先 Issue ソースが利用できません。原点を使用します。", "plus": "+{{githubProjectSummaryFieldCount}} フィールド", "baseBranch": "{{baseBranch}}に", @@ -483,7 +483,7 @@ "failedReopen": "スレッドを再開できませんでした", "failedResolve": "スレッドを解決できませんでした", "failedSync": "表示された状態を GitHub と同期できませんでした。", - "invalid": "無効なチェックの応答", + "invalid": "チェックの応答が無効です", "failedUpdateGitHubStatus": "GitHub ステータスの更新に失敗しました", "failedUpdateGitHubItem": "GitHub アイテムの更新に失敗しました", "selectedAgent": "選択した agent は無効になっています。作成する前に、有効な agent を選択。", @@ -762,25 +762,25 @@ "clearLine": "カーソル前の行をクリア", "delete": "単語を後方に削除", "end": "行末", - "start": "行の始まり", - "reverse": "逆引き検索", - "suspend": "プロセスを一時停止する", - "clearScreen": "画面をクリアする", + "start": "行頭", + "reverse": "逆方向検索", + "suspend": "プロセスを一時停止", + "clearScreen": "画面をクリア", "send": "EOFを送信", - "interrupt": "割り込み terminal", + "interrupt": "ターミナルに割り込み", "arrowRight": "右矢印", "arrowLeft": "左矢印", "arrowDown": "下矢印", "arrowUp": "上矢印", "forward": "前方削除", - "del": "デル", + "del": "Del", "backspace": "バックスペース", "space": "スペース", - "shiftTab": "シフトタブ", - "enter": "入力", - "tab": "タブ", - "escape": "逃げる", - "esc": "ESC" + "shiftTab": "Shift+Tab", + "enter": "Enterキー", + "tab": "Tabキー", + "escape": "Escapeキー", + "esc": "Esc" }, "terminalWebviewEngineErrorState": { "reload": "リロード", @@ -797,17 +797,17 @@ "terminalAccessoryKeys": { "pageDown": "ページダウン", "pageUp": "ページアップ", - "end": "終わり", - "home": "ホーム", + "end": "Endキー", + "home": "Homeキー", "arrowRight": "右矢印", "arrowLeft": "左矢印", "arrowDown": "下矢印", "arrowUp": "上矢印", - "insert": "入れる", - "pgDn": "PGD​​N", - "ins": "インス", - "shift": "シフト", - "alt": "代替" + "insert": "Insertキー", + "pgDn": "PgDn", + "ins": "Ins", + "shift": "Shift", + "alt": "Alt" }, "terminalWebviewReadyWatchdog": { "terminal": "Terminal が初期化されませんでした - Terminal ビューから準備完了信号がありません" @@ -995,7 +995,7 @@ "rebase": "ベースにリベースする", "publishBranch": "ブランチを公開", "fetch": "フェッチ", - "pushCreate": "PR のプッシュと作成", + "pushCreate": "プッシュして PR を作成", "create": "PR を作成", "commitSync": "Commit と同期", "commitPush": "Commit とプッシュ", @@ -1012,10 +1012,10 @@ "branchUp": "ブランチは最新です", "sync": "同期", "syncBehind": "同期 (↓{{behindCommitCount}} ↑{{aheadCommitCount}})", - "nothingPull": "何も引っ張るものがない", + "nothingPull": "プルするものはありません", "pull": "プル", "pullBehind": "プル ({{behindCommitCount}})", - "nothingPush": "押し付けるものは何もない", + "nothingPush": "プッシュするものはありません", "push": "プッシュ", "pushAhead": "プッシュ ({{aheadCommitCount}})" }, @@ -1102,7 +1102,7 @@ "sync": "同期", "stageAll": "ステージオール", "forcePush": "フォースプッシュ", - "pullBehind": "{{behindCommitCount}} を引いて、{{aheadCommitCount}} を押します。", + "pullBehind": "{{behindCommitCount}} 件をプルし、{{aheadCommitCount}} 件をプッシュします。", "pullCommitCountCommit": "{{commitCount}} commit をプルします。", "pullCommitCountCommits": "{{commitCount}} commits をプルします。", "pushCommitCountCommit": "{{commitCount}} commit をプッシュします。", @@ -1528,7 +1528,7 @@ "label": "ラベル", "add": "追加", "more": "その他のキー — Tab、矢印、F1 ~ F12…", - "key": "鍵", + "key": "キー", "modifiers": "修飾子", "show": "ショートカット キーの表示、非表示、または並べ替え", "manage": "ショートカットの管理", @@ -1541,7 +1541,7 @@ "navigation": "ナビゲーション", "editing": "編集", "eGPnpm": "例: pnpm build", - "eGBuild": "例えば建てる", + "eGBuild": "例: ビルド", "pick": "キーを選択してください" }, "dragReorderList": { @@ -1702,7 +1702,7 @@ "strike": "取り消し線", "italic": "イタリック", "bold": "太字", - "body": "体" + "body": "本文" }, "mobileWorkspaceNameInput": { "workspace": "ワークスペース名" @@ -1866,8 +1866,8 @@ "mobilePrComposeForm": { "createReview": "{{reviewType}} を作成する", "createDraftReview": "ドラフト {{reviewType}} を作成する", - "pushCreateReview": "プッシュして{{reviewType}}を作成", - "pushCreateDraft": "プッシュしてドラフトを作成 {{reviewType}}", + "pushCreateReview": "プッシュして {{reviewType}} を作成", + "pushCreateDraft": "プッシュしてドラフト {{reviewType}} を作成", "createDraft": "下書きとして作成", "base": "ベース", "generatingTitle": "タイトルと説明を生成しています…", @@ -1919,9 +1919,9 @@ "inspect": "詳細を検査するか、AI 修正パスを開始します。", "rerun": "失敗したチェックを再実行する", "checks": "チェック", - "failingCheckCountFailingCheck": "{{failingCheckCount}} チェックに失敗しました", + "failingCheckCountFailingCheck": "失敗したチェック {{failingCheckCount}} 件", "failingCheckCountFailingChecks": "{{failingCheckCount}} チェックに失敗しました", - "failed": "チェックの詳細をロードできませんでした" + "failed": "チェックの詳細を読み込めませんでした" }, "prcommentCard": { "permanently": "これにより、GitHub 上のコメントが完全に削除されます。", @@ -2096,7 +2096,7 @@ }, "mobileFilePreviewScreen": { "unableLoad": "プレビューを読み込めません", - "save": "terminal アーティファクトを保存する", + "save": "ターミナルの成果物を保存", "back": "ファイルに戻る", "unsaved": "保存されていない編集内容は失われます。", "discardChanges": "変更を破棄しますか?", @@ -2309,8 +2309,8 @@ }, "mobileOnboarding": { "onboarding": "オンボーディングの進捗状況", - "notification": "Notification settings could not be updated.再試行。", - "your": "Your choice could not be saved.再試行。" + "notification": "通知設定を更新できませんでした。もう一度お試しください。", + "your": "選択内容を保存できませんでした。もう一度お試しください。" }, "nativeChatSettings": { "open": "チャット UI でセッションを開く", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index a541c92ead0..3327e5d060f 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -9,7 +9,7 @@ "pr": "PR 현황", "status": "상태", "no": "그룹화 없음", - "server": "서버 주문", + "server": "서버 순서", "manual": "수동", "repository": "저장소, 워크스페이스 이름", "most": "가장 최근 출력 먼저", @@ -61,7 +61,7 @@ "subIssues": "하위 이슈", "noPipeline": "이 MR에 실행된 파이프라인이 없습니다.", "none": "없음", - "pipelineJob": "{{pipelineJobCount}} 직업", + "pipelineJob": "파이프라인 작업 {{pipelineJobCount}}개", "pipeline": "파이프라인", "fileContents": "파일 콘텐츠를 사용할 수 없습니다.", "binary": "바이너리 파일.", @@ -70,7 +70,7 @@ "view": "보기", "hide": "숨기기", "changed": "변경된 파일", - "noChecks": "수표를 찾을 수 없습니다.", + "noChecks": "검사를 찾을 수 없습니다.", "rerunAll": "모두 다시 실행", "rerunFailed": "재실행 실패", "refresh": "새로고침", @@ -192,10 +192,10 @@ "createIssueSelectedRepository": "선택한 저장소에서 이슈를 생성합니다.", "sortTasks": "작업 정렬", "displayProperties": "디스플레이 속성", - "orderLinearIssues": "주문 Linear 문제", - "groupLinearIssues": "그룹 Linear 문제", + "orderLinearIssues": "Linear 이슈 정렬", + "groupLinearIssues": "Linear 이슈 그룹화", "linearView": "Linear 보기", - "noStates": "사용 가능한 주 없음", + "noStates": "사용 가능한 상태가 없습니다", "loadingStates": "상태 로드 중...", "changeStatus": "상태 변경", "allTeams": "모든 팀", @@ -272,7 +272,7 @@ "display": "표시", "tasks": "작업", "allWorkspaces": "모든 워크스페이스", - "viewOrder": "주문 보기", + "viewOrder": "보기 순서", "send": "댓글 보내기", "addComment": "댓글 추가", "failedUpdateIssueSource": "이슈 소스를 업데이트하지 못했습니다.", @@ -383,7 +383,7 @@ "sortGithub": "정렬: {{githubProjectSortLabel}}", "fieldsGithub": "필드: {{githubProjectFieldsLabel}}", "groupLinearGroup": "그룹: {{linearGroupLabel}}", - "orderLinearOrder": "주문: {{linearOrderLabel}}", + "orderLinearOrder": "순서: {{linearOrderLabel}}", "preferred": "{{repoName}}에는 기본 이슈 소스 업스트림을 사용할 수 없습니다. 원산지를 사용합니다.", "plus": "+{{githubProjectSummaryFieldCount}} 필드", "baseBranch": "{{baseBranch}}에", @@ -483,7 +483,7 @@ "failedReopen": "스레드를 다시 열지 못했습니다.", "failedResolve": "스레드를 해결하지 못했습니다.", "failedSync": "본 상태를 GitHub와 동기화하지 못했습니다.", - "invalid": "잘못된 수표 응답", + "invalid": "잘못된 검사 응답", "failedUpdateGitHubStatus": "GitHub 상태를 업데이트하지 못했습니다.", "failedUpdateGitHubItem": "GitHub 항목을 업데이트하지 못했습니다.", "selectedAgent": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.", @@ -767,18 +767,18 @@ "suspend": "프로세스 일시 중지", "clearScreen": "화면 지우기", "send": "EOF 보내기", - "interrupt": "인터럽트 terminal", + "interrupt": "터미널 중단", "arrowRight": "오른쪽 화살표", "arrowLeft": "왼쪽 화살표", "arrowDown": "아래쪽 화살표", "arrowUp": "위쪽 화살표", "forward": "앞으로 삭제", - "del": "델", - "backspace": "역행 키이", - "space": "공간", - "shiftTab": "시프트 탭", - "enter": "입력", - "escape": "탈출하다" + "del": "Del", + "backspace": "Backspace 키", + "space": "스페이스", + "shiftTab": "Shift+Tab", + "enter": "Enter 키", + "escape": "Esc 키" }, "terminalWebviewEngineErrorState": { "reload": "새로고침", @@ -795,16 +795,16 @@ "terminalAccessoryKeys": { "pageDown": "페이지 아래로", "pageUp": "페이지 위로", - "end": "끝", - "home": "홈", + "end": "End 키", + "home": "Home 키", "arrowRight": "오른쪽 화살표", "arrowLeft": "왼쪽 화살표", "arrowDown": "아래쪽 화살표", "arrowUp": "위쪽 화살표", - "insert": "삽입", - "ins": "인", - "shift": "옮기다", - "ctrl": "Ctrl 키" + "insert": "Insert 키", + "ins": "Ins", + "shift": "Shift", + "ctrl": "Ctrl" }, "terminalWebviewReadyWatchdog": { "terminal": "Terminal이 초기화되지 않았습니다. Terminal 보기에 준비 신호가 없습니다." @@ -972,7 +972,7 @@ "resolveStage": "PR을 생성하기 전에 변경 사항을 해결하거나 준비하세요.", "resolveConflicts": "PR을 생성하기 전에 충돌을 해결하세요.", "creating": "리뷰 작성 중...", - "force": "임대로 강제 추진 중...", + "force": "리스를 사용해 강제 푸시 중...", "pushing": "commits 푸시 중...", "publishing": "브랜치 게시 중...", "committing": "변경사항을 커밋하는 중...", @@ -1007,12 +1007,12 @@ "branchUp": "브랜치이 최신 상태입니다.", "sync": "동기화", "syncBehind": "동기화(↓{{behindCommitCount}} ↑{{aheadCommitCount}})", - "nothingPull": "당길 게 하나도 없어", + "nothingPull": "풀할 내용이 없습니다", "pull": "풀", - "pullBehind": "당기기({{behindCommitCount}})", - "nothingPush": "밀어붙일 게 하나도 없어", + "pullBehind": "풀 ({{behindCommitCount}})", + "nothingPush": "푸시할 내용이 없습니다", "push": "푸시", - "pushAhead": "푸시({{aheadCommitCount}})" + "pushAhead": "푸시 ({{aheadCommitCount}})" }, "useMobileCommitFailureRecovery": { "failed": "agent를 시작하지 못했습니다.", @@ -1081,7 +1081,7 @@ "nothing": "commit 할 항목이 없습니다. 브랜치가 최신 상태입니다.", "push": "푸시", "pull": "풀", - "forcePushLease": "원격 브랜치를 업데이트하려면 임대를 통해 강제로 푸시하세요.", + "forcePushLease": "원격 브랜치를 업데이트하려면 리스를 사용해 강제 푸시하세요.", "publishBranchOrigin": "이 브랜치를 원본에 게시합니다.", "check": "commits을 게시하기 전에 브랜치를 체크아웃하세요.", "stageLeast": "commit 할 파일을 하나 이상 준비하세요.", @@ -1097,9 +1097,9 @@ "sync": "동기화", "stageAll": "모두 스테이지", "forcePush": "강제 푸시", - "pullBehind": "{{behindCommitCount}}을 당기고, {{aheadCommitCount}}을 누르세요.", - "pullCommitCountCommit": "{{commitCount}} commit을 가져옵니다.", - "pullCommitCountCommits": "{{commitCount}} commits을 가져옵니다.", + "pullBehind": "{{behindCommitCount}}개를 풀하고 {{aheadCommitCount}}개를 푸시합니다.", + "pullCommitCountCommit": "{{commitCount}}개 commit을 풀합니다.", + "pullCommitCountCommits": "{{commitCount}}개 commits을 풀합니다.", "pushCommitCountCommit": "{{commitCount}} commit을 푸시합니다.", "pushCommitCountCommits": "{{commitCount}} commits을 푸시합니다." }, @@ -1698,7 +1698,7 @@ "strike": "취소선", "italic": "기울임", "bold": "굵게", - "body": "몸" + "body": "본문" }, "mobileWorkspaceNameInput": { "workspace": "워크스페이스 이름" @@ -1862,8 +1862,8 @@ "mobilePrComposeForm": { "createReview": "{{reviewType}} 만들기", "createDraftReview": "초안 {{reviewType}} 만들기", - "pushCreateReview": "{{reviewType}} 푸시 및 생성", - "pushCreateDraft": "초안 푸시 및 생성 {{reviewType}}", + "pushCreateReview": "푸시하고 {{reviewType}} 생성", + "pushCreateDraft": "푸시하고 초안 {{reviewType}} 생성", "createDraft": "초안으로 만들기", "base": "베이스", "generatingTitle": "제목 및 설명 생성 중…", @@ -1915,9 +1915,9 @@ "inspect": "세부 정보를 검사하거나 AI 수정 패스를 시작하세요.", "rerun": "실패한 검사 다시 실행", "checks": "체크", - "failingCheckCountFailingCheck": "{{failingCheckCount}} 확인 실패", + "failingCheckCountFailingCheck": "실패한 검사 {{failingCheckCount}}개", "failingCheckCountFailingChecks": "{{failingCheckCount}} 검사 실패", - "failed": "수표 세부정보를 로드하지 못했습니다." + "failed": "검사 세부 정보를 불러오지 못했습니다." }, "prcommentCard": { "permanently": "GitHub의 댓글이 영구적으로 삭제됩니다.", @@ -2094,7 +2094,7 @@ }, "mobileFilePreviewScreen": { "unableLoad": "미리보기를 로드할 수 없습니다.", - "save": "terminal 아티팩트 저장", + "save": "터미널 아티팩트 저장", "back": "파일로 돌아가기", "unsaved": "저장되지 않은 수정사항은 손실됩니다.", "discardChanges": "변경사항을 삭제하시겠습니까?", @@ -2424,8 +2424,8 @@ }, "voiceSettings": { "speechModelMessage": "음성 모델", - "speechModel": "연설 모델", - "togglePress": "토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 보류: 누르고 있는 동안 지시합니다.", + "speechModel": "음성 모델", + "togglePress": "토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 길게 누르기: 누르고 있는 동안 받아씁니다.", "dictationMode": "받아쓰기 모드", "dictate": "데스크탑의 초점이 맞춰진 창에 ​​텍스트를 받아쓰세요.", "enable": "음성 받아쓰기 활성화", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index dbbb2fd2189..47c2b3e75b5 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -61,7 +61,7 @@ "subIssues": "子问题", "noPipeline": "此 MR 没有运行任何管道。", "none": "没有任何", - "pipelineJob": "{{pipelineJobCount}} 职位", + "pipelineJob": "{{pipelineJobCount}} 个作业", "pipeline": "管道", "fileContents": "文件内容不可用。", "binary": "二进制文件。", @@ -70,7 +70,7 @@ "view": "查看", "hide": "隐藏", "changed": "已更改文件", - "noChecks": "没有找到检查。", + "noChecks": "未找到检查项。", "rerunAll": "重新运行全部", "rerunFailed": "重新运行失败", "refresh": "刷新", @@ -194,8 +194,8 @@ "createIssueSelectedRepository": "在选定的存储库中创建议题。", "sortTasks": "排序任务", "displayProperties": "显示属性", - "orderLinearIssues": "阶 Linear 议题", - "groupLinearIssues": "群 Linear 议题", + "orderLinearIssues": "排序 Linear 议题", + "groupLinearIssues": "对 Linear 议题分组", "linearView": "Linear 视图", "noStates": "没有可用的状态", "loadingStates": "加载状态...", @@ -272,7 +272,7 @@ "display": "展示", "tasks": "任务", "allWorkspaces": "所有工作区", - "viewOrder": "查看订单", + "viewOrder": "视图顺序", "send": "发送评论", "addComment": "添加评论", "failedUpdateIssueSource": "无法更新议题来源", @@ -316,7 +316,7 @@ "failedSave": "无法保存稀疏预设。", "failedSearch": "搜索分支失败。", "failedLoadSparse": "无法加载稀疏预设。", - "openShell": "打开一个外壳", + "openShell": "打开 shell", "failedLoadIssue": "无法加载议题类型", "failedLoadAssignees": "无法加载负责人", "failedLoadLabels": "加载标签失败", @@ -383,7 +383,7 @@ "sortGithub": "排序:{{githubProjectSortLabel}}", "fieldsGithub": "字段:{{githubProjectFieldsLabel}}", "groupLinearGroup": "组:{{linearGroupLabel}}", - "orderLinearOrder": "订单:{{linearOrderLabel}}", + "orderLinearOrder": "顺序:{{linearOrderLabel}}", "preferred": "{{repoName}} 的首选议题源上游不可用。使用原点。", "plus": "+{{githubProjectSummaryFieldCount}} 字段", "baseBranch": "进入 {{baseBranch}}", @@ -482,7 +482,7 @@ "failedReopen": "无法重新打开线程", "failedResolve": "无法解析线程", "failedSync": "无法将查看的状态与 GitHub 同步。", - "invalid": "无效检查响应", + "invalid": "检查响应无效", "failedUpdateGitHubStatus": "无法更新 GitHub 状态", "failedUpdateGitHubItem": "无法更新 GitHub 项目", "selectedAgent": "所选 agent 已禁用。创建之前选择启用的 agent。", @@ -765,22 +765,22 @@ "start": "行首", "reverse": "反向搜索", "suspend": "暂停进程", - "clearScreen": "清晰的屏幕", + "clearScreen": "清除屏幕", "send": "发送文件尾", - "interrupt": "中断 terminal", + "interrupt": "中断终端", "arrowRight": "向右箭头", "arrowLeft": "向左箭头", "arrowDown": "向下箭头", "arrowUp": "向上箭头", - "forward": "转发删除", - "del": "德尔", + "forward": "向前删除", + "del": "Del", "backspace": "退格键", - "space": "空间", - "shiftTab": "切换选项卡", - "enter": "进入", - "tab": "选项卡", - "escape": "逃脱", - "esc": "Esc键" + "space": "空格键", + "shiftTab": "Shift+Tab 键", + "enter": "Enter 键", + "tab": "Tab 键", + "escape": "Esc 键", + "esc": "Esc" }, "terminalWebviewEngineErrorState": { "reload": "重新加载", @@ -797,19 +797,19 @@ "terminalAccessoryKeys": { "pageDown": "向下翻页", "pageUp": "向上翻页", - "end": "结尾", - "home": "家", + "end": "End 键", + "home": "Home 键", "arrowRight": "向右箭头", "arrowLeft": "向左箭头", "arrowDown": "向下箭头", "arrowUp": "向上箭头", - "insert": "插入", - "pgDn": "前列腺素", - "pgUp": "上页", - "ins": "INS", - "shift": "转移", - "alt": "替代", - "ctrl": "控制键" + "insert": "Insert 键", + "pgDn": "PgDn", + "pgUp": "PgUp", + "ins": "Ins", + "shift": "Shift", + "alt": "Alt", + "ctrl": "Ctrl" }, "terminalWebviewReadyWatchdog": { "terminal": "Terminal 未初始化 - Terminal 视图中没有就绪信号" @@ -977,8 +977,8 @@ "resolveStage": "在创建拉取请求之前解决或暂存更改。", "resolveConflicts": "在创建拉取请求之前解决冲突。", "creating": "正在创建评论...", - "force": "用租约强制推动...", - "pushing": "推动 commits...", + "force": "正在使用租约强制推送...", + "pushing": "正在推送 commits...", "publishing": "正在发布分支...", "committing": "正在提交更改...", "generating": "正在生成 commit 消息...", @@ -1528,7 +1528,7 @@ "label": "标签", "add": "添加", "more": "更多按键 — Tab、箭头、F1–F12...", - "key": "标识", + "key": "按键", "modifiers": "修饰符", "show": "显示、隐藏或重新排序快捷键", "manage": "管理快捷键", @@ -1541,8 +1541,8 @@ "navigation": "导航", "editing": "编辑", "eGPnpm": "例如:pnpm build", - "eGBuild": "例如建造", - "pick": "选择一把钥匙" + "eGBuild": "例如:构建", + "pick": "选择按键" }, "dragReorderList": { "moveDown": "下移", @@ -1703,8 +1703,8 @@ "strike": "删除线", "italic": "斜体", "bold": "粗体", - "h2": "氢2", - "body": "身体" + "h2": "H2", + "body": "正文" }, "mobileWorkspaceNameInput": { "workspace": "工作区名称" @@ -1921,7 +1921,7 @@ "inspect": "检查详细信息或启动 AI 修复过程。", "rerun": "重新运行失败的检查", "checks": "检查项", - "failingCheckCountFailingCheck": "{{failingCheckCount}} 检查失败", + "failingCheckCountFailingCheck": "{{failingCheckCount}} 项检查失败", "failingCheckCountFailingChecks": "{{failingCheckCount}} 检查失败", "failed": "无法加载检查详细信息" }, @@ -2100,7 +2100,7 @@ }, "mobileFilePreviewScreen": { "unableLoad": "无法加载预览", - "save": "保存 terminal 神器", + "save": "保存终端产物", "back": "返回文件", "unsaved": "未保存的编辑将会丢失。", "discardChanges": "放弃更改?", diff --git a/mobile/src/terminal/terminal-accessory-key-catalog.ts b/mobile/src/terminal/terminal-accessory-key-catalog.ts index f63bdbbf8bc..d2140e69b09 100644 --- a/mobile/src/terminal/terminal-accessory-key-catalog.ts +++ b/mobile/src/terminal/terminal-accessory-key-catalog.ts @@ -1,35 +1,37 @@ -import { t } from '@/i18n/mobile-i18n' +import { mobileI18n, t } from '@/i18n/mobile-i18n' import type { TerminalAccessoryKey } from './terminal-accessory-keys' +const canonicalTerminalLabel = mobileI18n.getFixedT('en') + export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ { id: 'escape', - label: t('terminalAccessoryKeyCatalog.esc'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.esc'), bytes: '\x1b', accessibilityLabel: t('terminalAccessoryKeyCatalog.escape') }, { id: 'tab', - label: t('terminalAccessoryKeyCatalog.tab'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.tab'), bytes: '\t', accessibilityLabel: t('terminalAccessoryKeyCatalog.tab') }, { id: 'enter', - label: t('terminalAccessoryKeyCatalog.enter'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.enter'), bytes: '\r', accessibilityLabel: t('terminalAccessoryKeyCatalog.enter') }, // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. { id: 'shiftTab', - label: t('terminalAccessoryKeyCatalog.shiftPlus'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.shiftPlus'), bytes: '\x1b[Z', accessibilityLabel: t('terminalAccessoryKeyCatalog.shiftTab') }, { id: 'space', - label: t('terminalAccessoryKeyCatalog.space'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.space'), bytes: ' ', accessibilityLabel: t('terminalAccessoryKeyCatalog.space') }, @@ -42,7 +44,7 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ }, { id: 'delete', - label: t('terminalAccessoryKeyCatalog.del'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.del'), bytes: '\x1b[3~', accessibilityLabel: t('terminalAccessoryKeyCatalog.forward'), repeatable: true @@ -77,55 +79,55 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ }, { id: 'ctrlC', - label: t('terminalAccessoryKeyCatalog.ctrlPlusC'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusC'), bytes: '\x03', accessibilityLabel: t('terminalAccessoryKeyCatalog.interrupt') }, { id: 'ctrlD', - label: t('terminalAccessoryKeyCatalog.ctrlPlusD'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusD'), bytes: '\x04', accessibilityLabel: t('terminalAccessoryKeyCatalog.send') }, { id: 'ctrlL', - label: t('terminalAccessoryKeyCatalog.ctrlPlusL'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusL'), bytes: '\x0c', accessibilityLabel: t('terminalAccessoryKeyCatalog.clearScreen') }, { id: 'ctrlZ', - label: t('terminalAccessoryKeyCatalog.ctrlPlusZ'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusZ'), bytes: '\x1a', accessibilityLabel: t('terminalAccessoryKeyCatalog.suspend') }, { id: 'ctrlR', - label: t('terminalAccessoryKeyCatalog.ctrlPlusR'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusR'), bytes: '\x12', accessibilityLabel: t('terminalAccessoryKeyCatalog.reverse') }, { id: 'ctrlA', - label: t('terminalAccessoryKeyCatalog.ctrlPlus'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlus'), bytes: '\x01', accessibilityLabel: t('terminalAccessoryKeyCatalog.start') }, { id: 'ctrlE', - label: t('terminalAccessoryKeyCatalog.ctrlPlusE'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusE'), bytes: '\x05', accessibilityLabel: t('terminalAccessoryKeyCatalog.end') }, { id: 'ctrlW', - label: t('terminalAccessoryKeyCatalog.ctrlPlusW'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusW'), bytes: '\x17', accessibilityLabel: t('terminalAccessoryKeyCatalog.delete') }, { id: 'ctrlU', - label: t('terminalAccessoryKeyCatalog.ctrlPlusU'), + label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusU'), bytes: '\x15', accessibilityLabel: t('terminalAccessoryKeyCatalog.clearLine') }