localewarden 0.1.1 → 0.2.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 +54 -9
- package/dist/checks.d.ts +19 -2
- package/dist/checks.js +94 -18
- package/dist/cli.js +24 -3
- package/dist/config.d.ts +10 -0
- package/dist/config.js +13 -0
- package/dist/files.d.ts +21 -0
- package/dist/files.js +52 -1
- package/dist/placeholders.js +2 -1
- package/dist/project.d.ts +15 -0
- package/dist/project.js +84 -4
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.js +9 -2
- package/dist/review.js +25 -5
- package/dist/scope.d.ts +15 -0
- package/dist/scope.js +26 -0
- package/dist/state.d.ts +1 -1
- package/dist/translate.js +216 -169
- package/package.json +12 -1
package/dist/translate.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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,
|
|
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
4
|
import { BudgetExceededError, Client, FatalModelError, OpenAICompatibleModel } from './llm.js';
|
|
5
5
|
import { batchPrompt, repairInstruction, singlePrompt } from './prompt.js';
|
|
6
|
+
import { Scope } from './scope.js';
|
|
6
7
|
import { reviewId, State } from './state.js';
|
|
7
8
|
import { baseLanguage, hash, inParallel, today } from './util.js';
|
|
8
9
|
export const consoleLogger = {
|
|
@@ -13,6 +14,10 @@ export const consoleLogger = {
|
|
|
13
14
|
// Scripts whose output costs many tokens per character; smaller batches avoid timeouts.
|
|
14
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']);
|
|
15
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
|
+
}
|
|
16
21
|
/** Words not shared by both texts, counted on the longer side (word-level LCS). */
|
|
17
22
|
export function changedWords(before, after) {
|
|
18
23
|
const split = (text) => text.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).map(w => w.toLocaleLowerCase());
|
|
@@ -65,7 +70,8 @@ export async function run(config, options = {}) {
|
|
|
65
70
|
log.warn('"context" still has the example text from init; it is ignored. Describe your product in localewarden.config.json for better translations.');
|
|
66
71
|
config = { ...config, context: undefined };
|
|
67
72
|
}
|
|
68
|
-
const
|
|
73
|
+
const scope = new Scope(config);
|
|
74
|
+
const files = findSourceFiles(config.root, config.files, config.sourceLanguage).filter(file => !scope.isExcluded(file));
|
|
69
75
|
if (files.length === 0) {
|
|
70
76
|
throw new Error(`No ${config.sourceLanguage} files match "${config.files}" under ${config.root}.`);
|
|
71
77
|
}
|
|
@@ -192,201 +198,242 @@ export async function run(config, options = {}) {
|
|
|
192
198
|
log.warn(`[${lang}] ${item.key}: ${defect.hard}; not written`);
|
|
193
199
|
continue;
|
|
194
200
|
}
|
|
195
|
-
|
|
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)
|
|
196
205
|
log.debug?.(`[${lang}] ${item.key}: ${defect.soft} (kept)`);
|
|
197
|
-
results.set(item
|
|
206
|
+
results.set(item, text);
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
return results;
|
|
201
210
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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);
|
|
216
231
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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;
|
|
227
254
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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() === '') {
|
|
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() === '')
|
|
238
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) });
|
|
239
269
|
continue;
|
|
240
270
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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;
|
|
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++;
|
|
264
276
|
}
|
|
265
|
-
if (entry && entry.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
state.set(lang, file.id, key, source, cur);
|
|
269
|
-
log.info(`[${lang}] ${key}: edited by hand; protected and listed for review`);
|
|
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`);
|
|
270
280
|
counts.protected++;
|
|
271
|
-
continue;
|
|
272
281
|
}
|
|
282
|
+
state.set(lang, file.id, key, source, cur);
|
|
283
|
+
continue;
|
|
273
284
|
}
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 });
|
|
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;
|
|
293
292
|
}
|
|
294
293
|
}
|
|
295
|
-
|
|
296
|
-
|
|
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)];
|
|
294
|
+
if (!hasValue || options.retranslateAll) {
|
|
295
|
+
fresh.push({ key, source, maxLength: scope.maxLength(key) });
|
|
300
296
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
return;
|
|
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);
|
|
310
304
|
}
|
|
311
|
-
if (
|
|
312
|
-
|
|
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
|
-
}
|
|
305
|
+
else if (entry.source !== sourceHash) {
|
|
306
|
+
revise.push({ key, source, previous: cur, maxLength: scope.maxLength(key) });
|
|
327
307
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 };
|
|
331
|
+
}
|
|
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;
|
|
343
341
|
}
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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++;
|
|
349
347
|
continue;
|
|
350
348
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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);
|
|
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++;
|
|
367
355
|
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
try {
|
|
371
|
-
for (const file of files) {
|
|
356
|
+
}
|
|
357
|
+
for (const { key, source, issues } of repairs) {
|
|
372
358
|
if (stopped())
|
|
373
359
|
break;
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
let
|
|
379
|
-
|
|
380
|
-
|
|
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);
|
|
381
372
|
}
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
}
|
|
384
378
|
continue;
|
|
385
379
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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);
|
|
389
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);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
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;
|
|
407
|
+
try {
|
|
408
|
+
const doc = parseDoc(sourceRel, text);
|
|
409
|
+
sources.push({ file, doc, text, leaves: stringLeaves(doc) });
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
log.error(`${sourceRel} is not valid JSON (${error.message}); skipped.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
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
|
+
});
|
|
390
437
|
}
|
|
391
438
|
finally {
|
|
392
439
|
if (!dryRun)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localewarden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Incremental AI translation for JSON locale files: translates only what changed, never overwrites hand edits, and checks every result before writing it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,17 @@
|
|
|
39
39
|
"json",
|
|
40
40
|
"locales",
|
|
41
41
|
"i18next",
|
|
42
|
+
"react-intl",
|
|
43
|
+
"vue-i18n",
|
|
44
|
+
"next-intl",
|
|
45
|
+
"fastlane",
|
|
46
|
+
"flutter",
|
|
47
|
+
"arb",
|
|
48
|
+
"app-store",
|
|
49
|
+
"ai-translation",
|
|
50
|
+
"gpt",
|
|
51
|
+
"chatgpt",
|
|
52
|
+
"translation-quality",
|
|
42
53
|
"cli"
|
|
43
54
|
],
|
|
44
55
|
"repository": {
|