translate-app.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. #!/usr/bin/env bun
  2. import path from "path"
  3. import { parseArgs } from "util"
  4. import { pathToFileURL } from "url"
  5. import {
  6. DESKTOP_NATIVE_LOCALES,
  7. desktopNativePluralCategories,
  8. type DesktopNativeLocale,
  9. } from "../packages/app/src/i18n/desktop-native"
  10. type Locale = Exclude<DesktopNativeLocale, "en">
  11. const locales = DESKTOP_NATIVE_LOCALES.filter((locale): locale is Locale => locale !== "en")
  12. const languages = {
  13. ar: "Arabic",
  14. br: "Brazilian Portuguese",
  15. bs: "Bosnian",
  16. da: "Danish",
  17. de: "German",
  18. es: "Spanish",
  19. fr: "French",
  20. ja: "Japanese",
  21. ko: "Korean",
  22. no: "Norwegian Bokmal",
  23. pl: "Polish",
  24. ru: "Russian",
  25. uk: "Ukrainian",
  26. th: "Thai",
  27. tr: "Turkish",
  28. hi: "Hindi",
  29. nl: "Dutch",
  30. id: "Indonesian",
  31. vi: "Vietnamese",
  32. it: "Italian",
  33. ur: "Urdu",
  34. pa: "Punjabi (Shahmukhi)",
  35. az: "Azerbaijani (Latin)",
  36. fi: "Finnish",
  37. sv: "Swedish",
  38. am: "Amharic",
  39. bg: "Bulgarian",
  40. bn: "Bengali",
  41. ca: "Catalan",
  42. cs: "Czech",
  43. dv: "Dhivehi",
  44. dz: "Dzongkha",
  45. el: "Greek",
  46. et: "Estonian",
  47. fa: "Persian",
  48. fo: "Faroese",
  49. hr: "Croatian",
  50. hu: "Hungarian",
  51. hy: "Armenian",
  52. is: "Icelandic",
  53. ka: "Georgian",
  54. km: "Khmer",
  55. lo: "Lao",
  56. lt: "Lithuanian",
  57. lv: "Latvian",
  58. mk: "Macedonian",
  59. mn: "Mongolian (Cyrillic)",
  60. ms: "Malay",
  61. my: "Burmese",
  62. ne: "Nepali",
  63. ro: "Romanian",
  64. si: "Sinhala",
  65. sk: "Slovak",
  66. sl: "Slovenian",
  67. sq: "Albanian",
  68. sr: "Serbian (Cyrillic)",
  69. tg: "Tajik",
  70. tk: "Turkmen",
  71. uz: "Uzbek (Latin)",
  72. zh: "Simplified Chinese",
  73. zht: "Traditional Chinese",
  74. } as const satisfies Record<Locale, string>
  75. type Dictionary = Record<string, string>
  76. type Drift = ReturnType<typeof findDrift>
  77. type Domain = { name: string; source: string; target: string; drift: Drift }
  78. const desktopLocales = new Set<Locale>(locales)
  79. const root = path.resolve(import.meta.dir, "..")
  80. export function parseTranslationArgs(args: string[]) {
  81. const parsed = parseArgs({
  82. args,
  83. options: {
  84. concurrency: { type: "string", short: "c", default: "4" },
  85. model: { type: "string", default: "opencode/gpt-5.5" },
  86. variant: { type: "string", default: "xhigh" },
  87. "dry-run": { type: "boolean", default: false },
  88. check: { type: "boolean", default: false },
  89. help: { type: "boolean", short: "h", default: false },
  90. },
  91. allowPositionals: true,
  92. })
  93. const target = parsed.positionals[0] ?? "all"
  94. const concurrency = Number(parsed.values.concurrency)
  95. if (!parsed.values.help && parsed.positionals.length !== 1) throw new Error("Pass one locale or 'all'.")
  96. if (target !== "all" && !isLocale(target)) throw new Error(`Unknown locale '${target}'.`)
  97. if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("Concurrency must be a positive integer.")
  98. return {
  99. target,
  100. concurrency: target === "all" ? concurrency : 1,
  101. model: parsed.values.model,
  102. variant: parsed.values.variant,
  103. dryRun: parsed.values["dry-run"],
  104. check: parsed.values.check,
  105. help: parsed.values.help,
  106. }
  107. }
  108. export function targetFiles(locale: Locale) {
  109. return [
  110. `packages/app/src/i18n/${locale}.ts`,
  111. `packages/ui/src/i18n/${locale}.ts`,
  112. ...(desktopLocales.has(locale) ? [`packages/desktop/src/renderer/i18n/${locale}.ts`] : []),
  113. ]
  114. }
  115. export function glossaryFile(locale: Locale) {
  116. if (locale === "zh") return ".opencode/glossary/zh-cn.md"
  117. if (locale === "zht") return ".opencode/glossary/zh-tw.md"
  118. return `.opencode/glossary/${locale}.md`
  119. }
  120. export function findDrift(source: Dictionary, target: Dictionary, locale?: Locale) {
  121. const pluralVariants = new Map(
  122. (locale
  123. ? pluralFamilies(source).flatMap((key) =>
  124. desktopNativePluralCategories(locale)
  125. .filter((category) => category !== "one" && category !== "other")
  126. .map((category) => [`${key}.${category}`, `${key}.other`] as const),
  127. )
  128. : []) as ReadonlyArray<readonly [string, string]>,
  129. )
  130. return {
  131. missing: [
  132. ...Object.keys(source).filter((key) => !Object.hasOwn(target, key)),
  133. ...Array.from(pluralVariants.keys()).filter((key) => !Object.hasOwn(target, key)),
  134. ],
  135. extra: Object.keys(target).filter((key) => !Object.hasOwn(source, key) && !pluralVariants.has(key)),
  136. placeholders: [
  137. ...Object.keys(source).filter(
  138. (key) => Object.hasOwn(target, key) && tokens(source[key]).join() !== tokens(target[key]).join(),
  139. ),
  140. ...Array.from(pluralVariants.entries())
  141. .filter(
  142. ([key, sourceKey]) =>
  143. Object.hasOwn(target, key) && tokens(source[sourceKey]).join() !== tokens(target[key]).join(),
  144. )
  145. .map(([key]) => key),
  146. ],
  147. }
  148. }
  149. export function sessionIDFromEvents(output: string) {
  150. const match = output.match(/"sessionID"\s*:\s*"([^"]+)"/)
  151. if (!match?.[1]) throw new Error("OpenCode did not report a session ID.")
  152. return match[1]
  153. }
  154. export function sessionModels(value: unknown) {
  155. if (!isRecord(value) || !Array.isArray(value.messages))
  156. throw new Error("OpenCode returned an invalid session export.")
  157. return value.messages.flatMap((message) => {
  158. if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant") return []
  159. if (typeof message.info.providerID !== "string" || typeof message.info.modelID !== "string") {
  160. throw new Error("OpenCode session export omitted the assistant model.")
  161. }
  162. return [
  163. {
  164. model: `${message.info.providerID}/${message.info.modelID}`,
  165. variant: typeof message.info.variant === "string" ? message.info.variant : undefined,
  166. },
  167. ]
  168. })
  169. }
  170. export function modelVariants(output: string, model: string) {
  171. const normalized = output.replaceAll("\r\n", "\n")
  172. const marker = `${model}\n`
  173. const start = normalized.indexOf(marker)
  174. if (start < 0) throw new Error(`Model not found: ${model}`)
  175. const provider = model.split("/")[0]
  176. const rest = normalized.slice(start + marker.length)
  177. const next = rest.search(new RegExp(`^${escapeRegExp(provider)}/`, "m"))
  178. const metadata: unknown = JSON.parse((next < 0 ? rest : rest.slice(0, next)).trim())
  179. if (!isRecord(metadata) || !isRecord(metadata.variants)) throw new Error(`Model variants not found: ${model}`)
  180. return metadata.variants
  181. }
  182. export function translationConfig(agent: string, model: string, targets: string[]) {
  183. return {
  184. $schema: "https://opencode.ai/config.json",
  185. model,
  186. default_agent: agent,
  187. share: "disabled" as const,
  188. formatter: false,
  189. lsp: false,
  190. snapshot: false,
  191. agent: {
  192. [agent]: {
  193. mode: "primary" as const,
  194. model,
  195. permission: {
  196. "*": "deny" as const,
  197. read: "allow" as const,
  198. glob: "allow" as const,
  199. grep: "allow" as const,
  200. webfetch: "allow" as const,
  201. websearch: "allow" as const,
  202. edit: Object.fromEntries([["*", "deny"], ...targets.map((target) => [target, "allow"])]),
  203. },
  204. },
  205. },
  206. }
  207. }
  208. export function unexpectedChanges(before: Record<string, string>, after: Record<string, string>, allowed: string[]) {
  209. const targets = new Set(allowed)
  210. return [...new Set([...Object.keys(before), ...Object.keys(after)])]
  211. .filter((file) => !targets.has(file) && before[file] !== after[file])
  212. .sort()
  213. }
  214. export async function runPool<T, R>(items: readonly T[], concurrency: number, task: (item: T) => Promise<R>) {
  215. const results = new Map<number, R>()
  216. const entries = items.entries()
  217. const worker = async (): Promise<void> => {
  218. const next = entries.next()
  219. if (next.done) return
  220. results.set(next.value[0], await task(next.value[1]))
  221. await worker()
  222. }
  223. await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
  224. return Array.from(results.entries())
  225. .sort((a, b) => a[0] - b[0])
  226. .map((entry) => entry[1])
  227. }
  228. async function main() {
  229. const options = parseTranslationArgs(Bun.argv.slice(2))
  230. if (options.help) {
  231. console.log(`
  232. Usage: bun run translate:app -- <locale|all> [options]
  233. Synchronizes product app translations with the English app, UI, and desktop dictionaries.
  234. Options:
  235. -c, --concurrency <count> Maximum parallel OpenCode runs for 'all' (default: 4)
  236. --model <provider/id> OpenCode model (default: opencode/gpt-5.5)
  237. --variant <name> Model variant (default: xhigh)
  238. --dry-run Report drift without running OpenCode
  239. --check Exit nonzero when translation drift exists
  240. -h, --help Show this help message
  241. Examples:
  242. bun run translate:app -- fr
  243. bun run translate:app -- all --concurrency 4
  244. `)
  245. return
  246. }
  247. const selected = options.target === "all" ? locales : [options.target]
  248. const plans = await Promise.all(selected.map((locale) => inspect(locale)))
  249. plans.forEach(report)
  250. const pending = plans.filter((plan) => plan.domains.some((domain) => changed(domain.drift)))
  251. if (options.check) {
  252. if (pending.length) process.exitCode = 1
  253. return
  254. }
  255. if (options.dryRun || pending.length === 0) return
  256. const targets = pending.flatMap((plan) => plan.domains.map((domain) => domain.target))
  257. const baseline = await worktreeSnapshot()
  258. const variant = await resolveModelVariant(options.model, options.variant)
  259. console.log(`Resolved ${options.model} (${options.variant}): ${JSON.stringify(variant)}`)
  260. const template = await commandTemplate()
  261. const results = await runPool(pending, options.concurrency, (plan) =>
  262. translate(plan, template, options.model, options.variant).catch((error) => ({
  263. locale: plan.locale,
  264. code: 1,
  265. stdout: "",
  266. stderr: error instanceof Error ? error.message : String(error),
  267. })),
  268. )
  269. results.forEach((result) => {
  270. if (result.stdout) process.stdout.write(`\n[${result.locale}]\n${result.stdout}`)
  271. if (result.stderr) process.stderr.write(`\n[${result.locale}]\n${result.stderr}`)
  272. })
  273. const failed = results.filter((result) => result.code !== 0)
  274. const checks = await runPool(pending, options.concurrency, (plan) => check(plan.locale))
  275. const incomplete = checks.filter((result) => result.code !== 0)
  276. const escaped = unexpectedChanges(baseline, await worktreeSnapshot(), targets)
  277. incomplete.forEach((result) => {
  278. if (result.stdout) process.stderr.write(`\n[${result.locale} verification]\n${result.stdout}`)
  279. if (result.stderr) process.stderr.write(`\n[${result.locale} verification]\n${result.stderr}`)
  280. })
  281. if (failed.length === 0 && incomplete.length === 0 && escaped.length === 0) {
  282. console.log(`\nTranslated ${pending.map((plan) => plan.locale).join(", ")}.`)
  283. return
  284. }
  285. if (failed.length) console.error(`\nOpenCode failed for: ${failed.map((result) => result.locale).join(", ")}`)
  286. if (incomplete.length)
  287. console.error(`Translation remains incomplete for: ${incomplete.map((plan) => plan.locale).join(", ")}`)
  288. if (escaped.length) console.error(`Translation changed files outside its locale targets: ${escaped.join(", ")}`)
  289. process.exitCode = 1
  290. }
  291. async function worktreeSnapshot() {
  292. const groups = await Promise.all([
  293. gitPaths(["diff", "--name-only", "-z", "HEAD"]),
  294. gitPaths(["ls-files", "--others", "--exclude-standard", "-z"]),
  295. ])
  296. const files = [...new Set(groups.flat())]
  297. return Object.fromEntries(
  298. await Promise.all(
  299. files.map(async (file) => {
  300. const target = Bun.file(path.join(root, file))
  301. if (!(await target.exists())) return [file, "<missing>"] as const
  302. const hash = new Bun.CryptoHasher("sha256")
  303. hash.update(await target.arrayBuffer())
  304. return [file, hash.digest("hex")] as const
  305. }),
  306. ),
  307. )
  308. }
  309. async function gitPaths(args: string[]) {
  310. const proc = Bun.spawn(["git", ...args], {
  311. cwd: root,
  312. stdin: "ignore",
  313. stdout: "pipe",
  314. stderr: "pipe",
  315. })
  316. const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
  317. if (result[2] !== 0) throw new Error(result[1] || `git ${args.join(" ")} failed`)
  318. return result[0].split("\0").filter(Boolean)
  319. }
  320. async function check(locale: Locale) {
  321. const proc = Bun.spawn([process.execPath, import.meta.path, locale, "--check"], {
  322. cwd: root,
  323. stdin: "ignore",
  324. stdout: "pipe",
  325. stderr: "pipe",
  326. })
  327. const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
  328. return { locale, stdout: result[0], stderr: result[1], code: result[2] }
  329. }
  330. async function inspect(locale: Locale) {
  331. const domains = await Promise.all(
  332. targetFiles(locale).map(async (target) => {
  333. const source = target.replace(`/${locale}.ts`, "/en.ts")
  334. const dictionaries = await Promise.all([dictionary(source), dictionary(target)])
  335. return {
  336. name: target.includes("packages/app/") ? "app" : target.includes("packages/ui/") ? "ui" : "desktop",
  337. source,
  338. target,
  339. drift: findDrift(dictionaries[0], dictionaries[1], locale),
  340. }
  341. }),
  342. )
  343. return { locale, language: languages[locale], domains }
  344. }
  345. async function dictionary(file: string) {
  346. const module: unknown = await import(pathToFileURL(path.join(root, file)).href)
  347. if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
  348. throw new Error(`Invalid translation dictionary: ${file}`)
  349. }
  350. return module.dict
  351. }
  352. async function commandTemplate() {
  353. return (await Bun.file(path.join(root, "script/translate-app.md")).text()).trim()
  354. }
  355. async function translate(
  356. plan: { locale: Locale; language: string; domains: Domain[] },
  357. template: string,
  358. model: string,
  359. variant: string,
  360. ) {
  361. const glossary = glossaryFile(plan.locale)
  362. const glossaryContent = (await Bun.file(path.join(root, glossary)).exists())
  363. ? await Bun.file(path.join(root, glossary)).text()
  364. : undefined
  365. const prompt = template.replaceAll("$1", plan.locale).replaceAll(
  366. "$ARGUMENTS",
  367. JSON.stringify(
  368. {
  369. locale: plan.locale,
  370. language: plan.language,
  371. glossary: glossaryContent ? { file: glossary, content: glossaryContent } : undefined,
  372. domains: plan.domains.map((domain) => ({
  373. source: domain.source,
  374. target: domain.target,
  375. ...domain.drift,
  376. })),
  377. },
  378. null,
  379. 2,
  380. ),
  381. )
  382. const agent = `translate-app-${plan.locale}-${process.pid}`
  383. const env = isolatedEnvironment()
  384. env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
  385. env.OPENCODE_CONFIG_CONTENT = JSON.stringify(
  386. translationConfig(
  387. agent,
  388. model,
  389. plan.domains.map((domain) => domain.target),
  390. ),
  391. )
  392. const proc = Bun.spawn(
  393. [
  394. "opencode",
  395. "--pure",
  396. "run",
  397. "--dir",
  398. root,
  399. "--agent",
  400. agent,
  401. "--model",
  402. model,
  403. "--variant",
  404. variant,
  405. "--title",
  406. `Translate app ${plan.locale}`,
  407. "--format",
  408. "json",
  409. ],
  410. {
  411. cwd: root,
  412. env,
  413. stdin: "pipe",
  414. stdout: "pipe",
  415. stderr: "pipe",
  416. },
  417. )
  418. const stdout = new Response(proc.stdout).text()
  419. const stderr = new Response(proc.stderr).text()
  420. await proc.stdin.write(prompt)
  421. await proc.stdin.end()
  422. const result = await Promise.all([stdout, stderr, proc.exited])
  423. if (result[2] !== 0) return { locale: plan.locale, stdout: result[0], stderr: result[1], code: result[2] }
  424. const sessionID = sessionIDFromEvents(result[0])
  425. const exported = Bun.spawn(["opencode", "--pure", "export", sessionID, "--sanitize"], {
  426. cwd: root,
  427. env,
  428. stdout: "pipe",
  429. stderr: "pipe",
  430. })
  431. const exportResult = await Promise.all([
  432. new Response(exported.stdout).text(),
  433. new Response(exported.stderr).text(),
  434. exported.exited,
  435. ])
  436. if (exportResult[2] !== 0) {
  437. return { locale: plan.locale, stdout: textFromEvents(result[0]), stderr: exportResult[1], code: exportResult[2] }
  438. }
  439. const session: unknown = JSON.parse(exportResult[0])
  440. const observed = sessionModels(session)
  441. const mismatch = observed.length === 0 || observed.some((item) => item.model !== model || item.variant !== variant)
  442. const actual = Array.from(new Set(observed.map((item) => `${item.model} (${item.variant ?? "default"})`))).join(", ")
  443. return {
  444. locale: plan.locale,
  445. stdout: `${textFromEvents(result[0])}\nVerified session model: ${actual}\n`,
  446. stderr: mismatch
  447. ? `Requested ${model} (${variant}), but session used ${actual || "no assistant model"}.\n`
  448. : result[1],
  449. code: mismatch ? 1 : 0,
  450. }
  451. }
  452. function report(plan: { locale: Locale; domains: Domain[] }) {
  453. const details = plan.domains
  454. .map(
  455. (domain) =>
  456. `${domain.name}: ${domain.drift.missing.length} missing, ${domain.drift.extra.length} extra, ${domain.drift.placeholders.length} placeholder mismatches`,
  457. )
  458. .join("; ")
  459. console.log(`[${plan.locale}] ${details}`)
  460. }
  461. function changed(drift: Drift) {
  462. return drift.missing.length > 0 || drift.extra.length > 0 || drift.placeholders.length > 0
  463. }
  464. function isLocale(value: string): value is Locale {
  465. return Object.hasOwn(languages, value)
  466. }
  467. function isDictionary(value: unknown): value is Dictionary {
  468. if (typeof value !== "object" || value === null || Array.isArray(value)) return false
  469. return Object.values(value).every((item) => typeof item === "string")
  470. }
  471. function pluralFamilies(dictionary: Dictionary) {
  472. return Object.keys(dictionary)
  473. .filter(
  474. (key) =>
  475. key.endsWith(".one") &&
  476. dictionary[key].includes("{{count}}") &&
  477. dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
  478. )
  479. .map((key) => key.slice(0, -4))
  480. }
  481. function isRecord(value: unknown): value is Record<string, unknown> {
  482. return typeof value === "object" && value !== null && !Array.isArray(value)
  483. }
  484. async function resolveModelVariant(model: string, variant: string) {
  485. const provider = model.split("/")[0]
  486. if (!provider || !model.includes("/")) throw new Error(`Model must use provider/model syntax: ${model}`)
  487. const env = isolatedEnvironment()
  488. env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
  489. const proc = Bun.spawn(["opencode", "--pure", "models", provider, "--verbose"], {
  490. cwd: root,
  491. env,
  492. stdin: "ignore",
  493. stdout: "pipe",
  494. stderr: "pipe",
  495. })
  496. const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
  497. if (result[2] !== 0) throw new Error(result[1] || `Unable to resolve model: ${model}`)
  498. const variants = modelVariants(result[0], model)
  499. if (!Object.hasOwn(variants, variant)) throw new Error(`Variant '${variant}' is not configured for ${model}.`)
  500. return variants[variant]
  501. }
  502. function isolatedEnvironment() {
  503. const env = { ...process.env }
  504. delete env.OPENCODE_CONFIG
  505. delete env.OPENCODE_CONFIG_DIR
  506. delete env.OPENCODE_CONFIG_CONTENT
  507. delete env.OPENCODE_PERMISSION
  508. delete env.OPENCODE_AUTO_SHARE
  509. return env
  510. }
  511. function escapeRegExp(value: string) {
  512. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
  513. }
  514. export function textFromEvents(output: string) {
  515. return output
  516. .split(/\r?\n/)
  517. .map((line) => line.trim())
  518. .filter((line) => line.startsWith("{") && line.endsWith("}"))
  519. .flatMap((line) => {
  520. const event: unknown = JSON.parse(line)
  521. if (!isRecord(event) || event.type !== "text" || !isRecord(event.part) || typeof event.part.text !== "string") {
  522. return []
  523. }
  524. return [event.part.text.trim()]
  525. })
  526. .filter(Boolean)
  527. .join("\n")
  528. }
  529. function tokens(value: string) {
  530. return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1] ?? "").sort()
  531. }
  532. if (import.meta.main) {
  533. main().catch((error) => {
  534. console.error(error instanceof Error ? error.message : error)
  535. process.exitCode = 1
  536. })
  537. }