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/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, serialize, stringLeaves, writeText, } from './files.js';
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
- const files = findSourceFiles(config.root, config.files, config.sourceLanguage);
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
- if (defect.soft && batch.length > 1) {
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
- if (defect.soft)
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.key, text);
206
+ results.set(item, text);
188
207
  }
189
208
  }
190
209
  return results;
191
210
  }
192
- async function processFile(file, sourceDoc, sourceText) {
193
- const leaves = stringLeaves(sourceDoc);
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
- await inParallel(languages, config.concurrency, async (lang) => {
196
- if (stopped())
197
- return;
198
- const counts = summary.languages[lang];
199
- const targetRel = file.pathFor(lang);
200
- const targetFile = path.join(config.root, targetRel);
201
- const targetText = readText(targetFile);
202
- let targetDoc = null;
203
- if (targetText !== null) {
204
- try {
205
- targetDoc = JSON.parse(targetText);
206
- }
207
- catch (error) {
208
- log.error(`${targetRel} is not valid JSON (${error.message}); skipped. Fix it by hand.`);
209
- return;
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
- const current = targetDoc === null ? new Map() : flatten(targetDoc);
213
- const values = new Map(current);
214
- const fresh = [];
215
- const revise = [];
216
- const repairs = [];
217
- for (const { key, value: source } of leaves) {
218
- const cur = current.get(key);
219
- const entry = state.get(lang, file.id, key);
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
- const hasValue = cur !== undefined && cur.trim() !== '';
226
- if (hasValue) {
227
- const id = reviewId(lang, file.id, key);
228
- const review = state.review[id];
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.value !== curHash) {
248
- const sourceChanged = entry.source !== sourceHash;
249
- state.addReview(lang, file.id, key, sourceChanged ? 'source-changed' : 'manual-edit', cur);
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 (!hasValue || options.retranslateAll) {
257
- fresh.push({ key, source });
258
- }
259
- else if (!entry) {
260
- // First time localewarden sees this string: adopt an existing translation, unless
261
- // it is just the source text copied over.
262
- if (isUnchangedProse(source, cur, checker.placeholderRe))
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
- // Strings removed from the source.
278
- const removed = [...current.keys()].filter(key => !sourceKeys.has(key));
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
- const work = [...fresh, ...revise];
284
- if (dryRun) {
285
- counts.planned += work.length + repairs.length;
286
- counts.plannedChars += [...work, ...repairs].reduce((sum, item) => sum + item.source.length, 0);
287
- counts.removed += removed.length;
288
- if (work.length + repairs.length + removed.length > 0) {
289
- log.info(`[${lang}] ${targetRel}: ${fresh.length} new, ${revise.length} changed${repairs.length ? `, ${repairs.length} to repair` : ''}${removed.length ? `, ${removed.length} to remove` : ''}`);
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 (work.length > 0) {
294
- log.info(`[${lang}] ${targetRel}: translating ${fresh.length} new, ${revise.length} changed`);
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
- for (const { key, source, issues } of repairs) {
311
- if (stopped())
312
- break;
313
- const before = values.get(key) ?? '';
314
- const problems = issues.map(issue => (issue.note ? `${issue.check}: ${issue.note}` : issue.check));
315
- const after = await translateOne(lang, { key, source }, repairInstruction(before, problems));
316
- const id = reviewId(lang, file.id, key);
317
- let reason = after === null ? 'no answer' : null;
318
- if (after !== null) {
319
- const checks = new Set(issues.map(issue => issue.check));
320
- const remaining = checker.checkString(lang, key, source, after).filter(issue => checks.has(issue.check));
321
- reason =
322
- checker.defect(lang, key, source, after).hard ??
323
- (remaining.length > 0 ? `still flagged: ${remaining.map(r => r.note ?? r.check).join('; ')}` : null) ??
324
- tooManyChanges(before, after);
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 (reason || after === null) {
327
- if (after !== null || !stopped()) {
328
- state.repairFailures[id] = { valueHash: hash(before), date: today(), problems, reason: reason ?? 'no answer', ...(after ? { attempted: after } : {}) };
329
- log.warn(`[${lang}] ${key}: repair rejected (${reason})`);
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
- delete state.repairFailures[id];
334
- values.set(key, after);
335
- state.set(lang, file.id, key, source, after);
336
- counts.repaired++;
337
- }
338
- counts.removed += removed.length;
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 sourceRel = file.pathFor(config.sourceLanguage);
352
- const sourceText = readText(path.join(config.root, sourceRel));
353
- if (sourceText === null)
354
- continue;
355
- let sourceDoc;
356
- try {
357
- sourceDoc = JSON.parse(sourceText);
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
- catch (error) {
360
- log.error(`${sourceRel} is not valid JSON (${error.message}); skipped.`);
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
- await processFile(file, sourceDoc, sourceText);
364
- if (!dryRun)
365
- state.save();
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.1.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": {