localewarden 0.1.1 → 0.3.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/README.md +161 -12
- package/dist/budget.d.ts +25 -0
- package/dist/budget.js +74 -0
- package/dist/checks.d.ts +34 -6
- package/dist/checks.js +111 -21
- package/dist/cli.js +66 -10
- package/dist/config.d.ts +37 -1
- package/dist/config.js +118 -3
- package/dist/engine/context.d.ts +81 -0
- package/dist/engine/context.js +75 -0
- package/dist/engine/copies.d.ts +9 -0
- package/dist/engine/copies.js +31 -0
- package/dist/engine/planner.d.ts +66 -0
- package/dist/engine/planner.js +167 -0
- package/dist/engine/repair.d.ts +16 -0
- package/dist/engine/repair.js +61 -0
- package/dist/engine/sources.d.ts +13 -0
- package/dist/engine/sources.js +40 -0
- package/dist/engine/translator.d.ts +29 -0
- package/dist/engine/translator.js +156 -0
- package/dist/engine/writer.d.ts +13 -0
- package/dist/engine/writer.js +63 -0
- package/dist/files.d.ts +30 -0
- package/dist/files.js +125 -12
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -3
- package/dist/llm.d.ts +5 -8
- package/dist/llm.js +7 -15
- package/dist/lock.d.ts +13 -0
- package/dist/lock.js +91 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +76 -0
- package/dist/placeholders.js +2 -1
- package/dist/plugins.d.ts +72 -0
- package/dist/plugins.js +71 -0
- package/dist/project.d.ts +33 -5
- package/dist/project.js +135 -42
- package/dist/prompt.d.ts +7 -1
- package/dist/prompt.js +10 -3
- package/dist/review.d.ts +1 -1
- package/dist/review.js +49 -23
- package/dist/scope.d.ts +15 -0
- package/dist/scope.js +26 -0
- package/dist/state.d.ts +11 -4
- package/dist/state.js +30 -11
- package/dist/translate.d.ts +9 -49
- package/dist/translate.js +119 -383
- package/dist/ui/data.d.ts +50 -0
- package/dist/ui/data.js +178 -0
- package/dist/ui/page.d.ts +5 -0
- package/dist/ui/page.js +277 -0
- package/dist/ui/server.d.ts +24 -0
- package/dist/ui/server.js +194 -0
- package/dist/util.d.ts +6 -2
- package/dist/util.js +17 -5
- package/package.json +12 -1
package/dist/translate.js
CHANGED
|
@@ -1,401 +1,137 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
prev = row;
|
|
27
|
-
}
|
|
28
|
-
return Math.max(a.length, b.length) - prev[b.length];
|
|
29
|
-
}
|
|
30
|
-
/** A targeted repair may change a few words of a short string or a quarter of a long one. */
|
|
31
|
-
export function tooManyChanges(before, after) {
|
|
32
|
-
const changed = changedWords(before, after);
|
|
33
|
-
const allowed = Math.max(6, Math.ceil(before.split(/\s+/).filter(Boolean).length * 0.25));
|
|
34
|
-
return changed > allowed ? `changed ${changed} words (allowed ${allowed})` : null;
|
|
35
|
-
}
|
|
36
|
-
function parseArray(raw, expected) {
|
|
37
|
-
const json = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
38
|
-
try {
|
|
39
|
-
const parsed = JSON.parse(json);
|
|
40
|
-
if (!Array.isArray(parsed) || parsed.length !== expected)
|
|
41
|
-
return null;
|
|
42
|
-
return parsed.map(value => String(value));
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
const emptySummary = () => ({
|
|
49
|
-
translated: 0,
|
|
50
|
-
revised: 0,
|
|
51
|
-
repaired: 0,
|
|
52
|
-
failed: 0,
|
|
53
|
-
protected: 0,
|
|
54
|
-
removed: 0,
|
|
55
|
-
planned: 0,
|
|
56
|
-
plannedChars: 0,
|
|
57
|
-
});
|
|
2
|
+
import { Budget } from './budget.js';
|
|
3
|
+
import { Checker } from './checks.js';
|
|
4
|
+
import { selectGroups } from './config.js';
|
|
5
|
+
import { applyCopies } from './engine/copies.js';
|
|
6
|
+
import { consoleLogger, RunContext } from './engine/context.js';
|
|
7
|
+
import { planFile } from './engine/planner.js';
|
|
8
|
+
import { runRepairs } from './engine/repair.js';
|
|
9
|
+
import { listFiles, loadSources } from './engine/sources.js';
|
|
10
|
+
import { Translator } from './engine/translator.js';
|
|
11
|
+
import { applyTranslations, writePlan } from './engine/writer.js';
|
|
12
|
+
import { Client, OpenAICompatibleModel } from './llm.js';
|
|
13
|
+
import { acquireLock } from './lock.js';
|
|
14
|
+
import { loadPlugins, PluginHost } from './plugins.js';
|
|
15
|
+
import { Scope } from './scope.js';
|
|
16
|
+
import { State } from './state.js';
|
|
17
|
+
import { inParallel } from './util.js';
|
|
18
|
+
export { consoleLogger } from './engine/context.js';
|
|
19
|
+
export { changedWords, tooManyChanges } from './engine/repair.js';
|
|
20
|
+
const noModel = { complete: async () => ({ text: '', tokens: 0 }) };
|
|
21
|
+
/**
|
|
22
|
+
* Translates new and changed strings, group by group (in config order), language by language.
|
|
23
|
+
* Within a language, all files of a group share requests, so many small files do not cost a
|
|
24
|
+
* request each. See engine/planner.ts for what is translated, revised, adopted or protected.
|
|
25
|
+
*/
|
|
58
26
|
export async function run(config, options = {}) {
|
|
59
27
|
const log = options.logger ?? consoleLogger;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
config = { ...config, context: undefined };
|
|
28
|
+
const groups = selectGroups(config, options.groups, options.languages);
|
|
29
|
+
const dryRun = Boolean(options.dryRun);
|
|
30
|
+
if (!options.model && !dryRun) {
|
|
31
|
+
const needsKey = groups.find(g => !process.env[g.apiKeyEnv] && g.baseUrl.includes('api.openai.com'));
|
|
32
|
+
if (needsKey)
|
|
33
|
+
throw new Error(`Set the ${needsKey.apiKeyEnv} environment variable to your OpenAI API key (or run with --dry-run).`);
|
|
67
34
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
35
|
+
const stateDir = path.join(config.root, config.stateDir);
|
|
36
|
+
const plugins = new PluginHost(options.plugins ?? (await loadPlugins(config)));
|
|
37
|
+
const budget = new Budget(options.maxTokens ?? config.maxTokensPerRun, config.dailyTokenBudget, path.join(stateDir, 'usage.json'));
|
|
38
|
+
const release = dryRun ? () => { } : acquireLock(stateDir);
|
|
39
|
+
let state;
|
|
40
|
+
try {
|
|
41
|
+
state = new State(stateDir);
|
|
71
42
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!process.env[config.apiKeyEnv] && config.baseUrl.includes('api.openai.com')) {
|
|
76
|
-
throw new Error(`Set the ${config.apiKeyEnv} environment variable to your OpenAI API key (or run with --dry-run).`);
|
|
77
|
-
}
|
|
78
|
-
model = new OpenAICompatibleModel(config);
|
|
43
|
+
catch (error) {
|
|
44
|
+
release();
|
|
45
|
+
throw error;
|
|
79
46
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
tokens: 0,
|
|
89
|
-
requests: 0,
|
|
90
|
-
stoppedByBudget: false,
|
|
91
|
-
pendingReview: 0,
|
|
92
|
-
dryRun,
|
|
93
|
-
};
|
|
94
|
-
let fatal = null;
|
|
95
|
-
const stopped = () => fatal !== null || summary.stoppedByBudget;
|
|
96
|
-
const handleError = (error, lang) => {
|
|
97
|
-
if (error instanceof BudgetExceededError) {
|
|
98
|
-
if (!summary.stoppedByBudget)
|
|
99
|
-
log.warn(`${error.message}; stopping. Run again to continue.`);
|
|
100
|
-
summary.stoppedByBudget = true;
|
|
101
|
-
}
|
|
102
|
-
else if (error instanceof FatalModelError) {
|
|
103
|
-
fatal ??= error;
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
log.warn(`[${lang}] ${error.message}`);
|
|
107
|
-
}
|
|
47
|
+
const ctx = new RunContext(options, log, state, budget, plugins);
|
|
48
|
+
// Ctrl+C or a CI timeout: keep what was translated so far, then stop.
|
|
49
|
+
const onSignal = (signal) => {
|
|
50
|
+
log.warn(`${signal}: saving progress and stopping.`);
|
|
51
|
+
if (!dryRun)
|
|
52
|
+
state.save();
|
|
53
|
+
release();
|
|
54
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
108
55
|
};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
handleError(error, lang);
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
56
|
+
process.once('SIGINT', onSignal);
|
|
57
|
+
process.once('SIGTERM', onSignal);
|
|
58
|
+
if (!dryRun && budget.exceeded) {
|
|
59
|
+
log.warn(`${budget.exceeded}; nothing to do until tomorrow (UTC).`);
|
|
60
|
+
ctx.summary.stoppedByBudget = true;
|
|
61
|
+
ctx.summary.stopReason = budget.exceeded;
|
|
120
62
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const size = SLOW_LANGUAGES.has(baseLanguage(lang)) ? Math.min(5, config.batchSize) : config.batchSize;
|
|
125
|
-
const batches = [];
|
|
126
|
-
let current = [];
|
|
127
|
-
let chars = 0;
|
|
128
|
-
for (const item of items) {
|
|
129
|
-
// List markup gets split into several array elements by models; send it alone.
|
|
130
|
-
const alone = /<li\b/i.test(item.source) || item.source.length > MAX_BATCH_CHARS / 2;
|
|
131
|
-
if (current.length > 0 && (alone || current.length >= size || chars + item.source.length > MAX_BATCH_CHARS)) {
|
|
132
|
-
batches.push(current);
|
|
133
|
-
current = [];
|
|
134
|
-
chars = 0;
|
|
135
|
-
}
|
|
136
|
-
current.push(item);
|
|
137
|
-
chars += item.source.length;
|
|
138
|
-
if (alone) {
|
|
139
|
-
batches.push(current);
|
|
140
|
-
current = [];
|
|
141
|
-
chars = 0;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (current.length > 0)
|
|
145
|
-
batches.push(current);
|
|
146
|
-
for (const batch of batches) {
|
|
147
|
-
if (stopped())
|
|
63
|
+
try {
|
|
64
|
+
for (const raw of groups) {
|
|
65
|
+
if (ctx.stopped)
|
|
148
66
|
break;
|
|
149
|
-
let
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
candidates = parseArray(raw, batch.length);
|
|
154
|
-
if (!candidates)
|
|
155
|
-
log.warn(`[${lang}] batch answer was not a matching JSON array; translating one by one`);
|
|
156
|
-
}
|
|
157
|
-
catch (error) {
|
|
158
|
-
handleError(error, lang);
|
|
159
|
-
if (stopped())
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (!candidates) {
|
|
164
|
-
candidates = [];
|
|
165
|
-
for (const item of batch)
|
|
166
|
-
candidates.push(await translateOne(lang, item));
|
|
167
|
-
}
|
|
168
|
-
for (const [i, item] of batch.entries()) {
|
|
169
|
-
let text = candidates[i];
|
|
170
|
-
if (text === null)
|
|
171
|
-
continue;
|
|
172
|
-
let defect = checker.defect(lang, item.key, item.source, text);
|
|
173
|
-
const echoed = isUnchangedProse(item.source, text, checker.placeholderRe);
|
|
174
|
-
if (defect.soft && (batch.length > 1 || echoed)) {
|
|
175
|
-
// One retry on its own, then keep whichever version is cleaner.
|
|
176
|
-
const retry = await translateOne(lang, item);
|
|
177
|
-
if (retry !== null) {
|
|
178
|
-
const retryDefect = checker.defect(lang, item.key, item.source, retry);
|
|
179
|
-
const rank = (d) => (d.hard ? 2 : d.soft ? 1 : 0);
|
|
180
|
-
if (rank(retryDefect) < rank(defect)) {
|
|
181
|
-
text = retry;
|
|
182
|
-
defect = retryDefect;
|
|
183
|
-
}
|
|
184
|
-
else if (echoed && isUnchangedProse(item.source, retry, checker.placeholderRe)) {
|
|
185
|
-
// Asked twice, the model keeps the source text: names and product lists are often
|
|
186
|
-
// the same in every language. Accept it instead of retrying on every run.
|
|
187
|
-
defect = { hard: null, soft: 'identical to the source (accepted after a retry)' };
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (defect.hard) {
|
|
192
|
-
log.warn(`[${lang}] ${item.key}: ${defect.hard}; not written`);
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
if (defect.soft)
|
|
196
|
-
log.debug?.(`[${lang}] ${item.key}: ${defect.soft} (kept)`);
|
|
197
|
-
results.set(item.key, text);
|
|
67
|
+
let group = raw;
|
|
68
|
+
if (group.context?.startsWith('Describe your product')) {
|
|
69
|
+
log.warn('"context" still has the example text from init; it is ignored. Describe your product in localewarden.config.json for better translations.');
|
|
70
|
+
group = { ...group, context: undefined };
|
|
198
71
|
}
|
|
72
|
+
await runGroup(ctx, group, options.model);
|
|
199
73
|
}
|
|
200
|
-
return results;
|
|
201
|
-
}
|
|
202
|
-
async function processFile(file, sourceDoc, sourceText) {
|
|
203
|
-
const sourceLeaves = stringLeaves(sourceDoc);
|
|
204
|
-
await inParallel(languages, config.concurrency, async (lang) => {
|
|
205
|
-
if (stopped())
|
|
206
|
-
return;
|
|
207
|
-
const counts = summary.languages[lang];
|
|
208
|
-
const pluralExtras = missingPluralLeaves(sourceLeaves, lang);
|
|
209
|
-
const leaves = [...sourceLeaves, ...pluralExtras];
|
|
210
|
-
const sourceKeys = new Set(leaves.map(leaf => leaf.key));
|
|
211
|
-
const targetRel = file.pathFor(lang);
|
|
212
|
-
const targetFile = path.join(config.root, targetRel);
|
|
213
|
-
if (!path.resolve(targetFile).startsWith(path.resolve(config.root) + path.sep)) {
|
|
214
|
-
log.error(`${targetRel} is outside the project; skipped.`);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const targetText = readText(targetFile);
|
|
218
|
-
let targetDoc = null;
|
|
219
|
-
if (targetText !== null) {
|
|
220
|
-
try {
|
|
221
|
-
targetDoc = JSON.parse(targetText);
|
|
222
|
-
}
|
|
223
|
-
catch (error) {
|
|
224
|
-
log.error(`${targetRel} is not valid JSON (${error.message}); skipped. Fix it by hand.`);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
const current = targetDoc === null ? new Map() : flatten(targetDoc);
|
|
229
|
-
const values = new Map(current);
|
|
230
|
-
const fresh = [];
|
|
231
|
-
const revise = [];
|
|
232
|
-
const repairs = [];
|
|
233
|
-
for (const { key, value: source } of leaves) {
|
|
234
|
-
const cur = current.get(key);
|
|
235
|
-
const entry = state.get(lang, file.id, key);
|
|
236
|
-
const sourceHash = hash(source);
|
|
237
|
-
if (source.trim() === '') {
|
|
238
|
-
values.set(key, source);
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
const hasValue = cur !== undefined && cur.trim() !== '';
|
|
242
|
-
if (hasValue) {
|
|
243
|
-
const id = reviewId(lang, file.id, key);
|
|
244
|
-
const review = state.review[id];
|
|
245
|
-
const curHash = hash(cur);
|
|
246
|
-
if (options.overwriteManual && (review || (entry && entry.value !== curHash))) {
|
|
247
|
-
delete state.review[id];
|
|
248
|
-
fresh.push({ key, source });
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (review) {
|
|
252
|
-
// Counted only when something new needs a person's attention, not on every run.
|
|
253
|
-
if (review.status === 'approved' && review.valueHash !== curHash) {
|
|
254
|
-
state.addReview(lang, file.id, key, 'edited-after-approval', cur);
|
|
255
|
-
counts.protected++;
|
|
256
|
-
}
|
|
257
|
-
else if (entry && entry.source !== sourceHash) {
|
|
258
|
-
state.addReview(lang, file.id, key, 'source-changed', cur);
|
|
259
|
-
log.warn(`[${lang}] ${key}: source changed, but the translation was edited by hand; kept and listed for review`);
|
|
260
|
-
counts.protected++;
|
|
261
|
-
}
|
|
262
|
-
state.set(lang, file.id, key, source, cur);
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
if (entry && entry.value !== curHash) {
|
|
266
|
-
const sourceChanged = entry.source !== sourceHash;
|
|
267
|
-
state.addReview(lang, file.id, key, sourceChanged ? 'source-changed' : 'manual-edit', cur);
|
|
268
|
-
state.set(lang, file.id, key, source, cur);
|
|
269
|
-
log.info(`[${lang}] ${key}: edited by hand; protected and listed for review`);
|
|
270
|
-
counts.protected++;
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (!hasValue || options.retranslateAll) {
|
|
275
|
-
fresh.push({ key, source });
|
|
276
|
-
}
|
|
277
|
-
else if (!entry) {
|
|
278
|
-
// First time localewarden sees this string: adopt an existing translation, unless
|
|
279
|
-
// it is just the source text copied over.
|
|
280
|
-
if (isUnchangedProse(source, cur, checker.placeholderRe))
|
|
281
|
-
fresh.push({ key, source });
|
|
282
|
-
else
|
|
283
|
-
state.set(lang, file.id, key, source, cur);
|
|
284
|
-
}
|
|
285
|
-
else if (entry.source !== sourceHash) {
|
|
286
|
-
revise.push({ key, source, previous: cur });
|
|
287
|
-
}
|
|
288
|
-
else if (options.fixFlagged) {
|
|
289
|
-
const issues = checker.checkString(lang, key, source, cur).filter(issue => FIXABLE_CHECKS.has(issue.check));
|
|
290
|
-
const failed = state.repairFailures[reviewId(lang, file.id, key)];
|
|
291
|
-
if (issues.length > 0 && failed?.valueHash !== hash(cur))
|
|
292
|
-
repairs.push({ key, source, issues });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
// Strings removed from the source.
|
|
296
|
-
const removed = [...current.keys()].filter(key => !sourceKeys.has(key));
|
|
297
|
-
for (const key of new Set([...removed, ...state.keys(lang, file.id).filter(key => !sourceKeys.has(key))])) {
|
|
298
|
-
state.delete(lang, file.id, key);
|
|
299
|
-
delete state.review[reviewId(lang, file.id, key)];
|
|
300
|
-
}
|
|
301
|
-
const work = [...fresh, ...revise];
|
|
302
|
-
if (dryRun) {
|
|
303
|
-
counts.planned += work.length + repairs.length;
|
|
304
|
-
counts.plannedChars += [...work, ...repairs].reduce((sum, item) => sum + item.source.length, 0);
|
|
305
|
-
counts.removed += removed.length;
|
|
306
|
-
if (work.length + repairs.length + removed.length > 0) {
|
|
307
|
-
log.info(`[${lang}] ${targetRel}: ${fresh.length} new, ${revise.length} changed${repairs.length ? `, ${repairs.length} to repair` : ''}${removed.length ? `, ${removed.length} to remove` : ''}`);
|
|
308
|
-
}
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
if (work.length > 0) {
|
|
312
|
-
log.info(`[${lang}] ${targetRel}: translating ${fresh.length} new, ${revise.length} changed`);
|
|
313
|
-
const translated = await translateItems(lang, work);
|
|
314
|
-
for (const item of work) {
|
|
315
|
-
const text = translated.get(item.key);
|
|
316
|
-
if (text === undefined) {
|
|
317
|
-
counts.failed++;
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
values.set(item.key, text);
|
|
321
|
-
state.set(lang, file.id, item.key, item.source, text);
|
|
322
|
-
if (item.previous)
|
|
323
|
-
counts.revised++;
|
|
324
|
-
else
|
|
325
|
-
counts.translated++;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
for (const { key, source, issues } of repairs) {
|
|
329
|
-
if (stopped())
|
|
330
|
-
break;
|
|
331
|
-
const before = values.get(key) ?? '';
|
|
332
|
-
const problems = issues.map(issue => (issue.note ? `${issue.check}: ${issue.note}` : issue.check));
|
|
333
|
-
const after = await translateOne(lang, { key, source }, repairInstruction(before, problems));
|
|
334
|
-
const id = reviewId(lang, file.id, key);
|
|
335
|
-
let reason = after === null ? 'no answer' : null;
|
|
336
|
-
if (after !== null) {
|
|
337
|
-
const checks = new Set(issues.map(issue => issue.check));
|
|
338
|
-
const remaining = checker.checkString(lang, key, source, after).filter(issue => checks.has(issue.check));
|
|
339
|
-
reason =
|
|
340
|
-
checker.defect(lang, key, source, after).hard ??
|
|
341
|
-
(remaining.length > 0 ? `still flagged: ${remaining.map(r => r.note ?? r.check).join('; ')}` : null) ??
|
|
342
|
-
tooManyChanges(before, after);
|
|
343
|
-
}
|
|
344
|
-
if (reason || after === null) {
|
|
345
|
-
if (after !== null || !stopped()) {
|
|
346
|
-
state.repairFailures[id] = { valueHash: hash(before), date: today(), problems, reason: reason ?? 'no answer', ...(after ? { attempted: after } : {}) };
|
|
347
|
-
log.warn(`[${lang}] ${key}: repair rejected (${reason})`);
|
|
348
|
-
}
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
delete state.repairFailures[id];
|
|
352
|
-
values.set(key, after);
|
|
353
|
-
state.set(lang, file.id, key, source, after);
|
|
354
|
-
counts.repaired++;
|
|
355
|
-
}
|
|
356
|
-
counts.removed += removed.length;
|
|
357
|
-
const built = buildTarget(sourceDoc, values) ?? {};
|
|
358
|
-
for (const leaf of pluralExtras) {
|
|
359
|
-
const value = values.get(leaf.key);
|
|
360
|
-
if (value !== undefined)
|
|
361
|
-
setAt(built, leaf.path, value);
|
|
362
|
-
}
|
|
363
|
-
const text = serialize(built, detectFormat(targetText ?? sourceText));
|
|
364
|
-
if (text !== targetText && !(targetText === null && Object.keys(built).length === 0)) {
|
|
365
|
-
writeText(targetFile, text);
|
|
366
|
-
summary.filesWritten.push(targetRel);
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
74
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const sourceRel = file.pathFor(config.sourceLanguage);
|
|
375
|
-
const sourceText = readText(path.join(config.root, sourceRel));
|
|
376
|
-
if (sourceText === null)
|
|
377
|
-
continue;
|
|
378
|
-
let sourceDoc;
|
|
379
|
-
try {
|
|
380
|
-
sourceDoc = JSON.parse(sourceText);
|
|
381
|
-
}
|
|
382
|
-
catch (error) {
|
|
383
|
-
log.error(`${sourceRel} is not valid JSON (${error.message}); skipped.`);
|
|
384
|
-
continue;
|
|
385
|
-
}
|
|
386
|
-
await processFile(file, sourceDoc, sourceText);
|
|
75
|
+
finally {
|
|
76
|
+
process.off('SIGINT', onSignal);
|
|
77
|
+
process.off('SIGTERM', onSignal);
|
|
78
|
+
try {
|
|
387
79
|
if (!dryRun)
|
|
388
80
|
state.save();
|
|
389
81
|
}
|
|
82
|
+
finally {
|
|
83
|
+
release();
|
|
84
|
+
}
|
|
390
85
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
86
|
+
ctx.summary.tokens = budget.tokens;
|
|
87
|
+
ctx.summary.requests = budget.requests;
|
|
88
|
+
ctx.summary.pendingReview = Object.values(state.review).filter(entry => entry.status === 'pending').length;
|
|
89
|
+
ctx.rethrow();
|
|
90
|
+
return ctx.summary;
|
|
91
|
+
}
|
|
92
|
+
async function runGroup(ctx, group, model) {
|
|
93
|
+
const { log, options } = ctx;
|
|
94
|
+
const scope = new Scope(group);
|
|
95
|
+
const checker = new Checker(group, ctx.plugins);
|
|
96
|
+
const files = listFiles(group, scope, ctx.plugins);
|
|
97
|
+
if (files.length === 0) {
|
|
98
|
+
const label = group.name ? `Group "${group.name}": no` : 'No';
|
|
99
|
+
if (group.groups || group.name)
|
|
100
|
+
log.warn(`${label} ${group.sourceLanguage} files match "${group.files}".`);
|
|
101
|
+
else
|
|
102
|
+
throw new Error(`No ${group.sourceLanguage} files match "${group.files}" under ${group.root}.`);
|
|
103
|
+
return;
|
|
394
104
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
105
|
+
if (group.name)
|
|
106
|
+
log.info(`== ${group.name}`);
|
|
107
|
+
const client = new Client(model ?? (ctx.dryRun ? noModel : new OpenAICompatibleModel(group)), ctx.budget, {
|
|
108
|
+
onRetry: message => log.warn(message),
|
|
109
|
+
});
|
|
110
|
+
const translator = new Translator(ctx, group, checker, client);
|
|
111
|
+
const sources = loadSources(group, files, log);
|
|
112
|
+
const languages = group.targetLanguages.filter(lang => !options.languages?.length || options.languages.includes(lang));
|
|
113
|
+
await inParallel(languages, group.concurrency, async (lang) => {
|
|
114
|
+
if (ctx.stopped)
|
|
115
|
+
return;
|
|
116
|
+
const plans = sources.map(source => planFile(ctx, { config: group, scope, checker }, source, lang)).filter((p) => p !== null);
|
|
117
|
+
const counts = ctx.counts(lang);
|
|
118
|
+
if (ctx.dryRun) {
|
|
119
|
+
for (const plan of plans) {
|
|
120
|
+
counts.planned += plan.work.length + plan.repairs.length;
|
|
121
|
+
counts.plannedChars += [...plan.work, ...plan.repairs].reduce((sum, item) => sum + item.source.length, 0);
|
|
122
|
+
counts.removed += plan.removed.length;
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const work = plans.flatMap(plan => plan.work);
|
|
127
|
+
const translated = work.length > 0 ? await translator.translateItems(lang, work) : new Map();
|
|
128
|
+
for (const plan of plans) {
|
|
129
|
+
applyTranslations(ctx, plan, translated);
|
|
130
|
+
await runRepairs(ctx, plan, checker, scope, translator);
|
|
131
|
+
writePlan(ctx, plan);
|
|
132
|
+
}
|
|
133
|
+
ctx.state.save();
|
|
134
|
+
});
|
|
135
|
+
if (!ctx.stopped)
|
|
136
|
+
applyCopies(ctx, group, files);
|
|
401
137
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Config } from '../config.js';
|
|
2
|
+
import { PluginHost } from '../plugins.js';
|
|
3
|
+
/** What the interface shows about one string. */
|
|
4
|
+
export interface StringRow {
|
|
5
|
+
group?: string;
|
|
6
|
+
file: string;
|
|
7
|
+
key: string;
|
|
8
|
+
source: string;
|
|
9
|
+
text: string | null;
|
|
10
|
+
status: 'missing' | 'translated' | 'pending-review' | 'approved' | 'not-text';
|
|
11
|
+
}
|
|
12
|
+
export interface GroupStatus {
|
|
13
|
+
name?: string;
|
|
14
|
+
files: number;
|
|
15
|
+
sourceLanguage: string;
|
|
16
|
+
languages: {
|
|
17
|
+
lang: string;
|
|
18
|
+
strings: number;
|
|
19
|
+
translated: number;
|
|
20
|
+
pendingReview: number;
|
|
21
|
+
}[];
|
|
22
|
+
}
|
|
23
|
+
/** Per group and language: strings, translated strings and hand edits waiting for review. */
|
|
24
|
+
export declare function status(config: Config, plugins: PluginHost): {
|
|
25
|
+
groups: GroupStatus[];
|
|
26
|
+
usage: unknown;
|
|
27
|
+
limits: unknown;
|
|
28
|
+
};
|
|
29
|
+
/** Strings of a group in one language, filtered by a search text (key, source or translation). */
|
|
30
|
+
export declare function strings(config: Config, plugins: PluginHost, query: {
|
|
31
|
+
group?: string;
|
|
32
|
+
lang: string;
|
|
33
|
+
q?: string;
|
|
34
|
+
only?: 'missing' | 'pending-review';
|
|
35
|
+
limit?: number;
|
|
36
|
+
}): {
|
|
37
|
+
rows: StringRow[];
|
|
38
|
+
total: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Saves a translation edited in the interface. It counts as checked by a person: it is
|
|
42
|
+
* approved on the review list, so runs leave it alone and the check skips its warnings.
|
|
43
|
+
*/
|
|
44
|
+
export declare function editString(config: Config, plugins: PluginHost, edit: {
|
|
45
|
+
group?: string;
|
|
46
|
+
lang: string;
|
|
47
|
+
file: string;
|
|
48
|
+
key: string;
|
|
49
|
+
value: string;
|
|
50
|
+
}): StringRow;
|