mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* fix(i18n): render revised UI copy and fail on fallback drift en.json kept stale values for 47 translate() keys whose source fallback was rewritten, so the revised copy never rendered in any language. - Adopt the newer source copy into en.json and retranslate zh/ko/ja/es/fr/pt. - Move copy whose meaning changed to new keys (artifacts sign-in, remote host access link / name, ssh config picker, jump palette automation label, Claude subagent navigation hint) so stale translations cannot survive. - Split five keys shared by different sentences at the sites the extractor flagged (activity group-by, automation destination notices, Linear open action, workspace options label). - Fix catalog/source mistakes: onboarding "notify you know" typo, crash report double dot and missing space, mobile search keyword "app", remote server restart warning repeating "live tabs", and stale source fallbacks where the catalog was right (Ghostty import, Skills button, too-many-changes banner, card style and chat UI descriptions). - verify-localization-extraction now fails on fallback drift and prints the en.json and source values with how to fix it. Closes #25572 * fix(i18n): translate es live tab/pane counts used in server restart warning --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
156 lines
5.4 KiB
JavaScript
156 lines
5.4 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
|
|
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
|
|
|
function flattenCatalog(value, prefix = '', entries = new Map()) {
|
|
if (typeof value === 'string') {
|
|
entries.set(prefix, value)
|
|
return entries
|
|
}
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return entries
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function placeholders(value) {
|
|
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
|
|
}
|
|
|
|
export function compareExtraction(extractedCatalog, englishCatalog) {
|
|
const extracted = flattenCatalog(extractedCatalog)
|
|
const english = flattenCatalog(englishCatalog)
|
|
const dynamicDefaults = [...extracted.entries()]
|
|
.filter(([, value]) => value.length === 0)
|
|
.map(([key]) => key)
|
|
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
|
|
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
|
|
const fallbackDrift = []
|
|
const placeholderMismatches = []
|
|
|
|
for (const [key, extractedValue] of extracted) {
|
|
const englishValue = english.get(key)
|
|
if (
|
|
extractedValue.length === 0 ||
|
|
englishValue === undefined ||
|
|
englishValue === extractedValue
|
|
) {
|
|
continue
|
|
}
|
|
fallbackDrift.push(key)
|
|
if (placeholders(extractedValue) !== placeholders(englishValue)) {
|
|
placeholderMismatches.push(key)
|
|
}
|
|
}
|
|
|
|
return {
|
|
extracted,
|
|
english,
|
|
dynamicDefaults,
|
|
missingFromEnglish,
|
|
orphans,
|
|
fallbackDrift,
|
|
placeholderMismatches
|
|
}
|
|
}
|
|
|
|
function printKeys(label, keys) {
|
|
if (keys.length === 0) {
|
|
return
|
|
}
|
|
console.error(`${label}:`)
|
|
for (const key of keys.slice(0, 20)) {
|
|
console.error(` ${key}`)
|
|
}
|
|
if (keys.length > 20) {
|
|
console.error(` ...and ${keys.length - 20} more`)
|
|
}
|
|
}
|
|
|
|
function printFallbackDrift(keys, extracted, english) {
|
|
console.error(
|
|
'Inline defaults differ from en.json. The catalog value is what renders, so editing a fallback alone changes nothing.\n' +
|
|
'Set the en.json value to the new copy and update every locale catalog to match, or move the new copy to a new key.'
|
|
)
|
|
for (const key of keys.slice(0, 20)) {
|
|
console.error(` ${key}`)
|
|
console.error(` en.json: ${JSON.stringify(english.get(key))}`)
|
|
console.error(` source: ${JSON.stringify(extracted.get(key))}`)
|
|
}
|
|
if (keys.length > 20) {
|
|
console.error(` ...and ${keys.length - 20} more`)
|
|
}
|
|
}
|
|
|
|
async function extractToTemporaryCatalog(root, tempDir) {
|
|
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
|
|
const outputPattern = path.join(tempDir, '{{language}}.json')
|
|
// Treat extraction as check evidence, not another committed catalog feature authors must synchronize.
|
|
const [{ runProcessSync }, { describeProcessFailure }, { resolvePinnedBunExecutable }] =
|
|
await Promise.all([
|
|
import('@orca/process-host'),
|
|
import('./process-failure-message.mjs'),
|
|
import('./pinned-bun-executable.mjs')
|
|
])
|
|
// Missing or unusable optional Bun keeps the existing parent runtime.
|
|
const program = resolvePinnedBunExecutable(root, runProcessSync) ?? process.execPath
|
|
const result = runProcessSync({
|
|
program,
|
|
args: [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
|
},
|
|
timeoutMs: null,
|
|
maxOutputBytes: 1024 * 1024
|
|
})
|
|
if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) {
|
|
throw new Error(`Localization extraction failed: ${describeProcessFailure(result)}`)
|
|
}
|
|
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
|
}
|
|
|
|
export async function main(root = process.cwd()) {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
|
|
|
|
try {
|
|
const [extractedCatalog, englishCatalog] = await Promise.all([
|
|
extractToTemporaryCatalog(root, tempDir),
|
|
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
|
|
])
|
|
const result = compareExtraction(extractedCatalog, englishCatalog)
|
|
|
|
console.log(
|
|
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ from en.json.`
|
|
)
|
|
|
|
let failed = false
|
|
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
|
|
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
|
|
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
|
|
failed = true
|
|
}
|
|
if (result.fallbackDrift.length > 0) {
|
|
printFallbackDrift(result.fallbackDrift, result.extracted, result.english)
|
|
failed = true
|
|
}
|
|
|
|
return failed ? 1 : 0
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|