localship 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -12
- package/dist/{chunk-QS3UG3KI.js → chunk-GNL5EFNP.js} +468 -248
- package/dist/cli.js +71 -45
- package/dist/{server-T6GQ6CV5.js → server-5AITZXZV.js} +27 -17
- package/package.json +2 -1
- package/skills/localship/SKILL.md +66 -43
- package/skills/localship-extract/SKILL.md +123 -39
- package/skills/localship-extract/references/platform-localization.md +65 -0
- package/skills/localship-review/SKILL.md +9 -1
- package/skills/localship-setup/SKILL.md +50 -36
- package/skills/localship-translate/SKILL.md +26 -18
|
@@ -341,15 +341,58 @@ async function addLocales(fs, file, requested) {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
// src/core/fs.ts
|
|
344
|
+
import { randomUUID } from "crypto";
|
|
344
345
|
import { promises as fsp } from "fs";
|
|
346
|
+
import os from "os";
|
|
345
347
|
import path2 from "path";
|
|
348
|
+
function busy(file) {
|
|
349
|
+
return new UserError(
|
|
350
|
+
`Another Localship write is in progress (${file}). Retry after it finishes. If a process crashed, verify no writer is running before removing this lock.`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
async function renameWithRetry(from, to) {
|
|
354
|
+
for (let attempt = 0; ; attempt++) {
|
|
355
|
+
try {
|
|
356
|
+
await fsp.rename(from, to);
|
|
357
|
+
return;
|
|
358
|
+
} catch (e) {
|
|
359
|
+
const code = e.code;
|
|
360
|
+
if (attempt >= 20 || !(code === "EPERM" || code === "EACCES" || code === "EBUSY")) throw e;
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 10 + attempt * 10));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
346
365
|
var nodeFs = {
|
|
347
366
|
readFile: (file) => fsp.readFile(file, "utf8"),
|
|
348
367
|
async writeFile(file, content) {
|
|
349
368
|
await fsp.mkdir(path2.dirname(file), { recursive: true });
|
|
350
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
351
|
-
|
|
352
|
-
|
|
369
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
370
|
+
try {
|
|
371
|
+
await fsp.writeFile(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
372
|
+
await renameWithRetry(tmp, file);
|
|
373
|
+
} finally {
|
|
374
|
+
await fsp.rm(tmp, { force: true });
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async withLock(file, run) {
|
|
378
|
+
await fsp.mkdir(path2.dirname(file), { recursive: true });
|
|
379
|
+
const handle = await fsp.open(file, "wx").catch((e) => {
|
|
380
|
+
if (e.code === "EEXIST") throw busy(file);
|
|
381
|
+
throw e;
|
|
382
|
+
});
|
|
383
|
+
try {
|
|
384
|
+
await handle.writeFile(
|
|
385
|
+
JSON.stringify({
|
|
386
|
+
pid: process.pid,
|
|
387
|
+
host: os.hostname(),
|
|
388
|
+
started: (/* @__PURE__ */ new Date()).toISOString()
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
return await run();
|
|
392
|
+
} finally {
|
|
393
|
+
await handle.close();
|
|
394
|
+
await fsp.unlink(file);
|
|
395
|
+
}
|
|
353
396
|
},
|
|
354
397
|
async exists(file) {
|
|
355
398
|
try {
|
|
@@ -426,6 +469,29 @@ function voiceFor(context, locale2) {
|
|
|
426
469
|
const text = parts.filter(Boolean).join("\n\n");
|
|
427
470
|
return text || void 0;
|
|
428
471
|
}
|
|
472
|
+
var VOICE_CONVENTIONS = {
|
|
473
|
+
hi: "Address the user as \u0906\u092A (polite). Everyday Hindi over Sanskritized words; common English tech words (app, sync) may stay in English.",
|
|
474
|
+
ar: "Modern Standard Arabic, addressing the user neutrally. Short sentences.",
|
|
475
|
+
de: "Address the user as du, as most consumer apps do.",
|
|
476
|
+
fr: "Address the user as vous.",
|
|
477
|
+
es: "Address the user as t\xFA. Neutral Spanish that works in Spain and Latin America.",
|
|
478
|
+
it: "Address the user as tu.",
|
|
479
|
+
"pt-BR": "Address the user as voc\xEA (Brazilian Portuguese conventions).",
|
|
480
|
+
"pt-PT": "European Portuguese spelling and vocabulary (utilizador, ecr\xE3), not Brazilian.",
|
|
481
|
+
nl: "Address the user as je.",
|
|
482
|
+
ru: "Address the user as \u0432\u044B (lowercase).",
|
|
483
|
+
ja: "Polite \u3067\u3059/\u307E\u3059 form. Short, natural UI phrasing.",
|
|
484
|
+
ko: "Polite \uD574\uC694\uCCB4. Short, natural UI phrasing.",
|
|
485
|
+
zh: "Neutral, concise UI language; no honorifics needed.",
|
|
486
|
+
tr: "Address the user as siz.",
|
|
487
|
+
pl: "Address the user informally (ty), as most consumer apps do.",
|
|
488
|
+
id: "Address the user as Anda.",
|
|
489
|
+
vi: "Address the user as b\u1EA1n.",
|
|
490
|
+
th: "Polite, neutral tone; use \u0E04\u0E23\u0E31\u0E1A/\u0E04\u0E48\u0E30 only where a sentence needs it."
|
|
491
|
+
};
|
|
492
|
+
function conventionalVoice(locale2) {
|
|
493
|
+
return VOICE_CONVENTIONS[locale2] ?? VOICE_CONVENTIONS[locale2.split("-")[0]] ?? "Polite, neutral tone, as a well-made app in this language would use.";
|
|
494
|
+
}
|
|
429
495
|
var WORD = "[\\p{L}\\p{M}\\p{N}]";
|
|
430
496
|
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
431
497
|
function glossaryHits(glossary, text) {
|
|
@@ -615,18 +681,40 @@ function inferLocaleStyle(matches2, fallback) {
|
|
|
615
681
|
// src/core/plurals.ts
|
|
616
682
|
var SAMPLE_INTEGERS = [...Array.from({ length: 1001 }, (_, i) => i), 1e4, 1e6];
|
|
617
683
|
var SAMPLE_DECIMALS = [0.5, 1.5, 2.5];
|
|
684
|
+
var specs = /* @__PURE__ */ new Map();
|
|
685
|
+
var copySpec = (spec) => ({
|
|
686
|
+
...spec,
|
|
687
|
+
forms: spec.forms.map((form) => ({ ...form }))
|
|
688
|
+
});
|
|
618
689
|
function pluralCategories(locale2) {
|
|
619
690
|
const found = new Intl.PluralRules(locale2).resolvedOptions().pluralCategories;
|
|
620
691
|
return PLURAL_CATEGORIES.filter((c) => found.includes(c));
|
|
621
692
|
}
|
|
622
693
|
function cldrPluralSpec(locale2) {
|
|
694
|
+
const key = Intl.getCanonicalLocales(locale2)[0] ?? locale2;
|
|
695
|
+
const cached = specs.get(key);
|
|
696
|
+
if (cached) return copySpec(cached);
|
|
623
697
|
const rules = new Intl.PluralRules(locale2);
|
|
624
|
-
|
|
698
|
+
const integers = /* @__PURE__ */ new Map();
|
|
699
|
+
const fractions = /* @__PURE__ */ new Map();
|
|
700
|
+
for (const [samples, grouped] of [
|
|
701
|
+
[SAMPLE_INTEGERS, integers],
|
|
702
|
+
[SAMPLE_DECIMALS, fractions]
|
|
703
|
+
]) {
|
|
704
|
+
for (const n of samples) {
|
|
705
|
+
const category = rules.select(n);
|
|
706
|
+
const values = grouped.get(category) ?? [];
|
|
707
|
+
values.push(n);
|
|
708
|
+
grouped.set(category, values);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const categories = rules.resolvedOptions().pluralCategories;
|
|
712
|
+
const spec = {
|
|
625
713
|
scheme: "cldr",
|
|
626
714
|
source: "CLDR",
|
|
627
|
-
forms:
|
|
628
|
-
const ints =
|
|
629
|
-
const decimals =
|
|
715
|
+
forms: PLURAL_CATEGORIES.filter((id2) => categories.includes(id2)).map((id2) => {
|
|
716
|
+
const ints = integers.get(id2) ?? [];
|
|
717
|
+
const decimals = fractions.get(id2) ?? [];
|
|
630
718
|
return {
|
|
631
719
|
id: id2,
|
|
632
720
|
examples: describeSamples(ints, decimals),
|
|
@@ -635,6 +723,10 @@ function cldrPluralSpec(locale2) {
|
|
|
635
723
|
};
|
|
636
724
|
})
|
|
637
725
|
};
|
|
726
|
+
const oldest = specs.keys().next().value;
|
|
727
|
+
if (specs.size >= 256 && oldest !== void 0) specs.delete(oldest);
|
|
728
|
+
specs.set(key, spec);
|
|
729
|
+
return copySpec(spec);
|
|
638
730
|
}
|
|
639
731
|
function describeSamples(ints, decimals = []) {
|
|
640
732
|
const shown = ints.slice(0, 5).map(String);
|
|
@@ -1020,6 +1112,7 @@ var androidAdapter = {
|
|
|
1020
1112
|
const issues = [];
|
|
1021
1113
|
const rootAttrs = /* @__PURE__ */ new Map();
|
|
1022
1114
|
const localeFiles = /* @__PURE__ */ new Map();
|
|
1115
|
+
let hasQualifiedStrings = false;
|
|
1023
1116
|
const target = {
|
|
1024
1117
|
id: id2,
|
|
1025
1118
|
format: "android-xml",
|
|
@@ -1045,8 +1138,7 @@ var androidAdapter = {
|
|
|
1045
1138
|
const dirs = (await fs.listDir(resDir)).filter((d) => d.isDirectory && d.name.startsWith("values")).map((d) => d.name).sort();
|
|
1046
1139
|
for (const dir of dirs) {
|
|
1047
1140
|
const locale2 = folderToLocale(dir);
|
|
1048
|
-
|
|
1049
|
-
const catalog = locale2 === null ? source : { locale: locale2, entries: /* @__PURE__ */ new Map() };
|
|
1141
|
+
const catalog = locale2 === null ? source : { locale: locale2 ?? sourceLocale, entries: /* @__PURE__ */ new Map() };
|
|
1050
1142
|
const xmlFiles = (await fs.listDir(path5.join(resDir, dir))).filter((f) => !f.isDirectory && f.name.endsWith(".xml")).map((f) => f.name).sort();
|
|
1051
1143
|
for (const name of xmlFiles) {
|
|
1052
1144
|
const abs = path5.join(resDir, dir, name);
|
|
@@ -1069,6 +1161,30 @@ var androidAdapter = {
|
|
|
1069
1161
|
}
|
|
1070
1162
|
const resources = doc.root;
|
|
1071
1163
|
if (resources?.name !== "resources") continue;
|
|
1164
|
+
if (locale2 === void 0) {
|
|
1165
|
+
const qualified = { locale: sourceLocale, entries: /* @__PURE__ */ new Map() };
|
|
1166
|
+
readEntries(
|
|
1167
|
+
doc,
|
|
1168
|
+
resources,
|
|
1169
|
+
rel,
|
|
1170
|
+
qualified,
|
|
1171
|
+
(issue) => issues.push({ ...issue, target: id2 })
|
|
1172
|
+
);
|
|
1173
|
+
if (qualified.entries.size > 0) hasQualifiedStrings = true;
|
|
1174
|
+
const entries = [...qualified.entries.values()].filter((e) => e.translatable);
|
|
1175
|
+
if (entries.length > 0) {
|
|
1176
|
+
issues.push({
|
|
1177
|
+
severity: "warning",
|
|
1178
|
+
blocksCompleteness: true,
|
|
1179
|
+
code: "resource-configuration-unsupported",
|
|
1180
|
+
message: `${entries.length} string resource${entries.length === 1 ? "" : "s"} in ${dir} use configuration qualifiers that Localship cannot validate or translate. Preserved unchanged; check this configuration with Android lint and on a device.`,
|
|
1181
|
+
target: id2,
|
|
1182
|
+
file: rel,
|
|
1183
|
+
line: entries[0]?.line
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1072
1188
|
const before = catalog.entries.size;
|
|
1073
1189
|
const spans = /* @__PURE__ */ new Map();
|
|
1074
1190
|
readEntries(
|
|
@@ -1087,7 +1203,17 @@ var androidAdapter = {
|
|
|
1087
1203
|
);
|
|
1088
1204
|
}
|
|
1089
1205
|
}
|
|
1090
|
-
if (locale2 !== null && catalog.entries.size > 0)
|
|
1206
|
+
if (locale2 !== null && locale2 !== void 0 && catalog.entries.size > 0)
|
|
1207
|
+
locales.set(locale2, catalog);
|
|
1208
|
+
}
|
|
1209
|
+
if (source.entries.size === 0 && (locales.size > 0 || hasQualifiedStrings)) {
|
|
1210
|
+
issues.push({
|
|
1211
|
+
severity: "error",
|
|
1212
|
+
code: "default-resources-missing",
|
|
1213
|
+
message: "String resources exist in qualified folders, but this target has no default strings in values. Restore the defaults or configure the target containing them; verify merged resources with Android lint.",
|
|
1214
|
+
target: id2,
|
|
1215
|
+
file: relativePath(root, path5.join(resDir, "values"))
|
|
1216
|
+
});
|
|
1091
1217
|
}
|
|
1092
1218
|
async function writeLocale(catalog) {
|
|
1093
1219
|
const folder = path5.join(resDir, localeToFolder(catalog.locale));
|
|
@@ -2387,6 +2513,11 @@ function deletePath(root, path17) {
|
|
|
2387
2513
|
// src/formats/xcstrings/adapter.ts
|
|
2388
2514
|
var order = (a, b) => a.localeCompare(b, "en");
|
|
2389
2515
|
var UNREVIEWED = /* @__PURE__ */ new Set(["needs_review", "new", "stale"]);
|
|
2516
|
+
function hasUnsupportedVariation(loc) {
|
|
2517
|
+
return !!loc.substitutions || !!loc.variations && (Object.keys(loc.variations).some((axis) => axis !== "plural") || Object.values(loc.variations.plural ?? {}).some(
|
|
2518
|
+
(form) => form.variations || form.substitutions
|
|
2519
|
+
));
|
|
2520
|
+
}
|
|
2390
2521
|
var xcstringsAdapter = {
|
|
2391
2522
|
format: "xcstrings",
|
|
2392
2523
|
async open(config, { fs, root, sourceLocale, id: id2 }) {
|
|
@@ -2474,23 +2605,35 @@ var xcstringsAdapter = {
|
|
|
2474
2605
|
});
|
|
2475
2606
|
}
|
|
2476
2607
|
const strings = child(doc, "strings");
|
|
2477
|
-
let unsupported = 0;
|
|
2478
2608
|
for (const e of strings?.kind === "object" ? strings.entries : []) {
|
|
2479
2609
|
const key = e.key;
|
|
2480
2610
|
const info = toJs(e.value) ?? {};
|
|
2481
2611
|
const locs = info.localizations ?? {};
|
|
2482
|
-
const variant = Object.values(locs).some(
|
|
2483
|
-
if (variant) unsupported++;
|
|
2612
|
+
const variant = Object.values(locs).some(hasUnsupportedVariation);
|
|
2484
2613
|
const line = lineOf(e.offset);
|
|
2614
|
+
const intended = key !== "" && info.shouldTranslate !== false && info.extractionState !== "stale";
|
|
2615
|
+
if (intended && variant) {
|
|
2616
|
+
issues.push({
|
|
2617
|
+
severity: "warning",
|
|
2618
|
+
blocksCompleteness: true,
|
|
2619
|
+
code: "variation-unsupported",
|
|
2620
|
+
message: "Device, nested or other unsupported variations or substitutions are present; this entire key is preserved but excluded from translation and validation. Review it in Xcode; Localship cannot certify completeness for this catalog.",
|
|
2621
|
+
target: id2,
|
|
2622
|
+
key,
|
|
2623
|
+
file: rel,
|
|
2624
|
+
line
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
const own = read(locs[sourceLanguage]);
|
|
2485
2628
|
const base = {
|
|
2486
2629
|
key,
|
|
2487
2630
|
// "" is Xcode's placeholder for empty Text(""); stale strings are gone from the code.
|
|
2488
|
-
|
|
2631
|
+
// Keys with no words (" ", "·", "%@%@") come from text built in pieces: nothing to translate.
|
|
2632
|
+
translatable: intended && !variant && hasWords(own?.message ?? { kind: "text", value: key }),
|
|
2489
2633
|
file: rel,
|
|
2490
2634
|
line
|
|
2491
2635
|
};
|
|
2492
2636
|
if (info.comment) base.comment = info.comment;
|
|
2493
|
-
const own = read(locs[sourceLanguage]);
|
|
2494
2637
|
source.entries.set(key, { ...base, message: own?.message ?? { kind: "text", value: key } });
|
|
2495
2638
|
if (!base.translatable) continue;
|
|
2496
2639
|
for (const [locale2, loc] of Object.entries(locs)) {
|
|
@@ -2507,15 +2650,6 @@ var xcstringsAdapter = {
|
|
|
2507
2650
|
catalog.entries.set(key, entry);
|
|
2508
2651
|
}
|
|
2509
2652
|
}
|
|
2510
|
-
if (unsupported > 0) {
|
|
2511
|
-
issues.push({
|
|
2512
|
-
severity: "info",
|
|
2513
|
-
code: "variation-unsupported",
|
|
2514
|
-
message: `${unsupported} string${unsupported === 1 ? " uses" : "s use"} device variations or substitutions, which aren't supported yet; skipped`,
|
|
2515
|
-
target: id2,
|
|
2516
|
-
file: rel
|
|
2517
|
-
});
|
|
2518
|
-
}
|
|
2519
2653
|
async function writeLocale(catalog) {
|
|
2520
2654
|
if (!doc) throw new Error(`${rel} could not be read, so it can't be written`);
|
|
2521
2655
|
for (const entry of catalog.entries.values()) {
|
|
@@ -2592,6 +2726,11 @@ function sortDeep(v) {
|
|
|
2592
2726
|
Object.keys(v).sort(order).map((k) => [k, sortDeep(v[k])])
|
|
2593
2727
|
);
|
|
2594
2728
|
}
|
|
2729
|
+
var FORMAT_SPECIFIER = /%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|j)?[@dDuUxXoOfFeEgGcCsSpaA%]/g;
|
|
2730
|
+
function hasWords(message) {
|
|
2731
|
+
const values = message.kind === "text" ? [message.value] : message.kind === "plural" ? Object.values(message.forms) : message.items;
|
|
2732
|
+
return values.some((v) => new RegExp("\\p{L}", "u").test(v.replace(FORMAT_SPECIFIER, "")));
|
|
2733
|
+
}
|
|
2595
2734
|
|
|
2596
2735
|
// src/core/lock.ts
|
|
2597
2736
|
function emptyLock() {
|
|
@@ -2658,6 +2797,20 @@ async function openProject(fs, opts) {
|
|
|
2658
2797
|
fs
|
|
2659
2798
|
};
|
|
2660
2799
|
}
|
|
2800
|
+
function projectGlossary(project) {
|
|
2801
|
+
const known = new Set(project.context.glossary.map((t) => t.term.toLowerCase()));
|
|
2802
|
+
const extra = [];
|
|
2803
|
+
for (const { target } of project.targets) {
|
|
2804
|
+
if (target.format !== "android-xml") continue;
|
|
2805
|
+
const entry = target.source.entries.get("app_name");
|
|
2806
|
+
const value = entry?.message.kind === "text" ? messageValues(entry.message)[0]?.[1] : void 0;
|
|
2807
|
+
const name = entry && value ? target.analyze(value, entry).text.trim() : "";
|
|
2808
|
+
if (!name || known.has(name.toLowerCase())) continue;
|
|
2809
|
+
known.add(name.toLowerCase());
|
|
2810
|
+
extra.push({ term: name, translate: false, caseSensitive: false });
|
|
2811
|
+
}
|
|
2812
|
+
return extra.length ? [...project.context.glossary, ...extra] : project.context.glossary;
|
|
2813
|
+
}
|
|
2661
2814
|
function openTarget(fs, root, config, sourceLocale, id2) {
|
|
2662
2815
|
const adapter = ADAPTERS[config.format];
|
|
2663
2816
|
return adapter.open(config, { fs, root, sourceLocale, id: id2 });
|
|
@@ -2665,6 +2818,15 @@ function openTarget(fs, root, config, sourceLocale, id2) {
|
|
|
2665
2818
|
function reopenProject(project) {
|
|
2666
2819
|
return openProject(project.fs, { cwd: project.root, config: project.configFile });
|
|
2667
2820
|
}
|
|
2821
|
+
async function withProjectWrite(project, dryRun, run) {
|
|
2822
|
+
if (dryRun) return run(project);
|
|
2823
|
+
return project.fs.withLock(path8.join(project.root, ".localship", "write.lock"), async () => {
|
|
2824
|
+
const fresh = await reopenProject(project);
|
|
2825
|
+
const result = await run(fresh);
|
|
2826
|
+
project.lock = fresh.lock;
|
|
2827
|
+
return result;
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2668
2830
|
function findTarget(project, id2) {
|
|
2669
2831
|
return project.targets.find((t) => t.target.id === id2)?.target;
|
|
2670
2832
|
}
|
|
@@ -3167,7 +3329,7 @@ async function detectXcstrings(fs, root, files) {
|
|
|
3167
3329
|
locales: [...t.locales.keys()].sort(),
|
|
3168
3330
|
...declared ? { sourceLocale: declared } : {},
|
|
3169
3331
|
questions: strings === 0 ? [
|
|
3170
|
-
"The catalog is empty. Build the
|
|
3332
|
+
"The catalog is empty. Build the targets that use it, then fill it: Xcode's catalog editor does this with the project open; from the command line, run `xcrun xcstringstool sync <catalog> --stringsdata <each target's .stringsdata in DerivedData>`, since a command-line build alone leaves the catalog unchanged. Then run detect again."
|
|
3171
3333
|
] : []
|
|
3172
3334
|
});
|
|
3173
3335
|
}
|
|
@@ -3209,12 +3371,12 @@ function hints(files, candidates, unsupported, hardcoded) {
|
|
|
3209
3371
|
}
|
|
3210
3372
|
const has = (format) => candidates.some((c) => c.target.format === format);
|
|
3211
3373
|
const appleLegacy = unsupported.some((u) => u.format.startsWith("Apple"));
|
|
3212
|
-
const
|
|
3213
|
-
|
|
3214
|
-
);
|
|
3374
|
+
const generator = files.some((f) => /(^|\/)project\.yml$/.test(f)) ? "XcodeGen (project.yml)" : files.some((f) => /(^|\/)Project\.swift$/.test(f)) ? "Tuist (Project.swift)" : void 0;
|
|
3375
|
+
const xcode = !!generator || files.some((f) => f.endsWith(".xcodeproj/project.pbxproj"));
|
|
3215
3376
|
if (xcode && !has("xcstrings") && !appleLegacy) {
|
|
3377
|
+
const add2 = generator ? `The project is generated by ${generator}: put a Localizable.xcstrings in each target's sources and set SWIFT_EMIT_LOC_STRINGS there, then regenerate; changes made in Xcode are lost when the project is regenerated` : "Add one in Xcode (File \u203A New \u203A File \u203A String Catalog, named Localizable) to each target that shows text";
|
|
3216
3378
|
out.push(
|
|
3217
|
-
`This looks like an Xcode project without a String Catalog.
|
|
3379
|
+
`This looks like an Xcode project without a String Catalog. ${add2}. Build, and fill the catalog from the build: Xcode's catalog editor does this; from the command line use \`xcrun xcstringstool sync\`. Text built from variables needs moving to String(localized:) first (the localship-extract skill helps)${iosCode ? `; UIKit code also has ${hardcodedSummary(iosCode)}` : ""}. Then run detect again.`
|
|
3218
3380
|
);
|
|
3219
3381
|
}
|
|
3220
3382
|
const gradle = files.some((f) => /(^|\/)build\.gradle(\.kts)?$/.test(f));
|
|
@@ -3276,6 +3438,7 @@ function entryState(source, target, lock) {
|
|
|
3276
3438
|
if (!lock) return target.needsReview ? "draft" : "untracked";
|
|
3277
3439
|
if (lock.source !== hashMessage(source.message)) return "stale";
|
|
3278
3440
|
if (lock.target !== hashMessage(target.message)) return "edited";
|
|
3441
|
+
if (target.needsReview && lock.state === "approved") return "draft";
|
|
3279
3442
|
return lock.state;
|
|
3280
3443
|
}
|
|
3281
3444
|
function editedByHand(target, lock) {
|
|
@@ -3305,6 +3468,15 @@ function computeStatus(project, locales = projectLocales(project)) {
|
|
|
3305
3468
|
function statusNotes(project, locales = projectLocales(project)) {
|
|
3306
3469
|
const notes = [];
|
|
3307
3470
|
for (const { target: loaded } of project.targets) {
|
|
3471
|
+
for (const issue of loaded.issues) {
|
|
3472
|
+
if (!issue.blocksCompleteness || issue.severity === "error") continue;
|
|
3473
|
+
notes.push({
|
|
3474
|
+
target: loaded.id,
|
|
3475
|
+
severity: "warning",
|
|
3476
|
+
code: issue.code,
|
|
3477
|
+
message: `${issue.file ? `${issue.file}: ` : ""}${issue.message}`
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3308
3480
|
const errors = loaded.issues.filter((i) => i.severity === "error");
|
|
3309
3481
|
const byCode = /* @__PURE__ */ new Map();
|
|
3310
3482
|
for (const e of errors) byCode.set(e.code, (byCode.get(e.code) ?? 0) + 1);
|
|
@@ -3327,7 +3499,7 @@ function statusNotes(project, locales = projectLocales(project)) {
|
|
|
3327
3499
|
locale: locale2,
|
|
3328
3500
|
severity: "warning",
|
|
3329
3501
|
code: "orphan",
|
|
3330
|
-
message: `${orphans.length} translation${orphans.length === 1 ? "" : "s"}
|
|
3502
|
+
message: `${orphans.length} translation${orphans.length === 1 ? "" : "s"} without a matching source; restore missing defaults or, after confirming the keys are unused, remove them with \`localship prune\``
|
|
3331
3503
|
});
|
|
3332
3504
|
}
|
|
3333
3505
|
}
|
|
@@ -3520,31 +3692,30 @@ function printPlan(p) {
|
|
|
3520
3692
|
if (p.locales.length) {
|
|
3521
3693
|
const per = Math.max(0, ...Object.values(p.work));
|
|
3522
3694
|
const total = Object.values(p.work).reduce((a, b) => a + b, 0);
|
|
3523
|
-
const
|
|
3524
|
-
const count = p.locales.length > 1 ? `${about}${per} strings, ${total} translations` : `${about}${per} strings`;
|
|
3695
|
+
const count = p.locales.length > 1 ? `${per} strings, ${total} translations` : `${per} strings`;
|
|
3525
3696
|
console.log(
|
|
3526
3697
|
`Languages: ${p.locales.map((l) => `${l.name} (${l.code}${l.isNew ? ", new" : ""})`).join(", ")} \xB7 ${count}`
|
|
3527
3698
|
);
|
|
3528
3699
|
}
|
|
3529
3700
|
for (const w of p.warnings) console.log(`${yellow("\u25B2")} ${w}`);
|
|
3530
|
-
for (const b of p.blockers) console.log(`${yellow("\u25B2")} ${b.message}`);
|
|
3531
|
-
console.log();
|
|
3532
|
-
|
|
3533
|
-
for (const [i, s] of p.steps.entries()) console.log(` ${i + 1}. ${s}`);
|
|
3534
|
-
if (p.locales.length || p.defaults.keepAsWritten.length) {
|
|
3701
|
+
for (const b of p.blockers) console.log(`${yellow("\u25B2")} ${b.message} ${dim(b.fix)}`);
|
|
3702
|
+
for (const n of p.notes) console.log(`${dim("\xB7")} ${n}`);
|
|
3703
|
+
if (p.locales.length) {
|
|
3535
3704
|
console.log();
|
|
3536
|
-
console.log(bold("
|
|
3537
|
-
for (const [l, v] of Object.entries(p.
|
|
3538
|
-
|
|
3539
|
-
console.log(` Kept as written: ${p.defaults.keepAsWritten.join(", ")}`);
|
|
3705
|
+
console.log(bold("Translators go by"));
|
|
3706
|
+
for (const [l, v] of Object.entries(p.voice)) {
|
|
3707
|
+
console.log(` ${l}: ${v.text}${v.fromProject ? "" : dim(" (usual convention)")}`);
|
|
3540
3708
|
}
|
|
3709
|
+
if (p.keepAsWritten.length) console.log(` Kept as written: ${p.keepAsWritten.join(", ")}`);
|
|
3710
|
+
if (!p.brief)
|
|
3711
|
+
console.log(dim(" No brief yet (.localship/brief.md); the README gives context."));
|
|
3541
3712
|
}
|
|
3542
3713
|
console.log();
|
|
3543
3714
|
const codes = p.locales.map((l) => l.code).join(",");
|
|
3544
3715
|
console.log(
|
|
3545
|
-
|
|
3546
|
-
`Ready.
|
|
3547
|
-
) :
|
|
3716
|
+
p.ready ? green(
|
|
3717
|
+
`Ready. Ask your agent to translate, or run \`localship start ${codes} --model \u2026\` to translate here.`
|
|
3718
|
+
) : yellow("Sort out the item above first.")
|
|
3548
3719
|
);
|
|
3549
3720
|
}
|
|
3550
3721
|
|
|
@@ -3982,6 +4153,7 @@ function inconsistencies(project, locales) {
|
|
|
3982
4153
|
if (!src.translatable || src.message.kind !== "text") continue;
|
|
3983
4154
|
const tgt = catalog.entries.get(src.key);
|
|
3984
4155
|
if (tgt?.message.kind !== "text") continue;
|
|
4156
|
+
if (getLockEntry(project.lock, target.id, locale2, src.key)?.note) continue;
|
|
3985
4157
|
const source = normalize(target.analyze(src.message.value, src).text);
|
|
3986
4158
|
const group = `${src.context ?? ""}${source}`;
|
|
3987
4159
|
const translation2 = normalize(target.analyze(tgt.message.value, src).text);
|
|
@@ -4004,7 +4176,7 @@ function inconsistencies(project, locales) {
|
|
|
4004
4176
|
out.push({
|
|
4005
4177
|
severity: "warning",
|
|
4006
4178
|
code: "inconsistent",
|
|
4007
|
-
message: `"${short(source)}" is translated ${ways.size} ways: ${listed}.
|
|
4179
|
+
message: `"${short(source)}" is translated ${ways.size} ways: ${listed}. If the context differs, add a note to the translation saying how`,
|
|
4008
4180
|
locale: locale2,
|
|
4009
4181
|
...odd ? { target: odd.target, key: odd.entry.key, file: odd.entry.file, line: odd.entry.line } : {}
|
|
4010
4182
|
});
|
|
@@ -4063,9 +4235,21 @@ function width(text) {
|
|
|
4063
4235
|
// src/core/validate.ts
|
|
4064
4236
|
function validateProject(project, opts = {}) {
|
|
4065
4237
|
const locales = opts.locales ?? projectLocales(project);
|
|
4238
|
+
const strict = opts.strict || opts.requireApproved;
|
|
4066
4239
|
const issues = [];
|
|
4240
|
+
if (opts.requireApproved && locales.length === 0) {
|
|
4241
|
+
issues.push({
|
|
4242
|
+
severity: "error",
|
|
4243
|
+
code: "locales-empty",
|
|
4244
|
+
message: "No target locales to check; configure locales or select them with --locale"
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4067
4247
|
for (const { target: loaded } of project.targets) {
|
|
4068
|
-
issues.push(
|
|
4248
|
+
issues.push(
|
|
4249
|
+
...loaded.issues.map(
|
|
4250
|
+
(issue) => strict && issue.blocksCompleteness ? { ...issue, severity: "error" } : issue
|
|
4251
|
+
)
|
|
4252
|
+
);
|
|
4069
4253
|
const at = (e, locale2) => ({
|
|
4070
4254
|
target: loaded.id,
|
|
4071
4255
|
locale: locale2,
|
|
@@ -4083,7 +4267,7 @@ function validateProject(project, opts = {}) {
|
|
|
4083
4267
|
const catalog = loaded.locales.get(locale2);
|
|
4084
4268
|
if (!catalog) {
|
|
4085
4269
|
issues.push({
|
|
4086
|
-
severity:
|
|
4270
|
+
severity: strict ? "error" : "info",
|
|
4087
4271
|
code: "locale-missing",
|
|
4088
4272
|
message: `No ${locale2} translations yet`,
|
|
4089
4273
|
target: loaded.id,
|
|
@@ -4106,7 +4290,7 @@ function validateProject(project, opts = {}) {
|
|
|
4106
4290
|
for (const src of loaded.source.entries.values()) {
|
|
4107
4291
|
if (src.translatable && !catalog.entries.has(src.key)) {
|
|
4108
4292
|
missing++;
|
|
4109
|
-
if (
|
|
4293
|
+
if (strict) {
|
|
4110
4294
|
issues.push({
|
|
4111
4295
|
...at(src, locale2),
|
|
4112
4296
|
severity: "error",
|
|
@@ -4116,7 +4300,7 @@ function validateProject(project, opts = {}) {
|
|
|
4116
4300
|
}
|
|
4117
4301
|
}
|
|
4118
4302
|
}
|
|
4119
|
-
if (missing > 0 && !
|
|
4303
|
+
if (missing > 0 && !strict) {
|
|
4120
4304
|
issues.push({
|
|
4121
4305
|
severity: "info",
|
|
4122
4306
|
code: "missing",
|
|
@@ -4131,9 +4315,9 @@ function validateProject(project, opts = {}) {
|
|
|
4131
4315
|
if (!src) {
|
|
4132
4316
|
issues.push({
|
|
4133
4317
|
...where,
|
|
4134
|
-
severity: "warning",
|
|
4318
|
+
severity: strict && loaded.format === "android-xml" ? "error" : "warning",
|
|
4135
4319
|
code: "orphan",
|
|
4136
|
-
message: "Not in the source strings any more"
|
|
4320
|
+
message: loaded.format === "android-xml" ? "No matching default resource in this target's values folder; restore the default, or remove this translation only if the key is no longer used. Check merged resources with Android lint." : "Not in the source strings any more"
|
|
4137
4321
|
});
|
|
4138
4322
|
continue;
|
|
4139
4323
|
}
|
|
@@ -4147,11 +4331,27 @@ function validateProject(project, opts = {}) {
|
|
|
4147
4331
|
continue;
|
|
4148
4332
|
}
|
|
4149
4333
|
if (!src.translatable) continue;
|
|
4334
|
+
if (opts.requireApproved) {
|
|
4335
|
+
const state = entryState(
|
|
4336
|
+
src,
|
|
4337
|
+
tgt,
|
|
4338
|
+
getLockEntry(project.lock, loaded.id, locale2, src.key)
|
|
4339
|
+
);
|
|
4340
|
+
if (state !== "approved") {
|
|
4341
|
+
const reason = state === "stale" ? "Source changed since this translation was recorded" : state === "edited" ? "Translation was edited since it was recorded" : `Translation is ${state}`;
|
|
4342
|
+
issues.push({
|
|
4343
|
+
...where,
|
|
4344
|
+
severity: "error",
|
|
4345
|
+
code: "not-approved",
|
|
4346
|
+
message: `${reason}; review it against the current source and approve it`
|
|
4347
|
+
});
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4150
4350
|
issues.push(
|
|
4151
4351
|
...checkTranslation(loaded, spec, src, tgt, {
|
|
4152
4352
|
locale: locale2,
|
|
4153
4353
|
sourceLocale: project.config.sourceLocale,
|
|
4154
|
-
glossary: project
|
|
4354
|
+
glossary: projectGlossary(project),
|
|
4155
4355
|
role: opts.usages ? roleOf(opts.usages.find(loaded, src)) : void 0
|
|
4156
4356
|
}).map((i) => ({ ...where, ...i }))
|
|
4157
4357
|
);
|
|
@@ -4451,8 +4651,8 @@ function buildMemory(project, locales) {
|
|
|
4451
4651
|
}
|
|
4452
4652
|
return memory;
|
|
4453
4653
|
}
|
|
4454
|
-
function differentWording(target, src, message, locale2, memory, thisBatch) {
|
|
4455
|
-
if (message.kind !== "text" || src.context) return void 0;
|
|
4654
|
+
function differentWording(target, src, message, locale2, memory, thisBatch, note) {
|
|
4655
|
+
if (message.kind !== "text" || src.context || note) return void 0;
|
|
4456
4656
|
const source = normalize2(plainText(target, src));
|
|
4457
4657
|
if (!source) return void 0;
|
|
4458
4658
|
const value = normalize2(message.value);
|
|
@@ -4467,7 +4667,7 @@ function differentWording(target, src, message, locale2, memory, thisBatch) {
|
|
|
4467
4667
|
return {
|
|
4468
4668
|
severity: "warning",
|
|
4469
4669
|
code: "inconsistent",
|
|
4470
|
-
message: `"${source}" is already translated as "${other.value}" (${other.key}). Use the same wording
|
|
4670
|
+
message: `"${source}" is already translated as "${other.value}" (${other.key}). Use the same wording, or add a note saying how the context differs`
|
|
4471
4671
|
};
|
|
4472
4672
|
}
|
|
4473
4673
|
async function nextBatch(project, opts = {}) {
|
|
@@ -4478,7 +4678,7 @@ async function nextBatch(project, opts = {}) {
|
|
|
4478
4678
|
const offset = opts.offset ?? 0;
|
|
4479
4679
|
const wanted = opts.keys ? new Set(opts.keys) : void 0;
|
|
4480
4680
|
const memory = buildMemory(project, locales);
|
|
4481
|
-
const glossary = project
|
|
4681
|
+
const glossary = projectGlossary(project);
|
|
4482
4682
|
const items = [];
|
|
4483
4683
|
let matched = 0;
|
|
4484
4684
|
for (const locale2 of locales) {
|
|
@@ -4567,7 +4767,13 @@ async function nextBatch(project, opts = {}) {
|
|
|
4567
4767
|
locales: Object.fromEntries(
|
|
4568
4768
|
usedLocales.map((l) => {
|
|
4569
4769
|
const voice = voiceFor(project.context, l);
|
|
4570
|
-
return [
|
|
4770
|
+
return [
|
|
4771
|
+
l,
|
|
4772
|
+
{
|
|
4773
|
+
language: languageName(l),
|
|
4774
|
+
...voice ? { voice } : { defaultVoice: conventionalVoice(l) }
|
|
4775
|
+
}
|
|
4776
|
+
];
|
|
4571
4777
|
})
|
|
4572
4778
|
),
|
|
4573
4779
|
formats: Object.fromEntries(
|
|
@@ -4621,6 +4827,13 @@ ${details.join("\n")}`
|
|
|
4621
4827
|
}
|
|
4622
4828
|
var REPLACEABLE = ["missing", "stale", "draft"];
|
|
4623
4829
|
async function submit(project, submissions, opts = {}) {
|
|
4830
|
+
return withProjectWrite(
|
|
4831
|
+
project,
|
|
4832
|
+
opts.dryRun,
|
|
4833
|
+
(fresh) => submitUnlocked(fresh, submissions, opts)
|
|
4834
|
+
);
|
|
4835
|
+
}
|
|
4836
|
+
async function submitUnlocked(project, submissions, opts) {
|
|
4624
4837
|
const result = { accepted: [], rejected: [], files: [], dryRun: !!opts.dryRun };
|
|
4625
4838
|
const configured = projectLocales(project);
|
|
4626
4839
|
const pending = /* @__PURE__ */ new Map();
|
|
@@ -4660,6 +4873,17 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4660
4873
|
continue;
|
|
4661
4874
|
}
|
|
4662
4875
|
const { target, src } = found;
|
|
4876
|
+
const expected = opts.expectedSources?.get(
|
|
4877
|
+
itemId({ target: target.id, locale: locale2, key: displayKey(src.key) })
|
|
4878
|
+
);
|
|
4879
|
+
if (expected !== void 0 && JSON.stringify(expected) !== JSON.stringify(toValue(src.message))) {
|
|
4880
|
+
reject(
|
|
4881
|
+
"The source changed while the model was translating; translate the current source",
|
|
4882
|
+
"source-changed",
|
|
4883
|
+
target.id
|
|
4884
|
+
);
|
|
4885
|
+
continue;
|
|
4886
|
+
}
|
|
4663
4887
|
const id2 = `${target.id}\0${locale2}\0${src.key}`;
|
|
4664
4888
|
if (seen.has(id2)) {
|
|
4665
4889
|
reject("Submitted twice in one batch", "duplicate", target.id);
|
|
@@ -4702,7 +4926,7 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4702
4926
|
...checkTranslation(target, target.pluralSpec(locale2), src, tgt, {
|
|
4703
4927
|
locale: locale2,
|
|
4704
4928
|
sourceLocale: project.config.sourceLocale,
|
|
4705
|
-
glossary: project
|
|
4929
|
+
glossary: projectGlossary(project),
|
|
4706
4930
|
role: usages ? roleOf(usages.find(target, src)) : void 0
|
|
4707
4931
|
}).filter((f) => f.code !== "plural-missing-category" && f.code !== "plural-extra-category"),
|
|
4708
4932
|
...checkPluralForms(target, locale2, message)
|
|
@@ -4712,7 +4936,15 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4712
4936
|
result.rejected.push({ target: target.id, locale: locale2, key: displayKey(src.key), errors });
|
|
4713
4937
|
continue;
|
|
4714
4938
|
}
|
|
4715
|
-
const differs = differentWording(
|
|
4939
|
+
const differs = differentWording(
|
|
4940
|
+
target,
|
|
4941
|
+
src,
|
|
4942
|
+
message,
|
|
4943
|
+
locale2,
|
|
4944
|
+
memory,
|
|
4945
|
+
thisBatch,
|
|
4946
|
+
sub.note?.trim()
|
|
4947
|
+
);
|
|
4716
4948
|
if (differs) findings.push(differs);
|
|
4717
4949
|
result.accepted.push({
|
|
4718
4950
|
target: target.id,
|
|
@@ -4870,6 +5102,9 @@ function pruneLock(project, lock) {
|
|
|
4870
5102
|
}
|
|
4871
5103
|
}
|
|
4872
5104
|
async function approve(project, opts = {}) {
|
|
5105
|
+
return withProjectWrite(project, opts.dryRun, (fresh) => approveUnlocked(fresh, opts));
|
|
5106
|
+
}
|
|
5107
|
+
async function approveUnlocked(project, opts) {
|
|
4873
5108
|
const locales = opts.locales ?? projectLocales(project);
|
|
4874
5109
|
const states = opts.states ?? ["draft", "reviewed"];
|
|
4875
5110
|
const to = opts.to ?? "approved";
|
|
@@ -4918,6 +5153,9 @@ async function approve(project, opts = {}) {
|
|
|
4918
5153
|
}
|
|
4919
5154
|
var DISPOSABLE = ["draft", "stale"];
|
|
4920
5155
|
async function removeTranslations(project, opts) {
|
|
5156
|
+
return withProjectWrite(project, opts.dryRun, (fresh) => removeUnlocked(fresh, opts));
|
|
5157
|
+
}
|
|
5158
|
+
async function removeUnlocked(project, opts) {
|
|
4921
5159
|
if (!opts.keys?.length && !opts.states?.length) {
|
|
4922
5160
|
throw new UserError("Say which translations to remove: keys, states (e.g. draft), or both");
|
|
4923
5161
|
}
|
|
@@ -4963,6 +5201,9 @@ async function removeTranslations(project, opts) {
|
|
|
4963
5201
|
return result;
|
|
4964
5202
|
}
|
|
4965
5203
|
async function prune(project, opts = {}) {
|
|
5204
|
+
return withProjectWrite(project, opts.dryRun, (fresh) => pruneUnlocked(fresh, opts));
|
|
5205
|
+
}
|
|
5206
|
+
async function pruneUnlocked(project, opts) {
|
|
4966
5207
|
const result = { removed: [], lockEntries: 0, files: [], dryRun: !!opts.dryRun };
|
|
4967
5208
|
const plan2 = /* @__PURE__ */ new Map();
|
|
4968
5209
|
for (const { target } of project.targets) {
|
|
@@ -5004,29 +5245,6 @@ async function prune(project, opts = {}) {
|
|
|
5004
5245
|
|
|
5005
5246
|
// src/core/plan.ts
|
|
5006
5247
|
import path16 from "path";
|
|
5007
|
-
import { stringify as toYaml } from "yaml";
|
|
5008
|
-
var VOICE_DEFAULTS = {
|
|
5009
|
-
hi: "Address the user as \u0906\u092A (polite). Everyday Hindi over Sanskritized words; common English tech words (app, sync) may stay in English.",
|
|
5010
|
-
ar: "Modern Standard Arabic, addressing the user neutrally. Short sentences.",
|
|
5011
|
-
de: "Address the user as du, as most consumer apps do.",
|
|
5012
|
-
fr: "Address the user as vous.",
|
|
5013
|
-
es: "Address the user as t\xFA. Neutral Spanish that works in Spain and Latin America.",
|
|
5014
|
-
it: "Address the user as tu.",
|
|
5015
|
-
pt: "Address the user as voc\xEA (Brazilian Portuguese conventions).",
|
|
5016
|
-
nl: "Address the user as je.",
|
|
5017
|
-
ru: "Address the user as \u0432\u044B (lowercase).",
|
|
5018
|
-
ja: "Polite \u3067\u3059/\u307E\u3059 form. Short, natural UI phrasing.",
|
|
5019
|
-
ko: "Polite \uD574\uC694\uCCB4. Short, natural UI phrasing.",
|
|
5020
|
-
zh: "Neutral, concise UI language; no honorifics needed.",
|
|
5021
|
-
tr: "Address the user as siz.",
|
|
5022
|
-
pl: "Address the user informally (ty), as most consumer apps do.",
|
|
5023
|
-
id: "Address the user as Anda.",
|
|
5024
|
-
vi: "Address the user as b\u1EA1n.",
|
|
5025
|
-
th: "Polite, neutral tone; use \u0E04\u0E23\u0E31\u0E1A/\u0E04\u0E48\u0E30 only where a sentence needs it."
|
|
5026
|
-
};
|
|
5027
|
-
function defaultVoice(locale2) {
|
|
5028
|
-
return VOICE_DEFAULTS[locale2] ?? VOICE_DEFAULTS[locale2.split("-")[0]] ?? "Polite, neutral tone, as a well-made app in this language would use.";
|
|
5029
|
-
}
|
|
5030
5248
|
var COMMON_LANGUAGES = "af am ar az be bg bn bs ca cs cy da de el en es et eu fa fi fil fr ga gl gu he hi hr hu hy id is it ja ka kk km kn ko ky lo lt lv mk ml mn mr ms my nb ne nl or pa pl ps pt ro ru si sk sl sq sr sv sw ta te th tl tr uk ur uz vi zh zu pt-BR pt-PT zh-Hans zh-Hant es-419 fr-CA en-GB".split(" ");
|
|
5031
5249
|
var NAMES = new Intl.DisplayNames(["en"], { type: "language" });
|
|
5032
5250
|
function resolveLocale(input) {
|
|
@@ -5047,7 +5265,7 @@ function resolveLocale(input) {
|
|
|
5047
5265
|
}
|
|
5048
5266
|
async function appNames(fs, root, result) {
|
|
5049
5267
|
const names = /* @__PURE__ */ new Set();
|
|
5050
|
-
for (const c of result
|
|
5268
|
+
for (const c of result.candidates) {
|
|
5051
5269
|
if (c.target.format !== "android-xml") continue;
|
|
5052
5270
|
try {
|
|
5053
5271
|
const xml = await fs.readFile(path16.join(root, c.target.path, "values", "strings.xml"));
|
|
@@ -5056,12 +5274,6 @@ async function appNames(fs, root, result) {
|
|
|
5056
5274
|
} catch {
|
|
5057
5275
|
}
|
|
5058
5276
|
}
|
|
5059
|
-
try {
|
|
5060
|
-
const readme = await fs.readFile(path16.join(root, "README.md"));
|
|
5061
|
-
const heading = /^#\s+(.+)$/m.exec(readme)?.[1]?.replace(/[^\p{L}\p{N} .-]/gu, "").trim();
|
|
5062
|
-
if (heading && heading.split(/\s+/).length <= 3) names.add(heading);
|
|
5063
|
-
} catch {
|
|
5064
|
-
}
|
|
5065
5277
|
return [...names];
|
|
5066
5278
|
}
|
|
5067
5279
|
async function plan(fs, cwd, opts = {}) {
|
|
@@ -5074,12 +5286,14 @@ async function plan(fs, cwd, opts = {}) {
|
|
|
5074
5286
|
}
|
|
5075
5287
|
return project ? planConfigured(project, requested) : planNew(fs, cwd, requested);
|
|
5076
5288
|
}
|
|
5289
|
+
var EXTRACT = "They won't be translated until they're moved into string resources (the localship-extract skill): part of making the app work in a language, otherwise only when the user asks.";
|
|
5077
5290
|
async function planNew(fs, cwd, requested) {
|
|
5078
5291
|
const result = await detect(fs, path16.resolve(cwd));
|
|
5079
5292
|
const picked = defaultSelection(result).map((n) => result.candidates[n - 1]);
|
|
5080
5293
|
const chosen = picked.filter((c) => c !== void 0);
|
|
5081
5294
|
const locales = requested.length ? requested : result.locales;
|
|
5082
5295
|
const blockers = [];
|
|
5296
|
+
const notes = [];
|
|
5083
5297
|
if (chosen.length === 0) {
|
|
5084
5298
|
const unsupported = result.unsupported.map((u) => u.format).join(", ");
|
|
5085
5299
|
blockers.push(
|
|
@@ -5090,40 +5304,29 @@ async function planNew(fs, cwd, requested) {
|
|
|
5090
5304
|
} : {
|
|
5091
5305
|
kind: "no-translation-files",
|
|
5092
5306
|
message: `No translation files yet. ${result.hints[0] ?? ""}`.trim(),
|
|
5093
|
-
fix: "Move the UI text out of the code into string resources (the localship-extract skill)
|
|
5307
|
+
fix: "Move the UI text out of the code into string resources (the localship-extract skill): part of making the app work in a language, otherwise only when the user asks."
|
|
5094
5308
|
}
|
|
5095
5309
|
);
|
|
5096
5310
|
}
|
|
5097
5311
|
for (const c of chosen) {
|
|
5098
5312
|
if (c.codeLiterals !== void 0 && mostlyHardcoded(c.codeLiterals, c.strings)) {
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
fix: `First move that text into ${c.target.path}/values/strings.xml on a new branch (the localship-extract skill), building as it goes, then translate.`
|
|
5103
|
-
});
|
|
5313
|
+
notes.push(
|
|
5314
|
+
`About ${c.codeLiterals} pieces of UI text are still in the code, against ${c.strings} strings in ${c.target.path}. ${EXTRACT}`
|
|
5315
|
+
);
|
|
5104
5316
|
}
|
|
5105
5317
|
if (c.target.format === "xcstrings" && c.strings === 0) {
|
|
5106
5318
|
blockers.push({
|
|
5107
5319
|
kind: "empty-catalog",
|
|
5108
5320
|
message: `${c.target.path} is empty.`,
|
|
5109
|
-
fix: "Build the
|
|
5321
|
+
fix: "Build the targets that use it, then fill it: Xcode's catalog editor does this with the project open; from the command line, run `xcrun xcstringstool sync <catalog> --stringsdata <each target's .stringsdata in DerivedData>`, since a command-line build alone leaves the catalog unchanged."
|
|
5110
5322
|
});
|
|
5111
5323
|
}
|
|
5112
5324
|
}
|
|
5113
|
-
if (locales.length === 0)
|
|
5114
|
-
|
|
5115
|
-
kind: "no-locales",
|
|
5116
|
-
message: "No languages chosen yet.",
|
|
5117
|
-
fix: "Tell me which languages to translate into (for example: Hindi and Arabic)."
|
|
5118
|
-
});
|
|
5119
|
-
}
|
|
5120
|
-
const strings = chosen.reduce(
|
|
5121
|
-
(n, c) => n + c.strings + (c.codeLiterals !== void 0 && mostlyHardcoded(c.codeLiterals, c.strings) ? c.codeLiterals : 0),
|
|
5122
|
-
0
|
|
5123
|
-
);
|
|
5124
|
-
const keep = await appNames(fs, path16.resolve(cwd), result);
|
|
5325
|
+
if (locales.length === 0) blockers.push(noLocales);
|
|
5326
|
+
const strings = chosen.reduce((n, c) => n + c.strings, 0);
|
|
5125
5327
|
return build({
|
|
5126
5328
|
warnings: result.otherSystems.map((o) => o.message),
|
|
5329
|
+
notes,
|
|
5127
5330
|
configured: false,
|
|
5128
5331
|
sourceLocale: result.sourceLocale.value,
|
|
5129
5332
|
locales: locales.map((code) => ({ code, name: languageName(code), isNew: true })),
|
|
@@ -5134,10 +5337,16 @@ async function planNew(fs, cwd, requested) {
|
|
|
5134
5337
|
})),
|
|
5135
5338
|
work: Object.fromEntries(locales.map((l) => [l, strings])),
|
|
5136
5339
|
blockers,
|
|
5137
|
-
keep,
|
|
5138
|
-
|
|
5340
|
+
keep: await appNames(fs, path16.resolve(cwd), result),
|
|
5341
|
+
brief: false,
|
|
5342
|
+
voice: () => void 0
|
|
5139
5343
|
});
|
|
5140
5344
|
}
|
|
5345
|
+
var noLocales = {
|
|
5346
|
+
kind: "no-locales",
|
|
5347
|
+
message: "No languages chosen yet.",
|
|
5348
|
+
fix: "Ask the user which languages to translate into."
|
|
5349
|
+
};
|
|
5141
5350
|
async function planConfigured(project, requested) {
|
|
5142
5351
|
const configured = projectLocales(project);
|
|
5143
5352
|
const locales = [.../* @__PURE__ */ new Set([...configured, ...requested])].filter(
|
|
@@ -5155,22 +5364,12 @@ async function planConfigured(project, requested) {
|
|
|
5155
5364
|
);
|
|
5156
5365
|
}
|
|
5157
5366
|
}
|
|
5158
|
-
const
|
|
5159
|
-
kind: "mostly-hardcoded",
|
|
5160
|
-
message: n.message,
|
|
5161
|
-
fix: "First move that text into strings.xml on a new branch (the localship-extract skill), then translate."
|
|
5162
|
-
}));
|
|
5163
|
-
if (focus.length === 0) {
|
|
5164
|
-
blockers.push({
|
|
5165
|
-
kind: "no-locales",
|
|
5166
|
-
message: "No languages chosen yet.",
|
|
5167
|
-
fix: "Tell me which languages to translate into."
|
|
5168
|
-
});
|
|
5169
|
-
}
|
|
5367
|
+
const notes = (await hardcodedNotes(project)).map((n) => `${n.message} ${EXTRACT}`);
|
|
5170
5368
|
const { files } = await walk(project.fs, project.root);
|
|
5171
5369
|
const others = await findOtherSystems(project.fs, project.root, files);
|
|
5172
5370
|
return build({
|
|
5173
5371
|
warnings: others.map((o) => o.message),
|
|
5372
|
+
notes,
|
|
5174
5373
|
configured: true,
|
|
5175
5374
|
sourceLocale: project.config.sourceLocale,
|
|
5176
5375
|
locales: focus.map((code) => ({
|
|
@@ -5184,43 +5383,18 @@ async function planConfigured(project, requested) {
|
|
|
5184
5383
|
strings: [...t.target.source.entries.values()].filter((e) => e.translatable).length
|
|
5185
5384
|
})),
|
|
5186
5385
|
work,
|
|
5187
|
-
blockers,
|
|
5188
|
-
keep: project.
|
|
5189
|
-
|
|
5190
|
-
|
|
5386
|
+
blockers: focus.length ? [] : [noLocales],
|
|
5387
|
+
keep: projectGlossary(project).filter((t) => !t.translate).map((t) => t.term),
|
|
5388
|
+
brief: !!project.context.brief,
|
|
5389
|
+
voice: (l) => voiceFor(project.context, l)
|
|
5191
5390
|
});
|
|
5192
5391
|
}
|
|
5193
5392
|
function build(p) {
|
|
5194
5393
|
const voice = Object.fromEntries(
|
|
5195
|
-
p.locales.map((l) =>
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
const total = Math.max(0, ...Object.values(p.work));
|
|
5200
|
-
const steps = [];
|
|
5201
|
-
for (const b of fixFirst) steps.push(b.fix);
|
|
5202
|
-
if (!p.configured) {
|
|
5203
|
-
steps.push(
|
|
5204
|
-
`Set up Localship for ${p.targets.map((t) => t.path).join(", ") || "the app"} with ${names || "your languages"} (localship_start).`
|
|
5205
|
-
);
|
|
5206
|
-
} else if (p.locales.some((l) => l.isNew)) {
|
|
5207
|
-
steps.push(
|
|
5208
|
-
`Add ${p.locales.filter((l) => l.isNew).map((l) => l.name).join(" and ")} (localship_start).`
|
|
5209
|
-
);
|
|
5210
|
-
}
|
|
5211
|
-
if (!p.hasBrief) {
|
|
5212
|
-
steps.push(
|
|
5213
|
-
"Draft a short brief from your README and strings, so translations match the app's tone."
|
|
5214
|
-
);
|
|
5215
|
-
}
|
|
5216
|
-
const about = fixFirst.some((b) => b.kind === "mostly-hardcoded") ? "about " : "";
|
|
5217
|
-
steps.push(
|
|
5218
|
-
`Translate ${total ? `${about}${total}` : "the"} strings${names ? ` into ${names}` : ""}, checking each one (localship_next_batch \u2192 localship_submit until nothing remains).`,
|
|
5219
|
-
"Run all checks (localship_validate) and report what was done.",
|
|
5220
|
-
"Leave everything as drafts for you to review; nothing is approved without you."
|
|
5221
|
-
);
|
|
5222
|
-
const blocked = p.blockers.some(
|
|
5223
|
-
(b) => b.kind === "unsupported-format" || b.kind === "no-locales"
|
|
5394
|
+
p.locales.map((l) => {
|
|
5395
|
+
const own = p.voice(l.code);
|
|
5396
|
+
return [l.code, { text: own ?? conventionalVoice(l.code), fromProject: !!own }];
|
|
5397
|
+
})
|
|
5224
5398
|
);
|
|
5225
5399
|
return {
|
|
5226
5400
|
ready: p.blockers.length === 0,
|
|
@@ -5230,27 +5404,18 @@ function build(p) {
|
|
|
5230
5404
|
targets: p.targets,
|
|
5231
5405
|
work: p.work,
|
|
5232
5406
|
warnings: p.warnings,
|
|
5407
|
+
notes: p.notes,
|
|
5233
5408
|
blockers: p.blockers,
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
brief: p.hasBrief ? "exists" : "will be drafted"
|
|
5238
|
-
},
|
|
5239
|
-
steps,
|
|
5240
|
-
confirm: blocked ? "" : fixFirst.length ? "start" : "start translating"
|
|
5409
|
+
voice,
|
|
5410
|
+
keepAsWritten: p.keep,
|
|
5411
|
+
brief: p.brief
|
|
5241
5412
|
};
|
|
5242
5413
|
}
|
|
5243
5414
|
async function start(fs, cwd, opts) {
|
|
5244
5415
|
const locales = opts.locales.map(resolveLocale);
|
|
5245
5416
|
if (locales.length === 0) throw new UserError("Say which languages to translate into");
|
|
5246
5417
|
const root = path16.resolve(cwd);
|
|
5247
|
-
const result = {
|
|
5248
|
-
config: "",
|
|
5249
|
-
createdConfig: false,
|
|
5250
|
-
addedLocales: [],
|
|
5251
|
-
wrote: [],
|
|
5252
|
-
warnings: []
|
|
5253
|
-
};
|
|
5418
|
+
const result = { config: "", createdConfig: false, addedLocales: [], warnings: [] };
|
|
5254
5419
|
let existing;
|
|
5255
5420
|
try {
|
|
5256
5421
|
existing = (await loadConfig(fs, root)).file;
|
|
@@ -5260,49 +5425,30 @@ async function start(fs, cwd, opts) {
|
|
|
5260
5425
|
if (existing) {
|
|
5261
5426
|
result.config = existing;
|
|
5262
5427
|
result.addedLocales = (await addLocales(fs, existing, locales)).added;
|
|
5263
|
-
|
|
5264
|
-
const out = [];
|
|
5265
|
-
const code = await runInit(
|
|
5266
|
-
fs,
|
|
5267
|
-
{ cwd: root, yes: true, locales },
|
|
5268
|
-
{
|
|
5269
|
-
interactive: false,
|
|
5270
|
-
log: (l) => out.push(l),
|
|
5271
|
-
error: (l) => out.push(l),
|
|
5272
|
-
ask: async (_q, f) => f
|
|
5273
|
-
}
|
|
5274
|
-
);
|
|
5275
|
-
if (code !== 0) throw new UserError(out.join("\n"));
|
|
5276
|
-
result.config = path16.join(root, CONFIG_FILE);
|
|
5277
|
-
result.createdConfig = true;
|
|
5278
|
-
result.addedLocales = locales;
|
|
5279
|
-
result.warnings = out.filter((l) => l.startsWith("\u25B2 ")).map((l) => l.slice(2));
|
|
5280
|
-
}
|
|
5281
|
-
const project = await openProject(fs, { cwd: root });
|
|
5282
|
-
const dir = project.context.dir;
|
|
5283
|
-
const write2 = async (rel, content) => {
|
|
5284
|
-
const file = path16.join(dir, rel);
|
|
5285
|
-
if (await fs.exists(file)) return;
|
|
5286
|
-
await fs.writeFile(file, content.endsWith("\n") ? content : `${content}
|
|
5287
|
-
`);
|
|
5288
|
-
result.wrote.push(rel);
|
|
5289
|
-
};
|
|
5290
|
-
for (const l of projectLocales(project)) {
|
|
5291
|
-
await write2(`${CONTEXT_FILES.voiceDir}/${l}.md`, opts.voice?.[l] ?? defaultVoice(l));
|
|
5292
|
-
}
|
|
5293
|
-
const keep = opts.keepAsWritten ?? await appNames(fs, root, await detect(fs, root));
|
|
5294
|
-
if (keep.length) {
|
|
5295
|
-
await write2(
|
|
5296
|
-
CONTEXT_FILES.glossary,
|
|
5297
|
-
toYaml({ terms: keep.map((term) => ({ term, translate: false })) }, { lineWidth: 0 })
|
|
5298
|
-
);
|
|
5428
|
+
return result;
|
|
5299
5429
|
}
|
|
5430
|
+
const out = [];
|
|
5431
|
+
const code = await runInit(
|
|
5432
|
+
fs,
|
|
5433
|
+
{ cwd: root, yes: true, locales },
|
|
5434
|
+
{
|
|
5435
|
+
interactive: false,
|
|
5436
|
+
log: (l) => out.push(l),
|
|
5437
|
+
error: (l) => out.push(l),
|
|
5438
|
+
ask: async (_q, f) => f
|
|
5439
|
+
}
|
|
5440
|
+
);
|
|
5441
|
+
if (code !== 0) throw new UserError(out.join("\n"));
|
|
5442
|
+
result.config = path16.join(root, CONFIG_FILE);
|
|
5443
|
+
result.createdConfig = true;
|
|
5444
|
+
result.addedLocales = locales;
|
|
5445
|
+
result.warnings = out.filter((l) => l.startsWith("\u25B2 ")).map((l) => l.slice(2));
|
|
5300
5446
|
return result;
|
|
5301
5447
|
}
|
|
5302
5448
|
|
|
5303
5449
|
// src/translate/prompt.ts
|
|
5304
5450
|
var TRANSLATION_RULES = `- Translate the source text of each item into the item's locale. Write what a native speaker would put in a well-made app: natural, concise UI language, not a word-for-word copy.
|
|
5305
|
-
- Follow the brief and the locale's voice. Work out where each text appears and what it does from its comment, context, and usage: the code that shows it, whose file path names the screen and whose code shows whether it is a button, title, label, hint or message.
|
|
5451
|
+
- Follow the brief and the locale's voice (defaultVoice when the project has set none: the usual convention for that language). Work out where each text appears and what it does from its comment, context, and usage: the code that shows it, whose file path names the screen and whose code shows whether it is a button, title, label, hint or message.
|
|
5306
5452
|
- Keep every placeholder exactly as written (%s, %1$s, %d, %@, {name}, %(count)d\u2026). You may move them to fit the grammar; numbered ones (%1$s) may be reordered freely.
|
|
5307
5453
|
- Keep markup and escapes exactly: tags such as <b> or <xliff:g>, entities, \\n, and any leading or trailing spaces and newlines.
|
|
5308
5454
|
- Glossary: a term marked keep stays exactly as written; a term with use must be translated that way.
|
|
@@ -5352,6 +5498,9 @@ function extractJson(reply) {
|
|
|
5352
5498
|
}
|
|
5353
5499
|
}
|
|
5354
5500
|
|
|
5501
|
+
// src/translate/translate.ts
|
|
5502
|
+
import { setTimeout as delay } from "timers/promises";
|
|
5503
|
+
|
|
5355
5504
|
// src/translate/providers.ts
|
|
5356
5505
|
var BUILT_IN = {
|
|
5357
5506
|
anthropic: {
|
|
@@ -5388,6 +5537,10 @@ function createCompletion(spec, opts = {}) {
|
|
|
5388
5537
|
const { provider, model } = parseModel(spec);
|
|
5389
5538
|
const env = opts.env ?? process.env;
|
|
5390
5539
|
const doFetch = opts.fetch ?? fetch;
|
|
5540
|
+
const timeoutMs = opts.timeoutMs ?? 12e4;
|
|
5541
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
|
|
5542
|
+
throw new UserError("timeoutMs must be a positive integer no greater than 2147483647");
|
|
5543
|
+
}
|
|
5391
5544
|
const custom = opts.custom?.[provider];
|
|
5392
5545
|
const builtIn = BUILT_IN[provider];
|
|
5393
5546
|
if (!custom && !builtIn) {
|
|
@@ -5402,30 +5555,44 @@ function createCompletion(spec, opts = {}) {
|
|
|
5402
5555
|
if (keyNames.length > 0 && !apiKey) {
|
|
5403
5556
|
throw new UserError(`Set ${keyNames[0]} to use ${provider} models.`);
|
|
5404
5557
|
}
|
|
5405
|
-
const post = async (url, headers, body) => {
|
|
5406
|
-
|
|
5558
|
+
const post = async (url, headers, body, external) => {
|
|
5559
|
+
const deadline = new AbortController();
|
|
5560
|
+
const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
|
|
5561
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
5407
5562
|
try {
|
|
5408
|
-
|
|
5563
|
+
signal.throwIfAborted();
|
|
5564
|
+
const res = await doFetch(url, {
|
|
5409
5565
|
method: "POST",
|
|
5410
5566
|
headers: { "content-type": "application/json", ...headers },
|
|
5411
|
-
body: JSON.stringify(body)
|
|
5567
|
+
body: JSON.stringify(body),
|
|
5568
|
+
signal
|
|
5412
5569
|
});
|
|
5570
|
+
const text = await res.text();
|
|
5571
|
+
signal.throwIfAborted();
|
|
5572
|
+
if (!res.ok) {
|
|
5573
|
+
throw new ProviderError(
|
|
5574
|
+
`${provider} returned ${res.status}: ${text.slice(0, 500)}`,
|
|
5575
|
+
res.status === 429 || res.status >= 500
|
|
5576
|
+
);
|
|
5577
|
+
}
|
|
5578
|
+
try {
|
|
5579
|
+
return JSON.parse(text);
|
|
5580
|
+
} catch {
|
|
5581
|
+
throw new ProviderError(
|
|
5582
|
+
`${provider} returned something that isn't JSON: ${text.slice(0, 200)}`
|
|
5583
|
+
);
|
|
5584
|
+
}
|
|
5413
5585
|
} catch (e) {
|
|
5586
|
+
if (external?.aborted) throw new UserError("Translation cancelled");
|
|
5587
|
+
if (deadline.signal.aborted) {
|
|
5588
|
+
throw new ProviderError(
|
|
5589
|
+
`${provider}: request timed out after ${timeoutMs} ms; retry or increase --timeout-ms`
|
|
5590
|
+
);
|
|
5591
|
+
}
|
|
5592
|
+
if (e instanceof ProviderError) throw e;
|
|
5414
5593
|
throw new ProviderError(`${provider}: could not reach ${baseUrl} (${e.message})`);
|
|
5415
|
-
}
|
|
5416
|
-
|
|
5417
|
-
if (!res.ok) {
|
|
5418
|
-
throw new ProviderError(
|
|
5419
|
-
`${provider} returned ${res.status}: ${text.slice(0, 500)}`,
|
|
5420
|
-
res.status === 429 || res.status >= 500
|
|
5421
|
-
);
|
|
5422
|
-
}
|
|
5423
|
-
try {
|
|
5424
|
-
return JSON.parse(text);
|
|
5425
|
-
} catch {
|
|
5426
|
-
throw new ProviderError(
|
|
5427
|
-
`${provider} returned something that isn't JSON: ${text.slice(0, 200)}`
|
|
5428
|
-
);
|
|
5594
|
+
} finally {
|
|
5595
|
+
clearTimeout(timer);
|
|
5429
5596
|
}
|
|
5430
5597
|
};
|
|
5431
5598
|
if (protocol === "anthropic") {
|
|
@@ -5438,7 +5605,8 @@ function createCompletion(spec, opts = {}) {
|
|
|
5438
5605
|
max_tokens: req.maxTokens ?? 16e3,
|
|
5439
5606
|
system: req.system,
|
|
5440
5607
|
messages: [{ role: "user", content: req.user }]
|
|
5441
|
-
}
|
|
5608
|
+
},
|
|
5609
|
+
req.signal
|
|
5442
5610
|
);
|
|
5443
5611
|
if (data.stop_reason === "max_tokens") {
|
|
5444
5612
|
throw new ProviderError(`${provider}: the response was cut off; use a smaller batch`);
|
|
@@ -5457,7 +5625,8 @@ function createCompletion(spec, opts = {}) {
|
|
|
5457
5625
|
{ role: "system", content: req.system },
|
|
5458
5626
|
{ role: "user", content: req.user }
|
|
5459
5627
|
]
|
|
5460
|
-
}
|
|
5628
|
+
},
|
|
5629
|
+
req.signal
|
|
5461
5630
|
);
|
|
5462
5631
|
const choice = data.choices?.[0];
|
|
5463
5632
|
if (choice?.finish_reason === "length") {
|
|
@@ -5487,15 +5656,38 @@ function modelFor(project, locale2, override) {
|
|
|
5487
5656
|
return model;
|
|
5488
5657
|
}
|
|
5489
5658
|
async function translateProject(start2, opts = {}) {
|
|
5659
|
+
const controller = new AbortController();
|
|
5660
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, controller.signal]) : controller.signal;
|
|
5661
|
+
try {
|
|
5662
|
+
return await runTranslation(start2, { ...opts, signal });
|
|
5663
|
+
} finally {
|
|
5664
|
+
controller.abort();
|
|
5665
|
+
}
|
|
5666
|
+
}
|
|
5667
|
+
async function runTranslation(start2, opts) {
|
|
5668
|
+
const checkCancelled = () => {
|
|
5669
|
+
if (opts.signal?.aborted) throw new UserError("Translation cancelled");
|
|
5670
|
+
};
|
|
5671
|
+
checkCancelled();
|
|
5490
5672
|
const log = opts.log ?? (() => {
|
|
5491
5673
|
});
|
|
5492
5674
|
const locales = opts.locales ?? projectLocales(start2);
|
|
5493
5675
|
if (locales.length === 0)
|
|
5494
5676
|
throw new UserError("No locales to translate. Add `locales:` to the config.");
|
|
5495
5677
|
const batchSize = opts.batchSize ?? 30;
|
|
5496
|
-
const concurrency =
|
|
5497
|
-
const maxAttempts =
|
|
5498
|
-
const
|
|
5678
|
+
const concurrency = opts.concurrency ?? 4;
|
|
5679
|
+
const maxAttempts = opts.attempts ?? 2;
|
|
5680
|
+
const maxPromptBytes = opts.maxPromptBytes ?? 48e3;
|
|
5681
|
+
for (const [name, value] of Object.entries({
|
|
5682
|
+
batchSize,
|
|
5683
|
+
concurrency,
|
|
5684
|
+
attempts: maxAttempts,
|
|
5685
|
+
maxPromptBytes
|
|
5686
|
+
})) {
|
|
5687
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
5688
|
+
throw new UserError(`${name} must be a positive integer`);
|
|
5689
|
+
}
|
|
5690
|
+
const makeCompletion = opts.completion ?? ((model) => createCompletion(model, { custom: start2.config.providers, timeoutMs: opts.timeoutMs }));
|
|
5499
5691
|
const models = Object.fromEntries(locales.map((l) => [l, modelFor(start2, l, opts.model)]));
|
|
5500
5692
|
const clients = /* @__PURE__ */ new Map();
|
|
5501
5693
|
const client = (model) => {
|
|
@@ -5517,9 +5709,11 @@ async function translateProject(start2, opts = {}) {
|
|
|
5517
5709
|
const files = /* @__PURE__ */ new Set();
|
|
5518
5710
|
const tries = /* @__PURE__ */ new Map();
|
|
5519
5711
|
const givenUp = /* @__PURE__ */ new Set();
|
|
5712
|
+
const finished = /* @__PURE__ */ new Set();
|
|
5520
5713
|
let project = start2;
|
|
5521
5714
|
const usages = await buildUsageIndex(start2);
|
|
5522
5715
|
for (; ; ) {
|
|
5716
|
+
checkCancelled();
|
|
5523
5717
|
const round = [];
|
|
5524
5718
|
for (const locale2 of locales) {
|
|
5525
5719
|
let offset = 0;
|
|
@@ -5531,34 +5725,42 @@ async function translateProject(start2, opts = {}) {
|
|
|
5531
5725
|
states: opts.states,
|
|
5532
5726
|
limit: batchSize,
|
|
5533
5727
|
offset,
|
|
5534
|
-
skip:
|
|
5728
|
+
skip: finished,
|
|
5535
5729
|
usages,
|
|
5536
5730
|
skipHandEdited: !opts.force
|
|
5537
5731
|
});
|
|
5538
5732
|
if (batch.items.length === 0) break;
|
|
5539
|
-
|
|
5733
|
+
const retries = /* @__PURE__ */ new Map();
|
|
5734
|
+
for (const item of batch.items) {
|
|
5735
|
+
const retry = tries.get(itemId(item));
|
|
5736
|
+
if (retry) retries.set(item, { value: retry.value, errors: retry.errors });
|
|
5737
|
+
}
|
|
5738
|
+
let user = userPrompt(batch, retries);
|
|
5739
|
+
while (Buffer.byteLength(systemPrompt()) + Buffer.byteLength(user) > maxPromptBytes) {
|
|
5740
|
+
if (batch.items.length === 1) {
|
|
5741
|
+
const item = batch.items[0];
|
|
5742
|
+
throw new UserError(
|
|
5743
|
+
`Prompt for ${item.locale}:${item.key} exceeds maxPromptBytes (${maxPromptBytes}); shorten context or increase --max-prompt-bytes`
|
|
5744
|
+
);
|
|
5745
|
+
}
|
|
5746
|
+
batch.items.pop();
|
|
5747
|
+
user = userPrompt(batch, retries);
|
|
5748
|
+
}
|
|
5749
|
+
round.push({ batch, user });
|
|
5540
5750
|
offset += batch.items.length;
|
|
5541
5751
|
}
|
|
5542
5752
|
if (round.length >= concurrency) break;
|
|
5543
5753
|
}
|
|
5544
5754
|
if (round.length === 0) break;
|
|
5545
5755
|
const replies = await Promise.all(
|
|
5546
|
-
round.map(async (batch) => {
|
|
5756
|
+
round.map(async ({ batch, user }) => {
|
|
5547
5757
|
const locale2 = batch.items[0]?.locale;
|
|
5548
5758
|
const model = models[locale2];
|
|
5549
|
-
const retries = /* @__PURE__ */ new Map();
|
|
5550
|
-
for (const item of batch.items) {
|
|
5551
|
-
const t = tries.get(itemId(item));
|
|
5552
|
-
if (t)
|
|
5553
|
-
retries.set(item, {
|
|
5554
|
-
...t.value !== void 0 ? { value: t.value } : {},
|
|
5555
|
-
errors: t.errors
|
|
5556
|
-
});
|
|
5557
|
-
}
|
|
5558
5759
|
log(`${locale2}: translating ${batch.items.length} strings with ${model}\u2026`);
|
|
5559
5760
|
try {
|
|
5560
5761
|
const reply = await callWithRetry(
|
|
5561
|
-
() => client(model)({ system: systemPrompt(), user:
|
|
5762
|
+
() => client(model)({ system: systemPrompt(), user, signal: opts.signal }),
|
|
5763
|
+
opts.signal
|
|
5562
5764
|
);
|
|
5563
5765
|
return { batch, model, reply };
|
|
5564
5766
|
} catch (e) {
|
|
@@ -5568,12 +5770,16 @@ async function translateProject(start2, opts = {}) {
|
|
|
5568
5770
|
})
|
|
5569
5771
|
);
|
|
5570
5772
|
for (const { batch, model, reply, error } of replies) {
|
|
5773
|
+
checkCancelled();
|
|
5571
5774
|
const byId = new Map(batch.items.map((i) => [itemId(i), i]));
|
|
5572
5775
|
const fail = (item, errors, value) => {
|
|
5573
5776
|
const id2 = itemId(item);
|
|
5574
5777
|
const count = (tries.get(id2)?.count ?? 0) + 1;
|
|
5575
5778
|
tries.set(id2, { count, errors, item, ...value !== void 0 ? { value } : {} });
|
|
5576
|
-
if (count >= maxAttempts)
|
|
5779
|
+
if (count >= maxAttempts) {
|
|
5780
|
+
givenUp.add(id2);
|
|
5781
|
+
finished.add(id2);
|
|
5782
|
+
}
|
|
5577
5783
|
};
|
|
5578
5784
|
if (error !== void 0) {
|
|
5579
5785
|
throw new UserError(error);
|
|
@@ -5604,12 +5810,20 @@ async function translateProject(start2, opts = {}) {
|
|
|
5604
5810
|
const r = await submit(
|
|
5605
5811
|
project,
|
|
5606
5812
|
answers.map((a) => a.sub),
|
|
5607
|
-
{
|
|
5813
|
+
{
|
|
5814
|
+
model,
|
|
5815
|
+
force: opts.force,
|
|
5816
|
+
usages,
|
|
5817
|
+
expectedSources: new Map(batch.items.map((item) => [itemId(item), item.source]))
|
|
5818
|
+
}
|
|
5608
5819
|
);
|
|
5609
5820
|
for (const f of r.files) files.add(f);
|
|
5610
5821
|
result.translated += r.accepted.length;
|
|
5611
5822
|
const accepted = new Set(r.accepted.map((a) => itemId(a)));
|
|
5612
|
-
for (const id2 of accepted)
|
|
5823
|
+
for (const id2 of accepted) {
|
|
5824
|
+
tries.delete(id2);
|
|
5825
|
+
finished.add(id2);
|
|
5826
|
+
}
|
|
5613
5827
|
for (const { item, sub } of answers) {
|
|
5614
5828
|
const id2 = itemId(item);
|
|
5615
5829
|
if (accepted.has(id2)) continue;
|
|
@@ -5644,15 +5858,21 @@ async function translateProject(start2, opts = {}) {
|
|
|
5644
5858
|
});
|
|
5645
5859
|
result.heldForReview = held.items.filter((i) => i.editedByHand).map(({ target, locale: locale2, key }) => ({ target, locale: locale2, key }));
|
|
5646
5860
|
}
|
|
5861
|
+
checkCancelled();
|
|
5647
5862
|
return result;
|
|
5648
5863
|
}
|
|
5649
|
-
async function callWithRetry(fn, tries = 3) {
|
|
5864
|
+
async function callWithRetry(fn, signal, tries = 3) {
|
|
5650
5865
|
for (let i = 1; ; i++) {
|
|
5866
|
+
if (signal?.aborted) throw new UserError("Translation cancelled");
|
|
5651
5867
|
try {
|
|
5652
5868
|
return await fn();
|
|
5653
5869
|
} catch (e) {
|
|
5654
5870
|
if (!(e instanceof ProviderError) || !e.transient || i >= tries) throw e;
|
|
5655
|
-
|
|
5871
|
+
try {
|
|
5872
|
+
await delay(1e3 * 2 ** i, void 0, { signal });
|
|
5873
|
+
} catch {
|
|
5874
|
+
throw new UserError("Translation cancelled");
|
|
5875
|
+
}
|
|
5656
5876
|
}
|
|
5657
5877
|
}
|
|
5658
5878
|
}
|
|
@@ -5693,4 +5913,4 @@ export {
|
|
|
5693
5913
|
TRANSLATION_RULES,
|
|
5694
5914
|
translateProject
|
|
5695
5915
|
};
|
|
5696
|
-
//# sourceMappingURL=chunk-
|
|
5916
|
+
//# sourceMappingURL=chunk-GNL5EFNP.js.map
|