msgai-cli 1.0.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6
+
7
+ ## 1.0.0 (2026-02-27)
8
+
9
+ ### Features
10
+
11
+ - handle fuzzy translations with --include-fuzzy ([c4c8784](https://github.com/AlexMost/msgai/commit/c4c8784c361e6d00618b234a3cb5983a40e120b9))
12
+ - handle gettext msgctxt so same msgid in different contexts are not mixed ([9f7ad7a](https://github.com/AlexMost/msgai/commit/9f7ad7a838ef81968a3e235dfd9359e42ee5557a))
13
+ - pass plural samples from getExamples into LLM prompt for plural forms ([70dd125](https://github.com/AlexMost/msgai/commit/70dd125658539e6fbee8417ebfe601bf9ac00145))
14
+
15
+ ### Bug Fixes
16
+
17
+ - **ci:** use github.event.workflow_run.conclusion in release-please ([0892b17](https://github.com/AlexMost/msgai/commit/0892b17aad1ec95a4e96a8208cb31d197264830f))
18
+ - **ci:** use RELEASE_PLEASE_TOKEN so release-please can create PRs ([204c6cd](https://github.com/AlexMost/msgai/commit/204c6cdf88d480decae841f91c9fae168c4c0ce9))
19
+ - use console.warn instead of console.error for pipeline compatibility ([8b30ebf](https://github.com/AlexMost/msgai/commit/8b30ebffba7c7447db3f51d931398938c39b6b85))
20
+
21
+ ## [Unreleased]
22
+
23
+ ## [1.0.0] - 2025-02-27
24
+
25
+ ### Added
26
+
27
+ - CLI to translate untranslated strings in gettext (`.po`) files using AI (OpenAI LLM)
28
+ - `msgai <file.po>`: translate empty `msgstr` entries and write back to the file
29
+ - `--dry-run`: list untranslated `msgid` values without API calls or file changes
30
+ - `--source-lang LANG`: specify source language (ISO 639-1); optional, model can infer
31
+ - `--include-fuzzy`: include fuzzy entries for re-translation and clear fuzzy flag
32
+ - `--api-key KEY` and `OPENAI_API_KEY` environment variable for API authentication
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # msgai
2
+
3
+ `msgai` is a Node.js CLI that **automatically translates all untranslated strings in gettext (`.po`) files using AI (LLM)**. It reads your `.po` file, detects entries with empty or missing translations, sends them to an LLM (OpenAI), and writes the translations back into the file.
4
+
5
+ **Install:** `npm install -g msgai-cli` (then run `msgai`).
6
+
7
+ ## Usage
8
+
9
+ ### Commands
10
+
11
+ - `msgai <file.po>`: translates all untranslated `msgid` / `msgid_plural` entries in the file using AI and updates the `.po` file in place.
12
+ - `msgai <file.po> --dry-run`: only lists untranslated `msgid` values (no API calls, no file changes).
13
+ - `msgai --help`: prints command usage.
14
+
15
+ ### Fuzzy entries
16
+
17
+ - By default, entries marked as **fuzzy** in the `.po` file (e.g. `#, fuzzy`) are **skipped** and not sent for translation.
18
+ - **`--include-fuzzy`**: include fuzzy entries. They are sent to the LLM with empty `msgstr` (like untranslated strings). After the translation is applied, the fuzzy flag is removed from those entries in the `.po` file.
19
+
20
+ ### Source language
21
+
22
+ - **`--source-lang LANG`**: source language of `msgid` strings as an ISO 639-1 code (e.g. `en`, `uk`). If omitted, the model will infer the source language. Invalid codes cause the CLI to exit with an error.
23
+
24
+ ### API key (for translation)
25
+
26
+ When running without `--dry-run`, the CLI needs an OpenAI API key. You can pass it in either of these ways:
27
+
28
+ - **Environment variable**: set `OPENAI_API_KEY` (e.g. in your shell or a `.env` file in the current directory).
29
+ - **CLI option**: pass `--api-key KEY` (e.g. `msgai messages.po --api-key sk-...`).
30
+
31
+ If neither is set, the CLI exits with code 1 and a message asking you to set the key.
32
+
33
+ On API errors (e.g. rate limit, quota, server errors), the CLI shows a status-specific message and exits with code 1. For error code reference, see [OpenAI API error codes](https://developers.openai.com/api/docs/guides/error-codes#api-errors).
34
+
35
+ ## Development environment
36
+
37
+ ### Requirements
38
+
39
+ - Node.js 20+ (recommended latest LTS)
40
+ - npm 10+
41
+
42
+ ### Setup
43
+
44
+ ```bash
45
+ npm install
46
+ ```
47
+
48
+ ### Commit messages
49
+
50
+ This repo follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages: use `feat:` for new features, `fix:` for bug fixes, optional scope (e.g. `feat(cli):`), and `BREAKING CHANGE:` or `!` for major changes. This drives version bumps and CHANGELOG updates via release-please.
51
+
52
+ ### Scripts
53
+
54
+ - `npm run build`: compile TypeScript to `dist/`.
55
+ - `npm test`: build project and run Jest tests.
56
+ - `npm run test:watch`: build project and run Jest in watch mode.
57
+ - `npm run format`: format code with Prettier.
58
+ - `npm run lint:format`: check formatting with Prettier.
59
+
60
+ ## Publishing
61
+
62
+ Releases are driven by [release-please](https://github.com/googleapis/release-please): it opens a **Release PR** that bumps the version and updates `CHANGELOG.md` from conventional commits. After the Release PR is merged, release-please creates the release tag on `main`.
63
+
64
+ **Release-please setup:** In the repo go to **Settings → Actions → General → Workflow permissions** and set to **Read and write** and enable **Allow GitHub Actions to create and approve pull requests**. You can then use the default `GITHUB_TOKEN` (no secret). If you see "Error adding to tree" or PR creation blocked, add a Personal Access Token as secret `RELEASE_PLEASE_TOKEN` (classic: `repo` + `workflow` scope; fine-grained: Contents + Pull requests + Workflows write).
65
+
66
+ **Publishing to npm (local):**
67
+
68
+ 1. Pull `main` with the new release tag.
69
+ 2. Run `npm publish`.
70
+
71
+ Before publishing, `prepublishOnly` runs build, unit tests, integration tests, lint, and format checks. Set `OPENAI_API_KEY` so integration tests pass.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const yargs_1 = __importDefault(require("yargs/yargs"));
8
+ const helpers_1 = require("yargs/helpers");
9
+ const runTranslate_1 = require("./runTranslate");
10
+ function parseArgs(argv) {
11
+ try {
12
+ const parsedArgs = (0, yargs_1.default)(argv)
13
+ .scriptName('msgai')
14
+ .usage('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]')
15
+ .option('dry-run', {
16
+ type: 'boolean',
17
+ default: false,
18
+ })
19
+ .option('include-fuzzy', {
20
+ type: 'boolean',
21
+ default: false,
22
+ description: 'Include fuzzy entries for translation (re-translate and clear fuzzy flag)',
23
+ })
24
+ .option('api-key', {
25
+ type: 'string',
26
+ description: 'OpenAI API key (otherwise read from OPENAI_API_KEY env)',
27
+ })
28
+ .option('source-lang', {
29
+ type: 'string',
30
+ description: 'Source language of msgid strings (ISO 639-1 code, e.g. en, uk). If omitted, the model will detect it.',
31
+ })
32
+ .option('help', {
33
+ alias: 'h',
34
+ type: 'boolean',
35
+ default: false,
36
+ })
37
+ .strictOptions()
38
+ .version(false)
39
+ .exitProcess(false)
40
+ .fail((message, error) => {
41
+ if (error) {
42
+ throw error;
43
+ }
44
+ throw new Error(message);
45
+ })
46
+ .parseSync();
47
+ const positionalArgs = parsedArgs._.map(String);
48
+ const sourceLangRaw = parsedArgs['source-lang'];
49
+ const sourceLang = sourceLangRaw != null && String(sourceLangRaw).trim() !== ''
50
+ ? String(sourceLangRaw).trim().toLowerCase()
51
+ : undefined;
52
+ if (positionalArgs.length > 1) {
53
+ return {
54
+ dryRun: Boolean(parsedArgs['dry-run']),
55
+ help: Boolean(parsedArgs.help),
56
+ apiKey: parsedArgs['api-key'],
57
+ sourceLang,
58
+ includeFuzzy: Boolean(parsedArgs['include-fuzzy']),
59
+ error: `Unexpected argument: ${positionalArgs[1]}`,
60
+ };
61
+ }
62
+ return {
63
+ poFilePath: positionalArgs[0],
64
+ dryRun: Boolean(parsedArgs['dry-run']),
65
+ help: Boolean(parsedArgs.help),
66
+ apiKey: parsedArgs['api-key'],
67
+ sourceLang,
68
+ includeFuzzy: Boolean(parsedArgs['include-fuzzy']),
69
+ };
70
+ }
71
+ catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ return { dryRun: false, help: false, error: message };
74
+ }
75
+ }
76
+ const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]';
77
+ function main(argv) {
78
+ const args = parseArgs(argv);
79
+ if (args.error) {
80
+ console.warn(args.error);
81
+ console.warn(USAGE);
82
+ return 1;
83
+ }
84
+ if (args.help) {
85
+ console.log(USAGE);
86
+ return 0;
87
+ }
88
+ const result = (0, runTranslate_1.runTranslateCommand)({
89
+ poFilePath: args.poFilePath,
90
+ dryRun: args.dryRun,
91
+ apiKey: args.apiKey,
92
+ sourceLang: args.sourceLang,
93
+ includeFuzzy: args.includeFuzzy,
94
+ });
95
+ if (result instanceof Promise) {
96
+ result.then((code) => process.exit(code));
97
+ return undefined;
98
+ }
99
+ return result;
100
+ }
101
+ const exitCode = main((0, helpers_1.hideBin)(process.argv));
102
+ if (typeof exitCode === 'number') {
103
+ process.exit(exitCode);
104
+ }
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runTranslate = runTranslate;
7
+ exports.runTranslateCommand = runTranslateCommand;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const plural_forms_1 = require("plural-forms");
10
+ const po_1 = require("../po");
11
+ const translate_1 = require("../translate");
12
+ const validate_source_lang_1 = require("../validate-source-lang");
13
+ const TRANSLATE_BATCH_SIZE = 15;
14
+ function isApiError(err) {
15
+ return (err != null &&
16
+ typeof err === 'object' &&
17
+ 'status' in err &&
18
+ typeof err.status === 'number');
19
+ }
20
+ function getApiErrorMessage(err) {
21
+ if (!isApiError(err))
22
+ return null;
23
+ const status = err.status;
24
+ const code = err.code;
25
+ const message = (err.message ?? '');
26
+ switch (status) {
27
+ case 401:
28
+ return `Invalid or missing API key. Set OPENAI_API_KEY or use --api-key. Check key at https://platform.openai.com/settings/organization/api-keys`;
29
+ case 403:
30
+ return `API not available in your country/region. See https://developers.openai.com/api/docs/supported-countries`;
31
+ case 429:
32
+ if (code === 'insufficient_quota' || /quota/i.test(message)) {
33
+ return `Quota exceeded (out of credits or usage limit). Check plan and billing: https://platform.openai.com/settings/organization/billing`;
34
+ }
35
+ return `Rate limit reached. Request was retried; if this persists, slow down or check https://developers.openai.com/api/docs/guides/rate-limits`;
36
+ case 500:
37
+ return `OpenAI server error. Retry later; see https://status.openai.com/`;
38
+ case 503:
39
+ return `OpenAI overloaded. Retry after a short wait.`;
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
45
+ try {
46
+ const poContent = node_fs_1.default.readFileSync(poFilePath, 'utf8');
47
+ const parsedPo = (0, po_1.parsePoContent)(poContent);
48
+ const { entries, keys } = (0, po_1.getEntriesToTranslate)(parsedPo, { includeFuzzy });
49
+ if (entries.length === 0) {
50
+ console.log(`Nothing to translate in ${poFilePath}.`);
51
+ return 0;
52
+ }
53
+ const targetLanguage = (0, po_1.getLanguage)(parsedPo) ?? 'en';
54
+ const formula = (0, po_1.getPluralForms)(parsedPo) ?? '';
55
+ const normalizedTarget = (targetLanguage ?? '').trim().split(/\s/)[0] ?? '';
56
+ let pluralSamples;
57
+ if (normalizedTarget) {
58
+ try {
59
+ pluralSamples = (0, plural_forms_1.getExamples)(normalizedTarget);
60
+ }
61
+ catch {
62
+ // locale not in plural-forms; rely on formula only
63
+ }
64
+ }
65
+ const options = { apiKey, sourceLanguage: sourceLang, formula, pluralSamples };
66
+ const allResults = [];
67
+ for (let i = 0; i < entries.length; i += TRANSLATE_BATCH_SIZE) {
68
+ const batch = entries.slice(i, i + TRANSLATE_BATCH_SIZE);
69
+ const batchNum = Math.floor(i / TRANSLATE_BATCH_SIZE) + 1;
70
+ const totalBatches = Math.ceil(entries.length / TRANSLATE_BATCH_SIZE);
71
+ console.log(`Translating batch ${batchNum}/${totalBatches} (${batch.length} phrase${batch.length === 1 ? '' : 's'})...`);
72
+ const batchResults = await (0, translate_1.translateStrings)(batch, targetLanguage, options);
73
+ for (const r of batchResults) {
74
+ if (typeof r.msgstr === 'string') {
75
+ console.log(` ${r.msgid} => ${r.msgstr}`);
76
+ }
77
+ else {
78
+ console.log(` ${r.msgid_plural} (plural) => ${r.msgstr.join(' | ')}`);
79
+ }
80
+ }
81
+ allResults.push(...batchResults);
82
+ }
83
+ (0, po_1.applyTranslations)(parsedPo, keys, allResults);
84
+ if (includeFuzzy) {
85
+ (0, po_1.clearFuzzyFromEntries)(parsedPo, keys);
86
+ }
87
+ node_fs_1.default.writeFileSync(poFilePath, (0, po_1.compilePo)(parsedPo), undefined);
88
+ return 0;
89
+ }
90
+ catch (error) {
91
+ const apiMessage = getApiErrorMessage(error);
92
+ if (apiMessage != null) {
93
+ console.warn(apiMessage);
94
+ return 1;
95
+ }
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ console.warn(`Failed to process PO file: ${message}`);
98
+ return 1;
99
+ }
100
+ }
101
+ const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]';
102
+ function runTranslateCommand(args) {
103
+ if (!args.poFilePath) {
104
+ console.warn(USAGE);
105
+ return 1;
106
+ }
107
+ if (args.sourceLang != null) {
108
+ try {
109
+ (0, validate_source_lang_1.validateSourceLang)(args.sourceLang);
110
+ }
111
+ catch (error) {
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ console.warn(message);
114
+ return 1;
115
+ }
116
+ }
117
+ if (!args.dryRun) {
118
+ let resultApiKey;
119
+ try {
120
+ resultApiKey = (0, translate_1.resolveApiKey)(args.apiKey);
121
+ }
122
+ catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ console.warn(message.replace('pass apiKey in options', 'pass --api-key'));
125
+ return 1;
126
+ }
127
+ return runTranslate(args.poFilePath, resultApiKey, args.sourceLang, args.includeFuzzy);
128
+ }
129
+ try {
130
+ const poContent = node_fs_1.default.readFileSync(args.poFilePath, 'utf8');
131
+ const parsedPo = (0, po_1.parsePoContent)(poContent);
132
+ const { entries } = (0, po_1.getEntriesToTranslate)(parsedPo, {
133
+ includeFuzzy: args.includeFuzzy,
134
+ });
135
+ const msgidsToShow = entries.map((e) => e.msgid);
136
+ for (const msgid of msgidsToShow) {
137
+ console.log(msgid);
138
+ }
139
+ return 0;
140
+ }
141
+ catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ console.warn(`Failed to process PO file: ${message}`);
144
+ return 1;
145
+ }
146
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.runTranslate = runTranslate;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const yargs_1 = __importDefault(require("yargs/yargs"));
10
+ const helpers_1 = require("yargs/helpers");
11
+ const po_1 = require("./po");
12
+ const translate_1 = require("./translate");
13
+ const validate_source_lang_1 = require("./validate-source-lang");
14
+ const TRANSLATE_BATCH_SIZE = 15;
15
+ function parseArgs(argv) {
16
+ try {
17
+ const parsedArgs = (0, yargs_1.default)(argv)
18
+ .scriptName('msgai')
19
+ .usage('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG]')
20
+ .option('dry-run', {
21
+ type: 'boolean',
22
+ default: false,
23
+ })
24
+ .option('api-key', {
25
+ type: 'string',
26
+ description: 'OpenAI API key (otherwise read from OPENAI_API_KEY env)',
27
+ })
28
+ .option('source-lang', {
29
+ type: 'string',
30
+ description: 'Source language of msgid strings (ISO 639-1 code, e.g. en, uk). If omitted, the model will detect it.',
31
+ })
32
+ .option('help', {
33
+ alias: 'h',
34
+ type: 'boolean',
35
+ default: false,
36
+ })
37
+ .strictOptions()
38
+ .version(false)
39
+ .exitProcess(false)
40
+ .fail((message, error) => {
41
+ if (error) {
42
+ throw error;
43
+ }
44
+ throw new Error(message);
45
+ })
46
+ .parseSync();
47
+ const positionalArgs = parsedArgs._.map(String);
48
+ const sourceLangRaw = parsedArgs['source-lang'];
49
+ const sourceLang = sourceLangRaw != null && String(sourceLangRaw).trim() !== ''
50
+ ? String(sourceLangRaw).trim().toLowerCase()
51
+ : undefined;
52
+ if (positionalArgs.length > 1) {
53
+ return {
54
+ dryRun: Boolean(parsedArgs['dry-run']),
55
+ help: Boolean(parsedArgs.help),
56
+ apiKey: parsedArgs['api-key'],
57
+ sourceLang,
58
+ error: `Unexpected argument: ${positionalArgs[1]}`,
59
+ };
60
+ }
61
+ return {
62
+ poFilePath: positionalArgs[0],
63
+ dryRun: Boolean(parsedArgs['dry-run']),
64
+ help: Boolean(parsedArgs.help),
65
+ apiKey: parsedArgs['api-key'],
66
+ sourceLang,
67
+ };
68
+ }
69
+ catch (error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ return { dryRun: false, help: false, error: message };
72
+ }
73
+ }
74
+ async function runTranslate(poFilePath, apiKey, sourceLang) {
75
+ try {
76
+ const poContent = node_fs_1.default.readFileSync(poFilePath, 'utf8');
77
+ const parsedPo = (0, po_1.parsePoContent)(poContent);
78
+ const { entries, keys } = (0, po_1.getEntriesToTranslate)(parsedPo);
79
+ if (entries.length === 0) {
80
+ console.log(`Nothing to translate in ${poFilePath}.`);
81
+ return 0;
82
+ }
83
+ const targetLanguage = (0, po_1.getLanguage)(parsedPo) ?? 'en';
84
+ const formula = (0, po_1.getPluralForms)(parsedPo) ?? '';
85
+ const options = { apiKey, sourceLanguage: sourceLang, formula };
86
+ const allResults = [];
87
+ for (let i = 0; i < entries.length; i += TRANSLATE_BATCH_SIZE) {
88
+ const batch = entries.slice(i, i + TRANSLATE_BATCH_SIZE);
89
+ const batchNum = Math.floor(i / TRANSLATE_BATCH_SIZE) + 1;
90
+ const totalBatches = Math.ceil(entries.length / TRANSLATE_BATCH_SIZE);
91
+ console.log(`Translating batch ${batchNum}/${totalBatches} (${batch.length} phrase${batch.length === 1 ? '' : 's'})...`);
92
+ const batchResults = await (0, translate_1.translateStrings)(batch, targetLanguage, options);
93
+ for (const r of batchResults) {
94
+ if (typeof r.msgstr === 'string') {
95
+ console.log(` ${r.msgid} => ${r.msgstr}`);
96
+ }
97
+ else {
98
+ console.log(` ${r.msgid_plural} (plural) => ${r.msgstr.join(' | ')}`);
99
+ }
100
+ }
101
+ allResults.push(...batchResults);
102
+ }
103
+ (0, po_1.applyTranslations)(parsedPo, keys, allResults);
104
+ node_fs_1.default.writeFileSync(poFilePath, (0, po_1.compilePo)(parsedPo), undefined);
105
+ return 0;
106
+ }
107
+ catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ console.error(`Failed to process PO file: ${message}`);
110
+ return 1;
111
+ }
112
+ }
113
+ function main(argv) {
114
+ const args = parseArgs(argv);
115
+ if (args.error) {
116
+ console.error(args.error);
117
+ console.error('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG]');
118
+ return 1;
119
+ }
120
+ if (args.help) {
121
+ console.log('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG]');
122
+ return 0;
123
+ }
124
+ if (!args.poFilePath) {
125
+ console.error('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG]');
126
+ return 1;
127
+ }
128
+ if (args.sourceLang != null) {
129
+ try {
130
+ (0, validate_source_lang_1.validateSourceLang)(args.sourceLang);
131
+ }
132
+ catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ console.error(message);
135
+ return 1;
136
+ }
137
+ }
138
+ if (!args.dryRun) {
139
+ let resultApiKey;
140
+ try {
141
+ resultApiKey = (0, translate_1.resolveApiKey)(args.apiKey);
142
+ }
143
+ catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ console.error(message.replace('pass apiKey in options', 'pass --api-key'));
146
+ return 1;
147
+ }
148
+ runTranslate(args.poFilePath, resultApiKey, args.sourceLang).then((code) => process.exit(code));
149
+ return undefined;
150
+ }
151
+ try {
152
+ const poContent = node_fs_1.default.readFileSync(args.poFilePath, 'utf8');
153
+ const parsedPo = (0, po_1.parsePoContent)(poContent);
154
+ const untranslatedMsgids = (0, po_1.getUntranslatedMsgids)(parsedPo);
155
+ for (const msgid of untranslatedMsgids) {
156
+ console.log(msgid);
157
+ }
158
+ }
159
+ catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ console.error(`Failed to process PO file: ${message}`);
162
+ return 1;
163
+ }
164
+ return 0;
165
+ }
166
+ const exitCode = main((0, helpers_1.hideBin)(process.argv));
167
+ if (typeof exitCode === 'number') {
168
+ process.exit(exitCode);
169
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadEnv = loadEnv;
7
+ /**
8
+ * Single place for loading .env. Used by the CLI and scripts so env is loaded once.
9
+ */
10
+ const dotenv_1 = __importDefault(require("dotenv"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ let loaded = false;
13
+ function loadEnv() {
14
+ if (loaded)
15
+ return;
16
+ dotenv_1.default.config({ path: node_path_1.default.resolve(process.cwd(), '.env') });
17
+ loaded = true;
18
+ }
@@ -0,0 +1,68 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Content-Type: text/plain; charset=utf-8\n"
4
+ "Plural-Forms: nplurals = 2; plural = (n != 1);\n"
5
+ "Language: en\n"
6
+ "MIME-Version: 1.0\n"
7
+ "Content-Transfer-Encoding: 8bit\n"
8
+
9
+ #: src/templates/baseHTML.mjs:11
10
+ msgid "Krejčovství Švadlenka – Oprava a úprava oděvů Praha 2"
11
+ msgstr "Švadlenka Tailor Shop – Repair and Alteration of Clothing Prague 2"
12
+
13
+ #: src/templates/baseHTML.mjs:12
14
+ msgid ""
15
+ "Švadlenka je krejčovství na Praze 2, které nabízí kvalitní opravy a úpravy "
16
+ "oblečení – džíny, kabáty, podšívky, batohy i záclony. Najdete nás na "
17
+ "Jaromírově 726/15."
18
+ msgstr ""
19
+ "Švadlenka is a tailor shop in Prague 2 offering quality repairs and "
20
+ "alterations of clothing – jeans, coats, linings, backpacks, and curtains. "
21
+ "You can find us at Jaromírova 726/15."
22
+
23
+ #: src/templates/baseHTML.mjs:13
24
+ msgid ""
25
+ "krejčovství, oprava oblečení, úprava oděvů, výměna zipu, podšívka, záclony, "
26
+ "Praha 2, Nusle"
27
+ msgstr ""
28
+ "tailor shop, clothes repair, clothing alteration, zipper replacement, "
29
+ "lining, curtains, Prague 2, Nusle"
30
+
31
+ #: src/templates/baseHTML.mjs:14
32
+ msgid "Krejčovství Švadlenka – Praha 2"
33
+ msgstr "Švadlenka Tailor Shop – Prague 2"
34
+
35
+ #: src/templates/baseHTML.mjs:15
36
+ msgid "Opravy a úpravy oblečení, záclon, batohů – najdete nás na Jaromírově 726/15."
37
+ msgstr ""
38
+ "Repairs and alterations of clothes, curtains, backpacks – you can find us "
39
+ "at Jaromírova 726/15."
40
+
41
+ #: src/templates/header.mjs:14
42
+ #: src/templates/header.mjs:43
43
+ msgid "Úvod"
44
+ msgstr "Introduction"
45
+
46
+ #: src/pages/sluzby.mjs:28
47
+ #: src/templates/header.mjs:15
48
+ #: src/templates/header.mjs:44
49
+ msgid "Služby a ceník"
50
+ msgstr "Services and Price List"
51
+
52
+ #: src/templates/header.mjs:16
53
+ #: src/templates/header.mjs:45
54
+ #: src/templates/kontakty.mjs:5
55
+ msgid "Kontakty"
56
+ msgstr "Contacts"
57
+
58
+ #: src/templates/kontakty.mjs:23
59
+ msgid "Po – Čt: 10:00 – 18:00"
60
+ msgstr "Mon – Thu: 10:00 AM – 6:00 PM"
61
+
62
+ #: src/templates/kontakty.mjs:24
63
+ msgid "Pá: 10:00 – 16:00"
64
+ msgstr "Fri: 10:00 AM – 4:00 PM"
65
+
66
+ #: src/templates/review.mjs:16
67
+ msgid "Budeme vděční za vaši recenzi"
68
+ msgstr "We would appreciate your review"
@@ -0,0 +1,61 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Content-Type: text/plain; charset=utf-8\n"
4
+ "Plural-Forms: nplurals = 3; plural = (n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);\n"
5
+ "Language: ru\n"
6
+ "MIME-Version: 1.0\n"
7
+ "Content-Transfer-Encoding: 8bit\n"
8
+
9
+ #: src/templates/baseHTML.mjs:11
10
+ msgid "Krejčovství Švadlenka – Oprava a úprava oděvů Praha 2"
11
+ msgstr ""
12
+
13
+ #: src/templates/baseHTML.mjs:12
14
+ msgid ""
15
+ "Švadlenka je krejčovství na Praze 2, které nabízí kvalitní opravy a úpravy "
16
+ "oblečení – džíny, kabáty, podšívky, batohy i záclony. Najdete nás na "
17
+ "Jaromírově 726/15."
18
+ msgstr ""
19
+
20
+ #: src/templates/baseHTML.mjs:13
21
+ msgid ""
22
+ "krejčovství, oprava oblečení, úprava oděvů, výměna zipu, podšívka, záclony, "
23
+ "Praha 2, Nusle"
24
+ msgstr ""
25
+
26
+ #: src/templates/baseHTML.mjs:14
27
+ msgid "Krejčovství Švadlenka – Praha 2"
28
+ msgstr ""
29
+
30
+ #: src/templates/baseHTML.mjs:15
31
+ msgid "Opravy a úpravy oblečení, záclon, batohů – najdete nás na Jaromírově 726/15."
32
+ msgstr ""
33
+
34
+ #: src/templates/header.mjs:14
35
+ #: src/templates/header.mjs:43
36
+ msgid "Úvod"
37
+ msgstr ""
38
+
39
+ #: src/pages/sluzby.mjs:28
40
+ #: src/templates/header.mjs:15
41
+ #: src/templates/header.mjs:44
42
+ msgid "Služby a ceník"
43
+ msgstr ""
44
+
45
+ #: src/templates/header.mjs:16
46
+ #: src/templates/header.mjs:45
47
+ #: src/templates/kontakty.mjs:5
48
+ msgid "Kontakty"
49
+ msgstr ""
50
+
51
+ #: src/templates/kontakty.mjs:23
52
+ msgid "Po – Čt: 10:00 – 18:00"
53
+ msgstr ""
54
+
55
+ #: src/templates/kontakty.mjs:24
56
+ msgid "Pá: 10:00 – 16:00"
57
+ msgstr ""
58
+
59
+ #: src/templates/review.mjs:16
60
+ msgid "Budeme vděční za vaši recenzi"
61
+ msgstr ""
@@ -0,0 +1,69 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Content-Type: text/plain; charset=utf-8\n"
4
+ "Plural-Forms: nplurals = 3; plural = (n % 10 == 1 && n % 100 != 11 ? 0 : n "
5
+ "% 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);\n"
6
+ "Language: uk\n"
7
+ "MIME-Version: 1.0\n"
8
+ "Content-Transfer-Encoding: 8bit\n"
9
+
10
+ #: src/templates/baseHTML.mjs:11
11
+ msgid "Krejčovství Švadlenka – Oprava a úprava oděvů Praha 2"
12
+ msgstr "Ательє Швадленка – Ремонт та переробка одягу Прага 2"
13
+
14
+ #: src/templates/baseHTML.mjs:12
15
+ msgid ""
16
+ "Švadlenka je krejčovství na Praze 2, které nabízí kvalitní opravy a úpravy "
17
+ "oblečení – džíny, kabáty, podšívky, batohy i záclony. Najdete nás na "
18
+ "Jaromírově 726/15."
19
+ msgstr ""
20
+ "Швадленка – це ательє в Празі 2, яке пропонує якісний ремонт та переробку "
21
+ "одягу – джинси, пальта, підкладки, рюкзаки та штори. Знайдете нас на "
22
+ "Яроміровій 726/15."
23
+
24
+ #: src/templates/baseHTML.mjs:13
25
+ msgid ""
26
+ "krejčovství, oprava oblečení, úprava oděvů, výměna zipu, podšívka, záclony, "
27
+ "Praha 2, Nusle"
28
+ msgstr ""
29
+ "ательє, ремонт одягу, переробка одягу, заміна блискавки, підкладка, штори, "
30
+ "Прага 2, Нусле"
31
+
32
+ #: src/templates/baseHTML.mjs:14
33
+ msgid "Krejčovství Švadlenka – Praha 2"
34
+ msgstr "Ательє Швадленка – Прага 2"
35
+
36
+ #: src/templates/baseHTML.mjs:15
37
+ msgid "Opravy a úpravy oblečení, záclon, batohů – najdete nás na Jaromírově 726/15."
38
+ msgstr ""
39
+ "Ремонт та переробка одягу, штор, рюкзаків – знайдете нас на Яроміровій "
40
+ "726/15."
41
+
42
+ #: src/templates/header.mjs:14
43
+ #: src/templates/header.mjs:43
44
+ msgid "Úvod"
45
+ msgstr "Вступ"
46
+
47
+ #: src/pages/sluzby.mjs:28
48
+ #: src/templates/header.mjs:15
49
+ #: src/templates/header.mjs:44
50
+ msgid "Služby a ceník"
51
+ msgstr "Послуги та прейскурант"
52
+
53
+ #: src/templates/header.mjs:16
54
+ #: src/templates/header.mjs:45
55
+ #: src/templates/kontakty.mjs:5
56
+ msgid "Kontakty"
57
+ msgstr "Контакти"
58
+
59
+ #: src/templates/kontakty.mjs:23
60
+ msgid "Po – Čt: 10:00 – 18:00"
61
+ msgstr "Пн – Чт: 10:00 – 18:00"
62
+
63
+ #: src/templates/kontakty.mjs:24
64
+ msgid "Pá: 10:00 – 16:00"
65
+ msgstr "Пт: 10:00 – 16:00"
66
+
67
+ #: src/templates/review.mjs:16
68
+ msgid "Budeme vděční za vaši recenzi"
69
+ msgstr "Будемо вдячні за ваш відгук"
package/dist/src/po.js ADDED
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getUntranslatedMsgids = getUntranslatedMsgids;
4
+ exports.getLanguage = getLanguage;
5
+ exports.getPluralForms = getPluralForms;
6
+ exports.isEntryFuzzy = isEntryFuzzy;
7
+ exports.parsePoContent = parsePoContent;
8
+ exports.getEntriesToTranslate = getEntriesToTranslate;
9
+ exports.applyTranslations = applyTranslations;
10
+ exports.clearFuzzyFromEntries = clearFuzzyFromEntries;
11
+ exports.compilePo = compilePo;
12
+ const gettext_parser_1 = require("gettext-parser");
13
+ function getUntranslatedMsgids(parsedPo) {
14
+ const untranslatedMsgids = [];
15
+ const translations = parsedPo.translations;
16
+ for (const contextEntries of Object.values(translations)) {
17
+ for (const entry of Object.values(contextEntries)) {
18
+ if (!entry.msgid) {
19
+ continue;
20
+ }
21
+ const msgstr = Array.isArray(entry.msgstr) ? entry.msgstr : [];
22
+ const isUntranslated = msgstr.length === 0 ||
23
+ msgstr.every((translation) => typeof translation !== 'string' || translation.trim() === '');
24
+ if (isUntranslated) {
25
+ untranslatedMsgids.push(entry.msgid);
26
+ }
27
+ }
28
+ }
29
+ return untranslatedMsgids;
30
+ }
31
+ function getLanguage(parsedPo) {
32
+ return parsedPo.headers?.['Language'];
33
+ }
34
+ function getPluralForms(parsedPo) {
35
+ return parsedPo.headers?.['Plural-Forms'];
36
+ }
37
+ function isEntryUntranslated(entry) {
38
+ const msgstr = Array.isArray(entry.msgstr) ? entry.msgstr : [];
39
+ return msgstr.length === 0 || msgstr.every((s) => typeof s !== 'string' || s.trim() === '');
40
+ }
41
+ /** Returns true if the entry has the fuzzy flag (e.g. "#, fuzzy" in PO). */
42
+ function isEntryFuzzy(entry) {
43
+ const flag = entry.comments?.flag;
44
+ if (flag == null || typeof flag !== 'string')
45
+ return false;
46
+ return flag.split(',').some((s) => s.trim().toLowerCase() === 'fuzzy');
47
+ }
48
+ /**
49
+ * Parses PO content and returns the gettext structure.
50
+ */
51
+ function parsePoContent(poContent) {
52
+ return gettext_parser_1.po.parse(Buffer.from(poContent, 'utf8'));
53
+ }
54
+ /**
55
+ * Returns untranslated entries and their keys in stable order (contexts and msgids sorted).
56
+ * Skips the header (msgid "").
57
+ * By default skips fuzzy entries; set includeFuzzy to include them (with empty msgstr for the request).
58
+ */
59
+ function getEntriesToTranslate(parsedPo, options) {
60
+ const includeFuzzy = options?.includeFuzzy === true;
61
+ const entries = [];
62
+ const keys = [];
63
+ const translations = parsedPo.translations;
64
+ const contextNames = Object.keys(translations).sort();
65
+ for (const context of contextNames) {
66
+ const contextEntries = translations[context];
67
+ if (contextEntries == null)
68
+ continue;
69
+ const msgids = Object.keys(contextEntries).sort();
70
+ for (const msgid of msgids) {
71
+ if (msgid === '')
72
+ continue;
73
+ const entry = contextEntries[msgid];
74
+ if (entry == null || !entry.msgid)
75
+ continue;
76
+ const untranslated = isEntryUntranslated(entry);
77
+ const fuzzy = isEntryFuzzy(entry);
78
+ const include = untranslated || (includeFuzzy && fuzzy);
79
+ if (!include)
80
+ continue;
81
+ if (entry.msgid_plural != null) {
82
+ entries.push({
83
+ msgid: entry.msgid,
84
+ msgid_plural: entry.msgid_plural,
85
+ msgstr: fuzzy && !untranslated ? [] : entry.msgstr.slice(),
86
+ msgctxt: context,
87
+ });
88
+ }
89
+ else {
90
+ entries.push({
91
+ msgid: entry.msgid,
92
+ msgctxt: context,
93
+ ...(fuzzy && !untranslated ? { msgstr: [] } : {}),
94
+ });
95
+ }
96
+ keys.push({ context, msgid });
97
+ }
98
+ }
99
+ return { entries, keys };
100
+ }
101
+ /**
102
+ * Applies translation results into the parsed PO (mutates parsedPo.translations).
103
+ * Singular results become one-element msgstr; plural results stay as string[].
104
+ */
105
+ function applyTranslations(parsedPo, keys, results) {
106
+ for (let i = 0; i < keys.length; i++) {
107
+ const key = keys[i];
108
+ const result = results[i];
109
+ if (result == null)
110
+ continue;
111
+ const contextEntries = parsedPo.translations[key.context];
112
+ if (contextEntries == null)
113
+ continue;
114
+ const entry = contextEntries[key.msgid];
115
+ if (entry == null)
116
+ continue;
117
+ if (typeof result.msgstr === 'string') {
118
+ entry.msgstr = [result.msgstr];
119
+ }
120
+ else {
121
+ entry.msgstr = result.msgstr.slice();
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Removes the "fuzzy" flag from entries at the given keys (mutates parsedPo.translations).
127
+ * Used after applying new translations so fuzzy entries are no longer marked fuzzy.
128
+ */
129
+ function clearFuzzyFromEntries(parsedPo, keys) {
130
+ for (const key of keys) {
131
+ const contextEntries = parsedPo.translations[key.context];
132
+ if (contextEntries == null)
133
+ continue;
134
+ const entry = contextEntries[key.msgid];
135
+ if (entry == null || !entry.comments?.flag)
136
+ continue;
137
+ const newFlag = entry.comments.flag
138
+ .split(',')
139
+ .map((s) => s.trim())
140
+ .filter((s) => s.toLowerCase() !== 'fuzzy')
141
+ .join(', ');
142
+ if (newFlag === '') {
143
+ delete entry.comments.flag;
144
+ if (Object.keys(entry.comments).length === 0) {
145
+ delete entry.comments;
146
+ }
147
+ }
148
+ else {
149
+ entry.comments.flag = newFlag;
150
+ }
151
+ }
152
+ }
153
+ /**
154
+ * Compiles the parsed PO to a buffer (no file I/O).
155
+ */
156
+ function compilePo(parsedPo) {
157
+ return gettext_parser_1.po.compile(parsedPo);
158
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runTranslate = runTranslate;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const po_1 = require("./po");
9
+ const translate_1 = require("./translate");
10
+ const TRANSLATE_BATCH_SIZE = 15;
11
+ async function runTranslate(poFilePath, apiKey, sourceLang) {
12
+ try {
13
+ const poContent = node_fs_1.default.readFileSync(poFilePath, 'utf8');
14
+ const parsedPo = (0, po_1.parsePoContent)(poContent);
15
+ const { entries, keys } = (0, po_1.getEntriesToTranslate)(parsedPo);
16
+ if (entries.length === 0) {
17
+ console.log(`Nothing to translate in ${poFilePath}.`);
18
+ return 0;
19
+ }
20
+ const targetLanguage = (0, po_1.getLanguage)(parsedPo) ?? 'en';
21
+ const formula = (0, po_1.getPluralForms)(parsedPo) ?? '';
22
+ const options = { apiKey, sourceLanguage: sourceLang, formula };
23
+ const allResults = [];
24
+ for (let i = 0; i < entries.length; i += TRANSLATE_BATCH_SIZE) {
25
+ const batch = entries.slice(i, i + TRANSLATE_BATCH_SIZE);
26
+ const batchNum = Math.floor(i / TRANSLATE_BATCH_SIZE) + 1;
27
+ const totalBatches = Math.ceil(entries.length / TRANSLATE_BATCH_SIZE);
28
+ console.log(`Translating batch ${batchNum}/${totalBatches} (${batch.length} phrase${batch.length === 1 ? '' : 's'})...`);
29
+ const batchResults = await (0, translate_1.translateStrings)(batch, targetLanguage, options);
30
+ for (const r of batchResults) {
31
+ if (typeof r.msgstr === 'string') {
32
+ console.log(` ${r.msgid} => ${r.msgstr}`);
33
+ }
34
+ else {
35
+ console.log(` ${r.msgid_plural} (plural) => ${r.msgstr.join(' | ')}`);
36
+ }
37
+ }
38
+ allResults.push(...batchResults);
39
+ }
40
+ (0, po_1.applyTranslations)(parsedPo, keys, allResults);
41
+ node_fs_1.default.writeFileSync(poFilePath, (0, po_1.compilePo)(parsedPo), undefined);
42
+ return 0;
43
+ }
44
+ catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ console.error(`Failed to process PO file: ${message}`);
47
+ return 1;
48
+ }
49
+ }
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveApiKey = resolveApiKey;
7
+ exports.translatePayload = translatePayload;
8
+ exports.translateItems = translateItems;
9
+ exports.translateStrings = translateStrings;
10
+ const openai_1 = __importDefault(require("openai"));
11
+ const loadEnv_1 = require("./loadEnv");
12
+ function resolveApiKey(apiKey) {
13
+ (0, loadEnv_1.loadEnv)();
14
+ if (apiKey != null && apiKey.trim() !== '')
15
+ return apiKey;
16
+ const env = process.env['OPENAI_API_KEY'];
17
+ if (env != null && env.trim() !== '')
18
+ return env;
19
+ throw new Error('OpenAI API key not set. Set OPENAI_API_KEY in the environment or pass apiKey in options.');
20
+ }
21
+ const DEFAULT_MODEL = 'gpt-4o';
22
+ /** Error codes: https://developers.openai.com/api/docs/guides/error-codes#api-errors */
23
+ const MAX_RETRIES = 3;
24
+ const RETRY_DELAYS_MS = [1000, 2000, 4000];
25
+ function sleep(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+ function isApiError(err) {
29
+ return (err != null &&
30
+ typeof err === 'object' &&
31
+ 'status' in err &&
32
+ typeof err.status === 'number');
33
+ }
34
+ function isRetryableStatus(status) {
35
+ return status === 429 || status === 500 || status === 503;
36
+ }
37
+ function buildSystemMessage() {
38
+ return `You are a deterministic translation engine for gettext PO entries.
39
+
40
+ Your task:
41
+ For each input entry, produce a translation in the corresponding "msgstr" field.
42
+ Each output entry MUST correspond exactly to the matching input entry.
43
+ Do not change, remove, or reorder any "msgid" or "msgid_plural" values.
44
+ Only fill the "msgstr" field.
45
+ If an entry has "msgctxt", leave it unchanged and return it in the output (so same msgids for different contexts are not mixed).
46
+
47
+ Critical rules:
48
+
49
+ - Preserve ALL placeholders exactly as in the source text.
50
+ - NEVER translate, modify, reorder, or remove placeholders.
51
+
52
+ Placeholders include (but are not limited to):
53
+ - printf-style specifiers: %s, %d, %f, %1$s, %(name)s
54
+ - variables in braces: {name}, {0}, {count}
55
+ - template string interpolation: \${name}
56
+ - template/ICU tokens
57
+ - HTML/XML tags
58
+ - any non-linguistic tokens
59
+
60
+ Only translate natural language text surrounding them.
61
+
62
+ Input:
63
+
64
+ You will receive a JSON object containing:
65
+ - "formula": plural formula of the target language
66
+ - "target_language": language code to translate into
67
+ - "source_language": language code of the source text
68
+ - "translations": ordered array of entries
69
+ - "plural_samples" (optional): array of { "plural": number, "sample": number } — one per plural form, giving the example count for that form (e.g. 1, 2, 5 for Ukrainian)
70
+
71
+ Each entry is either:
72
+
73
+ 1) { "msgid": string }
74
+ → Translate the value of "msgid"
75
+ → Set "msgstr" to a SINGLE translated string
76
+ → Copy "msgid" unchanged into the output entry
77
+
78
+ 2) { "msgid_plural": string }
79
+ → Translate the plural form
80
+ → Set "msgstr" to an ARRAY of translated strings
81
+ → The number and order of elements MUST follow the plural "formula"
82
+ → If "plural_samples" is present: "msgstr" must have length equal to plural_samples.length; the i-th string is the translation for the count plural_samples[i].sample (e.g. form for 1 item, form for 2 items, form for 5 items)
83
+ → If "plural_samples" is absent: the number and order of elements MUST follow the plural "formula"
84
+ → Copy "msgid_plural" unchanged into the output entry
85
+
86
+ Output:
87
+
88
+ You MUST respond with nothing but a single JSON object. No markdown, no code fences (no \`\`\`json or \`\`\`), no explanatory text before or after. The response must be parseable by JSON.parse() directly.
89
+
90
+ Return EXACTLY this structure (and nothing else):
91
+
92
+ {
93
+ "formula": "...",
94
+ "target_language": "...",
95
+ "source_language": "...",
96
+ "translations": [
97
+ { "msgid": "...", "msgstr": "..." },
98
+ { "msgid_plural": "...", "msgstr": ["...", "..."] }
99
+ ]
100
+ }
101
+
102
+ Additional constraints:
103
+
104
+ - Preserve the exact input order of entries.
105
+ - Do not modify "formula", "target_language", or "source_language".
106
+ - Do not add, remove, or rename fields.`;
107
+ }
108
+ /** Strip markdown code fences if the model wrapped JSON in ```json ... ``` */
109
+ function stripJsonFences(raw) {
110
+ const trimmed = raw.trim();
111
+ const jsonBlock = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/;
112
+ const match = trimmed.match(jsonBlock);
113
+ return match ? match[1].trim() : trimmed;
114
+ }
115
+ function parsePayloadResponse(content) {
116
+ if (content == null || content.trim() === '') {
117
+ throw new Error('Empty response from OpenAI');
118
+ }
119
+ const raw = content.trim();
120
+ const toParse = stripJsonFences(raw);
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(toParse);
124
+ }
125
+ catch {
126
+ console.warn('OpenAI model returned (raw):', raw);
127
+ throw new Error(`OpenAI response is not valid JSON: ${raw.slice(0, 200)}`);
128
+ }
129
+ if (parsed == null || typeof parsed !== 'object' || !('translations' in parsed)) {
130
+ console.warn('OpenAI model returned (raw):', raw);
131
+ throw new Error(`OpenAI response must be object with "translations" array: ${raw.slice(0, 200)}`);
132
+ }
133
+ const payload = parsed;
134
+ if (!Array.isArray(payload.translations)) {
135
+ console.warn('OpenAI model returned (raw):', raw);
136
+ throw new Error(`OpenAI response "translations" must be an array: ${raw.slice(0, 200)}`);
137
+ }
138
+ for (let i = 0; i < payload.translations.length; i++) {
139
+ const t = payload.translations[i];
140
+ if (t == null || typeof t !== 'object' || !('msgstr' in t)) {
141
+ console.warn('OpenAI model returned (raw):', raw);
142
+ throw new Error(`OpenAI response translations[${i}] must have msgstr`);
143
+ }
144
+ const entry = t;
145
+ const msgstr = entry.msgstr;
146
+ if (typeof msgstr === 'string')
147
+ continue;
148
+ if (Array.isArray(msgstr) && msgstr.every((s) => typeof s === 'string'))
149
+ continue;
150
+ console.warn('OpenAI model returned (raw):', raw);
151
+ throw new Error(`OpenAI response translations[${i}].msgstr must be a string or array of strings`);
152
+ }
153
+ return payload;
154
+ }
155
+ async function translatePayload(payload, options) {
156
+ if (payload.translations.length === 0) {
157
+ return { ...payload, translations: [] };
158
+ }
159
+ const client = options?.client ??
160
+ new openai_1.default({
161
+ apiKey: options.apiKey,
162
+ });
163
+ const model = options?.model ?? DEFAULT_MODEL;
164
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
165
+ try {
166
+ const response = await client.chat.completions.create({
167
+ model,
168
+ messages: [
169
+ { role: 'system', content: buildSystemMessage() },
170
+ { role: 'user', content: JSON.stringify(payload) },
171
+ ],
172
+ });
173
+ const content = response.choices[0]?.message?.content ?? null;
174
+ return parsePayloadResponse(content);
175
+ }
176
+ catch (err) {
177
+ const shouldRetry = attempt < MAX_RETRIES && isApiError(err) && isRetryableStatus(err.status);
178
+ if (shouldRetry) {
179
+ const delayMs = RETRY_DELAYS_MS[attempt] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
180
+ await sleep(delayMs);
181
+ continue;
182
+ }
183
+ throw err;
184
+ }
185
+ }
186
+ throw new Error('Unreachable');
187
+ }
188
+ async function translateItems(items, targetLanguage, options) {
189
+ if (items.length === 0)
190
+ return [];
191
+ const sourceLanguage = options?.sourceLanguage ?? 'en';
192
+ const formula = options?.formula ?? '';
193
+ const payload = {
194
+ formula,
195
+ target_language: targetLanguage,
196
+ source_language: sourceLanguage,
197
+ translations: items,
198
+ };
199
+ if (options.pluralSamples != null && options.pluralSamples.length > 0) {
200
+ payload.plural_samples = options.pluralSamples;
201
+ }
202
+ const result = await translatePayload(payload, options);
203
+ return result.translations.map((t) => t.msgstr);
204
+ }
205
+ /** Translate .po-style entries. Accepts entries with msgid or msgid_plural, passes them to translateItems, returns same array with msgstr filled. */
206
+ async function translateStrings(entries, targetLanguage, options) {
207
+ if (entries.length === 0)
208
+ return [];
209
+ const items = entries.map((e) => {
210
+ const base = e.msgid_plural != null ? { msgid_plural: e.msgid_plural } : { msgid: e.msgid };
211
+ return e.msgctxt !== undefined ? { ...base, msgctxt: e.msgctxt } : base;
212
+ });
213
+ const results = await translateItems(items, targetLanguage, options);
214
+ return entries.map((entry, i) => {
215
+ const msgstr = results[i];
216
+ if (entry.msgid_plural != null) {
217
+ const arr = typeof msgstr === 'string' ? [msgstr] : (msgstr ?? []);
218
+ return { ...entry, msgstr: arr };
219
+ }
220
+ const str = typeof msgstr === 'string' ? msgstr : Array.isArray(msgstr) ? (msgstr[0] ?? '') : '';
221
+ return { ...entry, msgstr: str };
222
+ });
223
+ }
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ISO_LANG_CODES_URL = void 0;
4
+ exports.validateSourceLang = validateSourceLang;
5
+ const plural_forms_1 = require("plural-forms");
6
+ exports.ISO_LANG_CODES_URL = 'https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes';
7
+ function validateSourceLang(code) {
8
+ const normalized = code.trim().toLowerCase();
9
+ if (!(0, plural_forms_1.hasLang)(normalized)) {
10
+ throw new Error(`Invalid --source-lang "${code}": not a known ISO language code. See ${exports.ISO_LANG_CODES_URL} for a list of valid codes.`);
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "msgai-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI that automatically translates all untranslated strings in gettext (.po) files using AI (LLM)",
5
+ "main": "dist/src/cli/index.js",
6
+ "bin": {
7
+ "msgai": "dist/src/cli/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "test": "npm run build && jest",
12
+ "test:integration": "npm run build && jest -c jest.integration.config.cjs",
13
+ "test:watch": "npm run build && jest --watch",
14
+ "format": "prettier --write .",
15
+ "lint": "eslint .",
16
+ "lint:format": "prettier --check .",
17
+ "script:test-openai": "ts-node scripts/test-openai.ts",
18
+ "prepublishOnly": "npm run build && npm test && npm run test:integration && npm run lint && npm run lint:format"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/AlexMost/msgai.git"
23
+ },
24
+ "keywords": [
25
+ "gettext",
26
+ "po",
27
+ "translate",
28
+ "openai",
29
+ "cli",
30
+ "i18n"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20",
34
+ "npm": ">=10"
35
+ },
36
+ "files": [
37
+ "dist/src",
38
+ "README.md",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "author": "",
42
+ "license": "ISC",
43
+ "type": "commonjs",
44
+ "bugs": {
45
+ "url": "https://github.com/AlexMost/msgai/issues"
46
+ },
47
+ "homepage": "https://github.com/AlexMost/msgai#readme",
48
+ "devDependencies": {
49
+ "@babel/core": "^7.29.0",
50
+ "@babel/preset-env": "^7.29.0",
51
+ "@eslint/js": "^10.0.1",
52
+ "@types/gettext-parser": "^9.0.0",
53
+ "@types/jest": "^30.0.0",
54
+ "@types/node": "^25.3.0",
55
+ "babel-jest": "^30.2.0",
56
+ "eslint": "^10.0.2",
57
+ "jest": "^30.2.0",
58
+ "prettier": "^3.8.1",
59
+ "ts-jest": "^29.4.6",
60
+ "ts-node": "^10.9.2",
61
+ "typescript": "^5.9.3",
62
+ "typescript-eslint": "^8.56.1"
63
+ },
64
+ "dependencies": {
65
+ "dotenv": "^17.3.1",
66
+ "gettext-parser": "^9.0.1",
67
+ "openai": "^6.25.0",
68
+ "plural-forms": "^0.5.5",
69
+ "yargs": "^18.0.0"
70
+ }
71
+ }