@quaso-i18n/cli 1.0.0-rc.2 → 1.0.0-rc.2.main.g0b25541

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/quaso.mjs +151 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quaso-i18n/cli",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.2.main.g0b25541",
4
4
  "description": "The command line of Quaso, localization for i18next JSON files: upload, download, status and import, from a laptop, from CI and for AI agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/quaso.mjs CHANGED
@@ -1073,6 +1073,16 @@ var CHECKS = {
1073
1073
  identical: "warning",
1074
1074
  /** The numbers differ from the English. */
1075
1075
  numbers_differ: "warning",
1076
+ /** An HTML or i18next Trans tag, such as `<b>` or `<1>`, is missing or extra. */
1077
+ tags_differ: "warning",
1078
+ /** Spaces or line breaks at the start or the end differ from the English. */
1079
+ edge_whitespace: "warning",
1080
+ /** The number of line breaks inside the text differs from the English. */
1081
+ line_breaks_differ: "warning",
1082
+ /** Two spaces in a row, which the English doesn't have. */
1083
+ double_space: "warning",
1084
+ /** Ends with a different question mark, exclamation mark, ellipsis or colon. */
1085
+ end_punctuation: "warning",
1076
1086
  /** A glossary translation or a term marked never translate is missing. */
1077
1087
  glossary: "warning"
1078
1088
  };
@@ -1208,7 +1218,11 @@ var QuasoConfig = s.object({
1208
1218
  minItems: 1
1209
1219
  }),
1210
1220
  limits: s.array(LengthLimit).optional().describe("Maximum lengths, such as for app store fields (FMT-4). They win over limits set on the website."),
1211
- pluralExclusions: s.array(PluralExclusion).optional()
1221
+ pluralExclusions: s.array(PluralExclusion).optional(),
1222
+ untranslated: s.enum([
1223
+ "source",
1224
+ "omit"
1225
+ ]).optional().describe("What download writes for untranslated strings: the source text (default) or nothing, for apps that fall back to the source language themselves")
1212
1226
  });
1213
1227
 
1214
1228
  // packages/core/src/api.ts
