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.
Files changed (53) hide show
  1. package/README.md +108 -4
  2. package/dist/budget.d.ts +25 -0
  3. package/dist/budget.js +74 -0
  4. package/dist/checks.d.ts +15 -4
  5. package/dist/checks.js +17 -3
  6. package/dist/cli.js +44 -9
  7. package/dist/config.d.ts +27 -1
  8. package/dist/config.js +105 -3
  9. package/dist/engine/context.d.ts +81 -0
  10. package/dist/engine/context.js +75 -0
  11. package/dist/engine/copies.d.ts +9 -0
  12. package/dist/engine/copies.js +31 -0
  13. package/dist/engine/planner.d.ts +66 -0
  14. package/dist/engine/planner.js +167 -0
  15. package/dist/engine/repair.d.ts +16 -0
  16. package/dist/engine/repair.js +61 -0
  17. package/dist/engine/sources.d.ts +13 -0
  18. package/dist/engine/sources.js +40 -0
  19. package/dist/engine/translator.d.ts +29 -0
  20. package/dist/engine/translator.js +156 -0
  21. package/dist/engine/writer.d.ts +13 -0
  22. package/dist/engine/writer.js +63 -0
  23. package/dist/files.d.ts +9 -0
  24. package/dist/files.js +76 -14
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.js +6 -3
  27. package/dist/llm.d.ts +5 -8
  28. package/dist/llm.js +7 -15
  29. package/dist/lock.d.ts +13 -0
  30. package/dist/lock.js +91 -0
  31. package/dist/output.d.ts +21 -0
  32. package/dist/output.js +76 -0
  33. package/dist/plugins.d.ts +72 -0
  34. package/dist/plugins.js +71 -0
  35. package/dist/project.d.ts +18 -5
  36. package/dist/project.js +70 -57
  37. package/dist/prompt.d.ts +3 -1
  38. package/dist/prompt.js +2 -2
  39. package/dist/review.d.ts +1 -1
  40. package/dist/review.js +41 -35
  41. package/dist/state.d.ts +10 -3
  42. package/dist/state.js +30 -11
  43. package/dist/translate.d.ts +9 -49
  44. package/dist/translate.js +118 -429
  45. package/dist/ui/data.d.ts +50 -0
  46. package/dist/ui/data.js +178 -0
  47. package/dist/ui/page.d.ts +5 -0
  48. package/dist/ui/page.js +277 -0
  49. package/dist/ui/server.d.ts +24 -0
  50. package/dist/ui/server.js +194 -0
  51. package/dist/util.d.ts +6 -2
  52. package/dist/util.js +17 -5
  53. package/package.json +1 -1
package/dist/translate.js CHANGED
@@ -1,448 +1,137 @@
1
1
  import path from 'node:path';
