| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587 |
- #!/usr/bin/env bun
- import path from "path"
- import { parseArgs } from "util"
- import { pathToFileURL } from "url"
- import {
- DESKTOP_NATIVE_LOCALES,
- desktopNativePluralCategories,
- type DesktopNativeLocale,
- } from "../packages/app/src/i18n/desktop-native"
- type Locale = Exclude<DesktopNativeLocale, "en">
- const locales = DESKTOP_NATIVE_LOCALES.filter((locale): locale is Locale => locale !== "en")
- const languages = {
- ar: "Arabic",
- br: "Brazilian Portuguese",
- bs: "Bosnian",
- da: "Danish",
- de: "German",
- es: "Spanish",
- fr: "French",
- ja: "Japanese",
- ko: "Korean",
- no: "Norwegian Bokmal",
- pl: "Polish",
- ru: "Russian",
- uk: "Ukrainian",
- th: "Thai",
- tr: "Turkish",
- hi: "Hindi",
- nl: "Dutch",
- id: "Indonesian",
- vi: "Vietnamese",
- it: "Italian",
- ur: "Urdu",
- pa: "Punjabi (Shahmukhi)",
- az: "Azerbaijani (Latin)",
- fi: "Finnish",
- sv: "Swedish",
- am: "Amharic",
- bg: "Bulgarian",
- bn: "Bengali",
- ca: "Catalan",
- cs: "Czech",
- dv: "Dhivehi",
- dz: "Dzongkha",
- el: "Greek",
- et: "Estonian",
- fa: "Persian",
- fo: "Faroese",
- hr: "Croatian",
- hu: "Hungarian",
- hy: "Armenian",
- is: "Icelandic",
- ka: "Georgian",
- km: "Khmer",
- lo: "Lao",
- lt: "Lithuanian",
- lv: "Latvian",
- mk: "Macedonian",
- mn: "Mongolian (Cyrillic)",
- ms: "Malay",
- my: "Burmese",
- ne: "Nepali",
- ro: "Romanian",
- si: "Sinhala",
- sk: "Slovak",
- sl: "Slovenian",
- sq: "Albanian",
- sr: "Serbian (Cyrillic)",
- tg: "Tajik",
- tk: "Turkmen",
- uz: "Uzbek (Latin)",
- zh: "Simplified Chinese",
- zht: "Traditional Chinese",
- } as const satisfies Record<Locale, string>
- type Dictionary = Record<string, string>
- type Drift = ReturnType<typeof findDrift>
- type Domain = { name: string; source: string; target: string; drift: Drift }
- const desktopLocales = new Set<Locale>(locales)
- const root = path.resolve(import.meta.dir, "..")
- export function parseTranslationArgs(args: string[]) {
- const parsed = parseArgs({
- args,
- options: {
- concurrency: { type: "string", short: "c", default: "4" },
- model: { type: "string", default: "opencode/gpt-5.5" },
- variant: { type: "string", default: "xhigh" },
- "dry-run": { type: "boolean", default: false },
- check: { type: "boolean", default: false },
- help: { type: "boolean", short: "h", default: false },
- },
- allowPositionals: true,
- })
- const target = parsed.positionals[0] ?? "all"
- const concurrency = Number(parsed.values.concurrency)
- if (!parsed.values.help && parsed.positionals.length !== 1) throw new Error("Pass one locale or 'all'.")
- if (target !== "all" && !isLocale(target)) throw new Error(`Unknown locale '${target}'.`)
- if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("Concurrency must be a positive integer.")
- return {
- target,
- concurrency: target === "all" ? concurrency : 1,
- model: parsed.values.model,
- variant: parsed.values.variant,
- dryRun: parsed.values["dry-run"],
- check: parsed.values.check,
- help: parsed.values.help,
- }
- }
- export function targetFiles(locale: Locale) {
- return [
- `packages/app/src/i18n/${locale}.ts`,
- `packages/ui/src/i18n/${locale}.ts`,
- ...(desktopLocales.has(locale) ? [`packages/desktop/src/renderer/i18n/${locale}.ts`] : []),
- ]
- }
- export function glossaryFile(locale: Locale) {
- if (locale === "zh") return ".opencode/glossary/zh-cn.md"
- if (locale === "zht") return ".opencode/glossary/zh-tw.md"
- return `.opencode/glossary/${locale}.md`
- }
- export function findDrift(source: Dictionary, target: Dictionary, locale?: Locale) {
- const pluralVariants = new Map(
- (locale
- ? pluralFamilies(source).flatMap((key) =>
- desktopNativePluralCategories(locale)
- .filter((category) => category !== "one" && category !== "other")
- .map((category) => [`${key}.${category}`, `${key}.other`] as const),
- )
- : []) as ReadonlyArray<readonly [string, string]>,
- )
- return {
- missing: [
- ...Object.keys(source).filter((key) => !Object.hasOwn(target, key)),
- ...Array.from(pluralVariants.keys()).filter((key) => !Object.hasOwn(target, key)),
- ],
- extra: Object.keys(target).filter((key) => !Object.hasOwn(source, key) && !pluralVariants.has(key)),
- placeholders: [
- ...Object.keys(source).filter(
- (key) => Object.hasOwn(target, key) && tokens(source[key]).join() !== tokens(target[key]).join(),
- ),
- ...Array.from(pluralVariants.entries())
- .filter(
- ([key, sourceKey]) =>
- Object.hasOwn(target, key) && tokens(source[sourceKey]).join() !== tokens(target[key]).join(),
- )
- .map(([key]) => key),
- ],
- }
- }
- export function sessionIDFromEvents(output: string) {
- const match = output.match(/"sessionID"\s*:\s*"([^"]+)"/)
- if (!match?.[1]) throw new Error("OpenCode did not report a session ID.")
- return match[1]
- }
- export function sessionModels(value: unknown) {
- if (!isRecord(value) || !Array.isArray(value.messages))
- throw new Error("OpenCode returned an invalid session export.")
- return value.messages.flatMap((message) => {
- if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant") return []
- if (typeof message.info.providerID !== "string" || typeof message.info.modelID !== "string") {
- throw new Error("OpenCode session export omitted the assistant model.")
- }
- return [
- {
- model: `${message.info.providerID}/${message.info.modelID}`,
- variant: typeof message.info.variant === "string" ? message.info.variant : undefined,
- },
- ]
- })
- }
- export function modelVariants(output: string, model: string) {
- const normalized = output.replaceAll("\r\n", "\n")
- const marker = `${model}\n`
- const start = normalized.indexOf(marker)
- if (start < 0) throw new Error(`Model not found: ${model}`)
- const provider = model.split("/")[0]
- const rest = normalized.slice(start + marker.length)
- const next = rest.search(new RegExp(`^${escapeRegExp(provider)}/`, "m"))
- const metadata: unknown = JSON.parse((next < 0 ? rest : rest.slice(0, next)).trim())
- if (!isRecord(metadata) || !isRecord(metadata.variants)) throw new Error(`Model variants not found: ${model}`)
- return metadata.variants
- }
- export function translationConfig(agent: string, model: string, targets: string[]) {
- return {
- $schema: "https://opencode.ai/config.json",
- model,
- default_agent: agent,
- share: "disabled" as const,
- formatter: false,
- lsp: false,
- snapshot: false,
- agent: {
- [agent]: {
- mode: "primary" as const,
- model,
- permission: {
- "*": "deny" as const,
- read: "allow" as const,
- glob: "allow" as const,
- grep: "allow" as const,
- webfetch: "allow" as const,
- websearch: "allow" as const,
- edit: Object.fromEntries([["*", "deny"], ...targets.map((target) => [target, "allow"])]),
- },
- },
- },
- }
- }
- export function unexpectedChanges(before: Record<string, string>, after: Record<string, string>, allowed: string[]) {
- const targets = new Set(allowed)
- return [...new Set([...Object.keys(before), ...Object.keys(after)])]
- .filter((file) => !targets.has(file) && before[file] !== after[file])
- .sort()
- }
- export async function runPool<T, R>(items: readonly T[], concurrency: number, task: (item: T) => Promise<R>) {
- const results = new Map<number, R>()
- const entries = items.entries()
- const worker = async (): Promise<void> => {
- const next = entries.next()
- if (next.done) return
- results.set(next.value[0], await task(next.value[1]))
- await worker()
- }
- await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
- return Array.from(results.entries())
- .sort((a, b) => a[0] - b[0])
- .map((entry) => entry[1])
- }
- async function main() {
- const options = parseTranslationArgs(Bun.argv.slice(2))
- if (options.help) {
- console.log(`
- Usage: bun run translate:app -- <locale|all> [options]
- Synchronizes product app translations with the English app, UI, and desktop dictionaries.
- Options:
- -c, --concurrency <count> Maximum parallel OpenCode runs for 'all' (default: 4)
- --model <provider/id> OpenCode model (default: opencode/gpt-5.5)
- --variant <name> Model variant (default: xhigh)
- --dry-run Report drift without running OpenCode
- --check Exit nonzero when translation drift exists
- -h, --help Show this help message
- Examples:
- bun run translate:app -- fr
- bun run translate:app -- all --concurrency 4
- `)
- return
- }
- const selected = options.target === "all" ? locales : [options.target]
- const plans = await Promise.all(selected.map((locale) => inspect(locale)))
- plans.forEach(report)
- const pending = plans.filter((plan) => plan.domains.some((domain) => changed(domain.drift)))
- if (options.check) {
- if (pending.length) process.exitCode = 1
- return
- }
- if (options.dryRun || pending.length === 0) return
- const targets = pending.flatMap((plan) => plan.domains.map((domain) => domain.target))
- const baseline = await worktreeSnapshot()
- const variant = await resolveModelVariant(options.model, options.variant)
- console.log(`Resolved ${options.model} (${options.variant}): ${JSON.stringify(variant)}`)
- const template = await commandTemplate()
- const results = await runPool(pending, options.concurrency, (plan) =>
- translate(plan, template, options.model, options.variant).catch((error) => ({
- locale: plan.locale,
- code: 1,
- stdout: "",
- stderr: error instanceof Error ? error.message : String(error),
- })),
- )
- results.forEach((result) => {
- if (result.stdout) process.stdout.write(`\n[${result.locale}]\n${result.stdout}`)
- if (result.stderr) process.stderr.write(`\n[${result.locale}]\n${result.stderr}`)
- })
- const failed = results.filter((result) => result.code !== 0)
- const checks = await runPool(pending, options.concurrency, (plan) => check(plan.locale))
- const incomplete = checks.filter((result) => result.code !== 0)
- const escaped = unexpectedChanges(baseline, await worktreeSnapshot(), targets)
- incomplete.forEach((result) => {
- if (result.stdout) process.stderr.write(`\n[${result.locale} verification]\n${result.stdout}`)
- if (result.stderr) process.stderr.write(`\n[${result.locale} verification]\n${result.stderr}`)
- })
- if (failed.length === 0 && incomplete.length === 0 && escaped.length === 0) {
- console.log(`\nTranslated ${pending.map((plan) => plan.locale).join(", ")}.`)
- return
- }
- if (failed.length) console.error(`\nOpenCode failed for: ${failed.map((result) => result.locale).join(", ")}`)
- if (incomplete.length)
- console.error(`Translation remains incomplete for: ${incomplete.map((plan) => plan.locale).join(", ")}`)
- if (escaped.length) console.error(`Translation changed files outside its locale targets: ${escaped.join(", ")}`)
- process.exitCode = 1
- }
- async function worktreeSnapshot() {
- const groups = await Promise.all([
- gitPaths(["diff", "--name-only", "-z", "HEAD"]),
- gitPaths(["ls-files", "--others", "--exclude-standard", "-z"]),
- ])
- const files = [...new Set(groups.flat())]
- return Object.fromEntries(
- await Promise.all(
- files.map(async (file) => {
- const target = Bun.file(path.join(root, file))
- if (!(await target.exists())) return [file, "<missing>"] as const
- const hash = new Bun.CryptoHasher("sha256")
- hash.update(await target.arrayBuffer())
- return [file, hash.digest("hex")] as const
- }),
- ),
- )
- }
- async function gitPaths(args: string[]) {
- const proc = Bun.spawn(["git", ...args], {
- cwd: root,
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- })
- const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
- if (result[2] !== 0) throw new Error(result[1] || `git ${args.join(" ")} failed`)
- return result[0].split("\0").filter(Boolean)
- }
- async function check(locale: Locale) {
- const proc = Bun.spawn([process.execPath, import.meta.path, locale, "--check"], {
- cwd: root,
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- })
- const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
- return { locale, stdout: result[0], stderr: result[1], code: result[2] }
- }
- async function inspect(locale: Locale) {
- const domains = await Promise.all(
- targetFiles(locale).map(async (target) => {
- const source = target.replace(`/${locale}.ts`, "/en.ts")
- const dictionaries = await Promise.all([dictionary(source), dictionary(target)])
- return {
- name: target.includes("packages/app/") ? "app" : target.includes("packages/ui/") ? "ui" : "desktop",
- source,
- target,
- drift: findDrift(dictionaries[0], dictionaries[1], locale),
- }
- }),
- )
- return { locale, language: languages[locale], domains }
- }
- async function dictionary(file: string) {
- const module: unknown = await import(pathToFileURL(path.join(root, file)).href)
- if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
- throw new Error(`Invalid translation dictionary: ${file}`)
- }
- return module.dict
- }
- async function commandTemplate() {
- return (await Bun.file(path.join(root, "script/translate-app.md")).text()).trim()
- }
- async function translate(
- plan: { locale: Locale; language: string; domains: Domain[] },
- template: string,
- model: string,
- variant: string,
- ) {
- const glossary = glossaryFile(plan.locale)
- const glossaryContent = (await Bun.file(path.join(root, glossary)).exists())
- ? await Bun.file(path.join(root, glossary)).text()
- : undefined
- const prompt = template.replaceAll("$1", plan.locale).replaceAll(
- "$ARGUMENTS",
- JSON.stringify(
- {
- locale: plan.locale,
- language: plan.language,
- glossary: glossaryContent ? { file: glossary, content: glossaryContent } : undefined,
- domains: plan.domains.map((domain) => ({
- source: domain.source,
- target: domain.target,
- ...domain.drift,
- })),
- },
- null,
- 2,
- ),
- )
- const agent = `translate-app-${plan.locale}-${process.pid}`
- const env = isolatedEnvironment()
- env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
- env.OPENCODE_CONFIG_CONTENT = JSON.stringify(
- translationConfig(
- agent,
- model,
- plan.domains.map((domain) => domain.target),
- ),
- )
- const proc = Bun.spawn(
- [
- "opencode",
- "--pure",
- "run",
- "--dir",
- root,
- "--agent",
- agent,
- "--model",
- model,
- "--variant",
- variant,
- "--title",
- `Translate app ${plan.locale}`,
- "--format",
- "json",
- ],
- {
- cwd: root,
- env,
- stdin: "pipe",
- stdout: "pipe",
- stderr: "pipe",
- },
- )
- const stdout = new Response(proc.stdout).text()
- const stderr = new Response(proc.stderr).text()
- await proc.stdin.write(prompt)
- await proc.stdin.end()
- const result = await Promise.all([stdout, stderr, proc.exited])
- if (result[2] !== 0) return { locale: plan.locale, stdout: result[0], stderr: result[1], code: result[2] }
- const sessionID = sessionIDFromEvents(result[0])
- const exported = Bun.spawn(["opencode", "--pure", "export", sessionID, "--sanitize"], {
- cwd: root,
- env,
- stdout: "pipe",
- stderr: "pipe",
- })
- const exportResult = await Promise.all([
- new Response(exported.stdout).text(),
- new Response(exported.stderr).text(),
- exported.exited,
- ])
- if (exportResult[2] !== 0) {
- return { locale: plan.locale, stdout: textFromEvents(result[0]), stderr: exportResult[1], code: exportResult[2] }
- }
- const session: unknown = JSON.parse(exportResult[0])
- const observed = sessionModels(session)
- const mismatch = observed.length === 0 || observed.some((item) => item.model !== model || item.variant !== variant)
- const actual = Array.from(new Set(observed.map((item) => `${item.model} (${item.variant ?? "default"})`))).join(", ")
- return {
- locale: plan.locale,
- stdout: `${textFromEvents(result[0])}\nVerified session model: ${actual}\n`,
- stderr: mismatch
- ? `Requested ${model} (${variant}), but session used ${actual || "no assistant model"}.\n`
- : result[1],
- code: mismatch ? 1 : 0,
- }
- }
- function report(plan: { locale: Locale; domains: Domain[] }) {
- const details = plan.domains
- .map(
- (domain) =>
- `${domain.name}: ${domain.drift.missing.length} missing, ${domain.drift.extra.length} extra, ${domain.drift.placeholders.length} placeholder mismatches`,
- )
- .join("; ")
- console.log(`[${plan.locale}] ${details}`)
- }
- function changed(drift: Drift) {
- return drift.missing.length > 0 || drift.extra.length > 0 || drift.placeholders.length > 0
- }
- function isLocale(value: string): value is Locale {
- return Object.hasOwn(languages, value)
- }
- function isDictionary(value: unknown): value is Dictionary {
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false
- return Object.values(value).every((item) => typeof item === "string")
- }
- function pluralFamilies(dictionary: Dictionary) {
- return Object.keys(dictionary)
- .filter(
- (key) =>
- key.endsWith(".one") &&
- dictionary[key].includes("{{count}}") &&
- dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
- )
- .map((key) => key.slice(0, -4))
- }
- function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value)
- }
- async function resolveModelVariant(model: string, variant: string) {
- const provider = model.split("/")[0]
- if (!provider || !model.includes("/")) throw new Error(`Model must use provider/model syntax: ${model}`)
- const env = isolatedEnvironment()
- env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
- const proc = Bun.spawn(["opencode", "--pure", "models", provider, "--verbose"], {
- cwd: root,
- env,
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- })
- const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
- if (result[2] !== 0) throw new Error(result[1] || `Unable to resolve model: ${model}`)
- const variants = modelVariants(result[0], model)
- if (!Object.hasOwn(variants, variant)) throw new Error(`Variant '${variant}' is not configured for ${model}.`)
- return variants[variant]
- }
- function isolatedEnvironment() {
- const env = { ...process.env }
- delete env.OPENCODE_CONFIG
- delete env.OPENCODE_CONFIG_DIR
- delete env.OPENCODE_CONFIG_CONTENT
- delete env.OPENCODE_PERMISSION
- delete env.OPENCODE_AUTO_SHARE
- return env
- }
- function escapeRegExp(value: string) {
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
- }
- export function textFromEvents(output: string) {
- return output
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter((line) => line.startsWith("{") && line.endsWith("}"))
- .flatMap((line) => {
- const event: unknown = JSON.parse(line)
- if (!isRecord(event) || event.type !== "text" || !isRecord(event.part) || typeof event.part.text !== "string") {
- return []
- }
- return [event.part.text.trim()]
- })
- .filter(Boolean)
- .join("\n")
- }
- function tokens(value: string) {
- return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1] ?? "").sort()
- }
- if (import.meta.main) {
- main().catch((error) => {
- console.error(error instanceof Error ? error.message : error)
- process.exitCode = 1
- })
- }
|