localewarden 0.1.0 → 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 +99 -9
- package/dist/checks.d.ts +28 -2
- package/dist/checks.js +144 -15
- package/dist/cli.js +25 -4
- package/dist/config.d.ts +10 -0
- package/dist/config.js +21 -1
- package/dist/files.d.ts +29 -0
- package/dist/files.js +105 -1
- package/dist/llm.js +3 -1
- package/dist/placeholders.js +2 -1
- package/dist/project.d.ts +15 -0
- package/dist/project.js +86 -5
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.js +15 -3
- 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 +226 -156
- 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, 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());
|
|
@@ -61,7 +66,12 @@ export async function run(config, options = {}) {
|
|
|
61
66
|
const unknown = languages.filter(lang => !config.targetLanguages.includes(lang));
|
|
62
67
|
if (unknown.length > 0)
|
|
63
68
|
throw new Error(`Not in targetLanguages: ${unknown.join(', ')}`);
|
|
64
|
-
|
|
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));
|
|
65
75
|
if (files.length === 0) {
|
|
66
76
|
throw new Error(`No ${config.sourceLanguage} files match "${config.files}" under ${config.root}.`);
|
|
67
77
|
}
|
|
@@ -166,7 +176,8 @@ export async function run(config, options = {}) {
|
|
|
166
176
|
if (text === null)
|
|
167
177
|
continue;
|
|
168
178
|
let defect = checker.defect(lang, item.key, item.source, text);
|
|
169
|
-
|
|
179
|
+
const echoed = isUnchangedProse(item.source, text, checker.placeholderRe);
|
|
180
|
+
if (defect.soft && (batch.length > 1 || echoed)) {
|
|
170
181
|
// One retry on its own, then keep whichever version is cleaner.
|
|
171
182
|
const retry = await translateOne(lang, item);
|
|
172
183
|
if (retry !== null) {
|
|
@@ -176,194 +187,253 @@ export async function run(config, options = {}) {
|
|
|
176
187
|
text = retry;
|
|
177
188
|
defect = retryDefect;
|
|
178
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
|
+
}
|
|
179
195
|
}
|
|
180
196
|
}
|
|
181
197
|
if (defect.hard) {
|
|
182
198
|
log.warn(`[${lang}] ${item.key}: ${defect.hard}; not written`);
|
|
183
199
|
continue;
|
|
184
200
|
}
|
|
185
|
-
|
|
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)
|
|
186
205
|
log.debug?.(`[${lang}] ${item.key}: ${defect.soft} (kept)`);
|
|
187
|
-
results.set(item
|
|
206
|
+
results.set(item, text);
|
|
188
207
|
}
|
|
189
208
|
}
|
|
190
209
|
return results;
|
|
191
210
|
}
|
|
192
|
-
|
|
193
|
-
|
|
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];
|
|
194
219
|
const sourceKeys = new Set(leaves.map(leaf => leaf.key));
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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;
|
|
211
249
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const sourceHash = hash(source);
|
|
221
|
-
if (source.trim() === '') {
|
|
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() === '')
|
|
222
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) });
|
|
223
269
|
continue;
|
|
224
270
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const curHash = hash(cur);
|
|
230
|
-
if (options.overwriteManual && (review || (entry && entry.value !== curHash))) {
|
|
231
|
-
delete state.review[id];
|
|
232
|
-
fresh.push({ key, source });
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
if (review) {
|
|
236
|
-
if (review.status === 'approved' && review.valueHash !== curHash) {
|
|
237
|
-
state.addReview(lang, file.id, key, 'edited-after-approval', cur);
|
|
238
|
-
}
|
|
239
|
-
else if (entry && entry.source !== sourceHash) {
|
|
240
|
-
state.addReview(lang, file.id, key, 'source-changed', cur);
|
|
241
|
-
log.warn(`[${lang}] ${key}: source changed, but the translation was edited by hand; kept and listed for review`);
|
|
242
|
-
}
|
|
243
|
-
state.set(lang, file.id, key, source, cur);
|
|
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);
|
|
244
275
|
counts.protected++;
|
|
245
|
-
continue;
|
|
246
276
|
}
|
|
247
|
-
if (entry && entry.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
state.set(lang, file.id, key, source, cur);
|
|
251
|
-
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`);
|
|
252
280
|
counts.protected++;
|
|
253
|
-
continue;
|
|
254
281
|
}
|
|
282
|
+
state.set(lang, file.id, key, source, cur);
|
|
283
|
+
continue;
|
|
255
284
|
}
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
fresh.push({ key, source });
|
|
264
|
-
else
|
|
265
|
-
state.set(lang, file.id, key, source, cur);
|
|
266
|
-
}
|
|
267
|
-
else if (entry.source !== sourceHash) {
|
|
268
|
-
revise.push({ key, source, previous: cur });
|
|
269
|
-
}
|
|
270
|
-
else if (options.fixFlagged) {
|
|
271
|
-
const issues = checker.checkString(lang, key, source, cur).filter(issue => FIXABLE_CHECKS.has(issue.check));
|
|
272
|
-
const failed = state.repairFailures[reviewId(lang, file.id, key)];
|
|
273
|
-
if (issues.length > 0 && failed?.valueHash !== hash(cur))
|
|
274
|
-
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;
|
|
275
292
|
}
|
|
276
293
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
for (const key of new Set([...removed, ...state.keys(lang, file.id).filter(key => !sourceKeys.has(key))])) {
|
|
280
|
-
state.delete(lang, file.id, key);
|
|
281
|
-
delete state.review[reviewId(lang, file.id, key)];
|
|
294
|
+
if (!hasValue || options.retranslateAll) {
|
|
295
|
+
fresh.push({ key, source, maxLength: scope.maxLength(key) });
|
|
282
296
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
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);
|
|
292
304
|
}
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
const translated = await translateItems(lang, work);
|
|
296
|
-
for (const item of work) {
|
|
297
|
-
const text = translated.get(item.key);
|
|
298
|
-
if (text === undefined) {
|
|
299
|
-
counts.failed++;
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
values.set(item.key, text);
|
|
303
|
-
state.set(lang, file.id, item.key, item.source, text);
|
|
304
|
-
if (item.previous)
|
|
305
|
-
counts.revised++;
|
|
306
|
-
else
|
|
307
|
-
counts.translated++;
|
|
308
|
-
}
|
|
305
|
+
else if (entry.source !== sourceHash) {
|
|
306
|
+
revise.push({ key, source, previous: cur, maxLength: scope.maxLength(key) });
|
|
309
307
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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;
|
|
325
341
|
}
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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++;
|
|
331
347
|
continue;
|
|
332
348
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const built = buildTarget(sourceDoc, values) ?? {};
|
|
340
|
-
const text = serialize(built, detectFormat(targetText ?? sourceText));
|
|
341
|
-
if (text !== targetText && !(targetText === null && Object.keys(built).length === 0)) {
|
|
342
|
-
writeText(targetFile, text);
|
|
343
|
-
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++;
|
|
344
355
|
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
for (const file of files) {
|
|
356
|
+
}
|
|
357
|
+
for (const { key, source, issues } of repairs) {
|
|
349
358
|
if (stopped())
|
|
350
359
|
break;
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
let
|
|
356
|
-
|
|
357
|
-
|
|
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);
|
|
358
372
|
}
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
}
|
|
361
378
|
continue;
|
|
362
379
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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);
|
|
366
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
|
+
});
|
|
367
437
|
}
|
|
368
438
|
finally {
|
|
369
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": {
|