@tolkbot/cli 0.7.0 → 0.8.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.
Files changed (2) hide show
  1. package/dist/index.js +1761 -1531
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -105,7 +105,7 @@ var require_path = __commonJS({
105
105
  Object.defineProperty(exports, "__esModule", { value: true });
106
106
  exports.convertPosixPathToPattern = exports.convertWindowsPathToPattern = exports.convertPathToPattern = exports.escapePosixPath = exports.escapeWindowsPath = exports.escape = exports.removeLeadingDotSegment = exports.makeAbsolute = exports.unixify = void 0;
107
107
  var os3 = __require("os");
108
- var path13 = __require("path");
108
+ var path11 = __require("path");
109
109
  var IS_WINDOWS_PLATFORM = os3.platform() === "win32";
110
110
  var LEADING_DOT_SEGMENT_CHARACTERS_COUNT = 2;
111
111
  var POSIX_UNESCAPED_GLOB_SYMBOLS_RE = /(\\?)([()*?[\]{|}]|^!|[!+@](?=\()|\\(?![!()*+?@[\]{|}]))/g;
@@ -117,7 +117,7 @@ var require_path = __commonJS({
117
117
  }
118
118
  exports.unixify = unixify;
119
119
  function makeAbsolute(cwd, filepath) {
120
- return path13.resolve(cwd, filepath);
120
+ return path11.resolve(cwd, filepath);
121
121
  }
122
122
  exports.makeAbsolute = makeAbsolute;
123
123
  function removeLeadingDotSegment(entry) {
@@ -1416,7 +1416,7 @@ var require_braces = __commonJS({
1416
1416
  var require_constants2 = __commonJS({
1417
1417
  "../../node_modules/.pnpm/picomatch@2.3.2/node_modules/picomatch/lib/constants.js"(exports, module) {
1418
1418
  "use strict";
1419
- var path13 = __require("path");
1419
+ var path11 = __require("path");
1420
1420
  var WIN_SLASH = "\\\\/";
1421
1421
  var WIN_NO_SLASH = `[^${WIN_SLASH}]`;
1422
1422
  var DEFAULT_MAX_EXTGLOB_RECURSION = 0;
@@ -1590,7 +1590,7 @@ var require_constants2 = __commonJS({
1590
1590
  /* | */
1591
1591
  CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279,
1592
1592
  /* \uFEFF */
1593
- SEP: path13.sep,
1593
+ SEP: path11.sep,
1594
1594
  /**
1595
1595
  * Create EXTGLOB_CHARS
1596
1596
  */
@@ -1617,7 +1617,7 @@ var require_constants2 = __commonJS({
1617
1617
  var require_utils2 = __commonJS({
1618
1618
  "../../node_modules/.pnpm/picomatch@2.3.2/node_modules/picomatch/lib/utils.js"(exports) {
1619
1619
  "use strict";
1620
- var path13 = __require("path");
1620
+ var path11 = __require("path");
1621
1621
  var win32 = process.platform === "win32";
1622
1622
  var {
1623
1623
  REGEX_BACKSLASH,
@@ -1646,7 +1646,7 @@ var require_utils2 = __commonJS({
1646
1646
  if (options && typeof options.windows === "boolean") {
1647
1647
  return options.windows;
1648
1648
  }
1649
- return win32 === true || path13.sep === "\\";
1649
+ return win32 === true || path11.sep === "\\";
1650
1650
  };
1651
1651
  exports.escapeLast = (input2, char, lastIdx) => {
1652
1652
  const idx = input2.lastIndexOf(char, lastIdx);
@@ -3010,7 +3010,7 @@ var require_parse2 = __commonJS({
3010
3010
  var require_picomatch = __commonJS({
3011
3011
  "../../node_modules/.pnpm/picomatch@2.3.2/node_modules/picomatch/lib/picomatch.js"(exports, module) {
3012
3012
  "use strict";
3013
- var path13 = __require("path");
3013
+ var path11 = __require("path");
3014
3014
  var scan = require_scan();
3015
3015
  var parse4 = require_parse2();
3016
3016
  var utils = require_utils2();
@@ -3095,7 +3095,7 @@ var require_picomatch = __commonJS({
3095
3095
  };
3096
3096
  picomatch.matchBase = (input2, glob, options, posix = utils.isWindows(options)) => {
3097
3097
  const regex = glob instanceof RegExp ? glob : picomatch.makeRe(glob, options);
3098
- return regex.test(path13.basename(input2));
3098
+ return regex.test(path11.basename(input2));
3099
3099
  };
3100
3100
  picomatch.isMatch = (str, patterns, options) => picomatch(patterns, options)(str);
3101
3101
  picomatch.parse = (pattern, options) => {
@@ -3322,7 +3322,7 @@ var require_pattern = __commonJS({
3322
3322
  "use strict";
3323
3323
  Object.defineProperty(exports, "__esModule", { value: true });
3324
3324
  exports.isAbsolute = exports.partitionAbsoluteAndRelative = exports.removeDuplicateSlashes = exports.matchAny = exports.convertPatternsToRe = exports.makeRe = exports.getPatternParts = exports.expandBraceExpansion = exports.expandPatternsWithBraceExpansion = exports.isAffectDepthOfReadingPattern = exports.endsWithSlashGlobStar = exports.hasGlobStar = exports.getBaseDirectory = exports.isPatternRelatedToParentDirectory = exports.getPatternsOutsideCurrentDirectory = exports.getPatternsInsideCurrentDirectory = exports.getPositivePatterns = exports.getNegativePatterns = exports.isPositivePattern = exports.isNegativePattern = exports.convertToNegativePattern = exports.convertToPositivePattern = exports.isDynamicPattern = exports.isStaticPattern = void 0;
3325
- var path13 = __require("path");
3325
+ var path11 = __require("path");
3326
3326
  var globParent = require_glob_parent();
3327
3327
  var micromatch = require_micromatch();
3328
3328
  var GLOBSTAR = "**";
@@ -3417,7 +3417,7 @@ var require_pattern = __commonJS({
3417
3417
  }
3418
3418
  exports.endsWithSlashGlobStar = endsWithSlashGlobStar;
3419
3419
  function isAffectDepthOfReadingPattern(pattern) {
3420
- const basename = path13.basename(pattern);
3420
+ const basename = path11.basename(pattern);
3421
3421
  return endsWithSlashGlobStar(pattern) || isStaticPattern(basename);
3422
3422
  }
3423
3423
  exports.isAffectDepthOfReadingPattern = isAffectDepthOfReadingPattern;
@@ -3475,7 +3475,7 @@ var require_pattern = __commonJS({
3475
3475
  }
3476
3476
  exports.partitionAbsoluteAndRelative = partitionAbsoluteAndRelative;
3477
3477
  function isAbsolute(pattern) {
3478
- return path13.isAbsolute(pattern);
3478
+ return path11.isAbsolute(pattern);
3479
3479
  }
3480
3480
  exports.isAbsolute = isAbsolute;
3481
3481
  }
@@ -3652,8 +3652,8 @@ var require_utils3 = __commonJS({
3652
3652
  exports.errno = errno;
3653
3653
  var fs10 = require_fs();
3654
3654
  exports.fs = fs10;
3655
- var path13 = require_path();
3656
- exports.path = path13;
3655
+ var path11 = require_path();
3656
+ exports.path = path11;
3657
3657
  var pattern = require_pattern();
3658
3658
  exports.pattern = pattern;
3659
3659
  var stream = require_stream();
@@ -3765,8 +3765,8 @@ var require_async = __commonJS({
3765
3765
  "use strict";
3766
3766
  Object.defineProperty(exports, "__esModule", { value: true });
3767
3767
  exports.read = void 0;
3768
- function read(path13, settings, callback) {
3769
- settings.fs.lstat(path13, (lstatError, lstat) => {
3768
+ function read(path11, settings, callback) {
3769
+ settings.fs.lstat(path11, (lstatError, lstat) => {
3770
3770
  if (lstatError !== null) {
3771
3771
  callFailureCallback(callback, lstatError);
3772
3772
  return;
@@ -3775,7 +3775,7 @@ var require_async = __commonJS({
3775
3775
  callSuccessCallback(callback, lstat);
3776
3776
  return;
3777
3777
  }
3778
- settings.fs.stat(path13, (statError, stat) => {
3778
+ settings.fs.stat(path11, (statError, stat) => {
3779
3779
  if (statError !== null) {
3780
3780
  if (settings.throwErrorOnBrokenSymbolicLink) {
3781
3781
  callFailureCallback(callback, statError);
@@ -3807,13 +3807,13 @@ var require_sync = __commonJS({
3807
3807
  "use strict";
3808
3808
  Object.defineProperty(exports, "__esModule", { value: true });
3809
3809
  exports.read = void 0;
3810
- function read(path13, settings) {
3811
- const lstat = settings.fs.lstatSync(path13);
3810
+ function read(path11, settings) {
3811
+ const lstat = settings.fs.lstatSync(path11);
3812
3812
  if (!lstat.isSymbolicLink() || !settings.followSymbolicLink) {
3813
3813
  return lstat;
3814
3814
  }
3815
3815
  try {
3816
- const stat = settings.fs.statSync(path13);
3816
+ const stat = settings.fs.statSync(path11);
3817
3817
  if (settings.markSymbolicLink) {
3818
3818
  stat.isSymbolicLink = () => true;
3819
3819
  }
@@ -3884,17 +3884,17 @@ var require_out = __commonJS({
3884
3884
  var sync = require_sync();
3885
3885
  var settings_1 = require_settings();
3886
3886
  exports.Settings = settings_1.default;
3887
- function stat(path13, optionsOrSettingsOrCallback, callback) {
3887
+ function stat(path11, optionsOrSettingsOrCallback, callback) {
3888
3888
  if (typeof optionsOrSettingsOrCallback === "function") {
3889
- async.read(path13, getSettings(), optionsOrSettingsOrCallback);
3889
+ async.read(path11, getSettings(), optionsOrSettingsOrCallback);
3890
3890
  return;
3891
3891
  }
3892
- async.read(path13, getSettings(optionsOrSettingsOrCallback), callback);
3892
+ async.read(path11, getSettings(optionsOrSettingsOrCallback), callback);
3893
3893
  }
3894
3894
  exports.stat = stat;
3895
- function statSync2(path13, optionsOrSettings) {
3895
+ function statSync2(path11, optionsOrSettings) {
3896
3896
  const settings = getSettings(optionsOrSettings);
3897
- return sync.read(path13, settings);
3897
+ return sync.read(path11, settings);
3898
3898
  }
3899
3899
  exports.statSync = statSync2;
3900
3900
  function getSettings(settingsOrOptions = {}) {
@@ -4112,16 +4112,16 @@ var require_async2 = __commonJS({
4112
4112
  return;
4113
4113
  }
4114
4114
  const tasks = names.map((name) => {
4115
- const path13 = common.joinPathSegments(directory, name, settings.pathSegmentSeparator);
4115
+ const path11 = common.joinPathSegments(directory, name, settings.pathSegmentSeparator);
4116
4116
  return (done) => {
4117
- fsStat.stat(path13, settings.fsStatSettings, (error62, stats) => {
4117
+ fsStat.stat(path11, settings.fsStatSettings, (error62, stats) => {
4118
4118
  if (error62 !== null) {
4119
4119
  done(error62);
4120
4120
  return;
4121
4121
  }
4122
4122
  const entry = {
4123
4123
  name,
4124
- path: path13,
4124
+ path: path11,
4125
4125
  dirent: utils.fs.createDirentFromStats(name, stats)
4126
4126
  };
4127
4127
  if (settings.stats) {
@@ -4239,7 +4239,7 @@ var require_settings2 = __commonJS({
4239
4239
  "../../node_modules/.pnpm/@nodelib+fs.scandir@2.1.5/node_modules/@nodelib/fs.scandir/out/settings.js"(exports) {
4240
4240
  "use strict";
4241
4241
  Object.defineProperty(exports, "__esModule", { value: true });
4242
- var path13 = __require("path");
4242
+ var path11 = __require("path");
4243
4243
  var fsStat = require_out();
4244
4244
  var fs10 = require_fs4();
4245
4245
  var Settings = class {
@@ -4247,7 +4247,7 @@ var require_settings2 = __commonJS({
4247
4247
  this._options = _options;
4248
4248
  this.followSymbolicLinks = this._getValue(this._options.followSymbolicLinks, false);
4249
4249
  this.fs = fs10.createFileSystemAdapter(this._options.fs);
4250
- this.pathSegmentSeparator = this._getValue(this._options.pathSegmentSeparator, path13.sep);
4250
+ this.pathSegmentSeparator = this._getValue(this._options.pathSegmentSeparator, path11.sep);
4251
4251
  this.stats = this._getValue(this._options.stats, false);
4252
4252
  this.throwErrorOnBrokenSymbolicLink = this._getValue(this._options.throwErrorOnBrokenSymbolicLink, true);
4253
4253
  this.fsStatSettings = new fsStat.Settings({
@@ -4274,17 +4274,17 @@ var require_out2 = __commonJS({
4274
4274
  var sync = require_sync2();
4275
4275
  var settings_1 = require_settings2();
4276
4276
  exports.Settings = settings_1.default;
4277
- function scandir(path13, optionsOrSettingsOrCallback, callback) {
4277
+ function scandir(path11, optionsOrSettingsOrCallback, callback) {
4278
4278
  if (typeof optionsOrSettingsOrCallback === "function") {
4279
- async.read(path13, getSettings(), optionsOrSettingsOrCallback);
4279
+ async.read(path11, getSettings(), optionsOrSettingsOrCallback);
4280
4280
  return;
4281
4281
  }
4282
- async.read(path13, getSettings(optionsOrSettingsOrCallback), callback);
4282
+ async.read(path11, getSettings(optionsOrSettingsOrCallback), callback);
4283
4283
  }
4284
4284
  exports.scandir = scandir;
4285
- function scandirSync(path13, optionsOrSettings) {
4285
+ function scandirSync(path11, optionsOrSettings) {
4286
4286
  const settings = getSettings(optionsOrSettings);
4287
- return sync.read(path13, settings);
4287
+ return sync.read(path11, settings);
4288
4288
  }
4289
4289
  exports.scandirSync = scandirSync;
4290
4290
  function getSettings(settingsOrOptions = {}) {
@@ -4932,7 +4932,7 @@ var require_settings3 = __commonJS({
4932
4932
  "../../node_modules/.pnpm/@nodelib+fs.walk@1.2.8/node_modules/@nodelib/fs.walk/out/settings.js"(exports) {
4933
4933
  "use strict";
4934
4934
  Object.defineProperty(exports, "__esModule", { value: true });
4935
- var path13 = __require("path");
4935
+ var path11 = __require("path");
4936
4936
  var fsScandir = require_out2();
4937
4937
  var Settings = class {
4938
4938
  constructor(_options = {}) {
@@ -4942,7 +4942,7 @@ var require_settings3 = __commonJS({
4942
4942
  this.deepFilter = this._getValue(this._options.deepFilter, null);
4943
4943
  this.entryFilter = this._getValue(this._options.entryFilter, null);
4944
4944
  this.errorFilter = this._getValue(this._options.errorFilter, null);
4945
- this.pathSegmentSeparator = this._getValue(this._options.pathSegmentSeparator, path13.sep);
4945
+ this.pathSegmentSeparator = this._getValue(this._options.pathSegmentSeparator, path11.sep);
4946
4946
  this.fsScandirSettings = new fsScandir.Settings({
4947
4947
  followSymbolicLinks: this._options.followSymbolicLinks,
4948
4948
  fs: this._options.fs,
@@ -5004,7 +5004,7 @@ var require_reader2 = __commonJS({
5004
5004
  "../../node_modules/.pnpm/fast-glob@3.3.3/node_modules/fast-glob/out/readers/reader.js"(exports) {
5005
5005
  "use strict";
5006
5006
  Object.defineProperty(exports, "__esModule", { value: true });
5007
- var path13 = __require("path");
5007
+ var path11 = __require("path");
5008
5008
  var fsStat = require_out();
5009
5009
  var utils = require_utils3();
5010
5010
  var Reader = class {
@@ -5017,7 +5017,7 @@ var require_reader2 = __commonJS({
5017
5017
  });
5018
5018
  }
5019
5019
  _getFullEntryPath(filepath) {
5020
- return path13.resolve(this._settings.cwd, filepath);
5020
+ return path11.resolve(this._settings.cwd, filepath);
5021
5021
  }
5022
5022
  _makeEntry(stats, pattern) {
5023
5023
  const entry = {
@@ -5433,7 +5433,7 @@ var require_provider = __commonJS({
5433
5433
  "../../node_modules/.pnpm/fast-glob@3.3.3/node_modules/fast-glob/out/providers/provider.js"(exports) {
5434
5434
  "use strict";
5435
5435
  Object.defineProperty(exports, "__esModule", { value: true });
5436
- var path13 = __require("path");
5436
+ var path11 = __require("path");
5437
5437
  var deep_1 = require_deep();
5438
5438
  var entry_1 = require_entry();
5439
5439
  var error_1 = require_error();
@@ -5447,7 +5447,7 @@ var require_provider = __commonJS({
5447
5447
  this.entryTransformer = new entry_2.default(this._settings);
5448
5448
  }
5449
5449
  _getRootDirectory(task) {
5450
- return path13.resolve(this._settings.cwd, task.base);
5450
+ return path11.resolve(this._settings.cwd, task.base);
5451
5451
  }
5452
5452
  _getReaderOptions(task) {
5453
5453
  const basePath = task.base === "." ? "" : task.base;
@@ -5784,10 +5784,10 @@ var require_out4 = __commonJS({
5784
5784
  });
5785
5785
 
5786
5786
  // src/main.ts
5787
- import { Command as Command9, CommanderError, Option as Option2 } from "commander";
5787
+ import { Command as Command10, CommanderError, Option as Option2 } from "commander";
5788
5788
 
5789
5789
  // package.json
5790
- var version = "0.7.0";
5790
+ var version = "0.8.0";
5791
5791
 
5792
5792
  // src/commands/auth/index.ts
5793
5793
  import { Command } from "commander";
@@ -28423,6 +28423,48 @@ function parseTranslations(raw) {
28423
28423
  return { locale, text };
28424
28424
  });
28425
28425
  }
28426
+ function pickTermMatch(term, candidates) {
28427
+ const exact = candidates.filter((c) => c.src.trim() === term.trim());
28428
+ const matches = exact.length > 0 ? exact : candidates;
28429
+ const [only, ...rest] = matches;
28430
+ if (!only) {
28431
+ throw new CliError(
28432
+ `No glossary term "${term}".`,
28433
+ "List the terms with 'tolkbot glossary list'.",
28434
+ "not_found"
28435
+ );
28436
+ }
28437
+ if (rest.length > 0) {
28438
+ const lines = matches.map(
28439
+ (c) => ` ${c._id} "${c.src}" (${c.srcLocale}, ${c.projectId === void 0 ? "organization" : "project"})`
28440
+ );
28441
+ throw new CliError(
28442
+ `"${term}" matches ${matches.length} glossary terms:
28443
+ ${lines.join("\n")}`,
28444
+ "Pass --id with the one you mean.",
28445
+ "ambiguous_term"
28446
+ );
28447
+ }
28448
+ return only;
28449
+ }
28450
+ async function resolveGlossaryId(client3, context, target) {
28451
+ if (target.id !== void 0 && target.term !== void 0) {
28452
+ throw new UsageError("Pass either a term or --id, not both.");
28453
+ }
28454
+ if (target.id !== void 0) return target.id;
28455
+ if (target.term === void 0) {
28456
+ throw new UsageError(
28457
+ "Name the term, or pass --id.",
28458
+ 'For example: tolkbot glossary show "Dashboard"'
28459
+ );
28460
+ }
28461
+ const candidates = await client3.query(api.glossaries.findByTerm, {
28462
+ organizationId: context.organizationId,
28463
+ projectId: context.projectId,
28464
+ term: target.term
28465
+ });
28466
+ return pickTermMatch(target.term, candidates)._id;
28467
+ }
28426
28468
 
28427
28469
  // src/commands/glossary/create.tsx
28428
28470
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
@@ -28914,25 +28956,27 @@ function RemoveUI({ state }) {
28914
28956
  async function runGlossaryRemove(opts) {
28915
28957
  const run = async () => {
28916
28958
  const convexClient = await getAuthenticatedConvexClient();
28917
- const { organizationId: organizationId2 } = await getProjectContext(convexClient);
28959
+ const context = await getProjectContext(convexClient);
28960
+ const glossaryId = await resolveGlossaryId(convexClient, context, opts);
28918
28961
  await convexClient.mutation(api.glossaries.remove, {
28919
- organizationId: organizationId2,
28920
- glossaryId: opts.id
28962
+ organizationId: context.organizationId,
28963
+ glossaryId
28921
28964
  });
28965
+ return glossaryId;
28922
28966
  };
28923
28967
  if (opts.json) {
28924
- await run();
28925
- jsonSuccess({ id: opts.id, ok: true });
28968
+ jsonSuccess({ id: await run(), ok: true });
28926
28969
  }
28927
28970
  const { unmount } = renderTransient(
28928
28971
  /* @__PURE__ */ jsx6(RemoveUI, { state: { type: "removing" } })
28929
28972
  );
28973
+ let id;
28930
28974
  try {
28931
- await run();
28975
+ id = await run();
28932
28976
  } finally {
28933
28977
  unmount();
28934
28978
  }
28935
- render7(/* @__PURE__ */ jsx6(RemoveUI, { state: { type: "success", id: opts.id } })).unmount();
28979
+ render7(/* @__PURE__ */ jsx6(RemoveUI, { state: { type: "success", id } })).unmount();
28936
28980
  }
28937
28981
 
28938
28982
  // src/commands/glossary/show.tsx
@@ -28980,11 +29024,12 @@ function GlossaryShowUI({ state }) {
28980
29024
  ] }) })
28981
29025
  ] });
28982
29026
  }
28983
- async function loadGlossary(id) {
29027
+ async function loadGlossary(target) {
28984
29028
  const convexClient = await getAuthenticatedConvexClient();
28985
- const { organizationId: organizationId2 } = await getProjectContext(convexClient);
29029
+ const context = await getProjectContext(convexClient);
29030
+ const id = await resolveGlossaryId(convexClient, context, target);
28986
29031
  const glossary = await convexClient.query(api.glossaries.getById, {
28987
- organizationId: organizationId2,
29032
+ organizationId: context.organizationId,
28988
29033
  glossaryId: id
28989
29034
  });
28990
29035
  if (!glossary) {
@@ -28998,7 +29043,7 @@ async function loadGlossary(id) {
28998
29043
  }
28999
29044
  async function runGlossaryShow(opts) {
29000
29045
  if (opts.json) {
29001
- const glossary2 = await loadGlossary(opts.id);
29046
+ const glossary2 = await loadGlossary(opts);
29002
29047
  jsonSuccess({
29003
29048
  id: glossary2._id,
29004
29049
  term: glossary2.src,
@@ -29013,7 +29058,7 @@ async function runGlossaryShow(opts) {
29013
29058
  );
29014
29059
  let glossary;
29015
29060
  try {
29016
- glossary = await loadGlossary(opts.id);
29061
+ glossary = await loadGlossary(opts);
29017
29062
  } finally {
29018
29063
  unmount();
29019
29064
  }
@@ -29063,29 +29108,36 @@ async function runGlossaryUpdate(opts) {
29063
29108
  const update = async () => {
29064
29109
  const patch = buildPatch();
29065
29110
  const convexClient = await getAuthenticatedConvexClient();
29066
- const { organizationId: organizationId2 } = await getProjectContext(convexClient);
29111
+ const context = await getProjectContext(convexClient);
29112
+ const glossaryId = await resolveGlossaryId(
29113
+ convexClient,
29114
+ context,
29115
+ opts.target
29116
+ );
29067
29117
  await convexClient.mutation(api.glossaries.update, {
29068
- organizationId: organizationId2,
29069
- glossaryId: opts.id,
29118
+ organizationId: context.organizationId,
29119
+ glossaryId,
29070
29120
  patch
29071
29121
  });
29122
+ return glossaryId;
29072
29123
  };
29073
29124
  if (opts.json) {
29074
- await update();
29075
- jsonSuccess({ id: opts.id, ok: true });
29125
+ jsonSuccess({ id: await update(), ok: true });
29076
29126
  }
29077
29127
  const { unmount } = renderTransient(
29078
29128
  /* @__PURE__ */ jsx8(UpdateUI, { state: { type: "updating" } })
29079
29129
  );
29130
+ let id;
29080
29131
  try {
29081
- await update();
29132
+ id = await update();
29082
29133
  } finally {
29083
29134
  unmount();
29084
29135
  }
29085
- render9(/* @__PURE__ */ jsx8(UpdateUI, { state: { type: "success", id: opts.id } })).unmount();
29136
+ render9(/* @__PURE__ */ jsx8(UpdateUI, { state: { type: "success", id } })).unmount();
29086
29137
  }
29087
29138
 
29088
29139
  // src/commands/glossary/index.ts
29140
+ var TERM_HELP = "The term, as the glossary spells it (any case). A term that matches more than one entry lists them; pass --id then";
29089
29141
  var glossaryCommand = new Command2("glossary").description(
29090
29142
  "Manage glossary terms"
29091
29143
  );
@@ -29098,13 +29150,13 @@ glossaryCommand.command("list").description("List glossary terms for the current
29098
29150
  const parsed = validateOrExit(schema, opts);
29099
29151
  await runGlossaryList(parsed);
29100
29152
  });
29101
- glossaryCommand.command("show").description("Show details of a glossary term").requiredOption("--id <glossaryId>", "Glossary term ID").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
29153
+ glossaryCommand.command("show").description("Show details of a glossary term").argument("[term]", TERM_HELP).option("--id <glossaryId>", "Glossary term ID, instead of the term").option("--json", "Output as JSON (machine-readable)").action(async (term, opts) => {
29102
29154
  const schema = z4.object({
29103
- id: z4.string().min(1),
29155
+ id: z4.string().min(1).optional(),
29104
29156
  json: z4.boolean().optional()
29105
29157
  });
29106
29158
  const parsed = validateOrExit(schema, opts);
29107
- await runGlossaryShow(parsed);
29159
+ await runGlossaryShow({ ...parsed, term });
29108
29160
  });
29109
29161
  glossaryCommand.command("create").description("Create a new glossary term").requiredOption("--term <text>", "Source term").requiredOption("--locale <locale>", "Source locale (e.g. en)").option("--context <text>", "AI context for translation guidance").option("--do-not-translate", "Mark term as do-not-translate").option(
29110
29162
  "--translation <locale:text...>",
@@ -29121,12 +29173,12 @@ glossaryCommand.command("create").description("Create a new glossary term").requ
29121
29173
  const parsed = validateOrExit(schema, opts);
29122
29174
  await runGlossaryCreate(parsed);
29123
29175
  });
29124
- glossaryCommand.command("update").description("Update an existing glossary term").requiredOption("--id <glossaryId>", "Glossary term ID").option("--term <text>", "New source term").option("--locale <locale>", "New source locale").option("--context <text>", "AI context for translation guidance").option("--do-not-translate", "Mark term as do-not-translate").option("--no-do-not-translate", "Unmark do-not-translate").option(
29176
+ glossaryCommand.command("update").description("Update an existing glossary term").argument("[term]", TERM_HELP).option("--id <glossaryId>", "Glossary term ID, instead of the term").option("--term <text>", "New source term").option("--locale <locale>", "New source locale").option("--context <text>", "AI context for translation guidance").option("--do-not-translate", "Mark term as do-not-translate").option("--no-do-not-translate", "Unmark do-not-translate").option(
29125
29177
  "--translation <locale:text...>",
29126
29178
  "Translations in locale:text format (replaces all translations)"
29127
- ).option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
29179
+ ).option("--json", "Output as JSON (machine-readable)").action(async (term, opts) => {
29128
29180
  const schema = z4.object({
29129
- id: z4.string().min(1),
29181
+ id: z4.string().min(1).optional(),
29130
29182
  term: z4.string().optional(),
29131
29183
  locale: z4.string().optional(),
29132
29184
  context: z4.string().optional(),
@@ -29134,16 +29186,16 @@ glossaryCommand.command("update").description("Update an existing glossary term"
29134
29186
  translation: z4.array(z4.string()).optional(),
29135
29187
  json: z4.boolean().optional()
29136
29188
  });
29137
- const parsed = validateOrExit(schema, opts);
29138
- await runGlossaryUpdate(parsed);
29189
+ const { id, ...parsed } = validateOrExit(schema, opts);
29190
+ await runGlossaryUpdate({ ...parsed, target: { id, term } });
29139
29191
  });
29140
- glossaryCommand.command("remove").description("Remove a glossary term").requiredOption("--id <glossaryId>", "Glossary term ID").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
29192
+ glossaryCommand.command("remove").description("Remove a glossary term").argument("[term]", TERM_HELP).option("--id <glossaryId>", "Glossary term ID, instead of the term").option("--json", "Output as JSON (machine-readable)").action(async (term, opts) => {
29141
29193
  const schema = z4.object({
29142
- id: z4.string().min(1),
29194
+ id: z4.string().min(1).optional(),
29143
29195
  json: z4.boolean().optional()
29144
29196
  });
29145
29197
  const parsed = validateOrExit(schema, opts);
29146
- await runGlossaryRemove(parsed);
29198
+ await runGlossaryRemove({ ...parsed, term });
29147
29199
  });
29148
29200
 
29149
29201
  // src/commands/init/index.tsx
@@ -29363,9 +29415,9 @@ function runtimeProperties(cliVersion) {
29363
29415
  };
29364
29416
  }
29365
29417
  var activeCommand = null;
29366
- function beginCommand(path13, cliVersion) {
29418
+ function beginCommand(path11, cliVersion) {
29367
29419
  activeCommand = {
29368
- path: path13,
29420
+ path: path11,
29369
29421
  startedAt: Date.now(),
29370
29422
  cliVersion,
29371
29423
  reported: false
@@ -31723,10 +31775,13 @@ var ERROR_CODES = {
31723
31775
  login_failed: EXIT_CODES.failure,
31724
31776
  repository_path_claimed: EXIT_CODES.failure,
31725
31777
  repository_path_in_use: EXIT_CODES.failure,
31778
+ key_not_found: EXIT_CODES.failure,
31779
+ placeholder_mismatch: EXIT_CODES.failure,
31726
31780
  // 2: usage
31727
31781
  usage: EXIT_CODES.usage,
31728
31782
  unsupported_locale: EXIT_CODES.usage,
31729
31783
  ambiguous_project: EXIT_CODES.usage,
31784
+ ambiguous_term: EXIT_CODES.usage,
31730
31785
  // 3: not authenticated, or not allowed
31731
31786
  not_authenticated: EXIT_CODES.auth,
31732
31787
  token_expired: EXIT_CODES.auth,
@@ -31806,10 +31861,10 @@ function serverMessage(message) {
31806
31861
  }
31807
31862
  function argumentError(message) {
31808
31863
  if (!message.includes("ArgumentValidationError")) return null;
31809
- const path13 = /Path: \.?(\S+)/.exec(message)?.[1];
31864
+ const path11 = /Path: \.?(\S+)/.exec(message)?.[1];
31810
31865
  const value = /Value: (.+)/.exec(message)?.[1];
31811
31866
  return new UsageError(
31812
- path13 && value ? `TolkBot refused ${value} as ${path13}.` : "TolkBot refused the arguments.",
31867
+ path11 && value ? `TolkBot refused ${value} as ${path11}.` : "TolkBot refused the arguments.",
31813
31868
  "Check the id or value passed; list ids with the command's list subcommand."
31814
31869
  );
31815
31870
  }
@@ -32422,6 +32477,50 @@ Examples:
32422
32477
  import { Command as Command5 } from "commander";
32423
32478
  import { z as z7 } from "zod/v4";
32424
32479
 
32480
+ // src/commands/learnings/add.ts
32481
+ async function addLearning(ctx, opts) {
32482
+ const { project } = ctx;
32483
+ if (sameLocale(opts.locale, project.baseLanguage)) {
32484
+ throw new UsageError(
32485
+ `${opts.locale} is the project's base language.`,
32486
+ "A learning is about translating into another language: pass that one as --locale."
32487
+ );
32488
+ }
32489
+ if (opts.translation !== void 0 && opts.source === void 0) {
32490
+ throw new UsageError(
32491
+ "--translation needs --source: the text it translates."
32492
+ );
32493
+ }
32494
+ const dstLocale = governanceLocale(project, opts.locale);
32495
+ const id = await ctx.client.mutation(api.learnings.createForProject, {
32496
+ organizationId: project.organizationId,
32497
+ projectId: project._id,
32498
+ srcLocale: project.baseLanguage,
32499
+ dstLocale,
32500
+ sourceText: opts.source ?? "",
32501
+ suggestedTranslation: opts.translation ?? "",
32502
+ reasoning: opts.note,
32503
+ feedbackType: opts.translation !== void 0 ? "incorrect-term" : "other"
32504
+ });
32505
+ return {
32506
+ id,
32507
+ srcLocale: project.baseLanguage,
32508
+ dstLocale,
32509
+ sourceText: opts.source ?? null,
32510
+ suggestedTranslation: opts.translation ?? null,
32511
+ note: opts.note
32512
+ };
32513
+ }
32514
+ async function runLearningsAdd(opts) {
32515
+ const ctx = await loadPlanContext(process.cwd());
32516
+ const learning = await addLearning(ctx, opts);
32517
+ if (opts.json) jsonSuccess(learning);
32518
+ console.log(
32519
+ `\u2713 Learning saved for ${learning.srcLocale} \u2192 ${learning.dstLocale}`
32520
+ );
32521
+ console.log(` ID: ${learning.id}`);
32522
+ }
32523
+
32425
32524
  // src/commands/learnings/archive.tsx
32426
32525
  import { Spinner as Spinner10 } from "@inkjs/ui";
32427
32526
  import { Box as Box13, render as render12, Text as Text13 } from "ink";
@@ -32504,15 +32603,14 @@ function LearningsListUI({ state }) {
32504
32603
  ] }),
32505
32604
  l.status === "archived" && /* @__PURE__ */ jsx14(Text14, { color: "yellow", children: "[archived]" })
32506
32605
  ] }),
32507
- /* @__PURE__ */ jsx14(Box14, { paddingLeft: 2, children: /* @__PURE__ */ jsxs14(Text14, { children: [
32606
+ l.sourceText ? /* @__PURE__ */ jsx14(Box14, { paddingLeft: 2, children: /* @__PURE__ */ jsxs14(Text14, { children: [
32508
32607
  '"',
32509
32608
  l.sourceText,
32510
- '" \u2192 "',
32511
- l.suggestedTranslation,
32512
- '"'
32513
- ] }) }),
32609
+ '"',
32610
+ l.suggestedTranslation ? ` \u2192 "${l.suggestedTranslation}"` : ""
32611
+ ] }) }) : /* @__PURE__ */ jsx14(Box14, { paddingLeft: 2, children: /* @__PURE__ */ jsx14(Text14, { children: "Every string" }) }),
32514
32612
  l.reasoning && /* @__PURE__ */ jsx14(Box14, { paddingLeft: 2, children: /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
32515
- "Reason: ",
32613
+ "Note: ",
32516
32614
  l.reasoning
32517
32615
  ] }) }),
32518
32616
  /* @__PURE__ */ jsx14(Box14, { paddingLeft: 2, children: /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
@@ -32618,9 +32716,9 @@ function LearningShowUI({ state }) {
32618
32716
  return /* @__PURE__ */ jsxs16(Box16, { flexDirection: "column", children: [
32619
32717
  /* @__PURE__ */ jsxs16(Box16, { gap: 1, children: [
32620
32718
  /* @__PURE__ */ jsx16(Text16, { bold: true, children: "Source text:" }),
32621
- /* @__PURE__ */ jsx16(Text16, { children: learning.sourceText })
32719
+ /* @__PURE__ */ jsx16(Text16, { children: learning.sourceText || "(every string)" })
32622
32720
  ] }),
32623
- /* @__PURE__ */ jsxs16(Box16, { gap: 1, children: [
32721
+ learning.suggestedTranslation && /* @__PURE__ */ jsxs16(Box16, { gap: 1, children: [
32624
32722
  /* @__PURE__ */ jsx16(Text16, { bold: true, children: "Suggested translation:" }),
32625
32723
  /* @__PURE__ */ jsx16(Text16, { children: learning.suggestedTranslation })
32626
32724
  ] }),
@@ -32637,7 +32735,7 @@ function LearningShowUI({ state }) {
32637
32735
  /* @__PURE__ */ jsx16(Text16, { children: learning.feedbackType })
32638
32736
  ] }),
32639
32737
  /* @__PURE__ */ jsxs16(Box16, { gap: 1, children: [
32640
- /* @__PURE__ */ jsx16(Text16, { bold: true, children: "Reasoning:" }),
32738
+ /* @__PURE__ */ jsx16(Text16, { bold: true, children: "Note:" }),
32641
32739
  /* @__PURE__ */ jsx16(Text16, { children: learning.reasoning })
32642
32740
  ] }),
32643
32741
  /* @__PURE__ */ jsxs16(Box16, { gap: 1, children: [
@@ -32699,7 +32797,7 @@ async function runLearningsShow(opts) {
32699
32797
 
32700
32798
  // src/commands/learnings/index.ts
32701
32799
  var learningsCommand = new Command5("learnings").description(
32702
- "View and manage translation learnings"
32800
+ "Add, view and manage translation learnings"
32703
32801
  );
32704
32802
  learningsCommand.command("list").description("List translation learnings for the current project").option("--status <status>", "Filter by status (active or archived)").option("--limit <n>", "Max number of results", (v) => Number(v), 100).option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
32705
32803
  const schema = z7.object({
@@ -32710,6 +32808,25 @@ learningsCommand.command("list").description("List translation learnings for the
32710
32808
  const parsed = validateOrExit(schema, opts);
32711
32809
  await runLearningsList(parsed);
32712
32810
  });
32811
+ learningsCommand.command("add").description(
32812
+ "Tell TolkBot how to translate for this project: a note, optionally about a source text"
32813
+ ).requiredOption("--locale <locale>", "Target locale the learning is for").requiredOption(
32814
+ "--note <text>",
32815
+ 'What to do and why, e.g. "Address the reader as du, never De"'
32816
+ ).option(
32817
+ "--source <text>",
32818
+ "Source text the note is about; without it the note applies to every string"
32819
+ ).option("--translation <text>", "The translation to prefer for --source").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
32820
+ const schema = z7.object({
32821
+ locale: z7.string().min(1),
32822
+ note: z7.string().trim().min(1),
32823
+ source: z7.string().trim().min(1).optional(),
32824
+ translation: z7.string().trim().min(1).optional(),
32825
+ json: z7.boolean().optional()
32826
+ });
32827
+ const parsed = validateOrExit(schema, opts);
32828
+ await runLearningsAdd(parsed);
32829
+ });
32713
32830
  learningsCommand.command("show").description("Show details of a learning").requiredOption("--id <learningId>", "Learning ID").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
32714
32831
  const schema = z7.object({
32715
32832
  id: z7.string().min(1),
@@ -32735,1583 +32852,1694 @@ learningsCommand.command("remove").description("Permanently remove a learning").
32735
32852
  await runLearningsRemove(parsed);
32736
32853
  });
32737
32854
 
32738
- // src/commands/status/index.ts
32855
+ // src/commands/set/index.ts
32739
32856
  import { Command as Command6 } from "commander";
32740
32857
  import { z as z8 } from "zod/v4";
32741
32858
 
32742
- // src/commands/status/status.tsx
32743
- import { Spinner as Spinner14 } from "@inkjs/ui";
32744
- import { Box as Box17, Text as Text17 } from "ink";
32745
-
32746
- // src/commands/status/status-core.ts
32747
- function fullKey4(namespace, key) {
32748
- return namespace ? `${namespace}.${key}` : key;
32859
+ // ../ai/src/validators/placeholders.ts
32860
+ var IDENTIFIER = /^[A-Za-z0-9_$][A-Za-z0-9_$.[\]-]*$/;
32861
+ var PRINTF_CONV = "sdiufFeEgGxXocpn%@";
32862
+ function isIdentifier(s) {
32863
+ return IDENTIFIER.test(s);
32749
32864
  }
32750
- function isPersonHold(reason) {
32751
- return reason === "locked" || reason === "pending-draft";
32865
+ function matchBrace(s, open3) {
32866
+ let depth = 0;
32867
+ for (let i = open3; i < s.length; i++) {
32868
+ if (s[i] === "{") depth++;
32869
+ else if (s[i] === "}") {
32870
+ depth--;
32871
+ if (depth === 0) return i;
32872
+ }
32873
+ }
32874
+ return -1;
32752
32875
  }
32753
- function count(reasons2) {
32754
- const byReason = {};
32755
- for (const reason of reasons2)
32756
- byReason[reason] = (byReason[reason] ?? 0) + 1;
32757
- return { total: reasons2.length, byReason };
32876
+ function splitTopLevel(s, limit) {
32877
+ const parts = [];
32878
+ let depth = 0;
32879
+ let start = 0;
32880
+ for (let i = 0; i < s.length; i++) {
32881
+ const c = s[i];
32882
+ if (c === "{") depth++;
32883
+ else if (c === "}") depth--;
32884
+ else if (c === "," && depth === 0 && parts.length < limit - 1) {
32885
+ parts.push(s.slice(start, i));
32886
+ start = i + 1;
32887
+ }
32888
+ }
32889
+ parts.push(s.slice(start));
32890
+ return parts;
32758
32891
  }
32759
- function openPullRequest(rows, locale) {
32760
- const row = rows.find(
32761
- (r) => r.prState === "open" && r.prUrl !== void 0 && sameLocale(r.targetLanguage, locale)
32762
- );
32763
- if (!row?.prUrl) return null;
32764
- return { number: row.prNumber ?? null, url: row.prUrl };
32892
+ function parseBranches(s) {
32893
+ const branches = [];
32894
+ let i = 0;
32895
+ while (i < s.length) {
32896
+ while (i < s.length && /\s/.test(s[i] ?? "")) i++;
32897
+ if (i >= s.length) break;
32898
+ const keyStart = i;
32899
+ while (i < s.length && !/[\s{]/.test(s[i] ?? "")) i++;
32900
+ const keyword = s.slice(keyStart, i);
32901
+ if (!keyword) break;
32902
+ if (keyword.startsWith("offset:")) {
32903
+ branches.push({ keyword, message: "" });
32904
+ continue;
32905
+ }
32906
+ while (i < s.length && /\s/.test(s[i] ?? "")) i++;
32907
+ if (s[i] !== "{") break;
32908
+ const close = matchBrace(s, i);
32909
+ if (close === -1) break;
32910
+ branches.push({ keyword, message: s.slice(i + 1, close) });
32911
+ i = close + 1;
32912
+ }
32913
+ return branches;
32765
32914
  }
32766
- function languageStatus(locale, plan, pullRequests, opts = {}) {
32767
- const { summary } = plan;
32768
- const held = [
32769
- ...plan.heldKeys,
32770
- ...plan.protectedKeys.flatMap(
32771
- ({ key, reason }) => isPersonHold(reason) ? [{ key, reason }] : []
32772
- )
32773
- ];
32774
- const kept = plan.protectedKeys.flatMap(
32775
- ({ key, reason }) => isPersonHold(reason) ? [] : [{ key, reason }]
32776
- );
32777
- const tasks = (reason) => plan.tasks.filter((t) => t.reason === reason).map((t) => fullKey4(t.namespace, t.key));
32915
+ function parseIcuComplex(token) {
32916
+ return token.complex ?? null;
32917
+ }
32918
+ function readBraceToken(s, i) {
32919
+ if (s[i + 1] === "{") {
32920
+ const close2 = s.indexOf("}}", i + 2);
32921
+ if (close2 !== -1) {
32922
+ const inner2 = s.slice(i + 2, close2);
32923
+ if (!inner2.includes("{") && !inner2.includes("}")) {
32924
+ const name2 = (inner2.split(",")[0] ?? "").trim();
32925
+ if (isIdentifier(name2)) {
32926
+ const rest = inner2.slice(inner2.indexOf(name2) + name2.length);
32927
+ const suffix = rest.trim();
32928
+ return {
32929
+ text: s.slice(i, close2 + 2),
32930
+ kind: "mustache",
32931
+ name: name2,
32932
+ signature: `mustache:${name2}${suffix ? `:${suffix.replace(/\s+/g, " ")}` : ""}`,
32933
+ start: i,
32934
+ end: close2 + 2
32935
+ };
32936
+ }
32937
+ }
32938
+ }
32939
+ }
32940
+ const close = matchBrace(s, i);
32941
+ if (close === -1) return null;
32942
+ const inner = s.slice(i + 1, close);
32943
+ const parts = splitTopLevel(inner, 3);
32944
+ const name = (parts[0] ?? "").trim();
32945
+ if (!isIdentifier(name)) return null;
32946
+ const text = s.slice(i, close + 1);
32947
+ const type = (parts[1] ?? "").trim();
32948
+ if (parts.length === 3 && (type === "plural" || type === "select" || type === "selectordinal")) {
32949
+ const branches = parseBranches(parts[2] ?? "");
32950
+ return {
32951
+ text,
32952
+ kind: "icu-complex",
32953
+ name,
32954
+ complex: { name, type, branches },
32955
+ // Branch MESSAGES are translatable, so they stay out of the
32956
+ // signature; their keywords and nested tokens are compared separately.
32957
+ signature: `icu-complex:${name}:${type}:${branches.map((b) => b.keyword).sort().join("|")}`,
32958
+ start: i,
32959
+ end: close + 1
32960
+ };
32961
+ }
32962
+ const normalized = parts.map((p) => p.trim().replace(/\s+/g, " ")).join(", ");
32778
32963
  return {
32779
- locale,
32780
- expected: summary.totalKeys,
32781
- upToDate: summary.upToDate,
32782
- missing: summary.missing,
32783
- changed: summary.changed,
32784
- heldByPerson: count(held.map((h) => h.reason)),
32785
- keptForPerson: count(kept.map((k) => k.reason)),
32786
- untracked: summary.untracked,
32787
- pullRequest: openPullRequest(pullRequests, locale),
32788
- ...opts.keys ? {
32789
- keys: {
32790
- missing: tasks("missing"),
32791
- changed: tasks("changed"),
32792
- heldByPerson: held,
32793
- keptForPerson: kept,
32794
- untracked: plan.untrackedKeys.map((k) => k.key)
32964
+ text,
32965
+ kind: "icu-simple",
32966
+ name,
32967
+ signature: `icu-simple:${normalized}`,
32968
+ start: i,
32969
+ end: close + 1
32970
+ };
32971
+ }
32972
+ function readPercentToken(s, i) {
32973
+ if (s[i + 1] === "{") {
32974
+ const close = s.indexOf("}", i + 2);
32975
+ if (close !== -1) {
32976
+ const name = s.slice(i + 2, close).trim();
32977
+ if (isIdentifier(name)) {
32978
+ return {
32979
+ text: s.slice(i, close + 1),
32980
+ kind: "ruby",
32981
+ name,
32982
+ signature: `ruby:${name}`,
32983
+ start: i,
32984
+ end: close + 1
32985
+ };
32795
32986
  }
32796
- } : {}
32987
+ }
32988
+ }
32989
+ let j = i + 1;
32990
+ if (j >= s.length || s[j] === " ") return null;
32991
+ const rest = s.slice(j);
32992
+ const argnum = /^(\d+)\$/.exec(rest);
32993
+ if (argnum) j += argnum[0].length;
32994
+ while (j < s.length && "-+0#".includes(s[j] ?? "")) j++;
32995
+ while (j < s.length && /\d/.test(s[j] ?? "")) j++;
32996
+ if (s[j] === ".") {
32997
+ j++;
32998
+ while (j < s.length && /\d/.test(s[j] ?? "")) j++;
32999
+ }
33000
+ const conv = s[j];
33001
+ if (!conv || !PRINTF_CONV.includes(conv)) return null;
33002
+ const text = s.slice(i, j + 1);
33003
+ return {
33004
+ text,
33005
+ kind: "printf",
33006
+ name: argnum?.[1] ?? "",
33007
+ signature: `printf:${text}`,
33008
+ start: i,
33009
+ end: j + 1
32797
33010
  };
32798
33011
  }
32799
- function needsTranslation(language) {
32800
- return language.missing > 0 || language.changed > 0;
33012
+ var TAG = /^<\/?[A-Za-z0-9][A-Za-z0-9._-]*\s*\/?>/;
33013
+ function readTagToken(s, i) {
33014
+ const m = TAG.exec(s.slice(i));
33015
+ if (!m) return null;
33016
+ const text = m[0];
33017
+ return {
33018
+ text,
33019
+ kind: "tag",
33020
+ name: text.replace(/[<>/\s]/g, ""),
33021
+ signature: `tag:${text.replace(/\s+/g, "")}`,
33022
+ start: i,
33023
+ end: i + text.length
33024
+ };
32801
33025
  }
32802
- var REASON_WORDS = {
32803
- locked: "locked",
32804
- "pending-draft": "pending draft",
32805
- manual: "written by a person",
32806
- "target-edited": "edited since TolkBot wrote it",
32807
- unknown: "no record of who wrote it"
32808
- };
32809
- function reasons(counts) {
32810
- return Object.entries(counts.byReason).map(
32811
- ([reason, n]) => `${n} ${REASON_WORDS[reason]}`
32812
- ).join(", ");
33026
+ function readNestingToken(s, i) {
33027
+ if (!s.startsWith("$t(", i)) return null;
33028
+ const close = s.indexOf(")", i + 3);
33029
+ if (close === -1) return null;
33030
+ const text = s.slice(i, close + 1);
33031
+ return {
33032
+ text,
33033
+ kind: "nesting",
33034
+ name: s.slice(i + 3, close).trim(),
33035
+ signature: `nesting:${text.replace(/\s+/g, "")}`,
33036
+ start: i,
33037
+ end: close + 1
33038
+ };
32813
33039
  }
32814
- function describeLanguage(language) {
32815
- const counts = [
32816
- `${language.expected} expected`,
32817
- `${language.upToDate} up to date`,
32818
- `${language.missing} missing`,
32819
- `${language.changed} changed since translated`
32820
- ].join(", ");
32821
- const mark = needsTranslation(language) ? "\u2022" : "\u2713";
32822
- const lines = [`${mark} ${language.locale}: ${counts}`];
32823
- if (language.heldByPerson.total > 0) {
32824
- lines.push(
32825
- ` ${language.heldByPerson.total} held by a person (${reasons(language.heldByPerson)})`
32826
- );
32827
- }
32828
- if (language.keptForPerson.total > 0) {
32829
- lines.push(
32830
- ` ${language.keptForPerson.total} changed but kept for a person (${reasons(language.keptForPerson)})`
32831
- );
32832
- }
32833
- if (language.untracked > 0) {
32834
- lines.push(
32835
- ` ${language.untracked} untracked: TolkBot has no record of them`
32836
- );
32837
- }
32838
- if (language.pullRequest) {
32839
- lines.push(` Pull request: ${language.pullRequest.url}`);
33040
+ function extractPlaceholders(s) {
33041
+ const tokens = [];
33042
+ let i = 0;
33043
+ while (i < s.length) {
33044
+ const c = s[i];
33045
+ let token = null;
33046
+ if (c === "{") token = readBraceToken(s, i);
33047
+ else if (c === "%") token = readPercentToken(s, i);
33048
+ else if (c === "<") token = readTagToken(s, i);
33049
+ else if (c === "$") token = readNestingToken(s, i);
33050
+ if (token) {
33051
+ tokens.push(token);
33052
+ i = token.end;
33053
+ continue;
33054
+ }
33055
+ i++;
32840
33056
  }
32841
- const { keys } = language;
32842
- if (keys) {
32843
- const list = (label, names) => {
32844
- if (names.length === 0) return;
32845
- lines.push(` ${label}:`);
32846
- for (const name of names) lines.push(` ${name}`);
32847
- };
32848
- list("Missing", keys.missing);
32849
- list("Changed", keys.changed);
32850
- list(
32851
- "Held by a person",
32852
- keys.heldByPerson.map(
32853
- (k) => `${k.key} (${REASON_WORDS[k.reason]})`
32854
- )
32855
- );
32856
- list(
32857
- "Kept for a person",
32858
- keys.keptForPerson.map(
32859
- (k) => `${k.key} (${REASON_WORDS[k.reason]})`
32860
- )
32861
- );
32862
- list("Untracked", keys.untracked);
33057
+ return tokens;
33058
+ }
33059
+ function countHashes(s) {
33060
+ return (s.match(/#/g) ?? []).length;
33061
+ }
33062
+ function subtract(a, b) {
33063
+ const pool = /* @__PURE__ */ new Map();
33064
+ for (const t of b) pool.set(t.signature, (pool.get(t.signature) ?? 0) + 1);
33065
+ const out = [];
33066
+ for (const t of a) {
33067
+ const left = pool.get(t.signature) ?? 0;
33068
+ if (left > 0) pool.set(t.signature, left - 1);
33069
+ else out.push(t);
32863
33070
  }
32864
- return lines;
33071
+ return out;
32865
33072
  }
32866
-
32867
- // src/commands/status/status.tsx
32868
- import { jsx as jsx17, jsxs as jsxs17 } from "react/jsx-runtime";
32869
- var OFFLINE_HINT = "Without a login, 'tolkbot validate' checks the files on this machine.";
32870
- async function runStatus(opts) {
32871
- let ctx;
32872
- try {
32873
- ctx = await loadPlanContext(process.cwd());
32874
- } catch (error62) {
32875
- if (error62 instanceof AuthError) {
32876
- throw new AuthError(
32877
- error62.baseMessage,
32878
- [error62.hint, OFFLINE_HINT].filter(Boolean).join(" "),
32879
- error62.code
33073
+ function compareIcuBranches(srcToken, outToken, problems, missing, extra) {
33074
+ const src = parseIcuComplex(srcToken);
33075
+ const out = parseIcuComplex(outToken);
33076
+ if (!src || !out) return;
33077
+ const outByKeyword = new Map(out.branches.map((b) => [b.keyword, b]));
33078
+ for (const branch of src.branches) {
33079
+ const counterpart = outByKeyword.get(branch.keyword);
33080
+ if (!counterpart) continue;
33081
+ const srcHashes = countHashes(branch.message);
33082
+ const outHashes = countHashes(counterpart.message);
33083
+ if (srcHashes !== outHashes) {
33084
+ problems.push(
33085
+ `{${src.name}} branch "${branch.keyword}" has ${srcHashes} "#" in the source but ${outHashes} in the translation`
32880
33086
  );
33087
+ if (srcHashes > outHashes) missing.push("#");
33088
+ else extra.push("#");
32881
33089
  }
32882
- throw error62;
32883
- }
32884
- const { project } = ctx;
32885
- const srcLocale = project.baseLanguage;
32886
- const locales = project.targetLanguages;
32887
- const progress = renderTransient(
32888
- /* @__PURE__ */ jsxs17(Box17, { children: [
32889
- /* @__PURE__ */ jsx17(Spinner14, {}),
32890
- /* @__PURE__ */ jsxs17(Text17, { children: [
32891
- " Reading the status of ",
32892
- project.name,
32893
- "\u2026"
32894
- ] })
32895
- ] })
32896
- );
32897
- const languages = [];
32898
- let credits;
32899
- try {
32900
- const pullRequests = locales.length === 0 ? [] : await ctx.client.query(api.prJobs.listByProject, {
32901
- organizationId: project.organizationId,
32902
- projectId: project._id
32903
- });
32904
- for (const locale of locales) {
32905
- const plan = await planForLocale(ctx, srcLocale, locale);
32906
- languages.push(
32907
- languageStatus(locale, plan, pullRequests, {
32908
- keys: opts.keys
32909
- })
33090
+ const nested = compareStrict(branch.message, counterpart.message);
33091
+ if (!nested.ok) {
33092
+ missing.push(...nested.missing);
33093
+ extra.push(...nested.extra);
33094
+ problems.push(
33095
+ ...nested.problems.map(
33096
+ (p) => `inside {${src.name}} branch "${branch.keyword}": ${p}`
33097
+ )
32910
33098
  );
32911
33099
  }
32912
- credits = await readCreditBalance(ctx);
32913
- } finally {
32914
- progress.unmount();
32915
33100
  }
32916
- const payload = {
32917
- project: {
32918
- name: project.name,
32919
- slug: project.slug,
32920
- url: `${env.TOLKBOT_WEB_URL}/projects/${project.slug}`,
32921
- baseLanguage: srcLocale,
32922
- pattern: fullPattern(scopeOf(project)),
32923
- autoCreatePR: project.workflow?.autoCreatePR ?? DEFAULT_BEHAVIORAL_SETTINGS.workflow.autoCreatePR
32924
- },
32925
- credits: { balance: credits },
32926
- languages
32927
- };
32928
- const outOfDate = languages.some(needsTranslation);
32929
- const exitCode = opts.exitCode && outOfDate ? 1 : 0;
32930
- if (opts.json) {
32931
- if (exitCode === 0) jsonSuccess(payload);
32932
- jsonResult(payload, exitCode);
32933
- return;
33101
+ }
33102
+ function compareStrict(src, out) {
33103
+ const srcTokens = extractPlaceholders(src);
33104
+ const outTokens = extractPlaceholders(out);
33105
+ const missingTokens = subtract(srcTokens, outTokens);
33106
+ const extraTokens = subtract(outTokens, srcTokens);
33107
+ const missing = missingTokens.map((t) => t.text);
33108
+ const extra = extraTokens.map((t) => t.text);
33109
+ const problems = [];
33110
+ for (const t of missingTokens) {
33111
+ problems.push(`"${t.text}" is missing from the translation`);
32934
33112
  }
32935
- const { project: p } = payload;
32936
- console.log(`${p.name} (${p.slug}) ${p.url}`);
32937
- console.log(
32938
- `Base language ${p.baseLanguage}, files ${p.pattern}, auto-create pull requests ${p.autoCreatePR ? "on" : "off"}`
32939
- );
32940
- console.log(
32941
- credits === null ? "Credits left: could not be read" : `Credits left: ${credits}`
32942
- );
32943
- console.log("");
32944
- if (languages.length === 0) {
32945
- console.log(
32946
- `No target languages configured. Add them in the project settings at ${p.url}/settings.`
33113
+ for (const t of extraTokens) {
33114
+ problems.push(
33115
+ `"${t.text}" appears in the translation but not the source`
32947
33116
  );
32948
33117
  }
32949
- for (const language of languages) {
32950
- for (const line of describeLanguage(language)) console.log(line);
33118
+ const matchedOut = /* @__PURE__ */ new Map();
33119
+ for (const t of outTokens) {
33120
+ if (t.kind !== "icu-complex") continue;
33121
+ const bucket = matchedOut.get(t.signature) ?? [];
33122
+ bucket.push(t);
33123
+ matchedOut.set(t.signature, bucket);
32951
33124
  }
32952
- if (outOfDate) {
32953
- console.log(
32954
- "\nRun 'tolkbot translate --all' to translate what is missing or changed."
32955
- );
33125
+ for (const t of srcTokens) {
33126
+ if (t.kind !== "icu-complex") continue;
33127
+ const counterpart = matchedOut.get(t.signature)?.shift();
33128
+ if (counterpart) {
33129
+ compareIcuBranches(t, counterpart, problems, missing, extra);
33130
+ }
32956
33131
  }
32957
- process.exitCode = exitCode;
33132
+ return {
33133
+ ok: problems.length === 0,
33134
+ missing,
33135
+ extra,
33136
+ problems,
33137
+ incompletePlurals: []
33138
+ };
32958
33139
  }
32959
-
32960
- // src/commands/status/index.ts
32961
- var statusCommand = new Command6("status").description(
32962
- "Show what each language is missing, what waits for a person, the open pull requests and the credits left"
32963
- ).option("--keys", "List the keys by name under each language").option(
32964
- "--exit-code",
32965
- "Exit 1 when any language has missing or changed keys"
32966
- ).option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
32967
- const schema = z8.object({
32968
- keys: z8.boolean().optional(),
32969
- exitCode: z8.boolean().optional(),
32970
- json: z8.boolean().optional()
32971
- });
32972
- await runStatus(validateOrExit(schema, opts));
32973
- });
32974
-
32975
- // src/commands/telemetry.ts
32976
- import { Command as Command7 } from "commander";
32977
- import { z as z9 } from "zod/v4";
32978
- var REASONS = {
32979
- env: `disabled by $${TELEMETRY_ENV_VAR}`,
32980
- "do-not-track": `disabled by $${DO_NOT_TRACK_ENV_VAR}`,
32981
- preference: "disabled with `tolkbot telemetry off`",
32982
- "no-key": "disabled: this build has no telemetry key",
32983
- default: "enabled"
32984
- };
32985
- async function printStatus(json2) {
32986
- const state = await telemetryState();
32987
- if (json2) {
32988
- jsonResult({ ...state, preferenceFile: getPreferencePath() }, 0);
32989
- return;
33140
+ function pluralArgument(token) {
33141
+ const complex = token.complex;
33142
+ if (token.kind !== "icu-complex" || !complex) return null;
33143
+ if (complex.type !== "plural" && complex.type !== "selectordinal") {
33144
+ return null;
32990
33145
  }
32991
- console.log(
32992
- `Telemetry is ${state.enabled ? "on" : "off"} (${REASONS[state.reason]}).`
32993
- );
32994
- console.log(
32995
- `Turn it off with \`tolkbot telemetry off\`, $${TELEMETRY_ENV_VAR}=0 or $${DO_NOT_TRACK_ENV_VAR}=1.`
32996
- );
33146
+ return {
33147
+ token,
33148
+ name: complex.name,
33149
+ type: complex.type,
33150
+ branches: complex.branches
33151
+ };
32997
33152
  }
32998
- var telemetryCommand = new Command7("telemetry").description(
32999
- "Control anonymous usage reporting"
33000
- );
33001
- telemetryCommand.command("status", { isDefault: true }).description("Show whether telemetry is on and why").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
33002
- const parsed = validateOrExit(
33003
- z9.object({ json: z9.boolean().optional() }),
33004
- opts
33005
- );
33006
- await printStatus(parsed.json);
33007
- });
33008
- telemetryCommand.command("off").description("Stop reporting usage from this machine").action(async () => {
33009
- await setTelemetryPreference(false);
33010
- console.log(
33011
- `Telemetry is off. Preference saved to ${getPreferencePath()}.`
33153
+ function isPluralToken(token) {
33154
+ return pluralArgument(token) !== null;
33155
+ }
33156
+ function withoutPlurals(s, plurals) {
33157
+ let out = s;
33158
+ const byPositionDesc = [...plurals].sort(
33159
+ (a, b) => b.token.start - a.token.start
33012
33160
  );
33013
- });
33014
- telemetryCommand.command("on").description("Resume reporting usage from this machine").action(async () => {
33015
- await setTelemetryPreference(true);
33016
- await printStatus(false);
33017
- });
33018
-
33019
- // src/commands/translate/index.ts
33020
- import { Command as Command8 } from "commander";
33021
- import { z as z10 } from "zod/v4";
33022
-
33023
- // src/commands/translate/adopt.tsx
33024
- var STAMP_CHUNK2 = 200;
33025
- async function adoptLocale(ctx, srcLocale, dstLocale) {
33026
- const plan = await planForLocale(ctx, srcLocale, dstLocale);
33027
- const locale = governanceLocale(ctx.project, dstLocale);
33028
- const byTemplate = /* @__PURE__ */ new Map();
33029
- for (const key of plan.untrackedKeys) {
33030
- const group = byTemplate.get(key.pathTemplate) ?? [];
33031
- group.push(key);
33032
- byTemplate.set(key.pathTemplate, group);
33033
- }
33034
- const outcome = {
33035
- dstLocale,
33036
- untracked: plan.untrackedKeys.length,
33037
- adopted: 0,
33038
- skipped: 0
33039
- };
33040
- for (const [pathTemplate, keys] of byTemplate) {
33041
- for (let i = 0; i < keys.length; i += STAMP_CHUNK2) {
33042
- const result = await ctx.client.mutation(
33043
- api.localeKeyStates.adoptUntrackedForCli,
33044
- {
33045
- organizationId: ctx.project.organizationId,
33046
- projectId: ctx.project._id,
33047
- locale,
33048
- pathTemplate,
33049
- entries: keys.slice(i, i + STAMP_CHUNK2).map((key) => ({
33050
- key: key.fileKey,
33051
- namespace: key.namespace,
33052
- baseValue: key.src,
33053
- translatedValue: key.dst
33054
- }))
33055
- }
33056
- );
33057
- outcome.adopted += result.adopted;
33058
- outcome.skipped += result.skipped;
33059
- }
33161
+ for (const p of byPositionDesc) {
33162
+ out = out.slice(0, p.token.start) + " " + out.slice(p.token.end);
33060
33163
  }
33061
- return outcome;
33164
+ return out;
33062
33165
  }
33063
- async function runAdopt(opts) {
33064
- const run = async () => {
33065
- const ctx = await loadPlanContext(process.cwd());
33066
- const src = opts.src ?? ctx.project.baseLanguage;
33067
- const locales = opts.all ? ctx.project.targetLanguages : opts.dst ? [opts.dst] : [];
33068
- if (locales.length === 0) {
33069
- throw opts.all ? new CliError(
33070
- "No target languages configured for this project.",
33071
- "Add one with 'tolkbot languages add <code>'.",
33072
- "no_target_languages"
33073
- ) : new UsageError(
33074
- "Either --dst <locale> or --all must be provided."
33075
- );
33076
- }
33077
- const outcomes2 = [];
33078
- for (const dst of locales)
33079
- outcomes2.push(await adoptLocale(ctx, src, dst));
33080
- track("cli_translate_completed", {
33081
- mode: "adopt",
33082
- key_count: outcomes2.reduce((n, o) => n + o.adopted, 0),
33083
- locale_count: outcomes2.length,
33084
- job_count: 0,
33085
- json: opts.json === true
33086
- });
33087
- return outcomes2;
33088
- };
33089
- if (opts.json) {
33090
- jsonSuccess({ adopted: await run() });
33166
+ function fixedKeywords(p) {
33167
+ return p.branches.map((b) => b.keyword).filter((k) => !PLURAL_CATEGORIES.has(k)).sort();
33168
+ }
33169
+ function comparePluralPair(src, out, dstLocale, result) {
33170
+ const srcFixed = fixedKeywords(src).join(" ");
33171
+ const outFixed = fixedKeywords(out).join(" ");
33172
+ if (srcFixed !== outFixed) {
33173
+ result.problems.push(
33174
+ `{${src.name}} has branches [${srcFixed}] in the source but [${outFixed}] in the translation`
33175
+ );
33091
33176
  }
33092
- const outcomes = await run();
33093
- for (const o of outcomes) {
33094
- console.log(
33095
- `\u2713 ${o.dstLocale}: adopted ${o.adopted} untracked translation(s)` + (o.skipped > 0 ? `, ${o.skipped} already tracked` : "")
33177
+ const pluralType = src.type === "plural" ? "cardinal" : "ordinal";
33178
+ const outKeywords = new Set(out.branches.map((b) => b.keyword));
33179
+ const lacking = requiredPluralCategories(dstLocale, pluralType).filter(
33180
+ (c) => !outKeywords.has(c)
33181
+ );
33182
+ if (lacking.length > 0) {
33183
+ result.incompletePlurals.push(
33184
+ `{${src.name}} has no ${lacking.map((c) => `"${c}"`).join(", ")} branch, which ${dstLocale} needs`
33096
33185
  );
33097
33186
  }
33098
- }
33099
-
33100
- // src/commands/translate/edit.tsx
33101
- import path12 from "path";
33102
- import { Spinner as Spinner15 } from "@inkjs/ui";
33103
- import { Box as Box18, render as render16, Text as Text18 } from "ink";
33104
-
33105
- // src/utils/locale-files.ts
33106
- import path11 from "path";
33107
- async function findLocaleFiles(baseDir) {
33108
- const adapter = createAdapter("json");
33109
- const result = await adapter.discover(baseDir);
33110
- const localeFiles = /* @__PURE__ */ new Map();
33111
- for (const [locale, files] of result.filesByLocale) {
33112
- localeFiles.set(
33113
- locale,
33114
- files.map((f) => f.path)
33187
+ const srcByKeyword = new Map(src.branches.map((b) => [b.keyword, b]));
33188
+ const srcOther = srcByKeyword.get("other");
33189
+ for (const branch of out.branches) {
33190
+ if (branch.keyword.startsWith("offset:")) continue;
33191
+ const counterpart = srcByKeyword.get(branch.keyword) ?? (PLURAL_CATEGORIES.has(branch.keyword) ? srcOther : void 0);
33192
+ if (!counterpart) continue;
33193
+ const srcHashes = countHashes(counterpart.message);
33194
+ const outHashes = countHashes(branch.message);
33195
+ const sayableInWords = branch.keyword.startsWith("=") || isSingleNumberCategory(dstLocale, pluralType, branch.keyword);
33196
+ if (srcHashes !== outHashes && !(sayableInWords && outHashes <= Math.max(srcHashes, 1))) {
33197
+ result.problems.push(
33198
+ `{${src.name}} branch "${branch.keyword}" has ${outHashes} "#" but the source's "${counterpart.keyword}" has ${srcHashes}`
33199
+ );
33200
+ if (srcHashes > outHashes) result.missing.push("#");
33201
+ else result.extra.push("#");
33202
+ }
33203
+ const nested = compareForLocale(
33204
+ counterpart.message,
33205
+ branch.message,
33206
+ dstLocale
33115
33207
  );
33208
+ result.missing.push(...nested.missing);
33209
+ result.extra.push(...nested.extra);
33210
+ result.problems.push(
33211
+ ...nested.problems.map(
33212
+ (p) => `inside {${src.name}} branch "${branch.keyword}": ${p}`
33213
+ )
33214
+ );
33215
+ result.incompletePlurals.push(...nested.incompletePlurals);
33116
33216
  }
33117
- return localeFiles;
33118
33217
  }
33119
- function resolveFileForKey(files, key, namespace) {
33120
- if (files.length === 0) return null;
33121
- if (files.length === 1) return files[0] ?? null;
33122
- const ns = namespace ?? key.split(".")[0];
33123
- for (const file2 of files) {
33124
- const fileName = path11.parse(path11.basename(file2)).name;
33125
- if (fileName === ns) {
33126
- return file2;
33218
+ function compareForLocale(src, out, dstLocale) {
33219
+ const srcPlurals = extractPlaceholders(src).map(pluralArgument).filter((p) => p !== null);
33220
+ const outPlurals = extractPlaceholders(out).map(pluralArgument).filter((p) => p !== null);
33221
+ const result = compareStrict(
33222
+ withoutPlurals(src, srcPlurals),
33223
+ withoutPlurals(out, outPlurals)
33224
+ );
33225
+ const unmatched = [...outPlurals];
33226
+ for (const p of srcPlurals) {
33227
+ const i = unmatched.findIndex(
33228
+ (o) => o.name === p.name && o.type === p.type
33229
+ );
33230
+ const counterpart = i === -1 ? void 0 : unmatched.splice(i, 1)[0];
33231
+ if (!counterpart) {
33232
+ result.missing.push(p.token.text);
33233
+ result.problems.push(
33234
+ `"${p.token.text}" is missing from the translation`
33235
+ );
33236
+ continue;
33127
33237
  }
33238
+ comparePluralPair(p, counterpart, dstLocale, result);
33128
33239
  }
33129
- return files[0] ?? null;
33130
- }
33131
- function resolveKeyInFile(key, targetFile, fileCount, namespace) {
33132
- if (fileCount <= 1 || namespace) return key;
33133
- const fileName = path11.parse(path11.basename(targetFile)).name;
33134
- if (key.startsWith(`${fileName}.`)) {
33135
- return key.slice(fileName.length + 1);
33240
+ for (const o of unmatched) {
33241
+ result.extra.push(o.token.text);
33242
+ result.problems.push(
33243
+ `"${o.token.text}" appears in the translation but not the source`
33244
+ );
33136
33245
  }
33137
- return key;
33246
+ result.ok = result.problems.length === 0;
33247
+ return result;
33138
33248
  }
33139
-
33140
- // src/commands/translate/edit.tsx
33141
- import { jsx as jsx18, jsxs as jsxs18 } from "react/jsx-runtime";
33142
- function EditUI({ state }) {
33143
- if (state.type === "editing") {
33144
- return /* @__PURE__ */ jsxs18(Box18, { children: [
33145
- /* @__PURE__ */ jsx18(Spinner15, {}),
33146
- /* @__PURE__ */ jsx18(Text18, { children: " Editing translation\u2026" })
33147
- ] });
33148
- }
33149
- return /* @__PURE__ */ jsxs18(Box18, { flexDirection: "column", children: [
33150
- /* @__PURE__ */ jsxs18(Box18, { children: [
33151
- /* @__PURE__ */ jsx18(Text18, { color: "green", children: "\u2713" }),
33152
- /* @__PURE__ */ jsxs18(Text18, { children: [
33153
- " ",
33154
- "Updated ",
33155
- /* @__PURE__ */ jsx18(Text18, { bold: true, children: state.key })
33156
- ] })
33157
- ] }),
33158
- /* @__PURE__ */ jsx18(Box18, { paddingLeft: 2, children: /* @__PURE__ */ jsx18(Text18, { dimColor: true, children: state.value }) }),
33159
- /* @__PURE__ */ jsx18(Box18, { marginTop: 1, children: /* @__PURE__ */ jsxs18(Text18, { dimColor: true, children: [
33160
- "File: ",
33161
- state.filePath
33162
- ] }) })
33163
- ] });
33249
+ function comparePlaceholders(src, out, options = {}) {
33250
+ return options.dstLocale ? compareForLocale(src, out, options.dstLocale) : compareStrict(src, out);
33164
33251
  }
33165
- async function performEdit(opts) {
33166
- const baseDir = process.cwd();
33167
- const config3 = readConfig(baseDir);
33168
- if (!config3) throw noConfigError();
33169
- const localeFiles = await findLocaleFiles(baseDir);
33170
- const files = localeFiles.get(opts.locale);
33171
- if (!files || files.length === 0) {
33172
- throw new CliError(
33173
- `No translation files found for locale "${opts.locale}".`,
33174
- `Available locales: ${[...localeFiles.keys()].join(", ") || "none"}.`,
33175
- "no_translation_files"
33176
- );
33252
+ function repairPlaceholders(src, out, options = {}) {
33253
+ const initial = comparePlaceholders(src, out, options);
33254
+ if (initial.ok) return out;
33255
+ const tokens = (s) => options.dstLocale ? extractPlaceholders(s).filter((t) => !isPluralToken(t)) : extractPlaceholders(s);
33256
+ const srcTokens = tokens(src);
33257
+ const outTokens = tokens(out);
33258
+ const missingTokens = subtract(srcTokens, outTokens);
33259
+ const extraTokens = subtract(outTokens, srcTokens);
33260
+ if (missingTokens.length === 0 || missingTokens.length !== extraTokens.length) {
33261
+ return null;
33177
33262
  }
33178
- const targetFile = resolveFileForKey(files, opts.key, opts.namespace);
33179
- if (!targetFile) {
33180
- throw new CliError(
33181
- `Could not determine target file for key "${opts.key}" in locale "${opts.locale}".`,
33182
- "Pass --namespace to say which file the key belongs to."
33263
+ const braceKinds = /* @__PURE__ */ new Set([
33264
+ "mustache",
33265
+ "icu-simple",
33266
+ "ruby"
33267
+ ]);
33268
+ const unclaimed = [...missingTokens];
33269
+ const rewrites = [];
33270
+ for (const bad of extraTokens) {
33271
+ if (!braceKinds.has(bad.kind)) return null;
33272
+ const idx = unclaimed.findIndex(
33273
+ (m) => braceKinds.has(m.kind) && m.name.toLowerCase() === bad.name.toLowerCase()
33183
33274
  );
33275
+ if (idx === -1) return null;
33276
+ const [good] = unclaimed.splice(idx, 1);
33277
+ if (!good) return null;
33278
+ rewrites.push({ start: bad.start, end: bad.end, text: good.text });
33184
33279
  }
33185
- const keyInFile = resolveKeyInFile(
33186
- opts.key,
33187
- targetFile,
33188
- files.length,
33189
- opts.namespace
33190
- );
33191
- const adapter = createAdapter("json");
33192
- let existingData = {};
33193
- try {
33194
- const parsed = await adapter.parse(targetFile);
33195
- existingData = parsed.data;
33196
- } catch {
33280
+ if (unclaimed.length > 0) return null;
33281
+ rewrites.sort((a, b) => b.start - a.start);
33282
+ let repaired = out;
33283
+ for (const r of rewrites) {
33284
+ repaired = repaired.slice(0, r.start) + r.text + repaired.slice(r.end);
33197
33285
  }
33198
- const updatedData = setByPath(
33199
- existingData,
33200
- keyInFile,
33201
- opts.value
33202
- );
33203
- await adapter.serialize(targetFile, updatedData, { merge: false });
33204
- const relativePath = path12.relative(baseDir, targetFile);
33205
- return { filePath: relativePath, key: opts.key, value: opts.value };
33286
+ return comparePlaceholders(src, repaired, options).ok ? repaired : null;
33206
33287
  }
33207
- async function runEdit(opts) {
33208
- if (opts.json) {
33209
- const result2 = await performEdit(opts);
33210
- jsonSuccess({
33211
- file: result2.filePath,
33212
- key: result2.key,
33213
- value: result2.value
33214
- });
33215
- }
33216
- const { unmount } = renderTransient(/* @__PURE__ */ jsx18(EditUI, { state: { type: "editing" } }));
33217
- let result;
33218
- try {
33219
- result = await performEdit(opts);
33220
- } finally {
33221
- unmount();
33222
- }
33223
- render16(/* @__PURE__ */ jsx18(EditUI, { state: { type: "success", ...result } })).unmount();
33288
+ function settlePlaceholders(src, out, options = {}) {
33289
+ const comparison = comparePlaceholders(src, out, options);
33290
+ if (comparison.ok) return { value: out, repaired: false, comparison };
33291
+ const value = repairPlaceholders(src, out, options);
33292
+ return { value, repaired: value !== null, comparison };
33224
33293
  }
33225
33294
 
33226
- // src/commands/translate/translate.tsx
33227
- import { Spinner as Spinner16 } from "@inkjs/ui";
33228
- import { Box as Box19, Text as Text19 } from "ink";
33229
- import { jsx as jsx19, jsxs as jsxs19 } from "react/jsx-runtime";
33230
- function planOutput(plan) {
33231
- return {
33232
- ...plan.summary,
33233
- protectedKeys: plan.protectedKeys,
33234
- heldKeys: plan.heldKeys,
33235
- untrackedKeys: plan.untrackedKeys.map((k) => k.key)
33236
- };
33237
- }
33238
- function fullKey5(namespace, key) {
33239
- return namespace ? `${namespace}.${key}` : key;
33240
- }
33241
- function characters(text) {
33242
- return Array.from(text).length;
33295
+ // ../ai/src/utils/term-match.ts
33296
+ function sharedPrefixLength(a, b) {
33297
+ const minLen = Math.min(a.length, b.length);
33298
+ let shared = 0;
33299
+ while (shared < minLen && a[shared] === b[shared]) shared++;
33300
+ return shared;
33243
33301
  }
33244
- function targetLocales(ctx, opts) {
33245
- if (opts.dst) return [opts.dst];
33246
- const locales = ctx.project.targetLanguages;
33247
- if (locales.length === 0) {
33248
- throw new CliError(
33249
- "No target languages configured.",
33250
- "Add one with 'tolkbot languages add <code>'.",
33251
- "no_target_languages"
33252
- );
33302
+ var MIN_FUZZY_LENGTH = 4;
33303
+ var MIN_SHARED_FRACTION = 0.7;
33304
+ var UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
33305
+ var WORD_SPLIT = /[\s,.:;!?()[\]{}'"/\\-]+/;
33306
+ var MAX_DERIVED_PREFIX = 4;
33307
+ var MIN_DERIVED_STEM = 5;
33308
+ function termAppearsInText(text, term, opts = {}) {
33309
+ const haystack = opts.caseSensitive ? text : text.toLowerCase();
33310
+ const needle = (opts.caseSensitive ? term : term.toLowerCase()).trim();
33311
+ if (needle.length === 0) return false;
33312
+ if (UNSPACED_SCRIPT.test(needle)) return haystack.includes(needle);
33313
+ if (/\s/.test(needle) && haystack.includes(needle)) return true;
33314
+ const termWords = needle.split(WORD_SPLIT).filter(Boolean);
33315
+ const words2 = haystack.split(WORD_SPLIT).filter(Boolean);
33316
+ for (let start = 0; start + termWords.length <= words2.length; start++) {
33317
+ if (termWords.every(
33318
+ (termWord, offset) => wordMatches(
33319
+ words2[start + offset] ?? "",
33320
+ termWord,
33321
+ opts.derivedForms ?? false
33322
+ )
33323
+ )) {
33324
+ return true;
33325
+ }
33253
33326
  }
33254
- return locales;
33327
+ return false;
33255
33328
  }
33256
- async function runTranslate(opts) {
33257
- const ctx = await loadPlanContext(process.cwd());
33258
- const srcLocale = opts.src ?? ctx.project.baseLanguage;
33259
- const dstLocales = targetLocales(ctx, opts);
33260
- if (opts.dryRun) {
33261
- await runDryRun(ctx, srcLocale, dstLocales, opts);
33262
- return;
33329
+ function wordMatches(word, termWord, derivedForms) {
33330
+ if (word === termWord) return true;
33331
+ if (termWord.length < MIN_FUZZY_LENGTH || word.length < MIN_FUZZY_LENGTH) {
33332
+ return false;
33263
33333
  }
33264
- await runLanguages(ctx, srcLocale, dstLocales, opts);
33265
- }
33266
- var NO_ESTIMATE = "No credit estimate exists for this project yet. Keys and characters are what a run would send.";
33267
- async function runDryRun(ctx, srcLocale, dstLocales, opts) {
33268
- const languages = [];
33269
- const progress = renderTransient(
33270
- /* @__PURE__ */ jsx19(Working, { text: `Planning ${dstLocales.join(", ")}\u2026` })
33271
- );
33272
- try {
33273
- for (const dstLocale of dstLocales) {
33274
- const { plan } = await generateTasks(ctx, srcLocale, dstLocale, {
33275
- retranslateUntracked: opts.retranslateUntracked
33276
- });
33277
- languages.push({
33278
- dstLocale,
33279
- keys: plan.tasks.length,
33280
- characters: plan.tasks.reduce(
33281
- (n, task) => n + characters(task.src),
33282
- 0
33283
- ),
33284
- tasks: plan.tasks.map((task) => ({
33285
- key: fullKey5(task.namespace, task.key),
33286
- path: task.path,
33287
- reason: task.reason,
33288
- ...opts.verbose ? { src: task.src } : {}
33289
- })),
33290
- plan
33291
- });
33334
+ const minShared = Math.ceil(termWord.length * MIN_SHARED_FRACTION);
33335
+ if (sharedPrefixLength(word, termWord) >= minShared) return true;
33336
+ if (!derivedForms || minShared < MIN_DERIVED_STEM) return false;
33337
+ for (let offset = 1; offset <= MAX_DERIVED_PREFIX; offset++) {
33338
+ if (sharedPrefixLength(word.slice(offset), termWord) >= minShared) {
33339
+ return true;
33292
33340
  }
33293
- } finally {
33294
- progress.unmount();
33295
- }
33296
- const total = {
33297
- keys: languages.reduce((n, l) => n + l.keys, 0),
33298
- characters: languages.reduce((n, l) => n + l.characters, 0)
33299
- };
33300
- const balance = await readCreditBalance(ctx);
33301
- track("cli_translate_completed", {
33302
- mode: "plan",
33303
- key_count: total.keys,
33304
- locale_count: languages.length,
33305
- job_count: 0,
33306
- json: opts.json ?? false
33307
- });
33308
- if (opts.json) {
33309
- jsonSuccess({
33310
- dryRun: true,
33311
- srcLocale,
33312
- languages: languages.map(({ plan, ...language }) => ({
33313
- ...language,
33314
- plan: planOutput(plan)
33315
- })),
33316
- total,
33317
- estimate: null,
33318
- estimateNote: NO_ESTIMATE,
33319
- credits: { balance }
33320
- });
33321
33341
  }
33322
- for (const language of languages) {
33323
- const head = `${srcLocale} \u2192 ${language.dstLocale}`;
33324
- if (language.keys === 0) {
33325
- console.log(`\u2713 ${head}: up to date`);
33342
+ return false;
33343
+ }
33344
+
33345
+ // ../ai/src/translations/glossary.ts
33346
+ function extractGlossaryData(glossaries, dstLocale) {
33347
+ const terms = [];
33348
+ const doNotTranslate = [];
33349
+ const contextTerms = [];
33350
+ for (const g of glossaries) {
33351
+ const id = g.id ? { id: g.id } : {};
33352
+ if (g.doNotTranslate) {
33353
+ doNotTranslate.push({ ...id, term: g.src });
33354
+ if (g.aiContext) {
33355
+ contextTerms.push({ ...id, term: g.src, context: g.aiContext });
33356
+ }
33326
33357
  } else {
33327
- console.log(
33328
- `\u2022 ${head}: ${language.keys} key(s) to translate, ${language.characters} characters`
33358
+ const translation = pickGlossaryTranslation(
33359
+ g.translations,
33360
+ dstLocale
33329
33361
  );
33330
- }
33331
- for (const line of describePlan(language.plan)) {
33332
- console.log(` ${line}`);
33333
- }
33334
- if (opts.verbose) {
33335
- for (const task of language.tasks) {
33336
- console.log(` ${task.key}: ${JSON.stringify(task.src)}`);
33362
+ if (translation) {
33363
+ terms.push({
33364
+ ...id,
33365
+ source: g.src,
33366
+ target: translation.text,
33367
+ ...g.aiContext ? { notes: g.aiContext } : {}
33368
+ });
33369
+ } else if (g.aiContext) {
33370
+ contextTerms.push({ ...id, term: g.src, context: g.aiContext });
33337
33371
  }
33338
33372
  }
33339
33373
  }
33340
- console.log(
33341
- `
33342
- In all: ${total.keys} key(s), ${total.characters} characters.` + (balance === null ? "" : ` Credit balance: ${balance}.`)
33343
- );
33344
- console.log(NO_ESTIMATE);
33345
- console.log("Nothing was started. Run without --dry-run to translate.");
33346
- }
33347
- function Working({ text }) {
33348
- return /* @__PURE__ */ jsxs19(Box19, { children: [
33349
- /* @__PURE__ */ jsx19(Spinner16, {}),
33350
- /* @__PURE__ */ jsxs19(Text19, { children: [
33351
- " ",
33352
- text
33353
- ] })
33354
- ] });
33374
+ return { terms, doNotTranslate, contextTerms };
33355
33375
  }
33356
- function progressText(dstLocale, summary) {
33357
- if (!summary) return `${dstLocale}: starting\u2026`;
33358
- const done = summary.completed + summary.failed;
33359
- return `${dstLocale}: translating ${done}/${summary.total}\u2026`;
33376
+ function filterGlossaryByRelevance(glossary, src) {
33377
+ return {
33378
+ terms: glossary.terms.filter(
33379
+ (t) => termAppearsInText(src, t.source, {
33380
+ caseSensitive: t.caseSensitive
33381
+ })
33382
+ ),
33383
+ doNotTranslate: glossary.doNotTranslate.filter(
33384
+ (t) => termAppearsInText(src, t.term)
33385
+ ),
33386
+ contextTerms: glossary.contextTerms.filter(
33387
+ (t) => termAppearsInText(src, t.term)
33388
+ )
33389
+ };
33360
33390
  }
33361
- function describeOutcome2(outcome) {
33362
- const { dstLocale, status, keys, written } = outcome;
33363
- const wrote = written ? `, ${written.keys} written to ${written.files} file(s)` : "";
33364
- const notApplied = outcome.notApplied.length > 0 ? `, ${outcome.notApplied.length} not applied` : "";
33365
- switch (status) {
33366
- case "up-to-date":
33367
- return `\u2713 ${dstLocale}: up to date`;
33368
- case "started":
33369
- return `\u2192 ${dstLocale}: started job ${outcome.jobId} with ${keys} key(s)`;
33370
- case "succeeded":
33371
- return `\u2713 ${dstLocale}: ${outcome.completed} translated${wrote}${notApplied}`;
33372
- case "partial":
33373
- return `\u26A0 ${dstLocale}: ${outcome.completed} of ${keys} translated, ${outcome.failed} failed${wrote}${notApplied}`;
33374
- case "failed":
33375
- return `\u2717 ${dstLocale}: the job failed (${outcome.jobId})${wrote}${notApplied}`;
33376
- case "cancelled":
33377
- return `\u2717 ${dstLocale}: the job was cancelled (${outcome.jobId})`;
33378
- case "timed-out":
33379
- return `\u2717 ${dstLocale}: ${outcome.error}`;
33380
- case "not-started":
33381
- return `\u2717 ${dstLocale}: not started. ${outcome.error}`;
33382
- case "pending":
33383
- case "running":
33384
- return `\u2026 ${dstLocale}: ${status}`;
33391
+ function checkGlossaryCompliance(terms, translation) {
33392
+ const missing = [];
33393
+ for (const term of terms) {
33394
+ const target = term.target.trim();
33395
+ if (!target) continue;
33396
+ if (!termAppearsInText(translation, target, {
33397
+ caseSensitive: term.caseSensitive,
33398
+ derivedForms: true
33399
+ })) {
33400
+ missing.push({ source: term.source, target: term.target });
33401
+ }
33385
33402
  }
33403
+ return missing;
33386
33404
  }
33387
- async function runLanguages(ctx, srcLocale, dstLocales, opts) {
33388
- const { project, framework } = ctx;
33389
- const json2 = opts.json ?? false;
33390
- const outcomes = [];
33391
- let failure2 = null;
33392
- let stopped = null;
33393
- const report = (outcome) => {
33394
- outcomes.push(outcome);
33395
- if (json2) return;
33396
- console.log(describeOutcome2(outcome));
33397
- for (const line of describeNotApplied(outcome.notApplied)) {
33398
- console.log(` ${line}`);
33399
- }
33400
- };
33401
- for (const dstLocale of dstLocales) {
33402
- const progress = renderTransient(
33403
- /* @__PURE__ */ jsx19(Working, { text: `${dstLocale}: planning\u2026` })
33404
- );
33405
- try {
33406
- const { tasks, plan } = await generateTasks(
33407
- ctx,
33408
- srcLocale,
33409
- dstLocale,
33410
- { retranslateUntracked: opts.retranslateUntracked }
33411
- );
33412
- const base = {
33413
- dstLocale,
33414
- status: "up-to-date",
33415
- jobId: null,
33416
- keys: tasks.length,
33417
- completed: null,
33418
- failed: null,
33419
- written: null,
33420
- notApplied: [],
33421
- error: null,
33422
- plan: planOutput(plan)
33423
- };
33424
- const leftOut = describeLeftOut(plan);
33425
- if (stopped !== null) {
33426
- progress.unmount();
33427
- report({ ...base, status: "not-started", error: stopped });
33428
- continue;
33429
- }
33430
- if (tasks.length === 0) {
33431
- progress.unmount();
33432
- report(base);
33433
- printNotes(json2, leftOut);
33434
- continue;
33435
- }
33436
- progress.rerender(/* @__PURE__ */ jsx19(Working, { text: progressText(dstLocale, null) }));
33437
- let jobId;
33438
- try {
33439
- jobId = await startTranslationJob({
33440
- projectId: project._id,
33441
- organizationId: project.organizationId,
33442
- framework,
33443
- srcLocale,
33444
- dstLocale,
33445
- tasks
33446
- });
33447
- } catch (error62) {
33448
- const refused = toCliError(error62);
33449
- failure2 ??= refused;
33450
- progress.unmount();
33451
- report({
33452
- ...base,
33453
- status: "not-started",
33454
- error: refused.baseMessage
33455
- });
33456
- if (exitCodeFor(refused.code) === EXIT_CODES.billing) {
33457
- stopped = `TolkBot refused the ${dstLocale} job: ${refused.baseMessage}`;
33458
- }
33459
- continue;
33460
- }
33461
- if (!opts.wait) {
33462
- progress.unmount();
33463
- report({ ...base, status: "started", jobId });
33464
- printNotes(json2, leftOut);
33465
- continue;
33466
- }
33467
- let summary;
33468
- try {
33469
- summary = await waitForTranslationJob(
33470
- project.organizationId,
33471
- jobId,
33472
- (latest) => progress.rerender(
33473
- /* @__PURE__ */ jsx19(Working, { text: progressText(dstLocale, latest) })
33474
- )
33475
- );
33476
- } catch (error62) {
33477
- if (!(error62 instanceof JobWaitTimeoutError)) throw error62;
33478
- failure2 ??= error62;
33479
- progress.unmount();
33480
- report({
33481
- ...base,
33482
- status: "timed-out",
33483
- jobId,
33484
- error: error62.baseMessage
33485
- });
33486
- stopped = `The CLI stopped waiting for the ${dstLocale} job.`;
33487
- continue;
33488
- }
33489
- const finished = {
33490
- ...base,
33491
- status: summary.status,
33492
- jobId,
33493
- completed: summary.completed,
33494
- failed: summary.failed
33495
- };
33496
- if (summary.status !== "cancelled") {
33497
- progress.rerender(
33498
- /* @__PURE__ */ jsx19(Working, { text: `${dstLocale}: writing the files\u2026` })
33499
- );
33500
- const applied = await applyJob(ctx, jobId);
33501
- finished.written = { files: applied.files, keys: applied.keys };
33502
- finished.notApplied = applied.notApplied;
33503
- }
33504
- const unsuccessful = unsuccessfulJobError(jobId, summary.status);
33505
- if (unsuccessful) {
33506
- failure2 ??= unsuccessful;
33507
- finished.error = unsuccessful.baseMessage;
33508
- }
33509
- progress.unmount();
33510
- report(finished);
33511
- printNotes(json2, leftOut);
33512
- if (summary.status === "cancelled") {
33513
- stopped = `The ${dstLocale} job was cancelled.`;
33514
- }
33515
- } finally {
33516
- progress.unmount();
33517
- }
33405
+
33406
+ // ../ai/src/translations/languages.ts
33407
+ var VERIFIER_LANGUAGES = {
33408
+ en: "English",
33409
+ nb: "Norwegian Bokm\xE5l",
33410
+ nn: "Norwegian Nynorsk",
33411
+ sv: "Swedish",
33412
+ da: "Danish",
33413
+ fi: "Finnish",
33414
+ is: "Icelandic",
33415
+ de: "German",
33416
+ nl: "Dutch",
33417
+ fr: "French",
33418
+ es: "Spanish",
33419
+ pt: "Portuguese",
33420
+ it: "Italian",
33421
+ ca: "Catalan",
33422
+ ro: "Romanian",
33423
+ pl: "Polish",
33424
+ cs: "Czech",
33425
+ sk: "Slovak",
33426
+ hu: "Hungarian",
33427
+ el: "Greek",
33428
+ ru: "Russian",
33429
+ uk: "Ukrainian",
33430
+ bg: "Bulgarian",
33431
+ sr: "Serbian",
33432
+ hr: "Croatian",
33433
+ tr: "Turkish",
33434
+ ar: "Arabic",
33435
+ he: "Hebrew",
33436
+ fa: "Persian",
33437
+ hi: "Hindi",
33438
+ bn: "Bengali",
33439
+ th: "Thai",
33440
+ vi: "Vietnamese",
33441
+ id: "Indonesian",
33442
+ ms: "Malay",
33443
+ ja: "Japanese",
33444
+ ko: "Korean",
33445
+ zh: "Chinese",
33446
+ other: "Any other language, or no natural language at all"
33447
+ };
33448
+ var LANGUAGE_ALIASES = { no: "nb" };
33449
+ function verifierLanguage(locale) {
33450
+ const head = locale.trim().split(/[-_]/)[0] ?? "";
33451
+ const base = (/^[a-z]{2,3}(?=[A-Z])/.exec(head) ?? /^[A-Za-z]{2,3}$/.exec(head))?.[0].toLowerCase();
33452
+ if (!base) return void 0;
33453
+ const language = LANGUAGE_ALIASES[base] ?? base;
33454
+ return language in VERIFIER_LANGUAGES && language !== "other" ? language : void 0;
33455
+ }
33456
+
33457
+ // ../ai/src/translations/deterministic-health.ts
33458
+ var REQUIRED_SCRIPT = {
33459
+ ko: /\p{Script=Hangul}/u,
33460
+ ja: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u,
33461
+ zh: /\p{Script=Han}/u,
33462
+ ar: /\p{Script=Arabic}/u,
33463
+ fa: /\p{Script=Arabic}/u,
33464
+ he: /\p{Script=Hebrew}/u,
33465
+ ru: /\p{Script=Cyrillic}/u,
33466
+ uk: /\p{Script=Cyrillic}/u,
33467
+ bg: /\p{Script=Cyrillic}/u,
33468
+ el: /\p{Script=Greek}/u,
33469
+ th: /\p{Script=Thai}/u,
33470
+ hi: /\p{Script=Devanagari}/u,
33471
+ bn: /\p{Script=Bengali}/u
33472
+ };
33473
+ var MIN_LETTERS = 4;
33474
+ var MIN_SENTENCE_WORDS = 3;
33475
+ var MIN_SENTENCE_LETTERS = 8;
33476
+ function proseOf(text) {
33477
+ return text.replace(/\{\{[^{}]*\}\}/g, " ").replace(/\{[^{}]*\}/g, " ").replace(/\{[^{}]*\}/g, " ").replace(/<[^<>]*>/g, " ").replace(/`[^`]*`/g, " ").replace(/https?:\/\/\S+/g, " ").replace(/%[sdif@]|%\d+\$[sdif@]/g, " ");
33478
+ }
33479
+ function letters(text) {
33480
+ return text.match(/\p{L}/gu)?.length ?? 0;
33481
+ }
33482
+ function words(text) {
33483
+ return text.trim().split(/\s+/).filter(Boolean).length;
33484
+ }
33485
+ function deterministicHealth(input2) {
33486
+ const findings = [];
33487
+ const translation = input2.translation.trim();
33488
+ const source = input2.source.trim();
33489
+ if (!translation) return { findings, needsModel: false };
33490
+ if (!comparePlaceholders(source, translation, {
33491
+ dstLocale: input2.dstLocale
33492
+ }).ok) {
33493
+ findings.push("placeholder_mismatch");
33518
33494
  }
33519
- track("cli_translate_completed", {
33520
- mode: "run",
33521
- key_count: outcomes.reduce((n, o) => n + o.keys, 0),
33522
- locale_count: outcomes.length,
33523
- job_count: outcomes.filter((o) => o.jobId).length,
33524
- json: json2
33525
- });
33526
- const payload = { srcLocale, languages: outcomes };
33527
- if (json2) {
33528
- if (!failure2) jsonSuccess(payload);
33529
- jsonResult(payload, 1);
33530
- fail(failure2);
33495
+ const prose = proseOf(translation);
33496
+ const letterCount = letters(prose);
33497
+ const sameLanguage = verifierLanguage(input2.srcLocale) !== void 0 && verifierLanguage(input2.srcLocale) === verifierLanguage(input2.dstLocale);
33498
+ if (translation === source) {
33499
+ if (!sameLanguage && words(prose) >= MIN_SENTENCE_WORDS && letterCount >= MIN_SENTENCE_LETTERS) {
33500
+ findings.push("same_as_source");
33501
+ }
33502
+ return { findings, needsModel: false };
33531
33503
  }
33532
- if (!opts.wait && outcomes.some((o) => o.status === "started")) {
33533
- console.log(
33534
- "\nThe jobs keep running. Follow them with 'tolkbot jobs list' and write them into the files with 'tolkbot jobs apply'."
33535
- );
33504
+ if (letterCount < MIN_LETTERS) return { findings, needsModel: false };
33505
+ const language = verifierLanguage(input2.dstLocale);
33506
+ const script = language ? REQUIRED_SCRIPT[language] : void 0;
33507
+ if (script && !script.test(prose)) {
33508
+ findings.push("wrong_script");
33509
+ return { findings, needsModel: false };
33536
33510
  }
33537
- if (failure2) fail(failure2);
33511
+ return { findings, needsModel: true };
33538
33512
  }
33539
- function printNotes(json2, notes) {
33540
- if (json2) return;
33541
- for (const note of notes) console.log(` ${note}`);
33513
+
33514
+ // ../ai/src/translations/consistency.ts
33515
+ function findSourceInconsistencies(pairs, referencePairs = []) {
33516
+ const groups = /* @__PURE__ */ new Map();
33517
+ const add = (p, reviewable) => {
33518
+ const src = p.src.trim();
33519
+ if (!src) return;
33520
+ const list = groups.get(src) ?? [];
33521
+ list.push({
33522
+ key: p.key,
33523
+ dst: p.dst,
33524
+ reviewable,
33525
+ ...p.context ? { context: p.context } : {}
33526
+ });
33527
+ groups.set(src, list);
33528
+ };
33529
+ for (const p of pairs) add(p, true);
33530
+ for (const p of referencePairs) add(p, false);
33531
+ const out = [];
33532
+ for (const [src, variants] of groups) {
33533
+ if (variants.length < 2) continue;
33534
+ if (new Set(variants.map((v) => v.dst.trim())).size < 2) continue;
33535
+ if (!variants.some((v) => v.reviewable)) continue;
33536
+ const contexts = new Set(variants.map((v) => v.context?.trim() ?? ""));
33537
+ out.push({ src, variants, unexplained: contexts.size === 1 });
33538
+ }
33539
+ return out;
33542
33540
  }
33543
- function checkTranslateOptions(opts) {
33544
- if (!opts.dst && !opts.all) {
33545
- throw new UsageError(
33546
- "Either --dst <locale> or --all must be provided.",
33547
- "Run 'tolkbot translate --help'."
33541
+
33542
+ // src/commands/set/set-core.ts
33543
+ async function locateKey(ctx, key, locale) {
33544
+ const { root, project } = ctx;
33545
+ const scope = scopeOf(project);
33546
+ const format = formatOf(scope);
33547
+ const files = await listLocaleFiles(root, scope);
33548
+ const sourceFiles = filesForLocale(files, project.baseLanguage);
33549
+ if (sourceFiles.length === 0) {
33550
+ throw new CliError(
33551
+ `No ${project.baseLanguage} translation file matches "${fullPattern(scope)}" under ${root}.`,
33552
+ "The pattern comes from the project settings; check it there, or that you are in the right repository.",
33553
+ "no_translation_files"
33548
33554
  );
33549
33555
  }
33550
- if (opts.dst && opts.all) {
33551
- throw new UsageError("Cannot use both --dst and --all.");
33556
+ const found = [];
33557
+ for (const file2 of sourceFiles) {
33558
+ const prefix = file2.namespace ? `${file2.namespace}.` : "";
33559
+ if (!key.startsWith(prefix)) continue;
33560
+ const fileKey = key.slice(prefix.length);
33561
+ const read = await readTranslationFile(file2.absolutePath, format);
33562
+ if (read.error !== void 0) {
33563
+ throw new CliError(
33564
+ `Cannot read the ${project.baseLanguage} file ${file2.relativePath}: ${read.error}`
33565
+ );
33566
+ }
33567
+ const expected = expectedTargetKeys(getKeys(read.data), locale).get(
33568
+ fileKey
33569
+ );
33570
+ if (!expected) continue;
33571
+ const source = getByPath(read.data, expected.sourceKey);
33572
+ if (typeof source !== "string") continue;
33573
+ found.push({ file: file2, data: read.data, fileKey, source });
33552
33574
  }
33553
- if (opts.dryRun && !opts.wait) {
33554
- throw new UsageError(
33555
- "Cannot use --dry-run with --no-wait: a dry run starts nothing."
33575
+ const [only, ...rest] = found;
33576
+ if (!only) {
33577
+ throw new CliError(
33578
+ `"${key}" is not a key of the ${project.baseLanguage} files.`,
33579
+ sourceFiles.some((file2) => file2.namespace) ? "Keys are named with their namespace first, as the dashboard names them: namespace.key." : "Run 'tolkbot status --keys' to list the keys a language is missing.",
33580
+ "key_not_found"
33581
+ );
33582
+ }
33583
+ if (rest.length > 0) {
33584
+ throw new CliError(
33585
+ `"${key}" names a key in more than one file: ${found.map((f) => f.file.relativePath).join(", ")}.`
33556
33586
  );
33557
33587
  }
33588
+ return only;
33558
33589
  }
33559
-
33560
- // src/commands/translate/index.ts
33561
- var RETRANSLATE_UNTRACKED_HELP = "Also retranslate translations TolkBot has no record of (kept by default)";
33562
- var translateCommand2 = new Command8("translate").description(
33563
- "Translate what is missing or changed, and write the results into your locale files"
33564
- ).option(
33565
- "--src <locale>",
33566
- "Source locale (defaults to the project's base language)"
33567
- ).option("--dst <locale>", "Destination locale").option("--all", "Every target language").option("--dry-run", "Print what a run would translate, and start nothing").option(
33568
- "--no-wait",
33569
- "Start the jobs and print their IDs without waiting; apply them later with 'tolkbot jobs apply'"
33570
- ).option("--retranslate-untracked", RETRANSLATE_UNTRACKED_HELP).option("--verbose", "With --dry-run, include each key's source string").option("--json", "Output as JSON (machine-readable)").allowExcessArguments(true).enablePositionalOptions().action(async (opts, command) => {
33571
- const [word] = command.args;
33572
- if (word !== void 0) {
33590
+ async function setValue(ctx, input2) {
33591
+ const { root, project } = ctx;
33592
+ if (sameLocale(input2.locale, project.baseLanguage)) {
33573
33593
  throw new UsageError(
33574
- `unknown command 'translate ${word}'`,
33575
- "Run 'tolkbot translate --help'. Jobs are under 'tolkbot jobs'."
33594
+ `${input2.locale} is the project's base language.`,
33595
+ "Edit the source file itself; 'tolkbot set' writes translations."
33576
33596
  );
33577
33597
  }
33578
- const schema = z10.object({
33579
- src: z10.string().min(1).optional(),
33580
- dst: z10.string().min(1).optional(),
33581
- all: z10.boolean().optional(),
33582
- dryRun: z10.boolean().optional(),
33583
- wait: z10.boolean(),
33584
- retranslateUntracked: z10.boolean().optional(),
33585
- verbose: z10.boolean().optional(),
33586
- json: z10.boolean().optional()
33587
- });
33588
- const parsed = validateOrExit(schema, opts);
33589
- checkTranslateOptions(parsed);
33590
- await runTranslate(parsed);
33591
- });
33592
- translateCommand2.command("adopt").description(
33593
- "Record existing translations TolkBot has no baseline for as reviewed, so they are kept until their source changes"
33594
- ).option(
33595
- "--src <locale>",
33596
- "Source locale (defaults to the project's base language)"
33597
- ).option("--dst <locale>", "Destination locale").option("--all", "Every target language").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
33598
- const schema = z10.object({
33599
- src: z10.string().min(1).optional(),
33600
- dst: z10.string().min(1).optional(),
33601
- all: z10.boolean().optional(),
33602
- json: z10.boolean().optional()
33603
- });
33604
- const parsed = validateOrExit(schema, opts);
33605
- if (parsed.dst && parsed.all) {
33606
- throw new UsageError("Cannot use both --dst and --all.");
33598
+ if (input2.value.trim().length === 0) {
33599
+ throw new UsageError("--value is empty.");
33607
33600
  }
33608
- await runAdopt(parsed);
33609
- });
33610
- translateCommand2.command("edit").description("Edit a translation value in a locale file").requiredOption("--key <key>", "Translation key (e.g. common.hello)").requiredOption("--locale <locale>", "Target locale (e.g. fr, de)").requiredOption("--value <text>", "New translation value").option("--namespace <ns>", "Namespace to resolve file (if ambiguous)").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
33611
- const schema = z10.object({
33612
- key: z10.string().min(1),
33613
- locale: z10.string().min(1),
33614
- value: z10.string().min(1),
33615
- namespace: z10.string().optional(),
33616
- json: z10.boolean().optional()
33601
+ const locale = governanceLocale(project, input2.locale);
33602
+ const located = await locateKey(ctx, input2.key, locale);
33603
+ const comparison = comparePlaceholders(located.source, input2.value, {
33604
+ dstLocale: locale
33617
33605
  });
33618
- const parsed = validateOrExit(schema, opts);
33619
- await runEdit(parsed);
33620
- });
33621
-
33622
- // src/commands/validate.tsx
33623
- import { Box as Box20, render as render17, Text as Text20 } from "ink";
33624
-
33625
- // src/utils/pattern-discovery.ts
33626
- import fs9 from "fs/promises";
33627
- var DEFAULT_NAMESPACE = "translation";
33628
- async function parseFile(absolutePath, format) {
33629
- let content;
33630
- try {
33631
- content = await fs9.readFile(absolutePath, "utf-8");
33632
- } catch {
33633
- throw new Error("Could not read file");
33606
+ if (!comparison.ok && !input2.force) {
33607
+ const repaired = settlePlaceholders(located.source, input2.value, {
33608
+ dstLocale: locale
33609
+ });
33610
+ throw new CliError(
33611
+ `The value's placeholders do not match the source "${located.source}": ${comparison.problems.join("; ")}.`,
33612
+ repaired.repaired && repaired.value !== null ? `Did you mean "${repaired.value}"? Pass --force to write it as it is.` : "Fix the value, or pass --force to write it as it is.",
33613
+ "placeholder_mismatch"
33614
+ );
33634
33615
  }
33635
- const parsed = format === "ts" ? parseTsContent(content) : parseJsonContent(content);
33636
- const problem = parsed.issues?.find((issue2) => issue2.type === "error");
33637
- if (problem) throw new Error(problem.message);
33638
- return parsed.data;
33639
- }
33640
- async function discoverByPattern(root, scope) {
33641
- const format = formatOf(scope);
33616
+ const scope = scopeOf(project);
33642
33617
  const files = await listLocaleFiles(root, scope);
33643
- if (files.length === 0) return null;
33644
- const filesByLocale = /* @__PURE__ */ new Map();
33645
- const parseErrors = [];
33646
- for (const file2 of files) {
33647
- try {
33648
- const data = await parseFile(file2.absolutePath, format);
33649
- const group = filesByLocale.get(file2.locale) ?? [];
33650
- group.push({
33651
- data,
33652
- path: file2.absolutePath,
33653
- relativePath: file2.relativePath,
33654
- locale: file2.locale,
33655
- namespace: file2.namespace ?? DEFAULT_NAMESPACE
33656
- });
33657
- filesByLocale.set(file2.locale, group);
33658
- } catch (error62) {
33659
- parseErrors.push({
33660
- path: file2.absolutePath,
33661
- relativePath: file2.relativePath,
33662
- locale: file2.locale,
33663
- message: error62 instanceof Error ? error62.message : String(error62)
33664
- });
33665
- }
33618
+ const target = targetFileFor(located.file, locale, files, scope);
33619
+ const before = target.existing ? await readTranslationFile(
33620
+ target.existing.absolutePath,
33621
+ formatOf(scope)
33622
+ ) : void 0;
33623
+ const previousValue = before?.data ? getByPath(before.data, located.fileKey) : void 0;
33624
+ const applied = await applyTasks({
33625
+ root,
33626
+ scope,
33627
+ srcLocale: project.baseLanguage,
33628
+ dstLocale: locale,
33629
+ tasks: [
33630
+ {
33631
+ key: located.fileKey,
33632
+ namespace: located.file.namespace,
33633
+ path: located.file.relativePath,
33634
+ src: located.source,
33635
+ dst: input2.value,
33636
+ status: "succeeded"
33637
+ }
33638
+ ]
33639
+ });
33640
+ const [written] = applied.written;
33641
+ if (!written) {
33642
+ const reason = applied.refused[0]?.reason;
33643
+ throw new CliError(
33644
+ reason === "target_unreadable" ? `Cannot read the ${locale} file ${target.relativePath}.` : `"${input2.key}" was not written (${reason ?? "unknown reason"}).`,
33645
+ reason === "target_unreadable" ? "Fix the file so it parses, then run this again." : void 0
33646
+ );
33647
+ }
33648
+ const ids = {
33649
+ organizationId: project.organizationId,
33650
+ projectId: project._id
33651
+ };
33652
+ try {
33653
+ await ctx.client.mutation(api.localeKeyStates.stampManualForCli, {
33654
+ ...ids,
33655
+ locale,
33656
+ pathTemplate: written.pathTemplate,
33657
+ entries: [
33658
+ {
33659
+ key: located.fileKey,
33660
+ namespace: located.file.namespace,
33661
+ baseValue: located.source,
33662
+ translatedValue: input2.value
33663
+ }
33664
+ ]
33665
+ });
33666
+ } catch (error62) {
33667
+ throw new CliError(
33668
+ `${written.relativePath} holds the new value, but TolkBot could not record it as yours: ${error62 instanceof Error ? error62.message : String(error62)}`,
33669
+ "Run the same command again to record it; until then a translation run may replace it."
33670
+ );
33671
+ }
33672
+ let learningId = null;
33673
+ if (input2.learn !== void 0) {
33674
+ learningId = await ctx.client.mutation(api.learnings.createForProject, {
33675
+ ...ids,
33676
+ srcLocale: project.baseLanguage,
33677
+ dstLocale: locale,
33678
+ sourceText: located.source,
33679
+ suggestedTranslation: input2.value,
33680
+ reasoning: input2.learn,
33681
+ feedbackType: "incorrect-term"
33682
+ });
33666
33683
  }
33667
- const locales = [...new Set(files.map((file2) => file2.locale))];
33668
33684
  return {
33669
- format,
33670
- pattern: fullPattern(scope),
33671
- filesByLocale,
33672
- locales,
33673
- baseLocale: detectBaseLocale(locales),
33674
- fileCount: files.length,
33675
- parseErrors
33685
+ key: input2.key,
33686
+ locale,
33687
+ file: written.relativePath,
33688
+ value: input2.value,
33689
+ previous: typeof previousValue === "string" ? previousValue : null,
33690
+ source: located.source,
33691
+ placeholderProblems: comparison.ok ? [] : comparison.problems,
33692
+ incompletePlurals: comparison.incompletePlurals,
33693
+ learningId
33676
33694
  };
33677
33695
  }
33678
33696
 
33679
- // ../ai/src/validators/placeholders.ts
33680
- var IDENTIFIER = /^[A-Za-z0-9_$][A-Za-z0-9_$.[\]-]*$/;
33681
- var PRINTF_CONV = "sdiufFeEgGxXocpn%@";
33682
- function isIdentifier(s) {
33683
- return IDENTIFIER.test(s);
33697
+ // src/commands/set/index.ts
33698
+ var setCommand = new Command6("set").description(
33699
+ "Write a translation yourself; TolkBot keeps it until its source text changes"
33700
+ ).argument("<key>", "The key, as the dashboard names it (e.g. common.save)").requiredOption("--locale <locale>", "Target locale (e.g. nb)").requiredOption("--value <text>", "The translation").option(
33701
+ "--force",
33702
+ "Write the value even when its placeholders do not match the source"
33703
+ ).option(
33704
+ "--learn <note>",
33705
+ `Also store a learning for the project, e.g. "use Oversikt, not Dashbord: it is the app's word"`
33706
+ ).option("--json", "Output as JSON (machine-readable)").action(async (key, opts) => {
33707
+ const schema = z8.object({
33708
+ locale: z8.string().min(1),
33709
+ value: z8.string(),
33710
+ force: z8.boolean().optional(),
33711
+ learn: z8.string().trim().min(1).optional(),
33712
+ json: z8.boolean().optional()
33713
+ });
33714
+ const parsed = validateOrExit(schema, opts);
33715
+ await runSet({ key, ...parsed });
33716
+ });
33717
+ async function runSet(opts) {
33718
+ const ctx = await loadPlanContext(process.cwd());
33719
+ const result = await setValue(ctx, opts);
33720
+ if (opts.json) jsonSuccess(result);
33721
+ printResult(result);
33684
33722
  }
33685
- function matchBrace(s, open3) {
33686
- let depth = 0;
33687
- for (let i = open3; i < s.length; i++) {
33688
- if (s[i] === "{") depth++;
33689
- else if (s[i] === "}") {
33690
- depth--;
33691
- if (depth === 0) return i;
33692
- }
33723
+ function printResult(result) {
33724
+ console.log(
33725
+ `\u2713 ${result.locale}: set ${result.key} in ${result.file}, recorded as yours`
33726
+ );
33727
+ if (result.previous !== null && result.previous !== result.value) {
33728
+ console.log(` was: ${result.previous}`);
33693
33729
  }
33694
- return -1;
33695
- }
33696
- function splitTopLevel(s, limit) {
33697
- const parts = [];
33698
- let depth = 0;
33699
- let start = 0;
33700
- for (let i = 0; i < s.length; i++) {
33701
- const c = s[i];
33702
- if (c === "{") depth++;
33703
- else if (c === "}") depth--;
33704
- else if (c === "," && depth === 0 && parts.length < limit - 1) {
33705
- parts.push(s.slice(start, i));
33706
- start = i + 1;
33707
- }
33730
+ if (result.learningId) {
33731
+ console.log(` learning saved: ${result.learningId}`);
33708
33732
  }
33709
- parts.push(s.slice(start));
33710
- return parts;
33711
- }
33712
- function parseBranches(s) {
33713
- const branches = [];
33714
- let i = 0;
33715
- while (i < s.length) {
33716
- while (i < s.length && /\s/.test(s[i] ?? "")) i++;
33717
- if (i >= s.length) break;
33718
- const keyStart = i;
33719
- while (i < s.length && !/[\s{]/.test(s[i] ?? "")) i++;
33720
- const keyword = s.slice(keyStart, i);
33721
- if (!keyword) break;
33722
- if (keyword.startsWith("offset:")) {
33723
- branches.push({ keyword, message: "" });
33724
- continue;
33725
- }
33726
- while (i < s.length && /\s/.test(s[i] ?? "")) i++;
33727
- if (s[i] !== "{") break;
33728
- const close = matchBrace(s, i);
33729
- if (close === -1) break;
33730
- branches.push({ keyword, message: s.slice(i + 1, close) });
33731
- i = close + 1;
33733
+ for (const problem of result.placeholderProblems) {
33734
+ console.error(`! written with --force: ${problem}`);
33735
+ }
33736
+ for (const plural2 of result.incompletePlurals) {
33737
+ console.error(`! ${plural2}`);
33732
33738
  }
33733
- return branches;
33734
33739
  }
33735
- function parseIcuComplex(token) {
33736
- return token.complex ?? null;
33740
+
33741
+ // src/commands/status/index.ts
33742
+ import { Command as Command7 } from "commander";
33743
+ import { z as z9 } from "zod/v4";
33744
+
33745
+ // src/commands/status/status.tsx
33746
+ import { Spinner as Spinner14 } from "@inkjs/ui";
33747
+ import { Box as Box17, Text as Text17 } from "ink";
33748
+
33749
+ // src/commands/status/status-core.ts
33750
+ function fullKey4(namespace, key) {
33751
+ return namespace ? `${namespace}.${key}` : key;
33737
33752
  }
33738
- function readBraceToken(s, i) {
33739
- if (s[i + 1] === "{") {
33740
- const close2 = s.indexOf("}}", i + 2);
33741
- if (close2 !== -1) {
33742
- const inner2 = s.slice(i + 2, close2);
33743
- if (!inner2.includes("{") && !inner2.includes("}")) {
33744
- const name2 = (inner2.split(",")[0] ?? "").trim();
33745
- if (isIdentifier(name2)) {
33746
- const rest = inner2.slice(inner2.indexOf(name2) + name2.length);
33747
- const suffix = rest.trim();
33748
- return {
33749
- text: s.slice(i, close2 + 2),
33750
- kind: "mustache",
33751
- name: name2,
33752
- signature: `mustache:${name2}${suffix ? `:${suffix.replace(/\s+/g, " ")}` : ""}`,
33753
- start: i,
33754
- end: close2 + 2
33755
- };
33756
- }
33757
- }
33758
- }
33759
- }
33760
- const close = matchBrace(s, i);
33761
- if (close === -1) return null;
33762
- const inner = s.slice(i + 1, close);
33763
- const parts = splitTopLevel(inner, 3);
33764
- const name = (parts[0] ?? "").trim();
33765
- if (!isIdentifier(name)) return null;
33766
- const text = s.slice(i, close + 1);
33767
- const type = (parts[1] ?? "").trim();
33768
- if (parts.length === 3 && (type === "plural" || type === "select" || type === "selectordinal")) {
33769
- const branches = parseBranches(parts[2] ?? "");
33770
- return {
33771
- text,
33772
- kind: "icu-complex",
33773
- name,
33774
- complex: { name, type, branches },
33775
- // Branch MESSAGES are translatable, so they stay out of the
33776
- // signature; their keywords and nested tokens are compared separately.
33777
- signature: `icu-complex:${name}:${type}:${branches.map((b) => b.keyword).sort().join("|")}`,
33778
- start: i,
33779
- end: close + 1
33780
- };
33781
- }
33782
- const normalized = parts.map((p) => p.trim().replace(/\s+/g, " ")).join(", ");
33783
- return {
33784
- text,
33785
- kind: "icu-simple",
33786
- name,
33787
- signature: `icu-simple:${normalized}`,
33788
- start: i,
33789
- end: close + 1
33790
- };
33753
+ function isPersonHold(reason) {
33754
+ return reason === "locked" || reason === "pending-draft";
33791
33755
  }
33792
- function readPercentToken(s, i) {
33793
- if (s[i + 1] === "{") {
33794
- const close = s.indexOf("}", i + 2);
33795
- if (close !== -1) {
33796
- const name = s.slice(i + 2, close).trim();
33797
- if (isIdentifier(name)) {
33798
- return {
33799
- text: s.slice(i, close + 1),
33800
- kind: "ruby",
33801
- name,
33802
- signature: `ruby:${name}`,
33803
- start: i,
33804
- end: close + 1
33805
- };
33806
- }
33807
- }
33808
- }
33809
- let j = i + 1;
33810
- if (j >= s.length || s[j] === " ") return null;
33811
- const rest = s.slice(j);
33812
- const argnum = /^(\d+)\$/.exec(rest);
33813
- if (argnum) j += argnum[0].length;
33814
- while (j < s.length && "-+0#".includes(s[j] ?? "")) j++;
33815
- while (j < s.length && /\d/.test(s[j] ?? "")) j++;
33816
- if (s[j] === ".") {
33817
- j++;
33818
- while (j < s.length && /\d/.test(s[j] ?? "")) j++;
33819
- }
33820
- const conv = s[j];
33821
- if (!conv || !PRINTF_CONV.includes(conv)) return null;
33822
- const text = s.slice(i, j + 1);
33823
- return {
33824
- text,
33825
- kind: "printf",
33826
- name: argnum?.[1] ?? "",
33827
- signature: `printf:${text}`,
33828
- start: i,
33829
- end: j + 1
33830
- };
33756
+ function count(reasons2) {
33757
+ const byReason = {};
33758
+ for (const reason of reasons2)
33759
+ byReason[reason] = (byReason[reason] ?? 0) + 1;
33760
+ return { total: reasons2.length, byReason };
33831
33761
  }
33832
- var TAG = /^<\/?[A-Za-z0-9][A-Za-z0-9._-]*\s*\/?>/;
33833
- function readTagToken(s, i) {
33834
- const m = TAG.exec(s.slice(i));
33835
- if (!m) return null;
33836
- const text = m[0];
33837
- return {
33838
- text,
33839
- kind: "tag",
33840
- name: text.replace(/[<>/\s]/g, ""),
33841
- signature: `tag:${text.replace(/\s+/g, "")}`,
33842
- start: i,
33843
- end: i + text.length
33844
- };
33762
+ function openPullRequest(rows, locale) {
33763
+ const row = rows.find(
33764
+ (r) => r.prState === "open" && r.prUrl !== void 0 && sameLocale(r.targetLanguage, locale)
33765
+ );
33766
+ if (!row?.prUrl) return null;
33767
+ return { number: row.prNumber ?? null, url: row.prUrl };
33845
33768
  }
33846
- function readNestingToken(s, i) {
33847
- if (!s.startsWith("$t(", i)) return null;
33848
- const close = s.indexOf(")", i + 3);
33849
- if (close === -1) return null;
33850
- const text = s.slice(i, close + 1);
33769
+ function languageStatus(locale, plan, pullRequests, opts = {}) {
33770
+ const { summary } = plan;
33771
+ const held = [
33772
+ ...plan.heldKeys,
33773
+ ...plan.protectedKeys.flatMap(
33774
+ ({ key, reason }) => isPersonHold(reason) ? [{ key, reason }] : []
33775
+ )
33776
+ ];
33777
+ const kept = plan.protectedKeys.flatMap(
33778
+ ({ key, reason }) => isPersonHold(reason) ? [] : [{ key, reason }]
33779
+ );
33780
+ const tasks = (reason) => plan.tasks.filter((t) => t.reason === reason).map((t) => fullKey4(t.namespace, t.key));
33851
33781
  return {
33852
- text,
33853
- kind: "nesting",
33854
- name: s.slice(i + 3, close).trim(),
33855
- signature: `nesting:${text.replace(/\s+/g, "")}`,
33856
- start: i,
33857
- end: close + 1
33782
+ locale,
33783
+ expected: summary.totalKeys,
33784
+ upToDate: summary.upToDate,
33785
+ missing: summary.missing,
33786
+ changed: summary.changed,
33787
+ heldByPerson: count(held.map((h) => h.reason)),
33788
+ keptForPerson: count(kept.map((k) => k.reason)),
33789
+ untracked: summary.untracked,
33790
+ pullRequest: openPullRequest(pullRequests, locale),
33791
+ ...opts.keys ? {
33792
+ keys: {
33793
+ missing: tasks("missing"),
33794
+ changed: tasks("changed"),
33795
+ heldByPerson: held,
33796
+ keptForPerson: kept,
33797
+ untracked: plan.untrackedKeys.map((k) => k.key)
33798
+ }
33799
+ } : {}
33858
33800
  };
33859
33801
  }
33860
- function extractPlaceholders(s) {
33861
- const tokens = [];
33862
- let i = 0;
33863
- while (i < s.length) {
33864
- const c = s[i];
33865
- let token = null;
33866
- if (c === "{") token = readBraceToken(s, i);
33867
- else if (c === "%") token = readPercentToken(s, i);
33868
- else if (c === "<") token = readTagToken(s, i);
33869
- else if (c === "$") token = readNestingToken(s, i);
33870
- if (token) {
33871
- tokens.push(token);
33872
- i = token.end;
33873
- continue;
33874
- }
33875
- i++;
33876
- }
33877
- return tokens;
33802
+ function needsTranslation(language) {
33803
+ return language.missing > 0 || language.changed > 0;
33878
33804
  }
33879
- function countHashes(s) {
33880
- return (s.match(/#/g) ?? []).length;
33805
+ var REASON_WORDS = {
33806
+ locked: "locked",
33807
+ "pending-draft": "pending draft",
33808
+ manual: "written by a person",
33809
+ "target-edited": "edited since TolkBot wrote it",
33810
+ unknown: "no record of who wrote it"
33811
+ };
33812
+ function reasons(counts) {
33813
+ return Object.entries(counts.byReason).map(
33814
+ ([reason, n]) => `${n} ${REASON_WORDS[reason]}`
33815
+ ).join(", ");
33881
33816
  }
33882
- function subtract(a, b) {
33883
- const pool = /* @__PURE__ */ new Map();
33884
- for (const t of b) pool.set(t.signature, (pool.get(t.signature) ?? 0) + 1);
33885
- const out = [];
33886
- for (const t of a) {
33887
- const left = pool.get(t.signature) ?? 0;
33888
- if (left > 0) pool.set(t.signature, left - 1);
33889
- else out.push(t);
33817
+ function describeLanguage(language) {
33818
+ const counts = [
33819
+ `${language.expected} expected`,
33820
+ `${language.upToDate} up to date`,
33821
+ `${language.missing} missing`,
33822
+ `${language.changed} changed since translated`
33823
+ ].join(", ");
33824
+ const mark = needsTranslation(language) ? "\u2022" : "\u2713";
33825
+ const lines = [`${mark} ${language.locale}: ${counts}`];
33826
+ if (language.heldByPerson.total > 0) {
33827
+ lines.push(
33828
+ ` ${language.heldByPerson.total} held by a person (${reasons(language.heldByPerson)})`
33829
+ );
33890
33830
  }
33891
- return out;
33831
+ if (language.keptForPerson.total > 0) {
33832
+ lines.push(
33833
+ ` ${language.keptForPerson.total} changed but kept for a person (${reasons(language.keptForPerson)})`
33834
+ );
33835
+ }
33836
+ if (language.untracked > 0) {
33837
+ lines.push(
33838
+ ` ${language.untracked} untracked: TolkBot has no record of them`
33839
+ );
33840
+ }
33841
+ if (language.pullRequest) {
33842
+ lines.push(` Pull request: ${language.pullRequest.url}`);
33843
+ }
33844
+ const { keys } = language;
33845
+ if (keys) {
33846
+ const list = (label, names) => {
33847
+ if (names.length === 0) return;
33848
+ lines.push(` ${label}:`);
33849
+ for (const name of names) lines.push(` ${name}`);
33850
+ };
33851
+ list("Missing", keys.missing);
33852
+ list("Changed", keys.changed);
33853
+ list(
33854
+ "Held by a person",
33855
+ keys.heldByPerson.map(
33856
+ (k) => `${k.key} (${REASON_WORDS[k.reason]})`
33857
+ )
33858
+ );
33859
+ list(
33860
+ "Kept for a person",
33861
+ keys.keptForPerson.map(
33862
+ (k) => `${k.key} (${REASON_WORDS[k.reason]})`
33863
+ )
33864
+ );
33865
+ list("Untracked", keys.untracked);
33866
+ }
33867
+ return lines;
33892
33868
  }
33893
- function compareIcuBranches(srcToken, outToken, problems, missing, extra) {
33894
- const src = parseIcuComplex(srcToken);
33895
- const out = parseIcuComplex(outToken);
33896
- if (!src || !out) return;
33897
- const outByKeyword = new Map(out.branches.map((b) => [b.keyword, b]));
33898
- for (const branch of src.branches) {
33899
- const counterpart = outByKeyword.get(branch.keyword);
33900
- if (!counterpart) continue;
33901
- const srcHashes = countHashes(branch.message);
33902
- const outHashes = countHashes(counterpart.message);
33903
- if (srcHashes !== outHashes) {
33904
- problems.push(
33905
- `{${src.name}} branch "${branch.keyword}" has ${srcHashes} "#" in the source but ${outHashes} in the translation`
33869
+
33870
+ // src/commands/status/status.tsx
33871
+ import { jsx as jsx17, jsxs as jsxs17 } from "react/jsx-runtime";
33872
+ var OFFLINE_HINT = "Without a login, 'tolkbot validate' checks the files on this machine.";
33873
+ async function runStatus(opts) {
33874
+ let ctx;
33875
+ try {
33876
+ ctx = await loadPlanContext(process.cwd());
33877
+ } catch (error62) {
33878
+ if (error62 instanceof AuthError) {
33879
+ throw new AuthError(
33880
+ error62.baseMessage,
33881
+ [error62.hint, OFFLINE_HINT].filter(Boolean).join(" "),
33882
+ error62.code
33906
33883
  );
33907
- if (srcHashes > outHashes) missing.push("#");
33908
- else extra.push("#");
33909
33884
  }
33910
- const nested = compareStrict(branch.message, counterpart.message);
33911
- if (!nested.ok) {
33912
- missing.push(...nested.missing);
33913
- extra.push(...nested.extra);
33914
- problems.push(
33915
- ...nested.problems.map(
33916
- (p) => `inside {${src.name}} branch "${branch.keyword}": ${p}`
33917
- )
33885
+ throw error62;
33886
+ }
33887
+ const { project } = ctx;
33888
+ const srcLocale = project.baseLanguage;
33889
+ const locales = project.targetLanguages;
33890
+ const progress = renderTransient(
33891
+ /* @__PURE__ */ jsxs17(Box17, { children: [
33892
+ /* @__PURE__ */ jsx17(Spinner14, {}),
33893
+ /* @__PURE__ */ jsxs17(Text17, { children: [
33894
+ " Reading the status of ",
33895
+ project.name,
33896
+ "\u2026"
33897
+ ] })
33898
+ ] })
33899
+ );
33900
+ const languages = [];
33901
+ let credits;
33902
+ try {
33903
+ const pullRequests = locales.length === 0 ? [] : await ctx.client.query(api.prJobs.listByProject, {
33904
+ organizationId: project.organizationId,
33905
+ projectId: project._id
33906
+ });
33907
+ for (const locale of locales) {
33908
+ const plan = await planForLocale(ctx, srcLocale, locale);
33909
+ languages.push(
33910
+ languageStatus(locale, plan, pullRequests, {
33911
+ keys: opts.keys
33912
+ })
33918
33913
  );
33919
33914
  }
33915
+ credits = await readCreditBalance(ctx);
33916
+ } finally {
33917
+ progress.unmount();
33920
33918
  }
33921
- }
33922
- function compareStrict(src, out) {
33923
- const srcTokens = extractPlaceholders(src);
33924
- const outTokens = extractPlaceholders(out);
33925
- const missingTokens = subtract(srcTokens, outTokens);
33926
- const extraTokens = subtract(outTokens, srcTokens);
33927
- const missing = missingTokens.map((t) => t.text);
33928
- const extra = extraTokens.map((t) => t.text);
33929
- const problems = [];
33930
- for (const t of missingTokens) {
33931
- problems.push(`"${t.text}" is missing from the translation`);
33919
+ const payload = {
33920
+ project: {
33921
+ name: project.name,
33922
+ slug: project.slug,
33923
+ url: `${env.TOLKBOT_WEB_URL}/projects/${project.slug}`,
33924
+ baseLanguage: srcLocale,
33925
+ pattern: fullPattern(scopeOf(project)),
33926
+ autoCreatePR: project.workflow?.autoCreatePR ?? DEFAULT_BEHAVIORAL_SETTINGS.workflow.autoCreatePR
33927
+ },
33928
+ credits: { balance: credits },
33929
+ languages
33930
+ };
33931
+ const outOfDate = languages.some(needsTranslation);
33932
+ const exitCode = opts.exitCode && outOfDate ? 1 : 0;
33933
+ if (opts.json) {
33934
+ if (exitCode === 0) jsonSuccess(payload);
33935
+ jsonResult(payload, exitCode);
33936
+ return;
33932
33937
  }
33933
- for (const t of extraTokens) {
33934
- problems.push(
33935
- `"${t.text}" appears in the translation but not the source`
33938
+ const { project: p } = payload;
33939
+ console.log(`${p.name} (${p.slug}) ${p.url}`);
33940
+ console.log(
33941
+ `Base language ${p.baseLanguage}, files ${p.pattern}, auto-create pull requests ${p.autoCreatePR ? "on" : "off"}`
33942
+ );
33943
+ console.log(
33944
+ credits === null ? "Credits left: could not be read" : `Credits left: ${credits}`
33945
+ );
33946
+ console.log("");
33947
+ if (languages.length === 0) {
33948
+ console.log(
33949
+ `No target languages configured. Add them in the project settings at ${p.url}/settings.`
33936
33950
  );
33937
33951
  }
33938
- const matchedOut = /* @__PURE__ */ new Map();
33939
- for (const t of outTokens) {
33940
- if (t.kind !== "icu-complex") continue;
33941
- const bucket = matchedOut.get(t.signature) ?? [];
33942
- bucket.push(t);
33943
- matchedOut.set(t.signature, bucket);
33952
+ for (const language of languages) {
33953
+ for (const line of describeLanguage(language)) console.log(line);
33944
33954
  }
33945
- for (const t of srcTokens) {
33946
- if (t.kind !== "icu-complex") continue;
33947
- const counterpart = matchedOut.get(t.signature)?.shift();
33948
- if (counterpart) {
33949
- compareIcuBranches(t, counterpart, problems, missing, extra);
33950
- }
33955
+ if (outOfDate) {
33956
+ console.log(
33957
+ "\nRun 'tolkbot translate --all' to translate what is missing or changed."
33958
+ );
33951
33959
  }
33952
- return {
33953
- ok: problems.length === 0,
33954
- missing,
33955
- extra,
33956
- problems,
33957
- incompletePlurals: []
33960
+ process.exitCode = exitCode;
33961
+ }
33962
+
33963
+ // src/commands/status/index.ts
33964
+ var statusCommand = new Command7("status").description(
33965
+ "Show what each language is missing, what waits for a person, the open pull requests and the credits left"
33966
+ ).option("--keys", "List the keys by name under each language").option(
33967
+ "--exit-code",
33968
+ "Exit 1 when any language has missing or changed keys"
33969
+ ).option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
33970
+ const schema = z9.object({
33971
+ keys: z9.boolean().optional(),
33972
+ exitCode: z9.boolean().optional(),
33973
+ json: z9.boolean().optional()
33974
+ });
33975
+ await runStatus(validateOrExit(schema, opts));
33976
+ });
33977
+
33978
+ // src/commands/telemetry.ts
33979
+ import { Command as Command8 } from "commander";
33980
+ import { z as z10 } from "zod/v4";
33981
+ var REASONS = {
33982
+ env: `disabled by $${TELEMETRY_ENV_VAR}`,
33983
+ "do-not-track": `disabled by $${DO_NOT_TRACK_ENV_VAR}`,
33984
+ preference: "disabled with `tolkbot telemetry off`",
33985
+ "no-key": "disabled: this build has no telemetry key",
33986
+ default: "enabled"
33987
+ };
33988
+ async function printStatus(json2) {
33989
+ const state = await telemetryState();
33990
+ if (json2) {
33991
+ jsonResult({ ...state, preferenceFile: getPreferencePath() }, 0);
33992
+ return;
33993
+ }
33994
+ console.log(
33995
+ `Telemetry is ${state.enabled ? "on" : "off"} (${REASONS[state.reason]}).`
33996
+ );
33997
+ console.log(
33998
+ `Turn it off with \`tolkbot telemetry off\`, $${TELEMETRY_ENV_VAR}=0 or $${DO_NOT_TRACK_ENV_VAR}=1.`
33999
+ );
34000
+ }
34001
+ var telemetryCommand = new Command8("telemetry").description(
34002
+ "Control anonymous usage reporting"
34003
+ );
34004
+ telemetryCommand.command("status", { isDefault: true }).description("Show whether telemetry is on and why").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
34005
+ const parsed = validateOrExit(
34006
+ z10.object({ json: z10.boolean().optional() }),
34007
+ opts
34008
+ );
34009
+ await printStatus(parsed.json);
34010
+ });
34011
+ telemetryCommand.command("off").description("Stop reporting usage from this machine").action(async () => {
34012
+ await setTelemetryPreference(false);
34013
+ console.log(
34014
+ `Telemetry is off. Preference saved to ${getPreferencePath()}.`
34015
+ );
34016
+ });
34017
+ telemetryCommand.command("on").description("Resume reporting usage from this machine").action(async () => {
34018
+ await setTelemetryPreference(true);
34019
+ await printStatus(false);
34020
+ });
34021
+
34022
+ // src/commands/translate/index.ts
34023
+ import { Command as Command9 } from "commander";
34024
+ import { z as z11 } from "zod/v4";
34025
+
34026
+ // src/commands/translate/adopt.tsx
34027
+ var STAMP_CHUNK2 = 200;
34028
+ async function adoptLocale(ctx, srcLocale, dstLocale) {
34029
+ const plan = await planForLocale(ctx, srcLocale, dstLocale);
34030
+ const locale = governanceLocale(ctx.project, dstLocale);
34031
+ const byTemplate = /* @__PURE__ */ new Map();
34032
+ for (const key of plan.untrackedKeys) {
34033
+ const group = byTemplate.get(key.pathTemplate) ?? [];
34034
+ group.push(key);
34035
+ byTemplate.set(key.pathTemplate, group);
34036
+ }
34037
+ const outcome = {
34038
+ dstLocale,
34039
+ untracked: plan.untrackedKeys.length,
34040
+ adopted: 0,
34041
+ skipped: 0
33958
34042
  };
34043
+ for (const [pathTemplate, keys] of byTemplate) {
34044
+ for (let i = 0; i < keys.length; i += STAMP_CHUNK2) {
34045
+ const result = await ctx.client.mutation(
34046
+ api.localeKeyStates.adoptUntrackedForCli,
34047
+ {
34048
+ organizationId: ctx.project.organizationId,
34049
+ projectId: ctx.project._id,
34050
+ locale,
34051
+ pathTemplate,
34052
+ entries: keys.slice(i, i + STAMP_CHUNK2).map((key) => ({
34053
+ key: key.fileKey,
34054
+ namespace: key.namespace,
34055
+ baseValue: key.src,
34056
+ translatedValue: key.dst
34057
+ }))
34058
+ }
34059
+ );
34060
+ outcome.adopted += result.adopted;
34061
+ outcome.skipped += result.skipped;
34062
+ }
34063
+ }
34064
+ return outcome;
33959
34065
  }
33960
- function pluralArgument(token) {
33961
- const complex = token.complex;
33962
- if (token.kind !== "icu-complex" || !complex) return null;
33963
- if (complex.type !== "plural" && complex.type !== "selectordinal") {
33964
- return null;
34066
+ async function runAdopt(opts) {
34067
+ const run = async () => {
34068
+ const ctx = await loadPlanContext(process.cwd());
34069
+ const src = opts.src ?? ctx.project.baseLanguage;
34070
+ const locales = opts.all ? ctx.project.targetLanguages : opts.dst ? [opts.dst] : [];
34071
+ if (locales.length === 0) {
34072
+ throw opts.all ? new CliError(
34073
+ "No target languages configured for this project.",
34074
+ "Add one with 'tolkbot languages add <code>'.",
34075
+ "no_target_languages"
34076
+ ) : new UsageError(
34077
+ "Either --dst <locale> or --all must be provided."
34078
+ );
34079
+ }
34080
+ const outcomes2 = [];
34081
+ for (const dst of locales)
34082
+ outcomes2.push(await adoptLocale(ctx, src, dst));
34083
+ track("cli_translate_completed", {
34084
+ mode: "adopt",
34085
+ key_count: outcomes2.reduce((n, o) => n + o.adopted, 0),
34086
+ locale_count: outcomes2.length,
34087
+ job_count: 0,
34088
+ json: opts.json === true
34089
+ });
34090
+ return outcomes2;
34091
+ };
34092
+ if (opts.json) {
34093
+ jsonSuccess({ adopted: await run() });
34094
+ }
34095
+ const outcomes = await run();
34096
+ for (const o of outcomes) {
34097
+ console.log(
34098
+ `\u2713 ${o.dstLocale}: adopted ${o.adopted} untracked translation(s)` + (o.skipped > 0 ? `, ${o.skipped} already tracked` : "")
34099
+ );
33965
34100
  }
34101
+ }
34102
+
34103
+ // src/commands/translate/translate.tsx
34104
+ import { Spinner as Spinner15 } from "@inkjs/ui";
34105
+ import { Box as Box18, Text as Text18 } from "ink";
34106
+ import { jsx as jsx18, jsxs as jsxs18 } from "react/jsx-runtime";
34107
+ function planOutput(plan) {
33966
34108
  return {
33967
- token,
33968
- name: complex.name,
33969
- type: complex.type,
33970
- branches: complex.branches
34109
+ ...plan.summary,
34110
+ protectedKeys: plan.protectedKeys,
34111
+ heldKeys: plan.heldKeys,
34112
+ untrackedKeys: plan.untrackedKeys.map((k) => k.key)
33971
34113
  };
33972
34114
  }
33973
- function withoutPlurals(s, plurals) {
33974
- let out = s;
33975
- const byPositionDesc = [...plurals].sort(
33976
- (a, b) => b.token.start - a.token.start
33977
- );
33978
- for (const p of byPositionDesc) {
33979
- out = out.slice(0, p.token.start) + " " + out.slice(p.token.end);
33980
- }
33981
- return out;
34115
+ function fullKey5(namespace, key) {
34116
+ return namespace ? `${namespace}.${key}` : key;
33982
34117
  }
33983
- function fixedKeywords(p) {
33984
- return p.branches.map((b) => b.keyword).filter((k) => !PLURAL_CATEGORIES.has(k)).sort();
34118
+ function characters(text) {
34119
+ return Array.from(text).length;
33985
34120
  }
33986
- function comparePluralPair(src, out, dstLocale, result) {
33987
- const srcFixed = fixedKeywords(src).join(" ");
33988
- const outFixed = fixedKeywords(out).join(" ");
33989
- if (srcFixed !== outFixed) {
33990
- result.problems.push(
33991
- `{${src.name}} has branches [${srcFixed}] in the source but [${outFixed}] in the translation`
34121
+ function targetLocales(ctx, opts) {
34122
+ if (opts.dst) return [opts.dst];
34123
+ const locales = ctx.project.targetLanguages;
34124
+ if (locales.length === 0) {
34125
+ throw new CliError(
34126
+ "No target languages configured.",
34127
+ "Add one with 'tolkbot languages add <code>'.",
34128
+ "no_target_languages"
33992
34129
  );
33993
34130
  }
33994
- const pluralType = src.type === "plural" ? "cardinal" : "ordinal";
33995
- const outKeywords = new Set(out.branches.map((b) => b.keyword));
33996
- const lacking = requiredPluralCategories(dstLocale, pluralType).filter(
33997
- (c) => !outKeywords.has(c)
34131
+ return locales;
34132
+ }
34133
+ async function runTranslate(opts) {
34134
+ const ctx = await loadPlanContext(process.cwd());
34135
+ const srcLocale = opts.src ?? ctx.project.baseLanguage;
34136
+ const dstLocales = targetLocales(ctx, opts);
34137
+ if (opts.dryRun) {
34138
+ await runDryRun(ctx, srcLocale, dstLocales, opts);
34139
+ return;
34140
+ }
34141
+ await runLanguages(ctx, srcLocale, dstLocales, opts);
34142
+ }
34143
+ var NO_ESTIMATE = "No credit estimate exists for this project yet. Keys and characters are what a run would send.";
34144
+ async function runDryRun(ctx, srcLocale, dstLocales, opts) {
34145
+ const languages = [];
34146
+ const progress = renderTransient(
34147
+ /* @__PURE__ */ jsx18(Working, { text: `Planning ${dstLocales.join(", ")}\u2026` })
33998
34148
  );
33999
- if (lacking.length > 0) {
34000
- result.incompletePlurals.push(
34001
- `{${src.name}} has no ${lacking.map((c) => `"${c}"`).join(", ")} branch, which ${dstLocale} needs`
34002
- );
34149
+ try {
34150
+ for (const dstLocale of dstLocales) {
34151
+ const { plan } = await generateTasks(ctx, srcLocale, dstLocale, {
34152
+ retranslateUntracked: opts.retranslateUntracked
34153
+ });
34154
+ languages.push({
34155
+ dstLocale,
34156
+ keys: plan.tasks.length,
34157
+ characters: plan.tasks.reduce(
34158
+ (n, task) => n + characters(task.src),
34159
+ 0
34160
+ ),
34161
+ tasks: plan.tasks.map((task) => ({
34162
+ key: fullKey5(task.namespace, task.key),
34163
+ path: task.path,
34164
+ reason: task.reason,
34165
+ ...opts.verbose ? { src: task.src } : {}
34166
+ })),
34167
+ plan
34168
+ });
34169
+ }
34170
+ } finally {
34171
+ progress.unmount();
34003
34172
  }
34004
- const srcByKeyword = new Map(src.branches.map((b) => [b.keyword, b]));
34005
- const srcOther = srcByKeyword.get("other");
34006
- for (const branch of out.branches) {
34007
- if (branch.keyword.startsWith("offset:")) continue;
34008
- const counterpart = srcByKeyword.get(branch.keyword) ?? (PLURAL_CATEGORIES.has(branch.keyword) ? srcOther : void 0);
34009
- if (!counterpart) continue;
34010
- const srcHashes = countHashes(counterpart.message);
34011
- const outHashes = countHashes(branch.message);
34012
- const sayableInWords = branch.keyword.startsWith("=") || isSingleNumberCategory(dstLocale, pluralType, branch.keyword);
34013
- if (srcHashes !== outHashes && !(sayableInWords && outHashes <= Math.max(srcHashes, 1))) {
34014
- result.problems.push(
34015
- `{${src.name}} branch "${branch.keyword}" has ${outHashes} "#" but the source's "${counterpart.keyword}" has ${srcHashes}`
34173
+ const total = {
34174
+ keys: languages.reduce((n, l) => n + l.keys, 0),
34175
+ characters: languages.reduce((n, l) => n + l.characters, 0)
34176
+ };
34177
+ const balance = await readCreditBalance(ctx);
34178
+ track("cli_translate_completed", {
34179
+ mode: "plan",
34180
+ key_count: total.keys,
34181
+ locale_count: languages.length,
34182
+ job_count: 0,
34183
+ json: opts.json ?? false
34184
+ });
34185
+ if (opts.json) {
34186
+ jsonSuccess({
34187
+ dryRun: true,
34188
+ srcLocale,
34189
+ languages: languages.map(({ plan, ...language }) => ({
34190
+ ...language,
34191
+ plan: planOutput(plan)
34192
+ })),
34193
+ total,
34194
+ estimate: null,
34195
+ estimateNote: NO_ESTIMATE,
34196
+ credits: { balance }
34197
+ });
34198
+ }
34199
+ for (const language of languages) {
34200
+ const head = `${srcLocale} \u2192 ${language.dstLocale}`;
34201
+ if (language.keys === 0) {
34202
+ console.log(`\u2713 ${head}: up to date`);
34203
+ } else {
34204
+ console.log(
34205
+ `\u2022 ${head}: ${language.keys} key(s) to translate, ${language.characters} characters`
34016
34206
  );
34017
- if (srcHashes > outHashes) result.missing.push("#");
34018
- else result.extra.push("#");
34019
34207
  }
34020
- const nested = compareForLocale(
34021
- counterpart.message,
34022
- branch.message,
34023
- dstLocale
34024
- );
34025
- result.missing.push(...nested.missing);
34026
- result.extra.push(...nested.extra);
34027
- result.problems.push(
34028
- ...nested.problems.map(
34029
- (p) => `inside {${src.name}} branch "${branch.keyword}": ${p}`
34030
- )
34031
- );
34032
- result.incompletePlurals.push(...nested.incompletePlurals);
34208
+ for (const line of describePlan(language.plan)) {
34209
+ console.log(` ${line}`);
34210
+ }
34211
+ if (opts.verbose) {
34212
+ for (const task of language.tasks) {
34213
+ console.log(` ${task.key}: ${JSON.stringify(task.src)}`);
34214
+ }
34215
+ }
34216
+ }
34217
+ console.log(
34218
+ `
34219
+ In all: ${total.keys} key(s), ${total.characters} characters.` + (balance === null ? "" : ` Credit balance: ${balance}.`)
34220
+ );
34221
+ console.log(NO_ESTIMATE);
34222
+ console.log("Nothing was started. Run without --dry-run to translate.");
34223
+ }
34224
+ function Working({ text }) {
34225
+ return /* @__PURE__ */ jsxs18(Box18, { children: [
34226
+ /* @__PURE__ */ jsx18(Spinner15, {}),
34227
+ /* @__PURE__ */ jsxs18(Text18, { children: [
34228
+ " ",
34229
+ text
34230
+ ] })
34231
+ ] });
34232
+ }
34233
+ function progressText(dstLocale, summary) {
34234
+ if (!summary) return `${dstLocale}: starting\u2026`;
34235
+ const done = summary.completed + summary.failed;
34236
+ return `${dstLocale}: translating ${done}/${summary.total}\u2026`;
34237
+ }
34238
+ function describeOutcome2(outcome) {
34239
+ const { dstLocale, status, keys, written } = outcome;
34240
+ const wrote = written ? `, ${written.keys} written to ${written.files} file(s)` : "";
34241
+ const notApplied = outcome.notApplied.length > 0 ? `, ${outcome.notApplied.length} not applied` : "";
34242
+ switch (status) {
34243
+ case "up-to-date":
34244
+ return `\u2713 ${dstLocale}: up to date`;
34245
+ case "started":
34246
+ return `\u2192 ${dstLocale}: started job ${outcome.jobId} with ${keys} key(s)`;
34247
+ case "succeeded":
34248
+ return `\u2713 ${dstLocale}: ${outcome.completed} translated${wrote}${notApplied}`;
34249
+ case "partial":
34250
+ return `\u26A0 ${dstLocale}: ${outcome.completed} of ${keys} translated, ${outcome.failed} failed${wrote}${notApplied}`;
34251
+ case "failed":
34252
+ return `\u2717 ${dstLocale}: the job failed (${outcome.jobId})${wrote}${notApplied}`;
34253
+ case "cancelled":
34254
+ return `\u2717 ${dstLocale}: the job was cancelled (${outcome.jobId})`;
34255
+ case "timed-out":
34256
+ return `\u2717 ${dstLocale}: ${outcome.error}`;
34257
+ case "not-started":
34258
+ return `\u2717 ${dstLocale}: not started. ${outcome.error}`;
34259
+ case "pending":
34260
+ case "running":
34261
+ return `\u2026 ${dstLocale}: ${status}`;
34033
34262
  }
34034
34263
  }
34035
- function compareForLocale(src, out, dstLocale) {
34036
- const srcPlurals = extractPlaceholders(src).map(pluralArgument).filter((p) => p !== null);
34037
- const outPlurals = extractPlaceholders(out).map(pluralArgument).filter((p) => p !== null);
34038
- const result = compareStrict(
34039
- withoutPlurals(src, srcPlurals),
34040
- withoutPlurals(out, outPlurals)
34041
- );
34042
- const unmatched = [...outPlurals];
34043
- for (const p of srcPlurals) {
34044
- const i = unmatched.findIndex(
34045
- (o) => o.name === p.name && o.type === p.type
34264
+ async function runLanguages(ctx, srcLocale, dstLocales, opts) {
34265
+ const { project, framework } = ctx;
34266
+ const json2 = opts.json ?? false;
34267
+ const outcomes = [];
34268
+ let failure2 = null;
34269
+ let stopped = null;
34270
+ const report = (outcome) => {
34271
+ outcomes.push(outcome);
34272
+ if (json2) return;
34273
+ console.log(describeOutcome2(outcome));
34274
+ for (const line of describeNotApplied(outcome.notApplied)) {
34275
+ console.log(` ${line}`);
34276
+ }
34277
+ };
34278
+ for (const dstLocale of dstLocales) {
34279
+ const progress = renderTransient(
34280
+ /* @__PURE__ */ jsx18(Working, { text: `${dstLocale}: planning\u2026` })
34046
34281
  );
34047
- const counterpart = i === -1 ? void 0 : unmatched.splice(i, 1)[0];
34048
- if (!counterpart) {
34049
- result.missing.push(p.token.text);
34050
- result.problems.push(
34051
- `"${p.token.text}" is missing from the translation`
34282
+ try {
34283
+ const { tasks, plan } = await generateTasks(
34284
+ ctx,
34285
+ srcLocale,
34286
+ dstLocale,
34287
+ { retranslateUntracked: opts.retranslateUntracked }
34052
34288
  );
34053
- continue;
34289
+ const base = {
34290
+ dstLocale,
34291
+ status: "up-to-date",
34292
+ jobId: null,
34293
+ keys: tasks.length,
34294
+ completed: null,
34295
+ failed: null,
34296
+ written: null,
34297
+ notApplied: [],
34298
+ error: null,
34299
+ plan: planOutput(plan)
34300
+ };
34301
+ const leftOut = describeLeftOut(plan);
34302
+ if (stopped !== null) {
34303
+ progress.unmount();
34304
+ report({ ...base, status: "not-started", error: stopped });
34305
+ continue;
34306
+ }
34307
+ if (tasks.length === 0) {
34308
+ progress.unmount();
34309
+ report(base);
34310
+ printNotes(json2, leftOut);
34311
+ continue;
34312
+ }
34313
+ progress.rerender(/* @__PURE__ */ jsx18(Working, { text: progressText(dstLocale, null) }));
34314
+ let jobId;
34315
+ try {
34316
+ jobId = await startTranslationJob({
34317
+ projectId: project._id,
34318
+ organizationId: project.organizationId,
34319
+ framework,
34320
+ srcLocale,
34321
+ dstLocale,
34322
+ tasks
34323
+ });
34324
+ } catch (error62) {
34325
+ const refused = toCliError(error62);
34326
+ failure2 ??= refused;
34327
+ progress.unmount();
34328
+ report({
34329
+ ...base,
34330
+ status: "not-started",
34331
+ error: refused.baseMessage
34332
+ });
34333
+ if (exitCodeFor(refused.code) === EXIT_CODES.billing) {
34334
+ stopped = `TolkBot refused the ${dstLocale} job: ${refused.baseMessage}`;
34335
+ }
34336
+ continue;
34337
+ }
34338
+ if (!opts.wait) {
34339
+ progress.unmount();
34340
+ report({ ...base, status: "started", jobId });
34341
+ printNotes(json2, leftOut);
34342
+ continue;
34343
+ }
34344
+ let summary;
34345
+ try {
34346
+ summary = await waitForTranslationJob(
34347
+ project.organizationId,
34348
+ jobId,
34349
+ (latest) => progress.rerender(
34350
+ /* @__PURE__ */ jsx18(Working, { text: progressText(dstLocale, latest) })
34351
+ )
34352
+ );
34353
+ } catch (error62) {
34354
+ if (!(error62 instanceof JobWaitTimeoutError)) throw error62;
34355
+ failure2 ??= error62;
34356
+ progress.unmount();
34357
+ report({
34358
+ ...base,
34359
+ status: "timed-out",
34360
+ jobId,
34361
+ error: error62.baseMessage
34362
+ });
34363
+ stopped = `The CLI stopped waiting for the ${dstLocale} job.`;
34364
+ continue;
34365
+ }
34366
+ const finished = {
34367
+ ...base,
34368
+ status: summary.status,
34369
+ jobId,
34370
+ completed: summary.completed,
34371
+ failed: summary.failed
34372
+ };
34373
+ if (summary.status !== "cancelled") {
34374
+ progress.rerender(
34375
+ /* @__PURE__ */ jsx18(Working, { text: `${dstLocale}: writing the files\u2026` })
34376
+ );
34377
+ const applied = await applyJob(ctx, jobId);
34378
+ finished.written = { files: applied.files, keys: applied.keys };
34379
+ finished.notApplied = applied.notApplied;
34380
+ }
34381
+ const unsuccessful = unsuccessfulJobError(jobId, summary.status);
34382
+ if (unsuccessful) {
34383
+ failure2 ??= unsuccessful;
34384
+ finished.error = unsuccessful.baseMessage;
34385
+ }
34386
+ progress.unmount();
34387
+ report(finished);
34388
+ printNotes(json2, leftOut);
34389
+ if (summary.status === "cancelled") {
34390
+ stopped = `The ${dstLocale} job was cancelled.`;
34391
+ }
34392
+ } finally {
34393
+ progress.unmount();
34054
34394
  }
34055
- comparePluralPair(p, counterpart, dstLocale, result);
34056
34395
  }
34057
- for (const o of unmatched) {
34058
- result.extra.push(o.token.text);
34059
- result.problems.push(
34060
- `"${o.token.text}" appears in the translation but not the source`
34396
+ track("cli_translate_completed", {
34397
+ mode: "run",
34398
+ key_count: outcomes.reduce((n, o) => n + o.keys, 0),
34399
+ locale_count: outcomes.length,
34400
+ job_count: outcomes.filter((o) => o.jobId).length,
34401
+ json: json2
34402
+ });
34403
+ const payload = { srcLocale, languages: outcomes };
34404
+ if (json2) {
34405
+ if (!failure2) jsonSuccess(payload);
34406
+ jsonResult(payload, 1);
34407
+ fail(failure2);
34408
+ }
34409
+ if (!opts.wait && outcomes.some((o) => o.status === "started")) {
34410
+ console.log(
34411
+ "\nThe jobs keep running. Follow them with 'tolkbot jobs list' and write them into the files with 'tolkbot jobs apply'."
34061
34412
  );
34062
34413
  }
34063
- result.ok = result.problems.length === 0;
34064
- return result;
34065
- }
34066
- function comparePlaceholders(src, out, options = {}) {
34067
- return options.dstLocale ? compareForLocale(src, out, options.dstLocale) : compareStrict(src, out);
34414
+ if (failure2) fail(failure2);
34068
34415
  }
34069
-
34070
- // ../ai/src/utils/term-match.ts
34071
- function sharedPrefixLength(a, b) {
34072
- const minLen = Math.min(a.length, b.length);
34073
- let shared = 0;
34074
- while (shared < minLen && a[shared] === b[shared]) shared++;
34075
- return shared;
34416
+ function printNotes(json2, notes) {
34417
+ if (json2) return;
34418
+ for (const note of notes) console.log(` ${note}`);
34076
34419
  }
34077
- var MIN_FUZZY_LENGTH = 4;
34078
- var MIN_SHARED_FRACTION = 0.7;
34079
- var UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
34080
- var WORD_SPLIT = /[\s,.:;!?()[\]{}'"/\\-]+/;
34081
- var MAX_DERIVED_PREFIX = 4;
34082
- var MIN_DERIVED_STEM = 5;
34083
- function termAppearsInText(text, term, opts = {}) {
34084
- const haystack = opts.caseSensitive ? text : text.toLowerCase();
34085
- const needle = (opts.caseSensitive ? term : term.toLowerCase()).trim();
34086
- if (needle.length === 0) return false;
34087
- if (UNSPACED_SCRIPT.test(needle)) return haystack.includes(needle);
34088
- if (/\s/.test(needle) && haystack.includes(needle)) return true;
34089
- const termWords = needle.split(WORD_SPLIT).filter(Boolean);
34090
- const words2 = haystack.split(WORD_SPLIT).filter(Boolean);
34091
- for (let start = 0; start + termWords.length <= words2.length; start++) {
34092
- if (termWords.every(
34093
- (termWord, offset) => wordMatches(
34094
- words2[start + offset] ?? "",
34095
- termWord,
34096
- opts.derivedForms ?? false
34097
- )
34098
- )) {
34099
- return true;
34100
- }
34420
+ function checkTranslateOptions(opts) {
34421
+ if (!opts.dst && !opts.all) {
34422
+ throw new UsageError(
34423
+ "Either --dst <locale> or --all must be provided.",
34424
+ "Run 'tolkbot translate --help'."
34425
+ );
34101
34426
  }
34102
- return false;
34103
- }
34104
- function wordMatches(word, termWord, derivedForms) {
34105
- if (word === termWord) return true;
34106
- if (termWord.length < MIN_FUZZY_LENGTH || word.length < MIN_FUZZY_LENGTH) {
34107
- return false;
34427
+ if (opts.dst && opts.all) {
34428
+ throw new UsageError("Cannot use both --dst and --all.");
34108
34429
  }
34109
- const minShared = Math.ceil(termWord.length * MIN_SHARED_FRACTION);
34110
- if (sharedPrefixLength(word, termWord) >= minShared) return true;
34111
- if (!derivedForms || minShared < MIN_DERIVED_STEM) return false;
34112
- for (let offset = 1; offset <= MAX_DERIVED_PREFIX; offset++) {
34113
- if (sharedPrefixLength(word.slice(offset), termWord) >= minShared) {
34114
- return true;
34115
- }
34430
+ if (opts.dryRun && !opts.wait) {
34431
+ throw new UsageError(
34432
+ "Cannot use --dry-run with --no-wait: a dry run starts nothing."
34433
+ );
34116
34434
  }
34117
- return false;
34118
34435
  }
34119
34436
 
34120
- // ../ai/src/translations/glossary.ts
34121
- function extractGlossaryData(glossaries, dstLocale) {
34122
- const terms = [];
34123
- const doNotTranslate = [];
34124
- const contextTerms = [];
34125
- for (const g of glossaries) {
34126
- const id = g.id ? { id: g.id } : {};
34127
- if (g.doNotTranslate) {
34128
- doNotTranslate.push({ ...id, term: g.src });
34129
- if (g.aiContext) {
34130
- contextTerms.push({ ...id, term: g.src, context: g.aiContext });
34131
- }
34132
- } else {
34133
- const translation = pickGlossaryTranslation(
34134
- g.translations,
34135
- dstLocale
34136
- );
34137
- if (translation) {
34138
- terms.push({
34139
- ...id,
34140
- source: g.src,
34141
- target: translation.text,
34142
- ...g.aiContext ? { notes: g.aiContext } : {}
34143
- });
34144
- } else if (g.aiContext) {
34145
- contextTerms.push({ ...id, term: g.src, context: g.aiContext });
34146
- }
34147
- }
34437
+ // src/commands/translate/index.ts
34438
+ var RETRANSLATE_UNTRACKED_HELP = "Also retranslate translations TolkBot has no record of (kept by default)";
34439
+ var translateCommand2 = new Command9("translate").description(
34440
+ "Translate what is missing or changed, and write the results into your locale files"
34441
+ ).option(
34442
+ "--src <locale>",
34443
+ "Source locale (defaults to the project's base language)"
34444
+ ).option("--dst <locale>", "Destination locale").option("--all", "Every target language").option("--dry-run", "Print what a run would translate, and start nothing").option(
34445
+ "--no-wait",
34446
+ "Start the jobs and print their IDs without waiting; apply them later with 'tolkbot jobs apply'"
34447
+ ).option("--retranslate-untracked", RETRANSLATE_UNTRACKED_HELP).option("--verbose", "With --dry-run, include each key's source string").option("--json", "Output as JSON (machine-readable)").allowExcessArguments(true).enablePositionalOptions().action(async (opts, command) => {
34448
+ const [word] = command.args;
34449
+ if (word !== void 0) {
34450
+ throw new UsageError(
34451
+ `unknown command 'translate ${word}'`,
34452
+ "Run 'tolkbot translate --help'. Jobs are under 'tolkbot jobs'."
34453
+ );
34148
34454
  }
34149
- return { terms, doNotTranslate, contextTerms };
34150
- }
34151
- function filterGlossaryByRelevance(glossary, src) {
34152
- return {
34153
- terms: glossary.terms.filter(
34154
- (t) => termAppearsInText(src, t.source, {
34155
- caseSensitive: t.caseSensitive
34156
- })
34157
- ),
34158
- doNotTranslate: glossary.doNotTranslate.filter(
34159
- (t) => termAppearsInText(src, t.term)
34160
- ),
34161
- contextTerms: glossary.contextTerms.filter(
34162
- (t) => termAppearsInText(src, t.term)
34163
- )
34164
- };
34165
- }
34166
- function checkGlossaryCompliance(terms, translation) {
34167
- const missing = [];
34168
- for (const term of terms) {
34169
- const target = term.target.trim();
34170
- if (!target) continue;
34171
- if (!termAppearsInText(translation, target, {
34172
- caseSensitive: term.caseSensitive,
34173
- derivedForms: true
34174
- })) {
34175
- missing.push({ source: term.source, target: term.target });
34176
- }
34455
+ const schema = z11.object({
34456
+ src: z11.string().min(1).optional(),
34457
+ dst: z11.string().min(1).optional(),
34458
+ all: z11.boolean().optional(),
34459
+ dryRun: z11.boolean().optional(),
34460
+ wait: z11.boolean(),
34461
+ retranslateUntracked: z11.boolean().optional(),
34462
+ verbose: z11.boolean().optional(),
34463
+ json: z11.boolean().optional()
34464
+ });
34465
+ const parsed = validateOrExit(schema, opts);
34466
+ checkTranslateOptions(parsed);
34467
+ await runTranslate(parsed);
34468
+ });
34469
+ translateCommand2.command("adopt").description(
34470
+ "Record existing translations TolkBot has no baseline for as reviewed, so they are kept until their source changes"
34471
+ ).option(
34472
+ "--src <locale>",
34473
+ "Source locale (defaults to the project's base language)"
34474
+ ).option("--dst <locale>", "Destination locale").option("--all", "Every target language").option("--json", "Output as JSON (machine-readable)").action(async (opts) => {
34475
+ const schema = z11.object({
34476
+ src: z11.string().min(1).optional(),
34477
+ dst: z11.string().min(1).optional(),
34478
+ all: z11.boolean().optional(),
34479
+ json: z11.boolean().optional()
34480
+ });
34481
+ const parsed = validateOrExit(schema, opts);
34482
+ if (parsed.dst && parsed.all) {
34483
+ throw new UsageError("Cannot use both --dst and --all.");
34177
34484
  }
34178
- return missing;
34179
- }
34485
+ await runAdopt(parsed);
34486
+ });
34180
34487
 
34181
- // ../ai/src/translations/languages.ts
34182
- var VERIFIER_LANGUAGES = {
34183
- en: "English",
34184
- nb: "Norwegian Bokm\xE5l",
34185
- nn: "Norwegian Nynorsk",
34186
- sv: "Swedish",
34187
- da: "Danish",
34188
- fi: "Finnish",
34189
- is: "Icelandic",
34190
- de: "German",
34191
- nl: "Dutch",
34192
- fr: "French",
34193
- es: "Spanish",
34194
- pt: "Portuguese",
34195
- it: "Italian",
34196
- ca: "Catalan",
34197
- ro: "Romanian",
34198
- pl: "Polish",
34199
- cs: "Czech",
34200
- sk: "Slovak",
34201
- hu: "Hungarian",
34202
- el: "Greek",
34203
- ru: "Russian",
34204
- uk: "Ukrainian",
34205
- bg: "Bulgarian",
34206
- sr: "Serbian",
34207
- hr: "Croatian",
34208
- tr: "Turkish",
34209
- ar: "Arabic",
34210
- he: "Hebrew",
34211
- fa: "Persian",
34212
- hi: "Hindi",
34213
- bn: "Bengali",
34214
- th: "Thai",
34215
- vi: "Vietnamese",
34216
- id: "Indonesian",
34217
- ms: "Malay",
34218
- ja: "Japanese",
34219
- ko: "Korean",
34220
- zh: "Chinese",
34221
- other: "Any other language, or no natural language at all"
34222
- };
34223
- var LANGUAGE_ALIASES = { no: "nb" };
34224
- function verifierLanguage(locale) {
34225
- const head = locale.trim().split(/[-_]/)[0] ?? "";
34226
- const base = (/^[a-z]{2,3}(?=[A-Z])/.exec(head) ?? /^[A-Za-z]{2,3}$/.exec(head))?.[0].toLowerCase();
34227
- if (!base) return void 0;
34228
- const language = LANGUAGE_ALIASES[base] ?? base;
34229
- return language in VERIFIER_LANGUAGES && language !== "other" ? language : void 0;
34230
- }
34488
+ // src/commands/validate.tsx
34489
+ import { Box as Box19, render as render16, Text as Text19 } from "ink";
34231
34490
 
34232
- // ../ai/src/translations/deterministic-health.ts
34233
- var REQUIRED_SCRIPT = {
34234
- ko: /\p{Script=Hangul}/u,
34235
- ja: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u,
34236
- zh: /\p{Script=Han}/u,
34237
- ar: /\p{Script=Arabic}/u,
34238
- fa: /\p{Script=Arabic}/u,
34239
- he: /\p{Script=Hebrew}/u,
34240
- ru: /\p{Script=Cyrillic}/u,
34241
- uk: /\p{Script=Cyrillic}/u,
34242
- bg: /\p{Script=Cyrillic}/u,
34243
- el: /\p{Script=Greek}/u,
34244
- th: /\p{Script=Thai}/u,
34245
- hi: /\p{Script=Devanagari}/u,
34246
- bn: /\p{Script=Bengali}/u
34247
- };
34248
- var MIN_LETTERS = 4;
34249
- var MIN_SENTENCE_WORDS = 3;
34250
- var MIN_SENTENCE_LETTERS = 8;
34251
- function proseOf(text) {
34252
- return text.replace(/\{\{[^{}]*\}\}/g, " ").replace(/\{[^{}]*\}/g, " ").replace(/\{[^{}]*\}/g, " ").replace(/<[^<>]*>/g, " ").replace(/`[^`]*`/g, " ").replace(/https?:\/\/\S+/g, " ").replace(/%[sdif@]|%\d+\$[sdif@]/g, " ");
34253
- }
34254
- function letters(text) {
34255
- return text.match(/\p{L}/gu)?.length ?? 0;
34256
- }
34257
- function words(text) {
34258
- return text.trim().split(/\s+/).filter(Boolean).length;
34259
- }
34260
- function deterministicHealth(input2) {
34261
- const findings = [];
34262
- const translation = input2.translation.trim();
34263
- const source = input2.source.trim();
34264
- if (!translation) return { findings, needsModel: false };
34265
- if (!comparePlaceholders(source, translation, {
34266
- dstLocale: input2.dstLocale
34267
- }).ok) {
34268
- findings.push("placeholder_mismatch");
34491
+ // src/utils/pattern-discovery.ts
34492
+ import fs9 from "fs/promises";
34493
+ var DEFAULT_NAMESPACE = "translation";
34494
+ async function parseFile(absolutePath, format) {
34495
+ let content;
34496
+ try {
34497
+ content = await fs9.readFile(absolutePath, "utf-8");
34498
+ } catch {
34499
+ throw new Error("Could not read file");
34269
34500
  }
34270
- const prose = proseOf(translation);
34271
- const letterCount = letters(prose);
34272
- const sameLanguage = verifierLanguage(input2.srcLocale) !== void 0 && verifierLanguage(input2.srcLocale) === verifierLanguage(input2.dstLocale);
34273
- if (translation === source) {
34274
- if (!sameLanguage && words(prose) >= MIN_SENTENCE_WORDS && letterCount >= MIN_SENTENCE_LETTERS) {
34275
- findings.push("same_as_source");
34501
+ const parsed = format === "ts" ? parseTsContent(content) : parseJsonContent(content);
34502
+ const problem = parsed.issues?.find((issue2) => issue2.type === "error");
34503
+ if (problem) throw new Error(problem.message);
34504
+ return parsed.data;
34505
+ }
34506
+ async function discoverByPattern(root, scope) {
34507
+ const format = formatOf(scope);
34508
+ const files = await listLocaleFiles(root, scope);
34509
+ if (files.length === 0) return null;
34510
+ const filesByLocale = /* @__PURE__ */ new Map();
34511
+ const parseErrors = [];
34512
+ for (const file2 of files) {
34513
+ try {
34514
+ const data = await parseFile(file2.absolutePath, format);
34515
+ const group = filesByLocale.get(file2.locale) ?? [];
34516
+ group.push({
34517
+ data,
34518
+ path: file2.absolutePath,
34519
+ relativePath: file2.relativePath,
34520
+ locale: file2.locale,
34521
+ namespace: file2.namespace ?? DEFAULT_NAMESPACE
34522
+ });
34523
+ filesByLocale.set(file2.locale, group);
34524
+ } catch (error62) {
34525
+ parseErrors.push({
34526
+ path: file2.absolutePath,
34527
+ relativePath: file2.relativePath,
34528
+ locale: file2.locale,
34529
+ message: error62 instanceof Error ? error62.message : String(error62)
34530
+ });
34276
34531
  }
34277
- return { findings, needsModel: false };
34278
- }
34279
- if (letterCount < MIN_LETTERS) return { findings, needsModel: false };
34280
- const language = verifierLanguage(input2.dstLocale);
34281
- const script = language ? REQUIRED_SCRIPT[language] : void 0;
34282
- if (script && !script.test(prose)) {
34283
- findings.push("wrong_script");
34284
- return { findings, needsModel: false };
34285
34532
  }
34286
- return { findings, needsModel: true };
34287
- }
34288
-
34289
- // ../ai/src/translations/consistency.ts
34290
- function findSourceInconsistencies(pairs, referencePairs = []) {
34291
- const groups = /* @__PURE__ */ new Map();
34292
- const add = (p, reviewable) => {
34293
- const src = p.src.trim();
34294
- if (!src) return;
34295
- const list = groups.get(src) ?? [];
34296
- list.push({
34297
- key: p.key,
34298
- dst: p.dst,
34299
- reviewable,
34300
- ...p.context ? { context: p.context } : {}
34301
- });
34302
- groups.set(src, list);
34533
+ const locales = [...new Set(files.map((file2) => file2.locale))];
34534
+ return {
34535
+ format,
34536
+ pattern: fullPattern(scope),
34537
+ filesByLocale,
34538
+ locales,
34539
+ baseLocale: detectBaseLocale(locales),
34540
+ fileCount: files.length,
34541
+ parseErrors
34303
34542
  };
34304
- for (const p of pairs) add(p, true);
34305
- for (const p of referencePairs) add(p, false);
34306
- const out = [];
34307
- for (const [src, variants] of groups) {
34308
- if (variants.length < 2) continue;
34309
- if (new Set(variants.map((v) => v.dst.trim())).size < 2) continue;
34310
- if (!variants.some((v) => v.reviewable)) continue;
34311
- const contexts = new Set(variants.map((v) => v.context?.trim() ?? ""));
34312
- out.push({ src, variants, unexplained: contexts.size === 1 });
34313
- }
34314
- return out;
34315
34543
  }
34316
34544
 
34317
34545
  // ../quality/src/load.ts
@@ -34777,7 +35005,7 @@ function finalize2(issues, stats, strict) {
34777
35005
  }
34778
35006
 
34779
35007
  // src/commands/validate.tsx
34780
- import { jsx as jsx20, jsxs as jsxs20 } from "react/jsx-runtime";
35008
+ import { jsx as jsx19, jsxs as jsxs19 } from "react/jsx-runtime";
34781
35009
  var LEVEL_COLOR = {
34782
35010
  error: "red",
34783
35011
  warning: "yellow",
@@ -34786,12 +35014,12 @@ var LEVEL_COLOR = {
34786
35014
  var GUTTER = 9;
34787
35015
  function IssueLine({ issue: issue2 }) {
34788
35016
  const location = [issue2.file, issue2.key].filter(Boolean).join(" \xB7 ");
34789
- return /* @__PURE__ */ jsxs20(Box20, { flexDirection: "column", children: [
34790
- /* @__PURE__ */ jsxs20(Box20, { children: [
34791
- /* @__PURE__ */ jsx20(Box20, { width: GUTTER, flexShrink: 0, children: /* @__PURE__ */ jsx20(Text20, { color: LEVEL_COLOR[issue2.level], children: issue2.level }) }),
34792
- /* @__PURE__ */ jsx20(Box20, { flexGrow: 1, children: /* @__PURE__ */ jsx20(Text20, { children: issue2.message }) })
35017
+ return /* @__PURE__ */ jsxs19(Box19, { flexDirection: "column", children: [
35018
+ /* @__PURE__ */ jsxs19(Box19, { children: [
35019
+ /* @__PURE__ */ jsx19(Box19, { width: GUTTER, flexShrink: 0, children: /* @__PURE__ */ jsx19(Text19, { color: LEVEL_COLOR[issue2.level], children: issue2.level }) }),
35020
+ /* @__PURE__ */ jsx19(Box19, { flexGrow: 1, children: /* @__PURE__ */ jsx19(Text19, { children: issue2.message }) })
34793
35021
  ] }),
34794
- /* @__PURE__ */ jsx20(Box20, { marginLeft: GUTTER, children: /* @__PURE__ */ jsxs20(Text20, { dimColor: true, children: [
35022
+ /* @__PURE__ */ jsx19(Box19, { marginLeft: GUTTER, children: /* @__PURE__ */ jsxs19(Text19, { dimColor: true, children: [
34795
35023
  issue2.rule,
34796
35024
  location ? ` \u2014 ${location}` : ""
34797
35025
  ] }) })
@@ -34800,20 +35028,20 @@ function IssueLine({ issue: issue2 }) {
34800
35028
  function ReportUI({ report }) {
34801
35029
  const { counts, stats } = report;
34802
35030
  const summary = ["error", "warning", "info"].filter((level) => counts[level] > 0).map((level) => `${counts[level]} ${level}(s)`).join(", ");
34803
- return /* @__PURE__ */ jsxs20(Box20, { flexDirection: "column", children: [
34804
- /* @__PURE__ */ jsxs20(Box20, { children: [
34805
- /* @__PURE__ */ jsx20(Text20, { color: report.valid ? "green" : "red", children: report.valid ? "\u2713" : "\u2717" }),
34806
- /* @__PURE__ */ jsxs20(Text20, { children: [
35031
+ return /* @__PURE__ */ jsxs19(Box19, { flexDirection: "column", children: [
35032
+ /* @__PURE__ */ jsxs19(Box19, { children: [
35033
+ /* @__PURE__ */ jsx19(Text19, { color: report.valid ? "green" : "red", children: report.valid ? "\u2713" : "\u2717" }),
35034
+ /* @__PURE__ */ jsxs19(Text19, { children: [
34807
35035
  " Validation ",
34808
35036
  report.valid ? "passed" : "failed"
34809
35037
  ] }),
34810
- summary ? /* @__PURE__ */ jsxs20(Text20, { dimColor: true, children: [
35038
+ summary ? /* @__PURE__ */ jsxs19(Text19, { dimColor: true, children: [
34811
35039
  " \u2014 ",
34812
35040
  summary
34813
35041
  ] }) : null
34814
35042
  ] }),
34815
- stats.locales.length > 0 && /* @__PURE__ */ jsxs20(Box20, { marginTop: 1, flexDirection: "column", children: [
34816
- /* @__PURE__ */ jsxs20(Text20, { dimColor: true, children: [
35043
+ stats.locales.length > 0 && /* @__PURE__ */ jsxs19(Box19, { marginTop: 1, flexDirection: "column", children: [
35044
+ /* @__PURE__ */ jsxs19(Text19, { dimColor: true, children: [
34817
35045
  stats.filesChecked,
34818
35046
  " ",
34819
35047
  stats.format,
@@ -34822,18 +35050,18 @@ function ReportUI({ report }) {
34822
35050
  stats.totalKeys,
34823
35051
  " keys"
34824
35052
  ] }),
34825
- /* @__PURE__ */ jsxs20(Text20, { dimColor: true, children: [
35053
+ /* @__PURE__ */ jsxs19(Text19, { dimColor: true, children: [
34826
35054
  "Locales: ",
34827
35055
  stats.locales.join(", "),
34828
35056
  stats.baseLocale ? ` (base: ${stats.baseLocale})` : ""
34829
35057
  ] }),
34830
- stats.pattern && /* @__PURE__ */ jsxs20(Text20, { dimColor: true, children: [
35058
+ stats.pattern && /* @__PURE__ */ jsxs19(Text19, { dimColor: true, children: [
34831
35059
  "Pattern: ",
34832
35060
  stats.pattern
34833
35061
  ] })
34834
35062
  ] }),
34835
- report.issues.length > 0 && /* @__PURE__ */ jsx20(Box20, { marginTop: 1, flexDirection: "column", gap: 1, children: report.issues.map((issue2, idx) => /* @__PURE__ */ jsx20(IssueLine, { issue: issue2 }, idx)) }),
34836
- !report.strict && counts.warning > 0 && report.valid && /* @__PURE__ */ jsx20(Box20, { marginTop: 1, children: /* @__PURE__ */ jsx20(Text20, { dimColor: true, children: "Warnings did not fail this run. Use --strict to treat them as errors." }) })
35063
+ report.issues.length > 0 && /* @__PURE__ */ jsx19(Box19, { marginTop: 1, flexDirection: "column", gap: 1, children: report.issues.map((issue2, idx) => /* @__PURE__ */ jsx19(IssueLine, { issue: issue2 }, idx)) }),
35064
+ !report.strict && counts.warning > 0 && report.valid && /* @__PURE__ */ jsx19(Box19, { marginTop: 1, children: /* @__PURE__ */ jsx19(Text19, { dimColor: true, children: "Warnings did not fail this run. Use --strict to treat them as errors." }) })
34837
35065
  ] });
34838
35066
  }
34839
35067
  function emit(report, json2) {
@@ -34853,7 +35081,7 @@ function emit(report, json2) {
34853
35081
  if (!report.valid) fail(validationFailure(report));
34854
35082
  return;
34855
35083
  }
34856
- render17(/* @__PURE__ */ jsx20(ReportUI, { report })).unmount();
35084
+ render16(/* @__PURE__ */ jsx19(ReportUI, { report })).unmount();
34857
35085
  process.exitCode = exitCode;
34858
35086
  }
34859
35087
  function validationFailure(report) {
@@ -34931,7 +35159,7 @@ function commandPath(command) {
34931
35159
  return names.join(" ");
34932
35160
  }
34933
35161
  function buildProgram() {
34934
- const program = new Command9().name("tolkbot").description("Localization workflow for your repo").version(version, "-v, --version", "Output the version number").helpOption("-h, --help", "Show help").helpCommand("help [command]", "Show help for a command").option("--token <token>", TOKEN_HELP).enablePositionalOptions();
35162
+ const program = new Command10().name("tolkbot").description("Localization workflow for your repo").version(version, "-v, --version", "Output the version number").helpOption("-h, --help", "Show help").helpCommand("help [command]", "Show help for a command").option("--token <token>", TOKEN_HELP).enablePositionalOptions();
34935
35163
  program.hook("preAction", (_thisCommand, actionCommand) => {
34936
35164
  const { token } = actionCommand.optsWithGlobals();
34937
35165
  setTokenOverride(token);
@@ -34965,6 +35193,7 @@ function buildProgram() {
34965
35193
  program.addCommand(jobsCommand);
34966
35194
  program.addCommand(languagesCommand);
34967
35195
  program.addCommand(learningsCommand);
35196
+ program.addCommand(setCommand);
34968
35197
  program.addCommand(statusCommand);
34969
35198
  program.addCommand(telemetryCommand);
34970
35199
  program.addCommand(translateCommand2);
@@ -35027,6 +35256,7 @@ Examples:
35027
35256
  $ tolkbot languages add nb
35028
35257
  $ tolkbot translate --all
35029
35258
  $ tolkbot translate --all --dry-run
35259
+ $ tolkbot set common.save --locale nb --value "Lagre"
35030
35260
  $ tolkbot jobs list
35031
35261
  $ tolkbot validate
35032
35262