mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
* fix(macos): add a Full Disk Access nudge to reduce recurring TCC prompts (#9756) macOS shows the "Orca wants to access other apps' data" (kTCCServiceSystemPolicyAppData) prompt and it can keep reappearing. The reappearing loop is not a fixable app bug: it is TCC identity churn — an unsigned local rebuild mints a new code identity each build, so macOS treats each as a new app — and Orca's other-app reads are already gated behind opt-in settings or explicit user actions. The durable remedy for the population we can help (release users) is Full Disk Access, a superset macOS grant that stops these prompts for a stable identity. Surface it with an ambient, dismissable sidebar card that reuses the existing developer-permissions IPC. macOS-only; probes FDA status at most once per renderer session (the probe itself reads protected data, so it must not repeat on focus/remount); "Open System Settings" opens the Full Disk Access pane; permanent localStorage dismissal. * fix(macos): stop the FDA nudge promising macOS will stop asking The card said Full Disk Access makes "macOS stop asking", but the grant covers this app while terminals are spawned by the detached PTY daemon (daemon-init.ts forks execPath with ELECTRON_RUN_AS_NODE + detached:true, reparented to launchd), which macOS treats as its own TCC identity. A user who followed the card would grant FDA and still be prompted from terminals. Scope the claim to reducing prompts and name the terminal caveat. * fix(macos): drop stale focus refreshes in the FDA nudge refreshFullDiskAccessStatus() applied whichever getStatus() round-trip resolved last. Rapid blur/focus puts several in flight, so an earlier pre-grant 'unknown' landing after a newer 'granted' un-hid the card and also wrote 'unknown' into the module-level session cache, re-nagging a user who already has Full Disk Access for the rest of the session. The adjacent FullDiskAccessSetupPrompt already guards this with a refresh sequence; mirror it here. Also unmount React roots in afterEach: clearing document.body left them mounted, leaking each test's window focus listener into later tests. * test(macos): unmount the StrictMode FDA nudge root between tests The afterEach unmount added in5a0f717only covers roots created through renderNudge(). The StrictMode probe test builds its own root, so it was never unmounted and its component stayed live for the rest of the file. Today that component has no window focus listener, so nothing breaks; add a CTA click to it and the same contamination5a0f717fixed comes back — the two tests after it see extra getStatus() calls and fail. Register the root so the fix covers every mount site. * fix(macos): attribute the FDA prompts to agent activity, not Orca's own reads The card said the prompts happen "when this copy of Orca reads protected app data", but Orca's own reads are small and gated; #9756's trigger is agent find/grep sweeps into ~/Library/Containers, which macOS bills to Orca because Orca is the responsible process for every terminal child. Blaming Orca reads as an accusation and hid why FDA works at all — the grant attaches to Orca rather than to each churning child binary. Name agents as the trigger, keep the "reduce" hedge and the terminal caveat, and drop the "this copy of Orca" dev-build hedge that cost a clause. Assert the causation wording so it can't silently regress. * fix(macos): explain the TCC prompts on the settings row, drop the sidebar card The sidebar nudge added in344d466bwas premised on FDA being reachable "only inside onboarding". It isn't: Settings > macOS Permissions has had a full-disk-access row all along (searchable), the Setup Guide hosts the same prompt from both a settings pane and a re-openable modal, and the sidebar already links to that modal via the "Onboarding checklist" entry. The card added a fifth affordance to the same sidebar that already had the fourth, so it bought prominence rather than access - shown to every macOS user without FDA, most of whom never hit #9756. Keep the part that was actually new. The settings row still described the prompts as something projects and worktrees trigger, which is the same misattribution the card carried: the reads come from the agents Orca runs, and macOS names Orca only because it is the responsible process for every terminal child. It also never mentioned that the grant has to cover Orca Helper, or that the preserved daemon keeps stale TCC state until restart. Non-English catalogs get the English string as a placeholder; the bootstrap translators key their cache on the English value, so a changed string is re-translated on the next run. * feat(macos): nudge Full Disk Access only after macOS repeatedly prompts The FDA hint is only worth showing to users macOS is actually prompting. tccd emits one AUTHREQ_PROMPTING line per consent dialog it displays, carrying the service and both identities, so a narrow log-stream predicate detects the real thing without correlating across lines or guessing whether a dialog appeared. Verified against a captured dialog: the predicate matched 1 line out of 1436 TCC lines in ~28s, because routine preflight checks - the overwhelming majority of TCC traffic - do not emit it. Count dialogs where Orca is the responsible process, persist across launches, and tell the renderer on the third one. The event separates the accessing binary from the responsible app, which is the crux of #9756, so the toast can name the tool that triggered it rather than blaming Orca generically. One toast per user, with a permanent opt-out; it deep-links to the FDA row in Settings > macOS Permissions rather than restating the guidance. macOS-only: the watcher no-ops elsewhere, the web client stubs the API, and the child is killed on before-quit since log stream ignores a closed stdout. * test(macos): pin the platform so the TCC watcher tests exercise the darwin path start() is darwin-gated, so on Linux CI it no-opped and the stream/kill assertions passed vacuously against a watcher that never spawned. Pin process.platform per the existing convention (shared/secure-file.test.ts), and cover the gate itself with an explicit non-darwin case. * fix(macos): start the TCC watcher from app bootstrap, not the window wiring attachMainWindowServices is called directly by its own unit test, so wiring initTccPromptNotice there made `vitest src/main/window/` spawn real `log stream` children that outlived the run - two orphaned watchers were left behind by a single test session. Only the IPC handler registration stays there; the spawn moves to the real app bootstrap in index.ts, which tests never execute. Verified: running the suite that leaked now leaves the watcher count unchanged. * fix(macos): clarify repeated permission notice * fix(macos): keep TCC notice lifecycle safe * fix(macos): retain pending TCC notice delivery * fix(macos): acknowledge TCC notice delivery * fix(macos): release failed TCC notice claims * fix(macos): retry transient TCC notice display * fix(macos): contain TCC notice IPC failures * fix(macos): harden TCC notice renderer lifecycle * fix(macos): contain TCC notice dismissal failures * test(macos): satisfy promise executor lint * fix(macos): detect helper-attributed TCC prompts * fix(macos): align TCC watcher lifecycle and helper identity * perf(macos): defer TCC log reader until first paint * fix(macos): recover deferred TCC watcher startup * fix(macos): recover TCC watcher from deferred quit * fix(macos): localize recurring file access notice * fix(macos): preserve TCC watcher and localized guidance * fix(macos): avoid duplicate TCC watcher recovery * fix(macos): wait for locale before TCC notice * perf(macos): isolate TCC notice subscriptions --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
616 lines
17 KiB
JavaScript
616 lines
17 KiB
JavaScript
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
|
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.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 (isLocalizedProseTermContext(brand, 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 phraseFixMatchesEnglish(enValue, fix) {
|
|
// Why: `whenEnMatches` (a RegExp) lets a rule guard on a real token (e.g. /\bPRs?\b/)
|
|
// instead of the looser case-insensitive `whenEnIncludes` substring, so a phrase fix can
|
|
// avoid firing on unrelated English that merely contains the substring (approve, preview).
|
|
if (fix.whenEnMatches) {
|
|
return fix.whenEnMatches.test(enValue)
|
|
}
|
|
return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase())
|
|
}
|
|
|
|
function applyPhraseFixes(enValue, localeValue, locale) {
|
|
let result = localeValue
|
|
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
|
|
if (!phraseFixMatchesEnglish(enValue, fix)) {
|
|
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
|
|
}
|