bctranslate 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # bctranslate ⚡
2
+
3
+ # Notice: This app is in Beta mode, work in progress; currently works for html files
4
+
5
+ `bctranslate` is a command-line tool to automatically transform source code into an i18n-ready format. It extracts hardcoded strings from your files, replaces them with calls to a translation function, and generates locale files with translations powered by [Argos Translate](https://www.argosopentech.com/).
6
+
7
+ It's designed to be a quick and easy way to "bake in" internationalization into a project with minimal refactoring.
8
+
9
+ ## Features
10
+
11
+ - **Automatic String Extraction:** Finds and extracts hardcoded strings from various file types.
12
+ - **Code Transformation:** Replaces extracted strings with i18n function calls (e.g., `t('key')`).
13
+ - **Machine Translation:** Uses Python and Argos Translate to provide instant translations for extracted strings.
14
+ - **Project-Aware:** Automatically detects project types (Vue, React, Vanilla JS/HTML) to apply the correct parsing and transformation rules.
15
+ - **Framework Support:**
16
+ - Vue (`.vue`, `.js`, `.ts`)
17
+ - React (`.jsx`, `.tsx`, `.js`, `.ts`)
18
+ - Vanilla JS and HTML
19
+ - JSON (`.json`)
20
+ - **Dry Run Mode:** Preview all changes without modifying any files.
21
+
22
+ ## Prerequisites
23
+
24
+ 1. **Node.js:** Requires Node.js version 18.0.0 or higher.
25
+ 2. **Python:** Requires Python 3.8 or higher.
26
+ 3. **Argos Translate:** The `argostranslate` Python package must be installed.
27
+ ```sh
28
+ pip install argostranslate
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ You can install `bctranslate` globally via npm:
34
+
35
+ ```sh
36
+ npm install -g .
37
+ ```
38
+
39
+ This will make the `bctranslate` command available in your terminal.
40
+
41
+ ## Usage
42
+
43
+ The most common use case is to run `bctranslate` in the root of your project to automatically detect and process all relevant files.
44
+
45
+ ```sh
46
+ bctranslate
47
+ ```
48
+
49
+ ### Command-Line Options
50
+
51
+ ```
52
+ bctranslate [file] [from] [options]
53
+ ```
54
+
55
+ **Arguments:**
56
+
57
+ - `[file]`: (Optional) A specific file or glob pattern to process. If omitted, the tool auto-detects files based on the project type.
58
+ - `[from]`: (Optional) The source language code for translation (default: `en`).
59
+
60
+ **Options:**
61
+
62
+ | Option | Description | Default |
63
+ | ---------------------- | ---------------------------------------------------------------------------- | ---------- |
64
+ | `-t, --to <lang>` | Target language code for translation. | `fr` |
65
+ | `-d, --dry-run` | Preview changes without writing to files. | `false` |
66
+ | `-o, --outdir <dir>` | Output directory for new locale files (default: in-place). | |
67
+ | `--no-setup` | Skip the generation of i18n setup files (e.g., `i18n.js`). | |
68
+ | `--json-mode <mode>` | For `.json` files, translate `values` or the `full` file structure. | `values` |
69
+ | `-v, --verbose` | Enable verbose logging for debugging. | `false` |
70
+ | `-h, --help` | Display help for the command. | |
71
+
72
+ ### Examples
73
+
74
+ **Auto-translate a whole project from English to French:**
75
+
76
+ ```sh
77
+ bctranslate --to fr
78
+ ```
79
+
80
+ **Translate a single HTML file from English to Spanish:**
81
+
82
+ ```sh
83
+ bctranslate "src/index.html" en -t es
84
+ ```
85
+
86
+ **Preview changes for all Vue files without modifying them:**
87
+
88
+ ```sh
89
+ bctranslate "src/**/*.vue" --dry-run
90
+ ```
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import { resolve, basename, dirname } from 'path';
6
+ import { existsSync, statSync } from 'fs';
7
+ import { glob } from 'glob';
8
+ import { translateAllFiles, generateDiff } from '../src/index.js';
9
+ import { detectProject } from '../src/detect.js';
10
+ import { ensureI18nSetup } from '../src/generators/setup.js';
11
+ import { checkPythonBridge, installArgostranslate } from '../src/bridges/python.js';
12
+ import { loadConfig, saveConfig } from '../src/config.js';
13
+
14
+ const program = new Command();
15
+
16
+ program
17
+ .name('bctranslate')
18
+ .description('Transform source files into i18n-ready code with automatic translation')
19
+ .version('1.0.0');
20
+
21
+ // ── Language lists ────────────────────────────────────────────────────────────
22
+ const COMMON_LANGUAGES = [
23
+ { name: 'French (fr)', value: 'fr' },
24
+ { name: 'Spanish (es)', value: 'es' },
25
+ { name: 'German (de)', value: 'de' },
26
+ { name: 'Italian (it)', value: 'it' },
27
+ { name: 'Portuguese (pt)', value: 'pt' },
28
+ { name: 'Dutch (nl)', value: 'nl' },
29
+ { name: 'Russian (ru)', value: 'ru' },
30
+ { name: 'Chinese (zh)', value: 'zh' },
31
+ { name: 'Japanese (ja)', value: 'ja' },
32
+ { name: 'Korean (ko)', value: 'ko' },
33
+ { name: 'Arabic (ar)', value: 'ar' },
34
+ { name: 'Turkish (tr)', value: 'tr' },
35
+ { name: 'Polish (pl)', value: 'pl' },
36
+ { name: 'Swedish (sv)', value: 'sv' },
37
+ { name: 'Norwegian (nb)', value: 'nb' },
38
+ { name: 'Danish (da)', value: 'da' },
39
+ { name: 'Finnish (fi)', value: 'fi' },
40
+ { name: 'Czech (cs)', value: 'cs' },
41
+ { name: 'Romanian (ro)', value: 'ro' },
42
+ { name: 'Hungarian (hu)', value: 'hu' },
43
+ ];
44
+
45
+ const SOURCE_LANGUAGES = [
46
+ { name: 'English (en)', value: 'en' },
47
+ { name: 'French (fr)', value: 'fr' },
48
+ { name: 'Spanish (es)', value: 'es' },
49
+ { name: 'German (de)', value: 'de' },
50
+ { name: 'Italian (it)', value: 'it' },
51
+ { name: 'Portuguese (pt)', value: 'pt' },
52
+ { name: 'Chinese (zh)', value: 'zh' },
53
+ { name: 'Other (type below)', value: '__other__' },
54
+ ];
55
+
56
+ // ── File patterns per project type ───────────────────────────────────────────
57
+ const FILE_PATTERNS = {
58
+ vue: ['**/*.vue', 'src/**/*.js', 'src/**/*.ts'],
59
+ react: ['**/*.jsx', '**/*.tsx', 'src/**/*.js', 'src/**/*.ts'],
60
+ vanilla: ['**/*.html', '**/*.htm', '**/*.js'],
61
+ };
62
+
63
+ const IGNORE_PATTERNS = [
64
+ '**/node_modules/**', '**/dist/**', '**/build/**',
65
+ '**/.git/**', '**/coverage/**', '**/*.min.js',
66
+ ];
67
+
68
+ // ── File resolution ───────────────────────────────────────────────────────────
69
+ async function resolveFiles(pathArg, cwd, project) {
70
+ const patterns = FILE_PATTERNS[project.type] || FILE_PATTERNS.vanilla;
71
+
72
+ if (!pathArg) {
73
+ const files = [];
74
+ for (const p of patterns) {
75
+ files.push(...await glob(p, { cwd, absolute: true, ignore: IGNORE_PATTERNS }));
76
+ }
77
+ return [...new Set(files)];
78
+ }
79
+
80
+ const resolved = resolve(cwd, pathArg);
81
+ if (existsSync(resolved)) {
82
+ if (statSync(resolved).isDirectory()) {
83
+ const files = [];
84
+ for (const p of patterns) {
85
+ files.push(...await glob(p, { cwd: resolved, absolute: true, ignore: IGNORE_PATTERNS }));
86
+ }
87
+ return [...new Set(files)];
88
+ }
89
+ return [resolved];
90
+ }
91
+ return glob(pathArg, { cwd, absolute: true, ignore: IGNORE_PATTERNS });
92
+ }
93
+
94
+ // ── Translation runner — uses global batching (Python spawned once) ───────────
95
+ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, profile, setup, autoImport, cwd }) {
96
+ const project = detectProject(cwd);
97
+ console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
98
+
99
+ const files = await resolveFiles(pathArg, cwd, project);
100
+ if (files.length === 0) {
101
+ console.log(chalk.yellow(' ⚠ No files found to translate.'));
102
+ return { totalStrings: 0, totalFiles: 0 };
103
+ }
104
+
105
+ console.log(chalk.cyan(` → ${files.length} file(s) [${from} → ${to}]...\n`));
106
+
107
+ if (setup !== false) {
108
+ const resolvedLocalesDir = localesDir ? resolve(cwd, localesDir) : undefined;
109
+ await ensureI18nSetup(cwd, project, from, to, resolvedLocalesDir, { autoImport });
110
+ }
111
+
112
+ // Global batch: all files parsed first, ONE Python call, then all writes
113
+ const results = await translateAllFiles(files, {
114
+ from,
115
+ to,
116
+ dryRun,
117
+ outdir,
118
+ project,
119
+ cwd,
120
+ verbose,
121
+ jsonMode,
122
+ profile: !!profile,
123
+ localesDir: localesDir ? resolve(cwd, localesDir) : undefined,
124
+ });
125
+
126
+ let totalStrings = 0;
127
+ let totalFiles = 0;
128
+
129
+ for (const result of results) {
130
+ if (result.count > 0) {
131
+ totalFiles++;
132
+ totalStrings += result.count;
133
+ const label = dryRun ? chalk.yellow('[DRY RUN]') : chalk.green('[DONE] ');
134
+ const skipNote = result.skipped > 0 ? chalk.gray(` (${result.skipped} already done)`) : '';
135
+ console.log(` ${label} ${chalk.white(result.relativePath)} — ${result.count} new${skipNote}`);
136
+ if (result.diff) {
137
+ console.log(chalk.gray(result.diff));
138
+ }
139
+ } else if (verbose) {
140
+ console.log(` ${chalk.gray('[SKIP] ')} ${result.relativePath}`);
141
+ }
142
+ }
143
+
144
+ return { totalStrings, totalFiles };
145
+ }
146
+
147
+ // ── init subcommand ───────────────────────────────────────────────────────────
148
+ program
149
+ .command('init')
150
+ .description('Interactive setup wizard — languages, locales folder, auto-install')
151
+ .action(async () => {
152
+ const { default: inquirer } = await import('inquirer');
153
+ const cwd = process.cwd();
154
+ const existing = loadConfig(cwd);
155
+ const project = detectProject(cwd);
156
+
157
+ const defaultLocalesDir = project.type === 'vanilla' ? './locales' : './src/locales';
158
+ const defaultI18nFile = project.type === 'vanilla' ? './i18n.js' : './src/i18n.js';
159
+
160
+ console.log(chalk.cyan.bold('\n ⚡ bctranslate init\n'));
161
+ if (existing) console.log(chalk.gray(' Existing config found — press Enter to keep values.\n'));
162
+
163
+ const answers = await inquirer.prompt([
164
+ {
165
+ type: 'input',
166
+ name: 'localesDir',
167
+ message: 'Folder for locale files (en.json, fr.json, ...):',
168
+ default: existing?.localesDir || defaultLocalesDir,
169
+ },
170
+ {
171
+ type: 'list',
172
+ name: 'from',
173
+ message: 'Source language:',
174
+ choices: SOURCE_LANGUAGES,
175
+ default: existing?.from || 'en',
176
+ },
177
+ {
178
+ type: 'input',
179
+ name: 'fromCustom',
180
+ message: 'Source language code (e.g. zh-TW):',
181
+ when: (ans) => ans.from === '__other__',
182
+ validate: (v) => v.trim().length >= 2 || 'Enter a valid BCP 47 code',
183
+ },
184
+ {
185
+ type: 'checkbox',
186
+ name: 'to',
187
+ message: 'Target language(s) — Space to select, Enter to confirm:',
188
+ choices: COMMON_LANGUAGES,
189
+ default: existing?.to,
190
+ validate: (v) => v.length > 0 || 'Select at least one target language',
191
+ },
192
+ {
193
+ type: 'input',
194
+ name: 'extraTo',
195
+ message: 'Extra language codes (comma-separated, e.g. zh-TW,sr — blank to skip):',
196
+ default: '',
197
+ },
198
+ {
199
+ type: 'input',
200
+ name: 'i18nFile',
201
+ message: 'i18n setup file path:',
202
+ default: existing?.i18nFile || defaultI18nFile,
203
+ },
204
+ ]);
205
+
206
+ const fromLang = answers.from === '__other__' ? answers.fromCustom.trim() : answers.from;
207
+ const extraTo = answers.extraTo
208
+ ? answers.extraTo.split(',').map((s) => s.trim()).filter(Boolean)
209
+ : [];
210
+ const toLangs = [...new Set([...answers.to, ...extraTo])].filter((l) => l !== fromLang);
211
+
212
+ if (toLangs.length === 0) {
213
+ console.log(chalk.red('\n ✗ No target languages selected.\n'));
214
+ process.exit(1);
215
+ }
216
+
217
+ const config = {
218
+ from: fromLang,
219
+ to: toLangs,
220
+ localesDir: answers.localesDir.trim(),
221
+ i18nFile: answers.i18nFile.trim(),
222
+ };
223
+
224
+ const configPath = saveConfig(config, cwd);
225
+ console.log(chalk.green(`\n ✓ Config saved → ${basename(configPath)}`));
226
+ console.log(chalk.cyan(`\n Source : ${chalk.bold(config.from)}`));
227
+ console.log(chalk.cyan(` Targets : ${chalk.bold(config.to.join(', '))}`));
228
+ console.log(chalk.cyan(` Locales : ${chalk.bold(config.localesDir)}`));
229
+ console.log(chalk.cyan(` i18n : ${chalk.bold(config.i18nFile)}`));
230
+
231
+ // ── Auto-install dependencies ─────────────────────────────────────────────
232
+ const { installDeps } = await inquirer.prompt([{
233
+ type: 'confirm',
234
+ name: 'installDeps',
235
+ message: 'Install/update argostranslate now? (required for offline translation)',
236
+ default: !existing,
237
+ }]);
238
+
239
+ if (installDeps) {
240
+ process.stdout.write(chalk.cyan('\n Installing argostranslate... '));
241
+ try {
242
+ await installArgostranslate();
243
+ console.log(chalk.green('done'));
244
+ } catch (err) {
245
+ console.log(chalk.red(`failed\n ${err.message}`));
246
+ console.log(chalk.gray(' Run manually: pip install argostranslate'));
247
+ }
248
+ }
249
+
250
+ // ── Offer to pre-download language models ─────────────────────────────────
251
+ const { downloadModels } = await inquirer.prompt([{
252
+ type: 'confirm',
253
+ name: 'downloadModels',
254
+ message: `Download language models for ${toLangs.join(', ')}? (can take a few minutes, required before first use)`,
255
+ default: true,
256
+ }]);
257
+
258
+ if (downloadModels) {
259
+ for (const to of toLangs) {
260
+ process.stdout.write(chalk.cyan(` Downloading ${fromLang} → ${to}... `));
261
+ try {
262
+ await checkPythonBridge(fromLang, to);
263
+ console.log(chalk.green('ready'));
264
+ } catch (err) {
265
+ console.log(chalk.red(`failed: ${err.message}`));
266
+ }
267
+ }
268
+ }
269
+
270
+ console.log(chalk.gray('\n Run `bctranslate` to start translating.\n'));
271
+ });
272
+
273
+ // ── Main translation command ──────────────────────────────────────────────────
274
+ program
275
+ .argument('[path]', 'File, directory, or glob pattern to translate')
276
+ .argument('[from]', 'Source language code (e.g. en)')
277
+ .argument('[keyword]', '"to" keyword')
278
+ .argument('[lang]', 'Target language code (e.g. fr)')
279
+ .option('-t, --to <lang>', 'Target language(s), comma-separated (e.g. fr or fr,es)')
280
+ .option('-d, --dry-run', 'Preview changes without writing files', false)
281
+ .option('-o, --outdir <dir>', 'Output directory for translated files')
282
+ .option('--no-setup', 'Skip i18n setup file generation')
283
+ .option('--no-import', 'Do not auto-inject i18n imports/script tags')
284
+ .option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
285
+ .option('--profile', 'Print timing breakdown', false)
286
+ .option('-v, --verbose', 'Show per-file diffs and skipped files', false)
287
+ .action(async (pathArg, fromArg, keyword, langArg, opts) => {
288
+ console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
289
+
290
+ const invokedCwd = process.cwd();
291
+ const config = loadConfig(invokedCwd);
292
+ const autoImport = opts.import !== false && (config?.autoImport !== false);
293
+
294
+ let cwd = invokedCwd;
295
+ if (!config && pathArg) {
296
+ const resolvedTarget = resolve(invokedCwd, pathArg);
297
+ if (existsSync(resolvedTarget)) {
298
+ try {
299
+ const st = statSync(resolvedTarget);
300
+ cwd = st.isDirectory() ? resolvedTarget : dirname(resolvedTarget);
301
+ } catch { cwd = invokedCwd; }
302
+ }
303
+ }
304
+
305
+ const hasExplicitArgs = !!(pathArg || fromArg || keyword || langArg);
306
+
307
+ if (!hasExplicitArgs && !config) {
308
+ console.log(chalk.yellow(' No config found. Run `bctranslate init` to set up your project.'));
309
+ console.log(chalk.gray('\n Or pass arguments directly:'));
310
+ console.log(chalk.gray(' bctranslate ./src en to fr'));
311
+ console.log(chalk.gray(' bctranslate ./App.vue en to fr,es\n'));
312
+ process.exit(0);
313
+ }
314
+
315
+ const from = fromArg || config?.from || 'en';
316
+
317
+ let targets;
318
+ if (keyword === 'to' && langArg) {
319
+ targets = langArg.split(',').map((s) => s.trim()).filter(Boolean);
320
+ } else if (opts.to) {
321
+ targets = opts.to.split(',').map((s) => s.trim()).filter(Boolean);
322
+ } else if (config?.to) {
323
+ targets = Array.isArray(config.to) ? config.to : [config.to];
324
+ } else {
325
+ targets = ['fr'];
326
+ }
327
+
328
+ // Check Python + download models for all pairs
329
+ for (const to of targets) {
330
+ try {
331
+ await checkPythonBridge(from, to);
332
+ console.log(chalk.green(` ✓ Python bridge ready (${from} → ${to})`));
333
+ } catch (err) {
334
+ console.error(chalk.red(` ✗ ${err.message}`));
335
+ process.exit(1);
336
+ }
337
+ }
338
+
339
+ let grandTotal = 0;
340
+ let grandFiles = 0;
341
+
342
+ for (const to of targets) {
343
+ if (targets.length > 1) console.log(chalk.cyan.bold(`\n ── Translating to ${to} ──`));
344
+
345
+ const { totalStrings, totalFiles } = await runTranslation({
346
+ pathArg,
347
+ from,
348
+ to,
349
+ localesDir: config?.localesDir,
350
+ dryRun: opts.dryRun,
351
+ outdir: opts.outdir,
352
+ verbose: opts.verbose,
353
+ jsonMode: opts.jsonMode,
354
+ profile: opts.profile,
355
+ setup: opts.setup,
356
+ autoImport,
357
+ cwd,
358
+ });
359
+
360
+ grandTotal += totalStrings;
361
+ grandFiles += totalFiles;
362
+ }
363
+
364
+ const langStr = targets.join(', ');
365
+ console.log(chalk.cyan.bold(
366
+ `\n Done: ${grandTotal} new string(s) in ${grandFiles} file(s) [→ ${langStr}]\n`
367
+ ));
368
+ console.log(chalk.gray(` Root : ${cwd}`));
369
+ console.log(chalk.gray(` Source : ${from}`));
370
+ console.log(chalk.gray(` Target : ${langStr}\n`));
371
+ });
372
+
373
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "bctranslate",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "CLI to transform source files into i18n-ready code with automatic translation via argostranslate",
5
+ "type": "module",
6
+ "bin": {
7
+ "bctranslate": "./bin/bctranslate.js"
8
+ },
9
+ "main": "src/index.js",
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "python/",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node test/run.js"
18
+ },
19
+ "dependencies": {
20
+ "@babel/generator": "^7.24.0",
21
+ "@babel/parser": "^7.24.0",
22
+ "@babel/traverse": "^7.24.0",
23
+ "@babel/types": "^7.24.0",
24
+ "@vue/compiler-dom": "^3.4.0",
25
+ "chalk": "^5.3.0",
26
+ "commander": "^12.1.0",
27
+ "glob": "^10.3.0",
28
+ "inquirer": "^9.3.8",
29
+ "magic-string": "^0.30.10"
30
+ },
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "keywords": [
35
+ "i18n",
36
+ "translate",
37
+ "internationalization",
38
+ "vue",
39
+ "react",
40
+ "cli",
41
+ "argostranslate",
42
+ "localization",
43
+ "l10n",
44
+ "vue-i18n",
45
+ "react-i18next",
46
+ "offline"
47
+ ],
48
+ "license": "MIT",
49
+ "preferGlobal": true
50
+ }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ bctranslate Python worker — translates a batch of strings using argostranslate.
4
+
5
+ Usage: python translator.py <from_code> <to_code>
6
+ Reads JSON array from stdin: [{"key": "t_abc123", "text": "Hello"}, ...]
7
+ Writes JSON array to stdout: [{"key": "t_abc123", "text": "Bonjour"}, ...]
8
+ """
9
+
10
+ import sys
11
+ import io
12
+ import json
13
+
14
+ # Force UTF-8 on all platforms (critical on Windows where default may be cp1252)
15
+ sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8')
16
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
17
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', line_buffering=True)
18
+
19
+
20
+ def ensure_model(from_code, to_code):
21
+ """Ensure the translation model is installed, downloading if needed."""
22
+ import argostranslate.package
23
+ import argostranslate.translate
24
+
25
+ installed_languages = argostranslate.translate.get_installed_languages()
26
+ from_lang = next((l for l in installed_languages if l.code == from_code), None)
27
+ to_lang = next((l for l in installed_languages if l.code == to_code), None)
28
+
29
+ if from_lang and to_lang:
30
+ translation = from_lang.get_translation(to_lang)
31
+ if translation:
32
+ return translation
33
+
34
+ # Try to install the package
35
+ print(f"Downloading language model {from_code}->{to_code}...", file=sys.stderr)
36
+ argostranslate.package.update_package_index()
37
+ available = argostranslate.package.get_available_packages()
38
+ pkg = next(
39
+ (p for p in available if p.from_code == from_code and p.to_code == to_code),
40
+ None
41
+ )
42
+
43
+ if not pkg:
44
+ print(
45
+ json.dumps({"error": f"No package available for {from_code}->{to_code}"}),
46
+ file=sys.stderr
47
+ )
48
+ sys.exit(1)
49
+
50
+ argostranslate.package.install_from_path(pkg.download())
51
+
52
+ # Reload
53
+ installed_languages = argostranslate.translate.get_installed_languages()
54
+ from_lang = next((l for l in installed_languages if l.code == from_code), None)
55
+ to_lang = next((l for l in installed_languages if l.code == to_code), None)
56
+
57
+ if not from_lang or not to_lang:
58
+ print(json.dumps({"error": "Failed to load model after install"}), file=sys.stderr)
59
+ sys.exit(1)
60
+
61
+ return from_lang.get_translation(to_lang)
62
+
63
+
64
+ def main():
65
+ if len(sys.argv) != 3:
66
+ print("Usage: translator.py <from_code> <to_code>", file=sys.stderr)
67
+ sys.exit(1)
68
+
69
+ from_code = sys.argv[1]
70
+ to_code = sys.argv[2]
71
+
72
+ # Load model once
73
+ try:
74
+ translator = ensure_model(from_code, to_code)
75
+ except Exception as e:
76
+ print(json.dumps({"error": f"Model loading failed: {str(e)}"}), file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ # Read batch from stdin
80
+ try:
81
+ raw = sys.stdin.read()
82
+ batch = json.loads(raw)
83
+ except json.JSONDecodeError as e:
84
+ print(json.dumps({"error": f"Invalid JSON input: {str(e)}"}), file=sys.stderr)
85
+ sys.exit(1)
86
+
87
+ # Translate each item
88
+ results = []
89
+ for item in batch:
90
+ key = item.get("key", "")
91
+ text = item.get("text", "")
92
+
93
+ if not text.strip():
94
+ results.append({"key": key, "text": text})
95
+ continue
96
+
97
+ try:
98
+ translated = translator.translate(text)
99
+ results.append({"key": key, "text": translated})
100
+ except Exception as e:
101
+ # On error, preserve original
102
+ print(f"Warning: failed to translate '{text[:50]}': {e}", file=sys.stderr)
103
+ results.append({"key": key, "text": text})
104
+
105
+ # Output result as JSON to stdout
106
+ print(json.dumps(results, ensure_ascii=False))
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()