1
0

translate-app.ts 19 KB

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