2
- import { Checker, FIXABLE_CHECKS, isUnchangedProse, muchShorter } from './checks.js';
3
- import { buildTarget, detectFormat, findSourceFiles, isArbMetadata, missingPluralLeaves, setAt, flatten, parseDoc, readText, serializeDoc, stringLeaves, writeText, } from './files.js';
4
- import { BudgetExceededError, Client, FatalModelError, OpenAICompatibleModel } from './llm.js';
5
- import { batchPrompt, repairInstruction, singlePrompt } from './prompt.js';
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 { reviewId, State } from './state.js';
8
- import { baseLanguage, hash, inParallel, today } from './util.js';
9
- export const consoleLogger = {
10
- info: message => console.log(message),
11
- warn: message => console.warn(`warning: ${message}`),
12
- error: message => console.error(`error: ${message}`),
13
- };
14
- // Scripts whose output costs many tokens per character; smaller batches avoid timeouts.
15
- const SLOW_LANGUAGES = new Set(['ar', 'fa', 'ur', 'he', 'hi', 'mr', 'ne', 'bn', 'th', 'ta', 'te', 'kn', 'ml', 'gu', 'pa', 'si', 'my', 'km', 'lo', 'am', 'ka', 'hy']);
16
- const MAX_BATCH_CHARS = 6000;
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 languages = options.languages?.length ? options.languages : config.targetLanguages;
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
- let model = options.model;
80
- if (!model && !dryRun) {
81
- if (!process.env[config.apiKeyEnv] && config.baseUrl.includes('api.openai.com')) {
82
- throw new Error(`Set the ${config.apiKeyEnv} environment variable to your OpenAI API key (or run with --dry-run).`);
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 client = new Client(model ?? { complete: async () => ({ text: '', tokens: 0 }) }, options.maxTokens ?? config.maxTokensPerRun, {
87
- onRetry: message => log.warn(message),
88
- });
89
- const state = new State(path.join(config.root, config.stateDir));
90
- const checker = new Checker(config);
91
- const summary = {
92
- languages: Object.fromEntries(languages.map(lang => [lang, emptySummary()])),
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
- /** Translates items in batches; returns key -> translation for the ones that passed. */
128
- async function translateItems(lang, items) {
129
- const results = new Map();
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
- * Decides what to do with one file in one language: what to translate, revise or repair,
213
- * what is protected and what was removed. Returns null when the file is skipped.
214
- */
215
- function planFile({ file, doc: sourceDoc, text: sourceText, leaves: sourceLeaves }, lang) {
216
- const counts = summary.languages[lang];
217
- const pluralExtras = missingPluralLeaves(sourceLeaves, lang);
218
- const leaves = [...sourceLeaves, ...pluralExtras];
219
- const sourceKeys = new Set(leaves.map(leaf => leaf.key));
220
- const targetRel = file.pathFor(lang);
221
- const targetFile = path.join(config.root, targetRel);
222
- if (!path.resolve(targetFile).startsWith(path.resolve(config.root) + path.sep)) {
223
- log.error(`${targetRel} is outside the project; skipped.`);
224
- return null;
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
- /** Applies translations, runs repairs and writes the target file. */
333
- async function finishPlan(plan, translated) {
334
- const { file, lang, counts, sourceDoc, sourceText, targetRel, targetFile, targetText, targetDoc, values, work, repairs, removed, pluralExtras } = plan;
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
- const before = values.get(key) ?? '';
361
- const problems = issues.map(issue => (issue.note ? `${issue.check}: ${issue.note}` : issue.check));
362
- const after = await translateOne(lang, { key, source, maxLength: scope.maxLength(key) }, repairInstruction(before, problems));
363
- const id = reviewId(lang, file.id, key);
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
- delete state.repairFailures[id];
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
- const sources = [];
402
- for (const file of files) {
403
- const sourceRel = file.pathFor(config.sourceLanguage);
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
- const doc = parseDoc(sourceRel, text);
409
- sources.push({ file, doc, text, leaves: stringLeaves(doc) });
79
+ if (!dryRun)
80
+ state.save();
410
81
  }
411
- catch (error) {
412
- log.error(`${sourceRel} is not valid JSON (${error.message}); skipped.`);
82
+ finally {
83
+ release();
413
84
  }
414
85
  }
415
- try {
416
- // Per language: plan every file, translate all their strings in shared batches (many
417
- // small files, e.g. store listings, do not cost a request each), then write each file.
418
- await inParallel(languages, config.concurrency, async (lang) => {
419
- if (stopped())
420
- return;
421
- const plans = sources.map(source => planFile(source, lang)).filter((plan) => plan !== null);
422
- const counts = summary.languages[lang];
423
- if (dryRun) {
424
- for (const plan of plans) {
425
- counts.planned += plan.work.length + plan.repairs.length;
426
- counts.plannedChars += [...plan.work, ...plan.repairs].reduce((sum, item) => sum + item.source.length, 0);
427
- counts.removed += plan.removed.length;
428
- }
429
- return;
430
- }
431
- const work = plans.flatMap(plan => plan.work);
432
- const translated = work.length > 0 ? await translateItems(lang, work) : new Map();
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
- summary.tokens = client.tokens;
443
- summary.requests = client.requests;
444
- summary.pendingReview = Object.values(state.review).filter(entry => entry.status === 'pending').length;
445
- if (fatal)
446
- throw fatal;
447
- return summary;
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;