msgai-cli 1.0.2 → 1.1.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/CHANGELOG.md +30 -15
- package/README.md +136 -38
- package/dist/src/cli/index.js +32 -4
- package/dist/src/cli/runTranslate.js +83 -4
- package/dist/src/debug.js +36 -0
- package/dist/src/translate.js +207 -29
- package/package.json +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to this project will be documented in this file.
|
|
3
|
+
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## [1.1.1](https://github.com/AlexMost/msgai/compare/v1.1.0...v1.1.1) (2026-03-01)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- normalize msgctxt validation and add debug mode ([7488a78](https://github.com/AlexMost/msgai/commit/7488a782594253c4cfc46b56500ca7fa7b102766))
|
|
10
|
+
|
|
11
|
+
## [1.1.0](https://github.com/AlexMost/msgai/compare/v1.0.2...v1.1.0) (2026-03-01)
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
- **cli:** add --model option for translation ([61a2ed8](https://github.com/AlexMost/msgai/commit/61a2ed8502a0f90bbb09a1715bbb58d0ddabe01e))
|
|
16
|
+
- use structured outputs for translations ([c57e75b](https://github.com/AlexMost/msgai/commit/c57e75bda4229923dfca1daf1b8146a50f1d9b7f))
|
|
17
|
+
|
|
18
|
+
## 1.0.2 (2026-02-27)
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
- **po:** preserve PO file order in `getEntriesToTranslate` ([55ba8d8](https://github.com/AlexMost/msgai/commit/55ba8d8c4c43d26adf4d1e23fa5dac2bebbf2052))
|
|
23
|
+
|
|
24
|
+
## 1.0.1 (2026-02-27)
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
- run formatter after each change and verify formatting in agent workflow ([1a1d909](https://github.com/AlexMost/msgai/commit/1a1d9097f9a88f6b260f5564fc6c800b52ea95a3))
|
|
29
|
+
|
|
30
|
+
### Chores
|
|
31
|
+
|
|
32
|
+
- clean `dist` folder before each build ([0e65234](https://github.com/AlexMost/msgai/commit/0e652342d076867d1795b69ca1cb07d7988b92d2))
|
|
33
|
+
- fix formatting ([0cb5742](https://github.com/AlexMost/msgai/commit/0cb5742452f37f03a292f70ff6acc433f6f3666f))
|
|
6
34
|
|
|
7
35
|
## 1.0.0 (2026-02-27)
|
|
8
36
|
|
|
@@ -17,16 +45,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
17
45
|
- **ci:** use github.event.workflow_run.conclusion in release-please ([0892b17](https://github.com/AlexMost/msgai/commit/0892b17aad1ec95a4e96a8208cb31d197264830f))
|
|
18
46
|
- **ci:** use RELEASE_PLEASE_TOKEN so release-please can create PRs ([204c6cd](https://github.com/AlexMost/msgai/commit/204c6cdf88d480decae841f91c9fae168c4c0ce9))
|
|
19
47
|
- 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
CHANGED
|
@@ -1,71 +1,169 @@
|
|
|
1
1
|
# msgai
|
|
2
2
|
|
|
3
|
-
`msgai` is
|
|
3
|
+
`msgai` is an AI-powered CLI for translating gettext `.po` files. It finds untranslated entries, sends them to an LLM, and writes the translated strings back into the same file.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 🤖 Project Purpose
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`msgai` is built for teams that already use gettext and want a simple way to translate missing strings without building a separate localization workflow.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Main features:
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- `
|
|
11
|
+
- `📝` Works directly with gettext `.po` files
|
|
12
|
+
- `🤖` Translates only untranslated entries using AI
|
|
13
|
+
- `🧠` Uses OpenAI `gpt-4o` by default for translation
|
|
14
|
+
- `🏷️` Respects gettext context (`msgctxt`) when translating entries
|
|
15
|
+
- `🔁` Supports singular and plural translations
|
|
16
|
+
- `⚠️` Skips fuzzy entries by default
|
|
17
|
+
- `🧭` Can infer source language or use `--source-lang`
|
|
18
|
+
- `💻` Runs as a small CLI that updates files in place
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
## ⚙️ How It Works
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
1. Read the `.po` file and parse its entries.
|
|
23
|
+
2. Find entries with empty or missing translations.
|
|
24
|
+
3. Send those strings to OpenAI `gpt-4o` for translation while preserving gettext context such as `msgctxt`.
|
|
25
|
+
4. Write the translated values back into the same `.po` file.
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
The translation API uses OpenAI `json_schema` structured outputs. Only models that support `json_schema` structured outputs are valid for `msgai`.
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
<details>
|
|
30
|
+
<summary>Supported model families</summary>
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
- `gpt-4o`
|
|
33
|
+
- `gpt-4o-mini`
|
|
34
|
+
- `gpt-4.1`
|
|
35
|
+
- `gpt-4.1-mini`
|
|
36
|
+
- `gpt-4.1-nano`
|
|
37
|
+
- `gpt-5`
|
|
38
|
+
- `gpt-5-mini`
|
|
39
|
+
- `gpt-5-nano`
|
|
40
|
+
- `gpt-5-pro`
|
|
41
|
+
- `gpt-5.1`
|
|
42
|
+
- `gpt-5.2`
|
|
43
|
+
- `gpt-5-codex`
|
|
44
|
+
- `gpt-5.1-codex`
|
|
45
|
+
- `gpt-5.1-codex-mini`
|
|
46
|
+
- `gpt-5.1-codex-max`
|
|
47
|
+
- `gpt-5.2-codex`
|
|
25
48
|
|
|
26
|
-
|
|
49
|
+
Dated snapshots are accepted where the model family supports them.
|
|
27
50
|
|
|
28
|
-
|
|
29
|
-
- **CLI option**: pass `--api-key KEY` (e.g. `msgai messages.po --api-key sk-...`).
|
|
51
|
+
</details>
|
|
30
52
|
|
|
31
|
-
If
|
|
53
|
+
By default, entries marked as `fuzzy` are skipped. If you use `--include-fuzzy`, `msgai` will translate those entries too and remove the fuzzy flag after applying the result.
|
|
32
54
|
|
|
33
|
-
|
|
55
|
+
## 📦 Install
|
|
34
56
|
|
|
35
|
-
|
|
57
|
+
Install the CLI globally:
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
```bash
|
|
60
|
+
npm install -g msgai-cli
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Set your OpenAI API key before running translations:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export OPENAI_API_KEY=your_api_key_here
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You can also pass the key directly:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
msgai messages.po --api-key sk-...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`OPENAI_API_KEY` can be loaded from your environment or from a `.env` file in the current directory.
|
|
76
|
+
|
|
77
|
+
## 💻 CLI Usage
|
|
78
|
+
|
|
79
|
+
Usage:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
|
|
87
|
+
- `--dry-run`: list untranslated `msgid` values only, with no API calls and no file changes
|
|
88
|
+
- `--include-fuzzy`: include fuzzy entries for translation and clear their fuzzy flag after translation
|
|
89
|
+
- `--source-lang LANG`: set the source language of `msgid` strings as an ISO 639-1 code such as `en` or `uk`
|
|
90
|
+
- `--model MODEL`: set the OpenAI model used for translation; default is `gpt-4o`. Only models with `json_schema` structured outputs are supported.
|
|
91
|
+
- `--api-key KEY`: pass the OpenAI API key directly instead of using `OPENAI_API_KEY`
|
|
92
|
+
- `--debug`: print debug logs for batch preparation, OpenAI request retries, request payloads, and raw response validation
|
|
93
|
+
- `--help`: print command usage
|
|
94
|
+
|
|
95
|
+
You can also enable the same debug logging with the environment variable `DEBUG=1`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
DEBUG=1 msgai messages.po
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If no API key is provided for a non-dry run, the CLI exits with code `1` and prints an error message.
|
|
102
|
+
|
|
103
|
+
On API failures such as rate limits, quota issues, or server errors, the CLI exits with code `1` and shows a status-specific message. Validation errors for protected fields such as `msgid`, `msgid_plural`, or `msgctxt` now tell you whether a retry is reasonable and when to rerun with `--debug` or `DEBUG=1` to inspect the request/response flow. For API error details, see [OpenAI API error codes](https://developers.openai.com/api/docs/guides/error-codes#api-errors).
|
|
104
|
+
|
|
105
|
+
## 🧪 Development
|
|
38
106
|
|
|
39
|
-
|
|
40
|
-
- npm 10+
|
|
107
|
+
Requirements:
|
|
41
108
|
|
|
42
|
-
|
|
109
|
+
- Node.js `20+`
|
|
110
|
+
- npm `10+`
|
|
111
|
+
|
|
112
|
+
Install dependencies:
|
|
43
113
|
|
|
44
114
|
```bash
|
|
45
115
|
npm install
|
|
46
116
|
```
|
|
47
117
|
|
|
48
|
-
|
|
118
|
+
Useful scripts:
|
|
119
|
+
|
|
120
|
+
- `npm run build`: compile TypeScript to `dist/`
|
|
121
|
+
- `npm test`: build the project and run Jest tests
|
|
122
|
+
- `npm run test:integration`: run integration tests
|
|
123
|
+
- `npm run test:watch`: run tests in watch mode
|
|
124
|
+
- `npm run lint`: run ESLint
|
|
125
|
+
- `npm run lint:format`: check formatting with Prettier
|
|
126
|
+
- `npm run format`: format the repository with Prettier
|
|
127
|
+
- `npm run release:dry-run`: preview the `commit-and-tag-version` release without writing files
|
|
128
|
+
- `npm run release`: run release checks, update `CHANGELOG.md`, bump the npm version, create a release commit, and create a local tag
|
|
129
|
+
|
|
130
|
+
This repo follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages.
|
|
131
|
+
|
|
132
|
+
### Release Flow
|
|
133
|
+
|
|
134
|
+
Maintainer releases are local-first and use `commit-and-tag-version`. The release command does not publish to npm or push tags for you.
|
|
49
135
|
|
|
50
|
-
|
|
136
|
+
Preview the next release:
|
|
51
137
|
|
|
52
|
-
|
|
138
|
+
```bash
|
|
139
|
+
npm run release:dry-run
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Create the release locally:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm run release
|
|
146
|
+
```
|
|
53
147
|
|
|
54
|
-
|
|
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.
|
|
148
|
+
This command:
|
|
59
149
|
|
|
60
|
-
|
|
150
|
+
- runs `build`, unit tests, integration tests, lint, and formatting checks through the `prerelease` lifecycle hook
|
|
151
|
+
- lets `commit-and-tag-version` infer `major`, `minor`, or `patch` from Conventional Commits since the latest `v*` tag
|
|
152
|
+
- updates `CHANGELOG.md`
|
|
153
|
+
- creates `chore(release): X.Y.Z`
|
|
154
|
+
- creates a local annotated tag `vX.Y.Z`
|
|
61
155
|
|
|
62
|
-
|
|
156
|
+
For reliable version bumps and changelog entries, keep commits in Conventional Commit format.
|
|
63
157
|
|
|
64
|
-
|
|
158
|
+
If you need to override the inferred bump manually:
|
|
65
159
|
|
|
66
|
-
|
|
160
|
+
```bash
|
|
161
|
+
npm run release -- --release-as minor
|
|
162
|
+
```
|
|
67
163
|
|
|
68
|
-
|
|
69
|
-
2. Run `npm publish`.
|
|
164
|
+
After the local release is created:
|
|
70
165
|
|
|
71
|
-
|
|
166
|
+
```bash
|
|
167
|
+
git push --follow-tags
|
|
168
|
+
npm publish
|
|
169
|
+
```
|
package/dist/src/cli/index.js
CHANGED
|
@@ -6,12 +6,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const yargs_1 = __importDefault(require("yargs/yargs"));
|
|
8
8
|
const helpers_1 = require("yargs/helpers");
|
|
9
|
+
const debug_1 = require("../debug");
|
|
9
10
|
const runTranslate_1 = require("./runTranslate");
|
|
10
11
|
function parseArgs(argv) {
|
|
11
12
|
try {
|
|
12
13
|
const parsedArgs = (0, yargs_1.default)(argv)
|
|
13
14
|
.scriptName('msgai')
|
|
14
|
-
.usage('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]')
|
|
15
|
+
.usage('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]')
|
|
15
16
|
.option('dry-run', {
|
|
16
17
|
type: 'boolean',
|
|
17
18
|
default: false,
|
|
@@ -28,11 +29,20 @@ function parseArgs(argv) {
|
|
|
28
29
|
.option('source-lang', {
|
|
29
30
|
type: 'string',
|
|
30
31
|
description: 'Source language of msgid strings (ISO 639-1 code, e.g. en, uk). If omitted, the model will detect it.',
|
|
32
|
+
})
|
|
33
|
+
.option('model', {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'OpenAI model to use for translation. Default: gpt-4o',
|
|
31
36
|
})
|
|
32
37
|
.option('help', {
|
|
33
38
|
alias: 'h',
|
|
34
39
|
type: 'boolean',
|
|
35
40
|
default: false,
|
|
41
|
+
})
|
|
42
|
+
.option('debug', {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
default: false,
|
|
45
|
+
description: 'Print debug logs for request/response validation and batch processing',
|
|
36
46
|
})
|
|
37
47
|
.strictOptions()
|
|
38
48
|
.version(false)
|
|
@@ -49,53 +59,71 @@ function parseArgs(argv) {
|
|
|
49
59
|
const sourceLang = sourceLangRaw != null && String(sourceLangRaw).trim() !== ''
|
|
50
60
|
? String(sourceLangRaw).trim().toLowerCase()
|
|
51
61
|
: undefined;
|
|
62
|
+
const modelRaw = parsedArgs.model;
|
|
63
|
+
const model = modelRaw != null && String(modelRaw).trim() !== '' ? String(modelRaw).trim() : undefined;
|
|
52
64
|
if (positionalArgs.length > 1) {
|
|
53
|
-
|
|
65
|
+
const result = {
|
|
54
66
|
dryRun: Boolean(parsedArgs['dry-run']),
|
|
55
67
|
help: Boolean(parsedArgs.help),
|
|
56
68
|
apiKey: parsedArgs['api-key'],
|
|
57
69
|
sourceLang,
|
|
70
|
+
model,
|
|
58
71
|
includeFuzzy: Boolean(parsedArgs['include-fuzzy']),
|
|
72
|
+
debug: Boolean(parsedArgs.debug),
|
|
59
73
|
error: `Unexpected argument: ${positionalArgs[1]}`,
|
|
60
74
|
};
|
|
75
|
+
return result;
|
|
61
76
|
}
|
|
62
|
-
|
|
77
|
+
const result = {
|
|
63
78
|
poFilePath: positionalArgs[0],
|
|
64
79
|
dryRun: Boolean(parsedArgs['dry-run']),
|
|
65
80
|
help: Boolean(parsedArgs.help),
|
|
66
81
|
apiKey: parsedArgs['api-key'],
|
|
67
82
|
sourceLang,
|
|
83
|
+
model,
|
|
68
84
|
includeFuzzy: Boolean(parsedArgs['include-fuzzy']),
|
|
85
|
+
debug: Boolean(parsedArgs.debug),
|
|
69
86
|
};
|
|
87
|
+
return result;
|
|
70
88
|
}
|
|
71
89
|
catch (error) {
|
|
72
90
|
const message = error instanceof Error ? error.message : String(error);
|
|
73
91
|
return { dryRun: false, help: false, error: message };
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
|
-
const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]';
|
|
94
|
+
const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]';
|
|
77
95
|
function main(argv) {
|
|
78
96
|
const args = parseArgs(argv);
|
|
97
|
+
(0, debug_1.initDebugLogger)(args.debug);
|
|
98
|
+
const debugLogger = (0, debug_1.getDebugLogger)();
|
|
99
|
+
debugLogger.log('cli.main', 'Entering CLI main', { argv, args });
|
|
79
100
|
if (args.error) {
|
|
101
|
+
debugLogger.log('cli.main', 'Exiting because args contained an error', { error: args.error });
|
|
80
102
|
console.warn(args.error);
|
|
81
103
|
console.warn(USAGE);
|
|
82
104
|
return 1;
|
|
83
105
|
}
|
|
84
106
|
if (args.help) {
|
|
107
|
+
debugLogger.log('cli.main', 'Printing help output');
|
|
85
108
|
console.log(USAGE);
|
|
86
109
|
return 0;
|
|
87
110
|
}
|
|
111
|
+
debugLogger.log('cli.main', 'Dispatching runTranslateCommand');
|
|
88
112
|
const result = (0, runTranslate_1.runTranslateCommand)({
|
|
89
113
|
poFilePath: args.poFilePath,
|
|
90
114
|
dryRun: args.dryRun,
|
|
91
115
|
apiKey: args.apiKey,
|
|
92
116
|
sourceLang: args.sourceLang,
|
|
117
|
+
model: args.model,
|
|
93
118
|
includeFuzzy: args.includeFuzzy,
|
|
119
|
+
debug: args.debug,
|
|
94
120
|
});
|
|
95
121
|
if (result instanceof Promise) {
|
|
122
|
+
debugLogger.log('cli.main', 'runTranslateCommand returned a promise');
|
|
96
123
|
result.then((code) => process.exit(code));
|
|
97
124
|
return undefined;
|
|
98
125
|
}
|
|
126
|
+
debugLogger.log('cli.main', 'runTranslateCommand returned synchronously', { exitCode: result });
|
|
99
127
|
return result;
|
|
100
128
|
}
|
|
101
129
|
const exitCode = main((0, helpers_1.hideBin)(process.argv));
|
|
@@ -7,6 +7,7 @@ exports.runTranslate = runTranslate;
|
|
|
7
7
|
exports.runTranslateCommand = runTranslateCommand;
|
|
8
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
9
|
const plural_forms_1 = require("plural-forms");
|
|
10
|
+
const debug_1 = require("../debug");
|
|
10
11
|
const po_1 = require("../po");
|
|
11
12
|
const translate_1 = require("../translate");
|
|
12
13
|
const validate_source_lang_1 = require("../validate-source-lang");
|
|
@@ -41,9 +42,27 @@ function getApiErrorMessage(err) {
|
|
|
41
42
|
return null;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
+
function getInvalidModelMessage(model) {
|
|
46
|
+
return [
|
|
47
|
+
`Invalid --model "${model}". msgai only supports OpenAI models with json_schema structured outputs.`,
|
|
48
|
+
`Supported model families: ${translate_1.SUPPORTED_STRUCTURED_OUTPUT_MODELS.join(', ')}.`,
|
|
49
|
+
].join(' ');
|
|
50
|
+
}
|
|
51
|
+
async function runTranslate(poFilePath, apiKey, sourceLang, model, includeFuzzy, debug) {
|
|
52
|
+
(0, debug_1.initDebugLogger)(debug);
|
|
53
|
+
const debugLogger = (0, debug_1.getDebugLogger)();
|
|
45
54
|
try {
|
|
55
|
+
debugLogger.log('cli.runTranslate', 'Starting translation run', {
|
|
56
|
+
poFilePath,
|
|
57
|
+
sourceLang,
|
|
58
|
+
model: model ?? 'gpt-4o',
|
|
59
|
+
includeFuzzy: includeFuzzy === true,
|
|
60
|
+
});
|
|
46
61
|
const poContent = node_fs_1.default.readFileSync(poFilePath, 'utf8');
|
|
62
|
+
debugLogger.log('cli.runTranslate', 'Read PO file', {
|
|
63
|
+
poFilePath,
|
|
64
|
+
bytes: Buffer.byteLength(poContent, 'utf8'),
|
|
65
|
+
});
|
|
47
66
|
const parsedPo = (0, po_1.parsePoContent)(poContent);
|
|
48
67
|
const { entries } = (0, po_1.getEntriesToTranslate)(parsedPo, { includeFuzzy });
|
|
49
68
|
if (entries.length === 0) {
|
|
@@ -62,13 +81,30 @@ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
|
|
|
62
81
|
// locale not in plural-forms; rely on formula only
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
|
-
const options = { apiKey, sourceLanguage: sourceLang, formula, pluralSamples };
|
|
84
|
+
const options = { apiKey, sourceLanguage: sourceLang, formula, pluralSamples, model, debug };
|
|
85
|
+
debugLogger.log('cli.runTranslate', 'Computed translation run inputs', {
|
|
86
|
+
targetLanguage,
|
|
87
|
+
formula,
|
|
88
|
+
pluralSamples,
|
|
89
|
+
entryCount: entries.length,
|
|
90
|
+
entries,
|
|
91
|
+
});
|
|
66
92
|
for (let i = 0; i < entries.length; i += TRANSLATE_BATCH_SIZE) {
|
|
67
93
|
const batch = entries.slice(i, i + TRANSLATE_BATCH_SIZE);
|
|
68
94
|
const batchNum = Math.floor(i / TRANSLATE_BATCH_SIZE) + 1;
|
|
69
95
|
const totalBatches = Math.ceil(entries.length / TRANSLATE_BATCH_SIZE);
|
|
96
|
+
debugLogger.log('cli.runTranslate', 'Preparing translation batch', {
|
|
97
|
+
batch: batchNum,
|
|
98
|
+
totalBatches,
|
|
99
|
+
batchSize: batch.length,
|
|
100
|
+
entries: batch,
|
|
101
|
+
});
|
|
70
102
|
console.log(`Translating batch ${batchNum}/${totalBatches} (${batch.length} phrase${batch.length === 1 ? '' : 's'})...`);
|
|
71
103
|
const batchResults = await (0, translate_1.translateStrings)(batch, targetLanguage, options);
|
|
104
|
+
debugLogger.log('cli.runTranslate', 'Received translation batch results', {
|
|
105
|
+
batch: batchNum,
|
|
106
|
+
results: batchResults,
|
|
107
|
+
});
|
|
72
108
|
for (const r of batchResults) {
|
|
73
109
|
if (typeof r.msgstr === 'string') {
|
|
74
110
|
console.log(` ${r.msgid} => ${r.msgstr}`);
|
|
@@ -82,10 +118,17 @@ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
|
|
|
82
118
|
(0, po_1.clearFuzzyFromEntries)(parsedPo, batchResults);
|
|
83
119
|
}
|
|
84
120
|
node_fs_1.default.writeFileSync(poFilePath, (0, po_1.compilePo)(parsedPo));
|
|
121
|
+
debugLogger.log('cli.runTranslate', 'Wrote translated batch back to PO file', {
|
|
122
|
+
batch: batchNum,
|
|
123
|
+
poFilePath,
|
|
124
|
+
});
|
|
85
125
|
}
|
|
86
126
|
return 0;
|
|
87
127
|
}
|
|
88
128
|
catch (error) {
|
|
129
|
+
debugLogger.log('cli.runTranslate', 'Translation run failed', {
|
|
130
|
+
error: error instanceof Error ? error.message : String(error),
|
|
131
|
+
});
|
|
89
132
|
const apiMessage = getApiErrorMessage(error);
|
|
90
133
|
if (apiMessage != null) {
|
|
91
134
|
console.warn(apiMessage);
|
|
@@ -96,8 +139,11 @@ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
|
|
|
96
139
|
return 1;
|
|
97
140
|
}
|
|
98
141
|
}
|
|
99
|
-
const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]';
|
|
142
|
+
const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]';
|
|
100
143
|
function runTranslateCommand(args) {
|
|
144
|
+
(0, debug_1.initDebugLogger)(args.debug);
|
|
145
|
+
const debugLogger = (0, debug_1.getDebugLogger)();
|
|
146
|
+
debugLogger.log('cli.runTranslateCommand', 'Received command args', args);
|
|
101
147
|
if (!args.poFilePath) {
|
|
102
148
|
console.warn(USAGE);
|
|
103
149
|
return 1;
|
|
@@ -107,29 +153,59 @@ function runTranslateCommand(args) {
|
|
|
107
153
|
(0, validate_source_lang_1.validateSourceLang)(args.sourceLang);
|
|
108
154
|
}
|
|
109
155
|
catch (error) {
|
|
156
|
+
debugLogger.log('cli.runTranslateCommand', 'Source language validation failed', {
|
|
157
|
+
sourceLang: args.sourceLang,
|
|
158
|
+
error: error instanceof Error ? error.message : String(error),
|
|
159
|
+
});
|
|
110
160
|
const message = error instanceof Error ? error.message : String(error);
|
|
111
161
|
console.warn(message);
|
|
112
162
|
return 1;
|
|
113
163
|
}
|
|
114
164
|
}
|
|
165
|
+
if (args.model != null) {
|
|
166
|
+
try {
|
|
167
|
+
(0, translate_1.validateModel)(args.model);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
debugLogger.log('cli.runTranslateCommand', 'Model validation failed', {
|
|
171
|
+
model: args.model,
|
|
172
|
+
});
|
|
173
|
+
console.warn(getInvalidModelMessage(args.model));
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
115
177
|
if (!args.dryRun) {
|
|
116
178
|
let resultApiKey;
|
|
117
179
|
try {
|
|
118
180
|
resultApiKey = (0, translate_1.resolveApiKey)(args.apiKey);
|
|
181
|
+
debugLogger.log('cli.runTranslateCommand', 'Resolved API key for translation run', {
|
|
182
|
+
source: args.apiKey != null && args.apiKey.trim() !== '' ? 'cli-arg' : 'env',
|
|
183
|
+
});
|
|
119
184
|
}
|
|
120
185
|
catch (error) {
|
|
186
|
+
debugLogger.log('cli.runTranslateCommand', 'API key resolution failed', {
|
|
187
|
+
error: error instanceof Error ? error.message : String(error),
|
|
188
|
+
});
|
|
121
189
|
const message = error instanceof Error ? error.message : String(error);
|
|
122
190
|
console.warn(message.replace('pass apiKey in options', 'pass --api-key'));
|
|
123
191
|
return 1;
|
|
124
192
|
}
|
|
125
|
-
return runTranslate(args.poFilePath, resultApiKey, args.sourceLang, args.includeFuzzy);
|
|
193
|
+
return runTranslate(args.poFilePath, resultApiKey, args.sourceLang, args.model, args.includeFuzzy, args.debug);
|
|
126
194
|
}
|
|
127
195
|
try {
|
|
128
196
|
const poContent = node_fs_1.default.readFileSync(args.poFilePath, 'utf8');
|
|
197
|
+
debugLogger.log('cli.runTranslateCommand', 'Dry-run read PO file', {
|
|
198
|
+
poFilePath: args.poFilePath,
|
|
199
|
+
bytes: Buffer.byteLength(poContent, 'utf8'),
|
|
200
|
+
});
|
|
129
201
|
const parsedPo = (0, po_1.parsePoContent)(poContent);
|
|
130
202
|
const { entries } = (0, po_1.getEntriesToTranslate)(parsedPo, {
|
|
131
203
|
includeFuzzy: args.includeFuzzy,
|
|
132
204
|
});
|
|
205
|
+
debugLogger.log('cli.runTranslateCommand', 'Dry-run extracted entries', {
|
|
206
|
+
entryCount: entries.length,
|
|
207
|
+
entries,
|
|
208
|
+
});
|
|
133
209
|
const msgidsToShow = entries.map((e) => e.msgid);
|
|
134
210
|
for (const msgid of msgidsToShow) {
|
|
135
211
|
console.log(msgid);
|
|
@@ -137,6 +213,9 @@ function runTranslateCommand(args) {
|
|
|
137
213
|
return 0;
|
|
138
214
|
}
|
|
139
215
|
catch (error) {
|
|
216
|
+
debugLogger.log('cli.runTranslateCommand', 'Dry-run failed', {
|
|
217
|
+
error: error instanceof Error ? error.message : String(error),
|
|
218
|
+
});
|
|
140
219
|
const message = error instanceof Error ? error.message : String(error);
|
|
141
220
|
console.warn(`Failed to process PO file: ${message}`);
|
|
142
221
|
return 1;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasDebugFlag = hasDebugFlag;
|
|
4
|
+
exports.initDebugLogger = initDebugLogger;
|
|
5
|
+
exports.getDebugLogger = getDebugLogger;
|
|
6
|
+
const debugState = {
|
|
7
|
+
enabled: false,
|
|
8
|
+
};
|
|
9
|
+
const debugLogger = {
|
|
10
|
+
get enabled() {
|
|
11
|
+
return debugState.enabled;
|
|
12
|
+
},
|
|
13
|
+
log(scope, message, details) {
|
|
14
|
+
if (!debugState.enabled)
|
|
15
|
+
return;
|
|
16
|
+
const prefix = `[debug] [${scope}] ${message}`;
|
|
17
|
+
if (details === undefined) {
|
|
18
|
+
console.warn(prefix);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.warn(prefix, details);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function hasDebugFlag(argv) {
|
|
25
|
+
return argv.includes('--debug');
|
|
26
|
+
}
|
|
27
|
+
function isDebugEnvEnabled() {
|
|
28
|
+
return process.env['DEBUG'] === '1';
|
|
29
|
+
}
|
|
30
|
+
function initDebugLogger(enabled) {
|
|
31
|
+
debugState.enabled = enabled === true || isDebugEnvEnabled();
|
|
32
|
+
return debugLogger;
|
|
33
|
+
}
|
|
34
|
+
function getDebugLogger() {
|
|
35
|
+
return debugLogger;
|
|
36
|
+
}
|
package/dist/src/translate.js
CHANGED
|
@@ -3,11 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SUPPORTED_STRUCTURED_OUTPUT_MODELS = void 0;
|
|
6
7
|
exports.resolveApiKey = resolveApiKey;
|
|
8
|
+
exports.validateModel = validateModel;
|
|
7
9
|
exports.translatePayload = translatePayload;
|
|
8
10
|
exports.translateItems = translateItems;
|
|
9
11
|
exports.translateStrings = translateStrings;
|
|
10
12
|
const openai_1 = __importDefault(require("openai"));
|
|
13
|
+
const debug_1 = require("./debug");
|
|
11
14
|
const loadEnv_1 = require("./loadEnv");
|
|
12
15
|
function resolveApiKey(apiKey) {
|
|
13
16
|
(0, loadEnv_1.loadEnv)();
|
|
@@ -19,6 +22,42 @@ function resolveApiKey(apiKey) {
|
|
|
19
22
|
throw new Error('OpenAI API key not set. Set OPENAI_API_KEY in the environment or pass apiKey in options.');
|
|
20
23
|
}
|
|
21
24
|
const DEFAULT_MODEL = 'gpt-4o';
|
|
25
|
+
const SUPPORTED_STRUCTURED_OUTPUT_MODEL_PATTERNS = [
|
|
26
|
+
/^gpt-4o(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
27
|
+
/^gpt-4o-mini(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
28
|
+
/^gpt-4\.1(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
29
|
+
/^gpt-4\.1-mini(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
30
|
+
/^gpt-4\.1-nano(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
31
|
+
/^gpt-5(?:-\d{4}-\d{2}-\d{2}|-chat-latest)?$/,
|
|
32
|
+
/^gpt-5-mini(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
33
|
+
/^gpt-5-nano(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
34
|
+
/^gpt-5-pro(?:-\d{4}-\d{2}-\d{2})?$/,
|
|
35
|
+
/^gpt-5\.1(?:-\d{4}-\d{2}-\d{2}|-chat-latest)?$/,
|
|
36
|
+
/^gpt-5\.2(?:-\d{4}-\d{2}-\d{2}|-chat-latest)?$/,
|
|
37
|
+
/^gpt-5-codex$/,
|
|
38
|
+
/^gpt-5\.1-codex$/,
|
|
39
|
+
/^gpt-5\.1-codex-mini$/,
|
|
40
|
+
/^gpt-5\.1-codex-max$/,
|
|
41
|
+
/^gpt-5\.2-codex$/,
|
|
42
|
+
];
|
|
43
|
+
exports.SUPPORTED_STRUCTURED_OUTPUT_MODELS = [
|
|
44
|
+
'gpt-4o',
|
|
45
|
+
'gpt-4o-mini',
|
|
46
|
+
'gpt-4.1',
|
|
47
|
+
'gpt-4.1-mini',
|
|
48
|
+
'gpt-4.1-nano',
|
|
49
|
+
'gpt-5',
|
|
50
|
+
'gpt-5-mini',
|
|
51
|
+
'gpt-5-nano',
|
|
52
|
+
'gpt-5-pro',
|
|
53
|
+
'gpt-5.1',
|
|
54
|
+
'gpt-5.2',
|
|
55
|
+
'gpt-5-codex',
|
|
56
|
+
'gpt-5.1-codex',
|
|
57
|
+
'gpt-5.1-codex-mini',
|
|
58
|
+
'gpt-5.1-codex-max',
|
|
59
|
+
'gpt-5.2-codex',
|
|
60
|
+
];
|
|
22
61
|
/** Error codes: https://developers.openai.com/api/docs/guides/error-codes#api-errors */
|
|
23
62
|
const MAX_RETRIES = 3;
|
|
24
63
|
const RETRY_DELAYS_MS = [1000, 2000, 4000];
|
|
@@ -34,20 +73,94 @@ function isApiError(err) {
|
|
|
34
73
|
function isRetryableStatus(status) {
|
|
35
74
|
return status === 429 || status === 500 || status === 503;
|
|
36
75
|
}
|
|
76
|
+
function isSupportedStructuredOutputModel(model) {
|
|
77
|
+
return SUPPORTED_STRUCTURED_OUTPUT_MODEL_PATTERNS.some((pattern) => pattern.test(model));
|
|
78
|
+
}
|
|
79
|
+
function validateStructuredOutputModel(model) {
|
|
80
|
+
if (isSupportedStructuredOutputModel(model))
|
|
81
|
+
return;
|
|
82
|
+
throw new Error(`Model "${model}" is not supported. This package requires an OpenAI Chat Completions model with json_schema structured outputs. Supported model families: ${exports.SUPPORTED_STRUCTURED_OUTPUT_MODELS.join(', ')}.`);
|
|
83
|
+
}
|
|
84
|
+
function validateModel(model) {
|
|
85
|
+
validateStructuredOutputModel(model);
|
|
86
|
+
}
|
|
87
|
+
const TRANSLATION_RESPONSE_SCHEMA = {
|
|
88
|
+
name: 'translation_payload',
|
|
89
|
+
strict: true,
|
|
90
|
+
schema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
required: ['translations'],
|
|
94
|
+
properties: {
|
|
95
|
+
translations: {
|
|
96
|
+
type: 'array',
|
|
97
|
+
items: {
|
|
98
|
+
anyOf: [
|
|
99
|
+
{
|
|
100
|
+
type: 'object',
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
required: ['msgid', 'msgstr'],
|
|
103
|
+
properties: {
|
|
104
|
+
msgid: { type: 'string' },
|
|
105
|
+
msgstr: { type: 'string' },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: 'object',
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
required: ['msgid', 'msgctxt', 'msgstr'],
|
|
112
|
+
properties: {
|
|
113
|
+
msgid: { type: 'string' },
|
|
114
|
+
msgctxt: { type: 'string' },
|
|
115
|
+
msgstr: { type: 'string' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: 'object',
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
required: ['msgid_plural', 'msgstr'],
|
|
122
|
+
properties: {
|
|
123
|
+
msgid_plural: { type: 'string' },
|
|
124
|
+
msgstr: {
|
|
125
|
+
type: 'array',
|
|
126
|
+
items: { type: 'string' },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'object',
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
required: ['msgid_plural', 'msgctxt', 'msgstr'],
|
|
134
|
+
properties: {
|
|
135
|
+
msgid_plural: { type: 'string' },
|
|
136
|
+
msgctxt: { type: 'string' },
|
|
137
|
+
msgstr: {
|
|
138
|
+
type: 'array',
|
|
139
|
+
items: { type: 'string' },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
37
149
|
function buildSystemMessage() {
|
|
38
150
|
return `You are a deterministic translation engine for gettext PO entries.
|
|
39
151
|
|
|
152
|
+
Return exactly one JSON object that matches the provided response schema.
|
|
153
|
+
|
|
40
154
|
Your task:
|
|
41
155
|
For each input entry, produce a translation in the corresponding "msgstr" field.
|
|
42
156
|
Each output entry MUST correspond exactly to the matching input entry.
|
|
43
|
-
Do not change, remove, or reorder any "msgid" or "
|
|
157
|
+
Do not change, remove, or reorder any "msgid", "msgid_plural", or "msgctxt" values.
|
|
44
158
|
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
159
|
|
|
47
160
|
Critical rules:
|
|
48
161
|
|
|
49
|
-
-
|
|
50
|
-
- NEVER translate, modify, reorder, or
|
|
162
|
+
- Copy ALL placeholders and non-linguistic tokens exactly, byte-for-byte.
|
|
163
|
+
- NEVER translate, modify, reorder, remove, escape, or unescape placeholders.
|
|
51
164
|
|
|
52
165
|
Placeholders include (but are not limited to):
|
|
53
166
|
- printf-style specifiers: %s, %d, %f, %1$s, %(name)s
|
|
@@ -87,22 +200,9 @@ Output:
|
|
|
87
200
|
|
|
88
201
|
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
202
|
|
|
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
203
|
Additional constraints:
|
|
103
204
|
|
|
104
205
|
- Preserve the exact input order of entries.
|
|
105
|
-
- Do not modify "formula", "target_language", or "source_language".
|
|
106
206
|
- Do not add, remove, or rename fields.`;
|
|
107
207
|
}
|
|
108
208
|
/** Strip markdown code fences if the model wrapped JSON in ```json ... ``` */
|
|
@@ -112,11 +212,26 @@ function stripJsonFences(raw) {
|
|
|
112
212
|
const match = trimmed.match(jsonBlock);
|
|
113
213
|
return match ? match[1].trim() : trimmed;
|
|
114
214
|
}
|
|
115
|
-
function
|
|
215
|
+
function normalizeMsgctxt(msgctxt) {
|
|
216
|
+
return typeof msgctxt === 'string' ? msgctxt : '';
|
|
217
|
+
}
|
|
218
|
+
function buildProtectedFieldMismatchMessage(index, field) {
|
|
219
|
+
const entryRef = `OpenAI response translations[${index}].${field}`;
|
|
220
|
+
const retryHint = 'Retry the command once because this can be a transient structured-output formatting issue.';
|
|
221
|
+
const debugHint = 'If it keeps happening, rerun with --debug and double-check that the PO entry content matches the returned protected fields.';
|
|
222
|
+
if (field === 'msgctxt') {
|
|
223
|
+
return `${entryRef} must match the input exactly. ${retryHint} If it keeps happening, rerun with --debug and check whether empty gettext context is being returned as omitted vs empty string.`;
|
|
224
|
+
}
|
|
225
|
+
return `${entryRef} must match the input exactly. ${retryHint} ${debugHint}`;
|
|
226
|
+
}
|
|
227
|
+
function parsePayloadResponse(request, content, options) {
|
|
228
|
+
(0, debug_1.initDebugLogger)(options?.debug);
|
|
229
|
+
const debug = (0, debug_1.getDebugLogger)();
|
|
116
230
|
if (content == null || content.trim() === '') {
|
|
117
231
|
throw new Error('Empty response from OpenAI');
|
|
118
232
|
}
|
|
119
233
|
const raw = content.trim();
|
|
234
|
+
debug.log('translate', 'Raw OpenAI response content received', raw);
|
|
120
235
|
const toParse = stripJsonFences(raw);
|
|
121
236
|
let parsed;
|
|
122
237
|
try {
|
|
@@ -135,6 +250,9 @@ function parsePayloadResponse(content) {
|
|
|
135
250
|
console.warn('OpenAI model returned (raw):', raw);
|
|
136
251
|
throw new Error(`OpenAI response "translations" must be an array: ${raw.slice(0, 200)}`);
|
|
137
252
|
}
|
|
253
|
+
if (payload.translations.length !== request.translations.length) {
|
|
254
|
+
throw new Error('OpenAI response "translations" must have the same number of entries as input');
|
|
255
|
+
}
|
|
138
256
|
for (let i = 0; i < payload.translations.length; i++) {
|
|
139
257
|
const t = payload.translations[i];
|
|
140
258
|
if (t == null || typeof t !== 'object' || !('msgstr' in t)) {
|
|
@@ -143,12 +261,38 @@ function parsePayloadResponse(content) {
|
|
|
143
261
|
}
|
|
144
262
|
const entry = t;
|
|
145
263
|
const msgstr = entry.msgstr;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
264
|
+
const requestEntry = request.translations[i];
|
|
265
|
+
const requestContext = normalizeMsgctxt(requestEntry.msgctxt);
|
|
266
|
+
const responseContext = normalizeMsgctxt(entry.msgctxt);
|
|
267
|
+
if (responseContext !== requestContext) {
|
|
268
|
+
throw new Error(buildProtectedFieldMismatchMessage(i, 'msgctxt'));
|
|
269
|
+
}
|
|
270
|
+
if ('msgid' in requestEntry) {
|
|
271
|
+
if (entry.msgid !== requestEntry.msgid) {
|
|
272
|
+
throw new Error(buildProtectedFieldMismatchMessage(i, 'msgid'));
|
|
273
|
+
}
|
|
274
|
+
if ('msgid_plural' in entry) {
|
|
275
|
+
throw new Error(`OpenAI response translations[${i}] must not include msgid_plural`);
|
|
276
|
+
}
|
|
277
|
+
if (typeof msgstr === 'string')
|
|
278
|
+
continue;
|
|
279
|
+
console.warn('OpenAI model returned (raw):', raw);
|
|
280
|
+
throw new Error(`OpenAI response translations[${i}].msgstr must be a string`);
|
|
281
|
+
}
|
|
282
|
+
if (entry.msgid_plural !== requestEntry.msgid_plural) {
|
|
283
|
+
throw new Error(buildProtectedFieldMismatchMessage(i, 'msgid_plural'));
|
|
284
|
+
}
|
|
285
|
+
if ('msgid' in entry) {
|
|
286
|
+
throw new Error(`OpenAI response translations[${i}] must not include msgid`);
|
|
287
|
+
}
|
|
288
|
+
if (Array.isArray(msgstr) && msgstr.every((s) => typeof s === 'string')) {
|
|
289
|
+
if (request.plural_samples != null && msgstr.length !== request.plural_samples.length) {
|
|
290
|
+
throw new Error(`OpenAI response translations[${i}].msgstr must have length ${request.plural_samples.length}`);
|
|
291
|
+
}
|
|
149
292
|
continue;
|
|
293
|
+
}
|
|
150
294
|
console.warn('OpenAI model returned (raw):', raw);
|
|
151
|
-
throw new Error(`OpenAI response translations[${i}].msgstr must be
|
|
295
|
+
throw new Error(`OpenAI response translations[${i}].msgstr must be an array of strings`);
|
|
152
296
|
}
|
|
153
297
|
return payload;
|
|
154
298
|
}
|
|
@@ -156,27 +300,61 @@ async function translatePayload(payload, options) {
|
|
|
156
300
|
if (payload.translations.length === 0) {
|
|
157
301
|
return { ...payload, translations: [] };
|
|
158
302
|
}
|
|
303
|
+
(0, debug_1.initDebugLogger)(options?.debug);
|
|
304
|
+
const debug = (0, debug_1.getDebugLogger)();
|
|
159
305
|
const client = options?.client ??
|
|
160
306
|
new openai_1.default({
|
|
161
307
|
apiKey: options.apiKey,
|
|
162
308
|
});
|
|
163
309
|
const model = options?.model ?? DEFAULT_MODEL;
|
|
310
|
+
validateStructuredOutputModel(model);
|
|
311
|
+
debug.log('translate', 'Prepared translatePayload request summary', {
|
|
312
|
+
model,
|
|
313
|
+
target_language: payload.target_language,
|
|
314
|
+
source_language: payload.source_language,
|
|
315
|
+
translation_count: payload.translations.length,
|
|
316
|
+
plural_samples: payload.plural_samples?.length ?? 0,
|
|
317
|
+
});
|
|
318
|
+
debug.log('translate', 'translatePayload request payload', payload);
|
|
319
|
+
const requestParams = {
|
|
320
|
+
model,
|
|
321
|
+
temperature: 0,
|
|
322
|
+
response_format: {
|
|
323
|
+
type: 'json_schema',
|
|
324
|
+
json_schema: TRANSLATION_RESPONSE_SCHEMA,
|
|
325
|
+
},
|
|
326
|
+
messages: [
|
|
327
|
+
{ role: 'system', content: buildSystemMessage() },
|
|
328
|
+
{ role: 'user', content: JSON.stringify(payload) },
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
debug.log('translate', 'OpenAI chat.completions.create request', requestParams);
|
|
164
332
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
165
333
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
334
|
+
debug.log('translate', 'Sending request to OpenAI', {
|
|
335
|
+
attempt: attempt + 1,
|
|
336
|
+
max_attempts: MAX_RETRIES + 1,
|
|
337
|
+
});
|
|
338
|
+
const response = await client.chat.completions.create(requestParams);
|
|
339
|
+
debug.log('translate', 'OpenAI chat.completions.create response metadata', {
|
|
340
|
+
id: response.id,
|
|
341
|
+
model: response.model,
|
|
342
|
+
finish_reason: response.choices[0]?.finish_reason ?? null,
|
|
343
|
+
choices: response.choices.length,
|
|
172
344
|
});
|
|
173
345
|
const content = response.choices[0]?.message?.content ?? null;
|
|
174
|
-
return parsePayloadResponse(content);
|
|
346
|
+
return parsePayloadResponse(payload, content, { debug: options?.debug });
|
|
175
347
|
}
|
|
176
348
|
catch (err) {
|
|
177
349
|
const shouldRetry = attempt < MAX_RETRIES && isApiError(err) && isRetryableStatus(err.status);
|
|
350
|
+
debug.log('translate', 'translatePayload request failed', {
|
|
351
|
+
attempt: attempt + 1,
|
|
352
|
+
shouldRetry,
|
|
353
|
+
error: err instanceof Error ? err.message : String(err),
|
|
354
|
+
});
|
|
178
355
|
if (shouldRetry) {
|
|
179
356
|
const delayMs = RETRY_DELAYS_MS[attempt] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
|
|
357
|
+
debug.log('translate', 'Retrying after backoff', { delay_ms: delayMs });
|
|
180
358
|
await sleep(delayMs);
|
|
181
359
|
continue;
|
|
182
360
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "msgai-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "CLI that automatically translates all untranslated strings in gettext (.po) files using AI (LLM)",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
"test:integration": "npm run build && jest -c jest.integration.config.cjs",
|
|
14
14
|
"test:watch": "npm run build && jest --watch",
|
|
15
15
|
"format": "prettier --write .",
|
|
16
|
+
"format:changelog": "prettier --write CHANGELOG.md",
|
|
16
17
|
"lint": "eslint .",
|
|
17
18
|
"lint:format": "prettier --check .",
|
|
19
|
+
"prerelease": "npm run build && npm test && npm run test:integration && npm run lint && npm run lint:format",
|
|
20
|
+
"release": "commit-and-tag-version",
|
|
21
|
+
"release:dry-run": "commit-and-tag-version --dry-run",
|
|
18
22
|
"script:test-openai": "ts-node scripts/test-openai.ts",
|
|
19
23
|
"prepublishOnly": "npm run build && npm test && npm run test:integration && npm run lint && npm run lint:format"
|
|
20
24
|
},
|
|
@@ -46,6 +50,13 @@
|
|
|
46
50
|
"url": "https://github.com/AlexMost/msgai/issues"
|
|
47
51
|
},
|
|
48
52
|
"homepage": "https://github.com/AlexMost/msgai#readme",
|
|
53
|
+
"commit-and-tag-version": {
|
|
54
|
+
"tagPrefix": "v",
|
|
55
|
+
"releaseCommitMessageFormat": "chore(release): {{currentTag}}",
|
|
56
|
+
"scripts": {
|
|
57
|
+
"postchangelog": "npm run format:changelog"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
49
60
|
"devDependencies": {
|
|
50
61
|
"@babel/core": "^7.29.0",
|
|
51
62
|
"@babel/preset-env": "^7.29.0",
|
|
@@ -54,6 +65,7 @@
|
|
|
54
65
|
"@types/jest": "^30.0.0",
|
|
55
66
|
"@types/node": "^25.3.0",
|
|
56
67
|
"babel-jest": "^30.2.0",
|
|
68
|
+
"commit-and-tag-version": "^12.6.1",
|
|
57
69
|
"eslint": "^10.0.2",
|
|
58
70
|
"jest": "^30.2.0",
|
|
59
71
|
"prettier": "^3.8.1",
|