602 lines
16 KiB
JavaScript
602 lines
16 KiB
JavaScript
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-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'
|
|
import { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
|
import { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
|
|
|
|
export { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
|
export { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
|
export { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
|
export { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
|
|
|
|
const AGENT_CATALOG_PREFIX = 'auto.lib.agent.catalog.'
|
|
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]
|
|
|
|
export const NEVER_TRANSLATE_VALUES = new Set([
|
|
'Agent',
|
|
'Agents',
|
|
'Aider',
|
|
'Amp',
|
|
'Android',
|
|
'Antigravity',
|
|
'Auggie',
|
|
'Autohand Code',
|
|
'Charm',
|
|
'Claude',
|
|
'Claude Agent Teams',
|
|
'Cline',
|
|
'Codebuff',
|
|
'Codex',
|
|
'Command Code',
|
|
'Continue',
|
|
'Cursor',
|
|
'Droid',
|
|
'Devin',
|
|
'Gemini',
|
|
'Git',
|
|
'Git Bash',
|
|
'GitHub Copilot',
|
|
'GitLab',
|
|
'Goose',
|
|
'Grok',
|
|
'Hermes',
|
|
'Jira',
|
|
'Kilocode',
|
|
'Kimi',
|
|
'Kiro',
|
|
'Linear',
|
|
'Mistral Vibe',
|
|
'OMP',
|
|
'OpenClaude',
|
|
'OpenClaw',
|
|
'OpenCode',
|
|
'OpenCode Go',
|
|
'Orca',
|
|
'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',
|
|
'Discord',
|
|
'WSL',
|
|
'wsl',
|
|
'darwin',
|
|
'Nautilus',
|
|
'GitHub',
|
|
'no_proxy',
|
|
'Beta',
|
|
// Round 6: product/tool names, language names, and code tokens that machine
|
|
// translation wrongly localized (e.g. tailscale→尾鱗, Swift→迅速, yarn→糸).
|
|
'Tailscale',
|
|
'tailscale',
|
|
'Ghostty',
|
|
'ghostty',
|
|
'pwsh',
|
|
'yarn',
|
|
'Kagi',
|
|
'kagi',
|
|
'Bitbucket',
|
|
'bitbucket',
|
|
'GNOME',
|
|
'gnome',
|
|
'iCloud',
|
|
'icloud',
|
|
'ripgrep',
|
|
'PowerShell',
|
|
'powershell',
|
|
'TypeScript',
|
|
'typescript',
|
|
'Mermaid',
|
|
'mermaid',
|
|
'Swift',
|
|
'swift',
|
|
'Rust',
|
|
'rust',
|
|
'Java',
|
|
'java',
|
|
'Go',
|
|
'Python',
|
|
'python',
|
|
'Kotlin',
|
|
'kotlin',
|
|
'Ruby',
|
|
'ruby',
|
|
'Bash',
|
|
'bash',
|
|
'GraphQL',
|
|
'graphql',
|
|
'iOS',
|
|
'iPhone',
|
|
'iPad',
|
|
'ide',
|
|
'IDE',
|
|
'ui',
|
|
'UI',
|
|
'calt',
|
|
'ai',
|
|
'AI',
|
|
'ci',
|
|
'CI',
|
|
'REST',
|
|
'rest',
|
|
'YAML',
|
|
'yaml',
|
|
'yml',
|
|
'XML',
|
|
'SQL',
|
|
'CSS',
|
|
'Token',
|
|
'token',
|
|
'HTTP/1.1',
|
|
'HTTP/2',
|
|
'true',
|
|
'false',
|
|
'/home/user',
|
|
'/home/user/project',
|
|
'/path/to/destination',
|
|
'.orca/issue-command',
|
|
'PLAN.md',
|
|
'feat/mobile-page',
|
|
'sk-...',
|
|
'main',
|
|
'master',
|
|
'HEAD',
|
|
'lint',
|
|
'MD',
|
|
'/home/user/projects',
|
|
'Claude Code'
|
|
])
|
|
|
|
export const BRAND_MISTRANSLATIONS = {
|
|
ko: {
|
|
Codex: ['사본', '코덱스'],
|
|
Gemini: ['쌍둥이자리'],
|
|
Claude: ['클로드'],
|
|
Grok: ['그록'],
|
|
Orca: ['오르카', '범고래'],
|
|
Cursor: ['커서'],
|
|
OpenCode: ['오픈코드'],
|
|
OpenClaw: ['오픈클로'],
|
|
OpenClaude: ['오픈클로드'],
|
|
Antigravity: ['반중력'],
|
|
Continue: ['계속하다'],
|
|
Charm: ['매력'],
|
|
Goose: ['거위'],
|
|
Pi: ['파이'],
|
|
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
|
Git: ['힘내'],
|
|
Discord: ['디스코드'],
|
|
Linear: ['선형'],
|
|
Agent: ['에이전트'],
|
|
Agents: ['에이전트'],
|
|
agent: ['에이전트'],
|
|
agents: ['에이전트'],
|
|
Commit: ['커밋'],
|
|
Commits: ['커밋'],
|
|
commit: ['커밋'],
|
|
commits: ['커밋'],
|
|
Markdown: ['마크다운', '가격 인하'],
|
|
markdown: ['마크다운', '가격 인하'],
|
|
Repo: ['저장소', '레포'],
|
|
Repos: ['저장소', '레포'],
|
|
repo: ['저장소', '레포'],
|
|
repos: ['저장소', '레포'],
|
|
Terminal: ['터미널'],
|
|
Terminals: ['터미널'],
|
|
terminal: ['터미널'],
|
|
terminals: ['터미널']
|
|
},
|
|
zh: {
|
|
Codex: ['法典'],
|
|
Gemini: ['双子座'],
|
|
Claude: ['克洛德', '克劳德'],
|
|
Grok: ['格罗克'],
|
|
Orca: ['虎鲸', '逆戟鲸'],
|
|
Cursor: ['光标'],
|
|
OpenCode: ['开放代码'],
|
|
OpenClaw: ['开爪'],
|
|
OpenClaude: ['开放克劳德'],
|
|
Antigravity: ['反重力'],
|
|
Continue: ['继续'],
|
|
Charm: ['魅力'],
|
|
Goose: ['鹅'],
|
|
Pi: ['圆周率'],
|
|
Droid: ['机器人'],
|
|
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
|
Bitbucket: ['位桶'],
|
|
Linear: ['线性', '线形'],
|
|
Jira: ['吉拉'],
|
|
Tailscale: ['尾鳞', '尾鱗'],
|
|
Agent: ['代理', '智能体'],
|
|
Agents: ['代理', '智能体'],
|
|
agent: ['代理', '智能体'],
|
|
agents: ['代理', '智能体'],
|
|
Commit: ['提交'],
|
|
Commits: ['提交'],
|
|
commit: ['提交'],
|
|
commits: ['提交'],
|
|
Markdown: ['降价'],
|
|
markdown: ['降价'],
|
|
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
|
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
|
repo: ['存储库', '仓库', '回购协议', '回购'],
|
|
repos: ['存储库', '仓库', '回购协议', '回购'],
|
|
Terminal: ['终端', '端子'],
|
|
Terminals: ['终端', '端子'],
|
|
terminal: ['终端', '端子'],
|
|
terminals: ['终端', '端子'],
|
|
Bash: ['重击'],
|
|
PowerShell: ['电源外壳'],
|
|
REST: ['休息'],
|
|
HEAD: ['头'],
|
|
Swift: ['迅速'],
|
|
Rust: ['锈'],
|
|
'Claude Code': ['Claude·科德'],
|
|
'Git AI Author': ['Git AI 作者']
|
|
},
|
|
ja: {
|
|
Codex: ['法典', 'コーデックス'],
|
|
Gemini: ['双子座'],
|
|
Claude: ['クロード'],
|
|
Grok: ['グロック'],
|
|
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
|
Cursor: ['カーソル'],
|
|
OpenCode: ['オープンコード', 'オープン・コード'],
|
|
OpenClaw: ['オープンクロー'],
|
|
OpenClaude: ['オープンクロード'],
|
|
Antigravity: ['反重力'],
|
|
Continue: ['続ける', '続行'],
|
|
Charm: ['魅力'],
|
|
Goose: ['ガチョウ', '雁'],
|
|
Pi: ['円周率'],
|
|
Droid: ['ロボット', 'ドロイド'],
|
|
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
|
Discord: ['不和'],
|
|
Linear: ['線形'],
|
|
Agent: ['エージェント'],
|
|
Agents: ['エージェント'],
|
|
agent: ['エージェント'],
|
|
agents: ['エージェント'],
|
|
Commit: ['コミット'],
|
|
Commits: ['コミット'],
|
|
commit: ['コミット'],
|
|
commits: ['コミット'],
|
|
Markdown: ['マークダウン'],
|
|
markdown: ['マークダウン'],
|
|
Repo: ['リポジトリ', 'リポ'],
|
|
Repos: ['リポジトリ', 'リポ'],
|
|
repo: ['リポジトリ', 'リポ'],
|
|
repos: ['リポジトリ', 'リポ'],
|
|
Terminal: ['ターミナル', '端子'],
|
|
Terminals: ['ターミナル', '端子'],
|
|
terminal: ['ターミナル', '端子'],
|
|
terminals: ['ターミナル', '端子']
|
|
},
|
|
es: {
|
|
Codex: ['códice', 'Códice'],
|
|
Gemini: ['Géminis'],
|
|
Claude: ['claudia', 'Claudia'],
|
|
Orca: ['orca', 'Orcas', 'orcas'],
|
|
OpenCode: ['código abierto', 'Código abierto'],
|
|
OpenClaude: ['Openclaude'],
|
|
Antigravity: ['antigravedad', 'Antigravedad'],
|
|
'GitHub Copilot': ['Copiloto de GitHub'],
|
|
Discord: ['discordia'],
|
|
Linear: ['lineal', 'Lineal'],
|
|
Jira: ['jira'],
|
|
Agent: ['Agente', 'agente'],
|
|
Agents: ['Agentes', 'agentes'],
|
|
agent: ['agente'],
|
|
agents: ['agentes'],
|
|
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
|
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
|
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
|
commits: ['confirmaciones', 'compromisos'],
|
|
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
|
markdown: ['reducción', 'rebaja', 'rebajas'],
|
|
Repo: ['Repositorio', 'repositorio'],
|
|
Repos: ['Repositorios', 'repositorios'],
|
|
repo: ['repositorio'],
|
|
repos: ['repositorios']
|
|
}
|
|
}
|
|
|
|
export const NATIVE_PICKER_LABELS = {
|
|
zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
|
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
|
ja: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
|
es: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }
|
|
}
|
|
|
|
const CJK_LATIN_SPACED_TERM_PATTERN = CJK_LATIN_SPACED_TERMS.join('|')
|
|
|
|
export function isEnglishOnlyKey(key) {
|
|
return ENGLISH_ONLY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
|
|
}
|
|
|
|
export function shouldPreserveEnglishValue(enValue, key = '') {
|
|
if (!enValue?.trim()) {
|
|
return true
|
|
}
|
|
if (/^https?:\/\//.test(enValue) || enValue.startsWith('orca://')) {
|
|
return true
|
|
}
|
|
if (isEnglishOnlyKey(key)) {
|
|
return true
|
|
}
|
|
return NEVER_TRANSLATE_VALUES.has(enValue)
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function includesPreservedLatinTerm(value, term) {
|
|
if (!/^[A-Za-z_]+$/.test(term)) {
|
|
return value.includes(term)
|
|
}
|
|
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
|
|
}
|
|
|
|
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
|
let result = localeValue
|
|
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
|
|
|
|
for (const [brand, wrongForms] of Object.entries(mistranslations).sort(
|
|
([left], [right]) => right.length - left.length
|
|
)) {
|
|
if (!includesPreservedLatinTerm(enValue, brand)) {
|
|
continue
|
|
}
|
|
// Why: terminal/theme "Cursor" labels name the on-screen カーソル, not the Cursor product —
|
|
// skip the revert so カーソル survives for these settings.
|
|
if (isScreenCursorContext(brand, enValue, key)) {
|
|
continue
|
|
}
|
|
if (includesPreservedLatinTerm(result, brand)) {
|
|
continue
|
|
}
|
|
for (const wrong of wrongForms) {
|
|
if (!result.includes(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)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function applyCjkLatinTermSpacing(localeValue, locale) {
|
|
// Why: CJK UI copy should keep protected Latin workflow terms readable when MT glues them to native text.
|
|
let result = localeValue
|
|
.replace(
|
|
new RegExp(
|
|
`(${CJK_LATIN_SPACED_TERM_PATTERN})([\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af])`,
|
|
'g'
|
|
),
|
|
'$1 $2'
|
|
)
|
|
.replace(
|
|
new RegExp(
|
|
`([\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af])(${CJK_LATIN_SPACED_TERM_PATTERN})`,
|
|
'g'
|
|
),
|
|
'$1 $2'
|
|
)
|
|
.replace(
|
|
new RegExp(`(${CJK_LATIN_SPACED_TERM_PATTERN})(${CJK_LATIN_SPACED_TERM_PATTERN})`, 'g'),
|
|
'$1 $2'
|
|
)
|
|
if (locale === 'ko') {
|
|
// Korean particles attach to the noun (no space) only when the particle is a complete token at a
|
|
// boundary — re-glue "Orca 에"/"PR 을"/"에서는" but keep "Jira 이슈"/"Orca 로고"/"agent 에뮬레이터".
|
|
result = result.replace(
|
|
new RegExp(
|
|
`(${CJK_LATIN_SPACED_TERM_PATTERN}) ((?:에서|에게|에는|에선|으로|로서|로써|부터|까지|보다|처럼|은|는|이|가|을|를|와|과|의|에|로|도|만)+)(?=$|[\\s.,!?…·:;)\\]}"'」』])`,
|
|
'g'
|
|
),
|
|
'$1$2'
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function applyPhraseFixes(enValue, localeValue, locale) {
|
|
let result = localeValue
|
|
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
|
|
if (!enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase())) {
|
|
continue
|
|
}
|
|
result = result.replace(fix.pattern, fix.replacement)
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
|
|
const keyOverride = LOCALE_KEY_OVERRIDES[key]?.[locale]
|
|
if (keyOverride) {
|
|
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
|
|
let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key)
|
|
result = applyPhraseFixes(enValue, result, locale)
|
|
if (['zh', 'ja', 'ko'].includes(locale)) {
|
|
result = applyCjkLatinTermSpacing(result, locale)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
|
|
if (valueOverride) {
|
|
let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key)
|
|
result = applyPhraseFixes(enValue, result, locale)
|
|
if (['zh', 'ja', 'ko'].includes(locale)) {
|
|
result = applyCjkLatinTermSpacing(result, locale)
|
|
}
|
|
return result
|
|
}
|
|
|
|
if (shouldPreserveEnglishValue(enValue, key)) {
|
|
return enValue
|
|
}
|
|
|
|
let result = localeValue
|
|
|
|
if (key.includes('.search.')) {
|
|
const searchOverride = SEARCH_KEYWORD_OVERRIDES[locale]?.[enValue]
|
|
if (searchOverride) {
|
|
result = searchOverride
|
|
}
|
|
}
|
|
|
|
result = applyBrandMistranslationFixes(enValue, result, locale, key)
|
|
result = applyPhraseFixes(enValue, result, locale)
|
|
if (['zh', 'ja', 'ko'].includes(locale)) {
|
|
result = applyCjkLatinTermSpacing(result, locale)
|
|
}
|
|
|
|
if (enValue.includes('orca://')) {
|
|
result = result.replace(/虎鲸:\/\//g, 'orca://')
|
|
}
|
|
|
|
if (enValue === 'Orca' || enValue.startsWith('Orca ')) {
|
|
result = result
|
|
.replaceAll('虎鲸', 'Orca')
|
|
.replaceAll('逆戟鲸', 'Orca')
|
|
.replaceAll('シャチ', 'Orca')
|
|
}
|
|
|
|
if (enValue.includes('orca://')) {
|
|
result = result.replace(/シャチ:\/\//g, 'orca://')
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export function collectStringLeaves(value, prefix = '', leaves = []) {
|
|
if (typeof value === 'string') {
|
|
leaves.push({ key: prefix, value })
|
|
return leaves
|
|
}
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return leaves
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
collectStringLeaves(child, prefix ? `${prefix}.${key}` : key, leaves)
|
|
}
|
|
return leaves
|
|
}
|
|
|
|
export function setLeaf(catalog, key, translatedValue) {
|
|
const parts = key.split('.')
|
|
let cursor = catalog
|
|
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
cursor = cursor[parts[index]]
|
|
}
|
|
cursor[parts.at(-1)] = translatedValue
|
|
}
|
|
|
|
export function repairCatalog(enCatalog, localeCatalog, locale) {
|
|
const leaves = collectStringLeaves(enCatalog)
|
|
let repaired = 0
|
|
|
|
for (const leaf of leaves) {
|
|
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
|
|
const next = repairTranslatedValue({
|
|
key: leaf.key,
|
|
enValue: leaf.value,
|
|
localeValue: current,
|
|
locale
|
|
})
|
|
if (next !== current) {
|
|
setLeaf(localeCatalog, leaf.key, next)
|
|
repaired += 1
|
|
}
|
|
}
|
|
|
|
if (localeCatalog.settings?.appearance?.language) {
|
|
for (const [labelKey, label] of Object.entries(NATIVE_PICKER_LABELS[locale] ?? {})) {
|
|
if (localeCatalog.settings.appearance.language[labelKey] !== label) {
|
|
localeCatalog.settings.appearance.language[labelKey] = label
|
|
repaired += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
if (localeCatalog.menu) {
|
|
if (locale === 'zh') {
|
|
if (localeCatalog.menu.exploreOrca !== '探索 Orca') {
|
|
localeCatalog.menu.exploreOrca = '探索 Orca'
|
|
repaired += 1
|
|
}
|
|
if (localeCatalog.menu.gettingStarted !== 'Orca 入门') {
|
|
localeCatalog.menu.gettingStarted = 'Orca 入门'
|
|
repaired += 1
|
|
}
|
|
}
|
|
if (locale === 'ko') {
|
|
if (localeCatalog.menu.exploreOrca !== 'Orca 둘러보기') {
|
|
localeCatalog.menu.exploreOrca = 'Orca 둘러보기'
|
|
repaired += 1
|
|
}
|
|
if (localeCatalog.menu.gettingStarted !== 'Orca 시작하기') {
|
|
localeCatalog.menu.gettingStarted = 'Orca 시작하기'
|
|
repaired += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return repaired
|
|
}
|
|
|
|
export function repairCacheMap(cache, locale) {
|
|
let repaired = 0
|
|
for (const [enValue, translated] of cache.entries()) {
|
|
const next = shouldPreserveEnglishValue(enValue)
|
|
? enValue
|
|
: repairTranslatedValue({
|
|
key: '',
|
|
enValue,
|
|
localeValue: translated,
|
|
locale
|
|
})
|
|
if (next !== translated) {
|
|
cache.set(enValue, next)
|
|
repaired += 1
|
|
}
|
|
}
|
|
return repaired
|
|
}
|