@@ -1310,7 +1324,11 @@ var FileVersionQuery = s.object({
1310
1324
  var ExportQuery = s.object({
1311
1325
  languages: s.array(LanguageTag).optional(),
1312
1326
  files: s.array(FilePath).optional(),
1313
- at: PublicationTime.optional()
1327
+ at: PublicationTime.optional(),
1328
+ untranslated: s.enum([
1329
+ "source",
1330
+ "omit"
1331
+ ]).optional().describe("Untranslated entries as the English (default) or left out")
1314
1332
  });
1315
1333
  var ImportRequest = s.object({
1316
1334
  language: LanguageTag,
@@ -1323,6 +1341,7 @@ var ImportRequest = s.object({
1323
1341
  ]),
1324
1342
  overwrite: s.boolean().optional().describe("Also replace blue (proofread) translations"),
1325
1343
  keepIdentical: s.boolean().optional().describe("Keep values identical to the English"),
1344
+ allowQaErrors: s.boolean().optional().describe("Import values that fail the quality checks, flagged, instead of refusing them"),
1326
1345
  dryRun: s.boolean().optional()
1327
1346
  });
1328
1347
  var JobScope = s.object({
@@ -1332,6 +1351,7 @@ var JobScope = s.object({
1332
1351
  maxItems: 5e3
1333
1352
  }).optional().describe("Only these strings"),
1334
1353
  retranslate: s.boolean().optional().describe("Also re-translate green strings (never blue ones, LLM-4)"),
1354
+ qa: s.boolean().optional().describe("Also re-translate the green translations that fail the quality checks"),
1335
1355
  outdated: s.boolean().optional().describe("Update outdated green translations and propose updates for outdated blue ones (default true)"),
1336
1356
  instruction: s.string({
1337
1357
  maxLength: 4e3
@@ -1602,7 +1622,31 @@ var ProjectSettings = s.object({
1602
1622
  suffix: s.string({
1603
1623
  minLength: 1,
1604
1624
  maxLength: 10
1605
- })
1625
+ }),
1626
+ extra: s.array(s.object({
1627
+ prefix: s.string({
1628
+ minLength: 1,
1629
+ maxLength: 10
1630
+ }),
1631
+ suffix: s.string({
1632
+ minLength: 1,
1633
+ maxLength: 10
1634
+ })
1635
+ }), {
1636
+ maxItems: 10
1637
+ }).optional().describe("Delimiters of placeholders the app fills in itself, such as { and } for {name}"),
1638
+ optional: s.array(s.object({
1639
+ placeholder: s.string({
1640
+ minLength: 1,
1641
+ maxLength: 100
1642
+ }),
1643
+ languages: s.array(LanguageTag, {
1644
+ minItems: 1,
1645
+ maxItems: 200
1646
+ })
1647
+ }), {
1648
+ maxItems: 50
1649
+ }).optional().describe("Placeholders some languages may leave out, such as {article}")
1606
1650
  }),
1607
1651
  logoUrl: s.string({
1608
1652
  maxLength: 2e3
@@ -1767,7 +1811,7 @@ var ReviewLanguageRequestRequest = s.object({
1767
1811
  // package.json
1768
1812
  var package_default = {
1769
1813
  name: "quaso-workspace",
1770
- version: "1.0.0-rc.2",
1814
+ version: "1.0.0-rc.2.main.g0b25541",
1771
1815
  private: true,
1772
1816
  type: "module",
1773
1817
  license: "MIT",
@@ -2431,6 +2475,8 @@ var LONG_TIMEOUT_MS = 5 * 6e4;
2431
2475
  var MAX_RETRIES = 3;
2432
2476
  var BASE_DELAY_MS = 500;
2433
2477
  var MAX_RETRY_AFTER_MS = 6e4;
2478
+ var WAKE_POLL_MS = 2e3;
2479
+ var MAX_WAKE_MS = 3 * 6e4;
2434
2480
  var ApiClient = class {
2435
2481
  baseUrl;
2436
2482
  #apiKey;
@@ -2439,6 +2485,7 @@ var ApiClient = class {
2439
2485
  #random;
2440
2486
  #now;
2441
2487
  #log;
2488
+ #awake;
2442
2489
  constructor(options) {
2443
2490
  this.baseUrl = options.baseUrl;
2444
2491
  this.#apiKey = options.apiKey;
@@ -2448,6 +2495,7 @@ var ApiClient = class {
2448
2495
  this.#now = options.now ?? Date.now;
2449
2496
  this.#log = options.log ?? (() => {
2450
2497
  });
2498
+ this.#awake = options.waitWhileAsleep ? null : Promise.resolve();
2451
2499
  }
2452
2500
  get(path, options = {}) {
2453
2501
  return this.request("GET", path, {
@@ -2472,12 +2520,61 @@ var ApiClient = class {
2472
2520
  }
2473
2521
  return url.toString();
2474
2522
  }
2523
+ /**
2524
+ * Asks `/wake` until the server is up, saying so every 15 seconds. Any other answer than
2525
+ * a start in progress (a server without `/wake`, an error) ends the wait: the requests
2526
+ * that follow report their own problems.
2527
+ */
2528
+ async #waitWhileAsleep() {
2529
+ const started = this.#now();
2530
+ let reported = -Infinity;
2531
+ while (this.#now() - started < MAX_WAKE_MS) {
2532
+ const start = await this.#askWake();
2533
+ if (start === null) return;
2534
+ if (this.#now() - reported >= 15e3) {
2535
+ const elapsed = Math.round(start.elapsedMs / 1e3);
2536
+ const expected = Math.round(start.expectedMs / 1e3);
2537
+ this.#log(`Waking up the instance: ${elapsed} s of about ${expected} s.`);
2538
+ reported = this.#now();
2539
+ }
2540
+ await this.#sleep(WAKE_POLL_MS);
2541
+ }
2542
+ }
2543
+ /** The start in progress, or null when the server is up or `/wake` says nothing. */
2544
+ async #askWake() {
2545
+ try {
2546
+ const response = await this.#fetch(`${this.baseUrl}/wake`, {
2547
+ headers: {
2548
+ Accept: "application/json",
2549
+ "User-Agent": USER_AGENT
2550
+ },
2551
+ redirect: "manual",
2552
+ signal: AbortSignal.timeout(TIMEOUT_MS)
2553
+ });
2554
+ const json2 = response.headers.get("Content-Type")?.includes("application/json");
2555
+ if (!response.ok || !json2) {
2556
+ await response.body?.cancel().catch(() => {
2557
+ });
2558
+ return null;
2559
+ }
2560
+ const answer = await response.json();
2561
+ const starting = answer?.state === "starting" && typeof answer.elapsedMs === "number" && typeof answer.expectedMs === "number";
2562
+ return starting ? {
2563
+ elapsedMs: answer.elapsedMs,
2564
+ expectedMs: answer.expectedMs
2565
+ } : null;
2566
+ } catch {
2567
+ return null;
2568
+ }
2569
+ }
2475
2570
  async request(method, path, options) {
2476
2571
  const url = this.url(path, options.query);
2477
2572
  const policy = options.retry ?? (method === "GET" ? "safe" : "once");
2478
2573
  const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
2479
2574
  const headers = requestHeaders(this.#apiKey, options.body !== void 0);
2480
2575
  const body = options.body === void 0 ? void 0 : JSON.stringify(options.body);
2576
+ this.#awake ??= this.#waitWhileAsleep();
2577
+ await this.#awake;
2481
2578
  for (let attempt = 0; ; attempt++) {
2482
2579
  let response;
2483
2580
  let text;
@@ -2610,9 +2707,9 @@ function hintFor(code, status2) {
2610
2707
  case "payload_too_large":
2611
2708
  return "Send fewer files at a time with --file.";
2612
2709
  case "llm_unavailable":
2613
- return "The instance has no LLM: its operator sets GEMINI_API_KEY.";
2710
+ return "An administrator enters the Gemini API key in the instance's Settings \u2192 LLM translation.";
2614
2711
  case "budget_exceeded":
2615
- return "LLM translation resumes next month, or when the instance's operator raises LLM_MONTHLY_TOKEN_BUDGET.";
2712
+ return "LLM translation resumes next month, or when an administrator raises the monthly token budget in Settings \u2192 LLM translation.";
2616
2713
  }
2617
2714
  return status2 >= 500 ? "Try again in a moment; if it persists, tell the instance's operator." : void 0;
2618
2715
  }
@@ -3273,6 +3370,7 @@ var Context = class {
3273
3370
  out;
3274
3371
  #fetch;
3275
3372
  #sleep;
3373
+ #waitWhileAsleep;
3276
3374
  #project;
3277
3375
  #sources;
3278
3376
  constructor(options) {
@@ -3282,6 +3380,7 @@ var Context = class {
3282
3380
  this.out = options.out;
3283
3381
  this.#fetch = options.fetch;
3284
3382
  this.#sleep = options.sleep;
3383
+ this.#waitWhileAsleep = options.waitWhileAsleep ?? true;
3285
3384
  }
3286
3385
  /** The project: `--config`, or the nearest `quaso.config.json`. */
3287
3386
  project() {
@@ -3311,7 +3410,8 @@ var Context = class {
3311
3410
  apiKey: apiKey(this.env),
3312
3411
  fetch: this.#fetch,
3313
3412
  sleep: this.#sleep,
3314
- log: (message) => this.out.info(message)
3413
+ log: (message) => this.out.info(message),
3414
+ waitWhileAsleep: this.#waitWhileAsleep
3315
3415
  });
3316
3416
  }
3317
3417
  };
@@ -7608,7 +7708,8 @@ var download = {
7608
7708
  query: {
7609
7709
  languages,
7610
7710
  files: askFiles ? wanted : void 0,
7611
- at
7711
+ at,
7712
+ untranslated: project.config.untranslated
7612
7713
  },
7613
7714
  timeoutMs: LONG_TIMEOUT_MS
7614
7715
  });
@@ -8006,6 +8107,11 @@ var importCommand = {
8006
8107
  type: "boolean",
8007
8108
  description: "Keep values identical to the English instead of skipping them"
8008
8109
  },
8110
+ {
8111
+ name: "allow-qa-errors",
8112
+ type: "boolean",
8113
+ description: "Import values that fail the quality checks, with their QA errors, instead of refusing them"
8114
+ },
8009
8115
  {
8010
8116
  name: "dry-run",
8011
8117
  type: "boolean",
@@ -8062,6 +8168,7 @@ var importCommand = {
8062
8168
  languages: [],
8063
8169
  withoutFiles: [],
8064
8170
  refused: [],
8171
+ flagged: [],
8065
8172
  unknownKeys: [],
8066
8173
  invalid: [],
8067
8174
  failed: []
@@ -8108,6 +8215,7 @@ var importCommand = {
8108
8215
  };
8109
8216
  if (flag(ctx.args, "overwrite")) request.overwrite = true;
8110
8217
  if (flag(ctx.args, "keep-identical")) request.keepIdentical = true;
8218
+ if (flag(ctx.args, "allow-qa-errors")) request.allowQaErrors = true;
8111
8219
  if (dryRun) request.dryRun = true;
8112
8220
  let result;
8113
8221
  try {
@@ -8149,6 +8257,17 @@ var importCommand = {
8149
8257
  });
8150
8258
  }
8151
8259
  }
8260
+ for (const flagged of result.flagged ?? []) {
8261
+ for (const check of errorChecks(flagged.checks)) {
8262
+ summary.flagged.push({
8263
+ file: pathOf(flagged.file),
8264
+ key: flagged.key,
8265
+ language: flagged.language,
8266
+ check: check.check,
8267
+ message: checkMessage(check)
8268
+ });
8269
+ }
8270
+ }
8152
8271
  for (const unknown of result.unknownKeys) {
8153
8272
  summary.unknownKeys.push({
8154
8273
  file: pathOf(unknown.file),
@@ -8220,8 +8339,14 @@ function renderImport(out, summary) {
8220
8339
  if (result.skippedIdentical > 0) {
8221
8340
  parts.push(`${result.skippedIdentical} identical to the English (skipped)`);
8222
8341
  }
8342
+ if (result.skippedEmpty > 0) parts.push(`${result.skippedEmpty} empty (skipped)`);
8343
+ if (result.droppedForms > 0) {
8344
+ parts.push(`${result.droppedForms} unused plural forms dropped`);
8345
+ }
8223
8346
  if (result.skippedBlue > 0) parts.push(`${result.skippedBlue} proofread kept`);
8224
8347
  if (result.refused.length > 0) parts.push(red(`${result.refused.length} refused`));
8348
+ const flagged = result.flagged ?? [];
8349
+ if (flagged.length > 0) parts.push(yellow(`${flagged.length} with QA errors`));
8225
8350
  if (result.unknownKeys.length > 0) parts.push(`${result.unknownKeys.length} unknown keys`);
8226
8351
  out.print(`${bold(result.language.padEnd(6))} ${parts.join(", ")}`);
8227
8352
  for (const file of result.unknownFiles) {
@@ -8246,6 +8371,7 @@ function renderImport(out, summary) {
8246
8371
  out.print(dim("The other languages were imported; import these again with --language."));
8247
8372
  }
8248
8373
  section("Refused by the quality checks", summary.refused);
8374
+ section("Imported with QA errors (fix them on the instance)", summary.flagged);
8249
8375
  section("Keys the English doesn't have", summary.unknownKeys);
8250
8376
  section("Files that can't be read (fix them and import again)", summary.invalid);
8251
8377
  if (summary.refused.length > 0 && !summary.dryRun) {
@@ -8279,6 +8405,11 @@ var translate = {
8279
8405
  type: "boolean",
8280
8406
  description: "Also translate green (not proofread) strings again; blue ones never change"
8281
8407
  },
8408
+ {
8409
+ name: "qa",
8410
+ type: "boolean",
8411
+ description: "Also translate again the green strings that fail the quality checks, such as imported ones"
8412
+ },
8282
8413
  {
8283
8414
  name: "instruction",
8284
8415
  type: "string",
@@ -8341,6 +8472,7 @@ var translate = {
8341
8472
  };
8342
8473
  if (files) request.files = files;
8343
8474
  if (flag(ctx.args, "retranslate")) request.retranslate = true;
8475
+ if (flag(ctx.args, "qa")) request.qa = true;
8344
8476
  const instruction = option(ctx.args, "instruction");
8345
8477
  if (instruction !== void 0 && instruction.trim() !== "") request.instruction = instruction;
8346
8478
  const model = option(ctx.args, "model");
@@ -8427,8 +8559,17 @@ function renderEstimate(out, estimate) {
8427
8559
  return;
8428
8560
  }
8429
8561
  out.print(`${count(estimate.strings, "string")} (${count(estimate.words, "word")}) in ${count(estimate.requests, "request")}: about ${estimate.estimatedTokens.input.toLocaleString("en")} input and ${estimate.estimatedTokens.output.toLocaleString("en")} output tokens.`);
8562
+ out.print(workSummary(estimate.work));
8430
8563
  out.print(dim("Thinking models use more tokens than this; retries add some too."));
8431
8564
  }
8565
+ function workSummary(work) {
8566
+ const parts = [];
8567
+ if (work.translate > 0) parts.push(`${work.translate} untranslated`);
8568
+ if (work.retranslate > 0) parts.push(`${work.retranslate} green to translate again`);
8569
+ if (work.update > 0) parts.push(`${work.update} outdated green to update`);
8570
+ if (work.propose > 0) parts.push(`${work.propose} outdated proofread to get a proposal for review`);
8571
+ return `Of these: ${parts.join(", ")}.`;
8572
+ }
8432
8573
  function renderQueued(out, job) {
8433
8574
  if (job.status === "done") {
8434
8575
  out.print("Nothing to translate: every string in scope is translated.");
@@ -8505,7 +8646,8 @@ async function run(argv, options = {}) {
8505
8646
  env,
8506
8647
  out,
8507
8648
  fetch: options.fetch,
8508
- sleep: options.sleep
8649
+ sleep: options.sleep,
8650
+ waitWhileAsleep: options.waitWhileAsleep
8509
8651
  });
8510
8652
  const result = await command.run(ctx);
8511
8653
  if (json2) out.document(resultDocument(name, result.exitCode, result.json));