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.
Files changed (56) hide show
  1. package/README.md +161 -12
  2. package/dist/budget.d.ts +25 -0
  3. package/dist/budget.js +74 -0
  4. package/dist/checks.d.ts +34 -6
  5. package/dist/checks.js +111 -21
  6. package/dist/cli.js +66 -10
  7. package/dist/config.d.ts +37 -1
  8. package/dist/config.js +118 -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 +30 -0
  24. package/dist/files.js +125 -12
  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/placeholders.js +2 -1
  34. package/dist/plugins.d.ts +72 -0
  35. package/dist/plugins.js +71 -0
  36. package/dist/project.d.ts +33 -5
  37. package/dist/project.js +135 -42
  38. package/dist/prompt.d.ts +7 -1
  39. package/dist/prompt.js +10 -3
  40. package/dist/review.d.ts +1 -1
  41. package/dist/review.js +49 -23
  42. package/dist/scope.d.ts +15 -0
  43. package/dist/scope.js +26 -0
  44. package/dist/state.d.ts +11 -4
  45. package/dist/state.js +30 -11
  46. package/dist/translate.d.ts +9 -49
  47. package/dist/translate.js +119 -383
  48. package/dist/ui/data.d.ts +50 -0
  49. package/dist/ui/data.js +178 -0
  50. package/dist/ui/page.d.ts +5 -0
  51. package/dist/ui/page.js +277 -0
  52. package/dist/ui/server.d.ts +24 -0
  53. package/dist/ui/server.js +194 -0
  54. package/dist/util.d.ts +6 -2
  55. package/dist/util.js +17 -5
  56. package/package.json +12 -1
package/dist/translate.js CHANGED
@@ -1,401 +1,137 @@
1
1
  import path from 'node:path';
2
- import { Checker, FIXABLE_CHECKS, isUnchangedProse } from './checks.js';
3
- import { buildTarget, detectFormat, findSourceFiles, missingPluralLeaves, setAt, flatten, readText, serialize, stringLeaves, writeText, } from './files.js';
4
- import { BudgetExceededError, Client, FatalModelError, OpenAICompatibleModel } from './llm.js';
5
- import { batchPrompt, repairInstruction, singlePrompt } from './prompt.js';
6
- import { reviewId, State } from './state.js';
7
- import { baseLanguage, hash, inParallel, today } from './util.js';
8
- export const consoleLogger = {
9
- info: message => console.log(message),
10
- warn: message => console.warn(`warning: ${message}`),
11
- error: message => console.error(`error: ${message}`),
12
- };
13
- // Scripts whose output costs many tokens per character; smaller batches avoid timeouts.
14
- 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']);
15
- const MAX_BATCH_CHARS = 6000;
16
- /** Words not shared by both texts, counted on the longer side (word-level LCS). */
17
- export function changedWords(before, after) {
18
- const split = (text) => text.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).map(w => w.toLocaleLowerCase());
19
- const a = split(before);
20
- const b = split(after);
21
- let prev = new Array(b.length + 1).fill(0);
22
- for (let i = 1; i <= a.length; i++) {
23
- const row = new Array(b.length + 1).fill(0);
24
- for (let j = 1; j <= b.length; j++)
25
- row[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], row[j - 1]);
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 languages = options.languages?.length ? options.languages : config.targetLanguages;
61
- const unknown = languages.filter(lang => !config.targetLanguages.includes(lang));
62
- if (unknown.length > 0)
63
- throw new Error(`Not in targetLanguages: ${unknown.join(', ')}`);
64
- if (config.context?.startsWith('Describe your product')) {
65
- log.warn('"context" still has the example text from init; it is ignored. Describe your product in localewarden.config.json for better translations.');
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 files = findSourceFiles(config.root, config.files, config.sourceLanguage);
69
- if (files.length === 0) {
70
- throw new Error(`No ${config.sourceLanguage} files match "${config.files}" under ${config.root}.`);
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
- const dryRun = Boolean(options.dryRun);
73
- let model = options.model;
74
- if (!model && !dryRun) {
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 client = new Client(model ?? { complete: async () => ({ text: '', tokens: 0 }) }, options.maxTokens ?? config.maxTokensPerRun, {
81
- onRetry: message => log.warn(message),
82
- });
83
- const state = new State(path.join(config.root, config.stateDir));
84
- const checker = new Checker(config);
85
- const summary = {
86
- languages: Object.fromEntries(languages.map(lang => [lang, emptySummary()])),
87
- filesWritten: [],
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
- const timeoutFor = (lang) => (SLOW_LANGUAGES.has(baseLanguage(lang)) ? 240_000 : 120_000);
110
- async function translateOne(lang, item, extra = '') {
111
- if (stopped())
112
- return null;
113
- try {
114
- return (await client.complete(singlePrompt(config, lang, item, extra), item.source, timeoutFor(lang))).trim();
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
- /** Translates items in batches; returns key -> translation for the ones that passed. */
122
- async function translateItems(lang, items) {
123
- const results = new Map();
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 candidates = null;
150
- if (batch.length > 1) {
151
- try {
152
- const raw = await client.complete(batchPrompt(config, lang, batch), JSON.stringify(batch.map(i => i.source)), timeoutFor(lang));
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
- try {
371
- for (const file of files) {
372
- if (stopped())
373
- break;
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
- finally {
392
- if (!dryRun)
393
- 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;
394
104
  }
395
- summary.tokens = client.tokens;
396
- summary.requests = client.requests;
397
- summary.pendingReview = Object.values(state.review).filter(entry => entry.status === 'pending').length;
398
- if (fatal)
399
- throw fatal;
400
- 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);
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;