localewarden 0.2.0 → 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 +108 -4
- package/dist/budget.d.ts +25 -0
- package/dist/budget.js +74 -0
- package/dist/checks.d.ts +15 -4
- package/dist/checks.js +17 -3
- package/dist/cli.js +44 -9
- package/dist/config.d.ts +27 -1
- package/dist/config.js +105 -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 +9 -0
- package/dist/files.js +76 -14
- 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/plugins.d.ts +72 -0
- package/dist/plugins.js +71 -0
- package/dist/project.d.ts +18 -5
- package/dist/project.js +70 -57
- package/dist/prompt.d.ts +3 -1
- package/dist/prompt.js +2 -2
- package/dist/review.d.ts +1 -1
- package/dist/review.js +41 -35
- package/dist/state.d.ts +10 -3
- package/dist/state.js +30 -11
- package/dist/translate.d.ts +9 -49
- package/dist/translate.js +118 -429
- 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 +1 -1
package/dist/translate.js
CHANGED
|
@@ -1,448 +1,137 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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';
|
|
6
15
|
import { Scope } from './scope.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** JSON with object keys sorted, to compare content regardless of key order and formatting. */
|
|
18
|
-
function canonical(value) {
|
|
19
|
-
return JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1))) : v);
|
|
20
|
-
}
|
|
21
|
-
/** Words not shared by both texts, counted on the longer side (word-level LCS). */
|
|
22
|
-
export function changedWords(before, after) {
|
|
23
|
-
const split = (text) => text.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).map(w => w.toLocaleLowerCase());
|
|
24
|
-
const a = split(before);
|
|
25
|
-
const b = split(after);
|
|
26
|
-
let prev = new Array(b.length + 1).fill(0);
|
|
27
|
-
for (let i = 1; i <= a.length; i++) {
|
|
28
|
-
const row = new Array(b.length + 1).fill(0);
|
|
29
|
-
for (let j = 1; j <= b.length; j++)
|
|
30
|
-
row[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], row[j - 1]);
|
|
31
|
-
prev = row;
|
|
32
|
-
}
|
|
33
|
-
return Math.max(a.length, b.length) - prev[b.length];
|
|
34
|
-
}
|
|
35
|
-
/** A targeted repair may change a few words of a short string or a quarter of a long one. */
|
|
36
|
-
export function tooManyChanges(before, after) {
|
|
37
|
-
const changed = changedWords(before, after);
|
|
38
|
-
const allowed = Math.max(6, Math.ceil(before.split(/\s+/).filter(Boolean).length * 0.25));
|
|
39
|
-
return changed > allowed ? `changed ${changed} words (allowed ${allowed})` : null;
|
|
40
|
-
}
|
|
41
|
-
function parseArray(raw, expected) {
|
|
42
|
-
const json = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
43
|
-
try {
|
|
44
|
-
const parsed = JSON.parse(json);
|
|
45
|
-
if (!Array.isArray(parsed) || parsed.length !== expected)
|
|
46
|
-
return null;
|
|
47
|
-
return parsed.map(value => String(value));
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const emptySummary = () => ({
|
|
54
|
-
translated: 0,
|
|
55
|
-
revised: 0,
|
|
56
|
-
repaired: 0,
|
|
57
|
-
failed: 0,
|
|
58
|
-
protected: 0,
|
|
59
|
-
removed: 0,
|
|
60
|
-
planned: 0,
|
|
61
|
-
plannedChars: 0,
|
|
62
|
-
});
|
|
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
|
+
*/
|
|
63
26
|
export async function run(config, options = {}) {
|
|
64
27
|
const log = options.logger ?? consoleLogger;
|
|
65
|
-
const
|
|
66
|
-
const unknown = languages.filter(lang => !config.targetLanguages.includes(lang));
|
|
67
|
-
if (unknown.length > 0)
|
|
68
|
-
throw new Error(`Not in targetLanguages: ${unknown.join(', ')}`);
|
|
69
|
-
if (config.context?.startsWith('Describe your product')) {
|
|
70
|
-
log.warn('"context" still has the example text from init; it is ignored. Describe your product in localewarden.config.json for better translations.');
|
|
71
|
-
config = { ...config, context: undefined };
|
|
72
|
-
}
|
|
73
|
-
const scope = new Scope(config);
|
|
74
|
-
const files = findSourceFiles(config.root, config.files, config.sourceLanguage).filter(file => !scope.isExcluded(file));
|
|
75
|
-
if (files.length === 0) {
|
|
76
|
-
throw new Error(`No ${config.sourceLanguage} files match "${config.files}" under ${config.root}.`);
|
|
77
|
-
}
|
|
28
|
+
const groups = selectGroups(config, options.groups, options.languages);
|
|
78
29
|
const dryRun = Boolean(options.dryRun);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
throw new Error(`Set the ${
|
|
83
|
-
}
|
|
84
|
-
model = new OpenAICompatibleModel(config);
|
|
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).`);
|
|
85
34
|
}
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
filesWritten: [],
|
|
94
|
-
tokens: 0,
|
|
95
|
-
requests: 0,
|
|
96
|
-
stoppedByBudget: false,
|
|
97
|
-
pendingReview: 0,
|
|
98
|
-
dryRun,
|
|
99
|
-
};
|
|
100
|
-
let fatal = null;
|
|
101
|
-
const stopped = () => fatal !== null || summary.stoppedByBudget;
|
|
102
|
-
const handleError = (error, lang) => {
|
|
103
|
-
if (error instanceof BudgetExceededError) {
|
|
104
|
-
if (!summary.stoppedByBudget)
|
|
105
|
-
log.warn(`${error.message}; stopping. Run again to continue.`);
|
|
106
|
-
summary.stoppedByBudget = true;
|
|
107
|
-
}
|
|
108
|
-
else if (error instanceof FatalModelError) {
|
|
109
|
-
fatal ??= error;
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
log.warn(`[${lang}] ${error.message}`);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
const timeoutFor = (lang) => (SLOW_LANGUAGES.has(baseLanguage(lang)) ? 240_000 : 120_000);
|
|
116
|
-
async function translateOne(lang, item, extra = '') {
|
|
117
|
-
if (stopped())
|
|
118
|
-
return null;
|
|
119
|
-
try {
|
|
120
|
-
return (await client.complete(singlePrompt(config, lang, item, extra), item.source, timeoutFor(lang))).trim();
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
handleError(error, lang);
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
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);
|
|
126
42
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const size = SLOW_LANGUAGES.has(baseLanguage(lang)) ? Math.min(5, config.batchSize) : config.batchSize;
|
|
131
|
-
const batches = [];
|
|
132
|
-
let current = [];
|
|
133
|
-
let chars = 0;
|
|
134
|
-
for (const item of items) {
|
|
135
|
-
// List markup gets split into several array elements by models; send it alone.
|
|
136
|
-
const alone = /<li\b/i.test(item.source) || item.source.length > MAX_BATCH_CHARS / 2;
|
|
137
|
-
if (current.length > 0 && (alone || current.length >= size || chars + item.source.length > MAX_BATCH_CHARS)) {
|
|
138
|
-
batches.push(current);
|
|
139
|
-
current = [];
|
|
140
|
-
chars = 0;
|
|
141
|
-
}
|
|
142
|
-
current.push(item);
|
|
143
|
-
chars += item.source.length;
|
|
144
|
-
if (alone) {
|
|
145
|
-
batches.push(current);
|
|
146
|
-
current = [];
|
|
147
|
-
chars = 0;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (current.length > 0)
|
|
151
|
-
batches.push(current);
|
|
152
|
-
for (const batch of batches) {
|
|
153
|
-
if (stopped())
|
|
154
|
-
break;
|
|
155
|
-
let candidates = null;
|
|
156
|
-
if (batch.length > 1) {
|
|
157
|
-
try {
|
|
158
|
-
const raw = await client.complete(batchPrompt(config, lang, batch), JSON.stringify(batch.map(i => i.source)), timeoutFor(lang));
|
|
159
|
-
candidates = parseArray(raw, batch.length);
|
|
160
|
-
if (!candidates)
|
|
161
|
-
log.warn(`[${lang}] batch answer was not a matching JSON array; translating one by one`);
|
|
162
|
-
}
|
|
163
|
-
catch (error) {
|
|
164
|
-
handleError(error, lang);
|
|
165
|
-
if (stopped())
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
if (!candidates) {
|
|
170
|
-
candidates = [];
|
|
171
|
-
for (const item of batch)
|
|
172
|
-
candidates.push(await translateOne(lang, item));
|
|
173
|
-
}
|
|
174
|
-
for (const [i, item] of batch.entries()) {
|
|
175
|
-
let text = candidates[i];
|
|
176
|
-
if (text === null)
|
|
177
|
-
continue;
|
|
178
|
-
let defect = checker.defect(lang, item.key, item.source, text);
|
|
179
|
-
const echoed = isUnchangedProse(item.source, text, checker.placeholderRe);
|
|
180
|
-
if (defect.soft && (batch.length > 1 || echoed)) {
|
|
181
|
-
// One retry on its own, then keep whichever version is cleaner.
|
|
182
|
-
const retry = await translateOne(lang, item);
|
|
183
|
-
if (retry !== null) {
|
|
184
|
-
const retryDefect = checker.defect(lang, item.key, item.source, retry);
|
|
185
|
-
const rank = (d) => (d.hard ? 2 : d.soft ? 1 : 0);
|
|
186
|
-
if (rank(retryDefect) < rank(defect)) {
|
|
187
|
-
text = retry;
|
|
188
|
-
defect = retryDefect;
|
|
189
|
-
}
|
|
190
|
-
else if (echoed && isUnchangedProse(item.source, retry, checker.placeholderRe)) {
|
|
191
|
-
// Asked twice, the model keeps the source text: names and product lists are often
|
|
192
|
-
// the same in every language. Accept it instead of retrying on every run.
|
|
193
|
-
defect = { hard: null, soft: 'identical to the source (accepted after a retry)' };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (defect.hard) {
|
|
198
|
-
log.warn(`[${lang}] ${item.key}: ${defect.hard}; not written`);
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
const long = checker.tooLong(item.key, text);
|
|
202
|
-
if (long)
|
|
203
|
-
log.warn(`[${lang}] ${item.key}: ${long}; written, shorten it by hand or run --fix-flagged`);
|
|
204
|
-
else if (defect.soft)
|
|
205
|
-
log.debug?.(`[${lang}] ${item.key}: ${defect.soft} (kept)`);
|
|
206
|
-
results.set(item, text);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return results;
|
|
43
|
+
catch (error) {
|
|
44
|
+
release();
|
|
45
|
+
throw error;
|
|
210
46
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const targetText = readText(targetFile);
|
|
227
|
-
let targetDoc = null;
|
|
228
|
-
if (targetText !== null) {
|
|
229
|
-
try {
|
|
230
|
-
targetDoc = parseDoc(targetRel, targetText);
|
|
231
|
-
}
|
|
232
|
-
catch (error) {
|
|
233
|
-
log.error(`${targetRel} is not valid JSON (${error.message}); skipped. Fix it by hand.`);
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
const current = targetDoc === null ? new Map() : flatten(targetDoc);
|
|
238
|
-
const values = new Map(current);
|
|
239
|
-
const fresh = [];
|
|
240
|
-
const revise = [];
|
|
241
|
-
const repairs = [];
|
|
242
|
-
for (const { key, value: source } of leaves) {
|
|
243
|
-
const cur = current.get(key);
|
|
244
|
-
const entry = state.get(lang, file.id, key);
|
|
245
|
-
const sourceHash = hash(source);
|
|
246
|
-
if (source.trim() === '') {
|
|
247
|
-
values.set(key, source);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (isArbMetadata(targetRel, key)) {
|
|
251
|
-
// Flutter ARB: "@@locale" names the target language; other metadata is copied.
|
|
252
|
-
values.set(key, key === '@@locale' ? lang : cur ?? source);
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
if (scope.isLiteral(key, source)) {
|
|
256
|
-
// Not text: keep a localized value someone set (e.g. a /de/ URL), else copy the source.
|
|
257
|
-
if (cur === undefined || cur.trim() === '')
|
|
258
|
-
values.set(key, source);
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
const hasValue = cur !== undefined && cur.trim() !== '';
|
|
262
|
-
if (hasValue) {
|
|
263
|
-
const id = reviewId(lang, file.id, key);
|
|
264
|
-
const review = state.review[id];
|
|
265
|
-
const curHash = hash(cur);
|
|
266
|
-
if (options.overwriteManual && (review || (entry && entry.value !== curHash))) {
|
|
267
|
-
delete state.review[id];
|
|
268
|
-
fresh.push({ key, source, maxLength: scope.maxLength(key) });
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (review) {
|
|
272
|
-
// Counted only when something new needs a person's attention, not on every run.
|
|
273
|
-
if (review.status === 'approved' && review.valueHash !== curHash) {
|
|
274
|
-
state.addReview(lang, file.id, key, 'edited-after-approval', cur);
|
|
275
|
-
counts.protected++;
|
|
276
|
-
}
|
|
277
|
-
else if (entry && entry.source !== sourceHash) {
|
|
278
|
-
state.addReview(lang, file.id, key, 'source-changed', cur);
|
|
279
|
-
log.warn(`[${lang}] ${key}: source changed, but the translation was edited by hand; kept and listed for review`);
|
|
280
|
-
counts.protected++;
|
|
281
|
-
}
|
|
282
|
-
state.set(lang, file.id, key, source, cur);
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (entry && entry.value !== curHash) {
|
|
286
|
-
const sourceChanged = entry.source !== sourceHash;
|
|
287
|
-
state.addReview(lang, file.id, key, sourceChanged ? 'source-changed' : 'manual-edit', cur);
|
|
288
|
-
state.set(lang, file.id, key, source, cur);
|
|
289
|
-
log.info(`[${lang}] ${key}: edited by hand; protected and listed for review`);
|
|
290
|
-
counts.protected++;
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (!hasValue || options.retranslateAll) {
|
|
295
|
-
fresh.push({ key, source, maxLength: scope.maxLength(key) });
|
|
296
|
-
}
|
|
297
|
-
else if (!entry) {
|
|
298
|
-
// First time localewarden sees this string: adopt an existing translation, unless
|
|
299
|
-
// it is just the source text copied over.
|
|
300
|
-
if (isUnchangedProse(source, cur, checker.placeholderRe))
|
|
301
|
-
fresh.push({ key, source, maxLength: scope.maxLength(key) });
|
|
302
|
-
else
|
|
303
|
-
state.set(lang, file.id, key, source, cur);
|
|
304
|
-
}
|
|
305
|
-
else if (entry.source !== sourceHash) {
|
|
306
|
-
revise.push({ key, source, previous: cur, maxLength: scope.maxLength(key) });
|
|
307
|
-
}
|
|
308
|
-
else if (options.fixFlagged) {
|
|
309
|
-
const issues = checker.checkString(lang, key, source, cur).filter(issue => FIXABLE_CHECKS.has(issue.check));
|
|
310
|
-
const failed = state.repairFailures[reviewId(lang, file.id, key)];
|
|
311
|
-
// Missing content needs more than a minimal correction: revise from the existing text.
|
|
312
|
-
if (failed?.valueHash === hash(cur))
|
|
313
|
-
continue; // this value could not be fixed before
|
|
314
|
-
if (muchShorter(lang, source, cur))
|
|
315
|
-
revise.push({ key, source, previous: cur, maxLength: scope.maxLength(key), completing: true });
|
|
316
|
-
else if (issues.length > 0)
|
|
317
|
-
repairs.push({ key, source, issues });
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
// Strings removed from the source.
|
|
321
|
-
const removed = [...current.keys()].filter(key => !sourceKeys.has(key));
|
|
322
|
-
for (const key of new Set([...removed, ...state.keys(lang, file.id).filter(key => !sourceKeys.has(key))])) {
|
|
323
|
-
state.delete(lang, file.id, key);
|
|
324
|
-
delete state.review[reviewId(lang, file.id, key)];
|
|
325
|
-
}
|
|
326
|
-
const work = [...fresh, ...revise];
|
|
327
|
-
if (work.length + repairs.length + removed.length > 0) {
|
|
328
|
-
log.info(`[${lang}] ${targetRel}: ${fresh.length} new, ${revise.length} changed${repairs.length ? `, ${repairs.length} to repair` : ''}${removed.length ? `, ${removed.length} to remove` : ''}`);
|
|
329
|
-
}
|
|
330
|
-
return { file, lang, counts, sourceDoc, sourceText, targetRel, targetFile, targetText, targetDoc, values, work, repairs, removed, pluralExtras };
|
|
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);
|
|
55
|
+
};
|
|
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;
|
|
331
62
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (work.length > 0) {
|
|
336
|
-
for (const item of work) {
|
|
337
|
-
const text = translated.get(item);
|
|
338
|
-
if (text === undefined) {
|
|
339
|
-
counts.failed++;
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
if (item.completing && item.previous && muchShorter(lang, item.source, text)) {
|
|
343
|
-
// Still missing content: keep the old text and do not spend tokens on it again.
|
|
344
|
-
state.repairFailures[reviewId(lang, file.id, item.key)] = { valueHash: hash(item.previous), date: today(), problems: ['content missing'], reason: 'revision still much shorter than the source', attempted: text };
|
|
345
|
-
log.warn(`[${lang}] ${item.key}: could not complete the missing content; listed in repair-failures.json`);
|
|
346
|
-
counts.failed++;
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
values.set(item.key, text);
|
|
350
|
-
state.set(lang, file.id, item.key, item.source, text);
|
|
351
|
-
if (item.previous)
|
|
352
|
-
counts.revised++;
|
|
353
|
-
else
|
|
354
|
-
counts.translated++;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
for (const { key, source, issues } of repairs) {
|
|
358
|
-
if (stopped())
|
|
63
|
+
try {
|
|
64
|
+
for (const raw of groups) {
|
|
65
|
+
if (ctx.stopped)
|
|
359
66
|
break;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
let reason = after === null ? 'no answer' : null;
|
|
365
|
-
if (after !== null) {
|
|
366
|
-
const checks = new Set(issues.map(issue => issue.check));
|
|
367
|
-
const remaining = checker.checkString(lang, key, source, after).filter(issue => checks.has(issue.check));
|
|
368
|
-
reason =
|
|
369
|
-
checker.defect(lang, key, source, after).hard ??
|
|
370
|
-
(remaining.length > 0 ? `still flagged: ${remaining.map(r => r.note ?? r.check).join('; ')}` : null) ??
|
|
371
|
-
tooManyChanges(before, after);
|
|
372
|
-
}
|
|
373
|
-
if (reason || after === null) {
|
|
374
|
-
if (after !== null || !stopped()) {
|
|
375
|
-
state.repairFailures[id] = { valueHash: hash(before), date: today(), problems, reason: reason ?? 'no answer', ...(after ? { attempted: after } : {}) };
|
|
376
|
-
log.warn(`[${lang}] ${key}: repair rejected (${reason})`);
|
|
377
|
-
}
|
|
378
|
-
continue;
|
|
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 };
|
|
379
71
|
}
|
|
380
|
-
|
|
381
|
-
values.set(key, after);
|
|
382
|
-
state.set(lang, file.id, key, source, after);
|
|
383
|
-
counts.repaired++;
|
|
384
|
-
}
|
|
385
|
-
counts.removed += removed.length;
|
|
386
|
-
const built = buildTarget(sourceDoc, values) ?? {};
|
|
387
|
-
for (const leaf of pluralExtras) {
|
|
388
|
-
const value = values.get(leaf.key);
|
|
389
|
-
if (value !== undefined)
|
|
390
|
-
setAt(built, leaf.path, value);
|
|
391
|
-
}
|
|
392
|
-
// Same content as on disk (only formatting or key order differs): leave the file alone, so
|
|
393
|
-
// projects formatted with Prettier do not get a diff on every run.
|
|
394
|
-
const unchanged = targetDoc !== null && canonical(built) === canonical(targetDoc);
|
|
395
|
-
const text = serializeDoc(targetRel, built, detectFormat(targetText ?? sourceText));
|
|
396
|
-
if (!unchanged && text !== null && text !== targetText && !(targetText === null && Object.keys(built).length === 0)) {
|
|
397
|
-
writeText(targetFile, text);
|
|
398
|
-
summary.filesWritten.push(targetRel);
|
|
72
|
+
await runGroup(ctx, group, options.model);
|
|
399
73
|
}
|
|
400
74
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const text = readText(path.join(config.root, sourceRel));
|
|
405
|
-
if (text === null)
|
|
406
|
-
continue;
|
|
75
|
+
finally {
|
|
76
|
+
process.off('SIGINT', onSignal);
|
|
77
|
+
process.off('SIGTERM', onSignal);
|
|
407
78
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
79
|
+
if (!dryRun)
|
|
80
|
+
state.save();
|
|
410
81
|
}
|
|
411
|
-
|
|
412
|
-
|
|
82
|
+
finally {
|
|
83
|
+
release();
|
|
413
84
|
}
|
|
414
85
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
for (const plan of plans)
|
|
434
|
-
await finishPlan(plan, translated);
|
|
435
|
-
state.save();
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
finally {
|
|
439
|
-
if (!dryRun)
|
|
440
|
-
state.save();
|
|
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;
|
|
441
104
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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);
|
|
448
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;
|