glotfile 0.8.7 → 0.8.9

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.
@@ -852,6 +852,153 @@ var init_state = __esm({
852
852
  }
853
853
  });
854
854
 
855
+ // src/server/stats.ts
856
+ function countWords(text) {
857
+ const t = text.trim();
858
+ return t === "" ? 0 : t.split(/\s+/).length;
859
+ }
860
+ function namespaceOf(key) {
861
+ const i = key.indexOf(".");
862
+ return i === -1 ? "(root)" : key.slice(0, i);
863
+ }
864
+ function pct(n, d) {
865
+ return d === 0 ? 0 : Math.round(n / d * 1e3) / 10;
866
+ }
867
+ function sourceText(entry, sourceLocale) {
868
+ const lv = entry.values[sourceLocale];
869
+ if (!lv) return "";
870
+ return entry.plural ? lv.forms?.other ?? "" : lv.value ?? "";
871
+ }
872
+ function isPresent(entry, locale) {
873
+ const lv = entry.values[locale];
874
+ if (!lv) return false;
875
+ return entry.plural ? (lv.forms?.other ?? "").trim() !== "" : (lv.value ?? "").trim() !== "";
876
+ }
877
+ function classify(entry, locale) {
878
+ if (!isPresent(entry, locale)) return "missing";
879
+ const st = entry.values[locale].state;
880
+ if (st === "reviewed") return "reviewed";
881
+ if (st === "needs-review") return "needsReview";
882
+ return "machine";
883
+ }
884
+ function groupCompletion(state, keys, targets, name) {
885
+ let translated = 0;
886
+ let reviewed = 0;
887
+ for (const k of keys) {
888
+ const entry = state.keys[k];
889
+ for (const locale of targets) {
890
+ const bucket = classify(entry, locale);
891
+ if (bucket !== "missing") translated++;
892
+ if (bucket === "reviewed") reviewed++;
893
+ }
894
+ }
895
+ const cells = keys.length * targets.length;
896
+ return { name, total: keys.length, translatedPct: pct(translated, cells), reviewedPct: pct(reviewed, cells) };
897
+ }
898
+ function worstFirst(a, b) {
899
+ return a.translatedPct - b.translatedPct || a.name.localeCompare(b.name);
900
+ }
901
+ function computeStats(state) {
902
+ const { sourceLocale, locales } = state.config;
903
+ const targets = locales.filter((l) => l !== sourceLocale);
904
+ const allKeys = Object.keys(state.keys);
905
+ const expected = allKeys.filter((k) => !state.keys[k].skipTranslate);
906
+ const locales_ = targets.map((locale) => {
907
+ const counts = { reviewed: 0, needsReview: 0, machine: 0, missing: 0 };
908
+ let sourceWords = 0;
909
+ let missingWords = 0;
910
+ for (const k of expected) {
911
+ const entry = state.keys[k];
912
+ const w = countWords(sourceText(entry, sourceLocale));
913
+ sourceWords += w;
914
+ const bucket = classify(entry, locale);
915
+ counts[bucket]++;
916
+ if (bucket === "missing") missingWords += w;
917
+ }
918
+ const total = expected.length;
919
+ const translated = counts.reviewed + counts.needsReview + counts.machine;
920
+ return {
921
+ locale,
922
+ total,
923
+ counts,
924
+ translated,
925
+ reviewed: counts.reviewed,
926
+ translatedPct: pct(translated, total),
927
+ reviewedPct: pct(counts.reviewed, total),
928
+ words: { source: sourceWords, missing: missingWords }
929
+ };
930
+ });
931
+ const cells = expected.length * targets.length;
932
+ let translatedCells = 0;
933
+ let reviewedCells = 0;
934
+ for (const ls of locales_) {
935
+ translatedCells += ls.translated;
936
+ reviewedCells += ls.reviewed;
937
+ }
938
+ const nsMap = /* @__PURE__ */ new Map();
939
+ for (const k of expected) {
940
+ const ns = namespaceOf(k);
941
+ (nsMap.get(ns) ?? nsMap.set(ns, []).get(ns)).push(k);
942
+ }
943
+ const byNamespace = [...nsMap.entries()].map(([name, keys]) => groupCompletion(state, keys, targets, name)).sort(worstFirst);
944
+ const tagMap = /* @__PURE__ */ new Map();
945
+ for (const k of expected) {
946
+ for (const tag of state.keys[k].tags ?? []) {
947
+ (tagMap.get(tag) ?? tagMap.set(tag, []).get(tag)).push(k);
948
+ }
949
+ }
950
+ const byTag = [...tagMap.entries()].map(([name, keys]) => groupCompletion(state, keys, targets, name)).sort(worstFirst);
951
+ return {
952
+ totals: {
953
+ keys: allKeys.length,
954
+ locales: targets.length,
955
+ translatedPct: pct(translatedCells, cells),
956
+ reviewedPct: pct(reviewedCells, cells),
957
+ sourceWords: expected.reduce((sum, k) => sum + countWords(sourceText(state.keys[k], sourceLocale)), 0)
958
+ },
959
+ locales: locales_,
960
+ byNamespace,
961
+ byTag
962
+ };
963
+ }
964
+ var init_stats = __esm({
965
+ "src/server/stats.ts"() {
966
+ "use strict";
967
+ }
968
+ });
969
+
970
+ // src/server/cell-state.ts
971
+ function cellState(entry, locale, sourceLocale) {
972
+ const lv = entry.values[locale];
973
+ if (locale === sourceLocale) {
974
+ const has = entry.plural ? !!lv?.forms?.other?.trim() : !!lv?.value?.trim();
975
+ return has ? "source" : "missing";
976
+ }
977
+ const present = entry.plural ? categoriesFor(locale).every((c) => (lv?.forms?.[c] ?? "") !== "") : !!lv?.value;
978
+ if (!present) return "missing";
979
+ const st = lv.state;
980
+ return st === "reviewed" ? "reviewed" : st === "needs-review" ? "needs-review" : "machine";
981
+ }
982
+ var EFFECTIVE_STATES;
983
+ var init_cell_state = __esm({
984
+ "src/server/cell-state.ts"() {
985
+ "use strict";
986
+ init_plurals();
987
+ EFFECTIVE_STATES = ["source", "missing", "machine", "needs-review", "reviewed"];
988
+ }
989
+ });
990
+
991
+ // src/server/glob.ts
992
+ function globToRegExp(glob) {
993
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
994
+ return new RegExp(`^${escaped}$`);
995
+ }
996
+ var init_glob = __esm({
997
+ "src/server/glob.ts"() {
998
+ "use strict";
999
+ }
1000
+ });
1001
+
855
1002
  // src/server/adapters/options.ts
856
1003
  function applyCase(canonical, style) {
857
1004
  const sep4 = style === "lower-underscore" || style === "bcp47-underscore" ? "_" : "-";
@@ -999,6 +1146,9 @@ function extractLiterals(value) {
999
1146
  });
1000
1147
  return out;
1001
1148
  }
1149
+ function quotedLiterals(value) {
1150
+ return extractLiterals(value).map((content) => `'${content}'`);
1151
+ }
1002
1152
  function toLaravel(value) {
1003
1153
  if (isIcuPluralOrSelect(value)) return value;
1004
1154
  return withLiterals(value, (gap) => gap.replace(/\{(\w+)\}/g, ":$1"), (lit) => lit);
@@ -2070,10 +2220,11 @@ function buildSystemPrompt(hasPluralItems) {
2070
2220
  "You are a professional software localization engine for a UI string catalog.",
2071
2221
  "Your goal: translate each source UI string into its target locale accurately and idiomatically, as a native speaker would phrase it in a real app interface.",
2072
2222
  "",
2073
- "You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
2223
+ "You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, an optional `literals` list, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
2074
2224
  "",
2075
2225
  "Hard rules:",
2076
2226
  "- Preserve every interpolation placeholder EXACTLY as written: {name}, {{count}}, %s, %d, :name. Never translate, rename, reorder, or remove them.",
2227
+ "- Reproduce every entry of the item's `literals` array EXACTLY, including its surrounding apostrophes (e.g. '{{visitor}}', '{name}'). These are app-managed literal tokens, not prose: translate the words around them, but never translate, rename, unquote, or drop them. The apostrophes are required \u2014 a result with bare {{visitor}} instead of '{{visitor}}' is wrong.",
2077
2228
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
2078
2229
  "- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
2079
2230
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
@@ -2102,6 +2253,7 @@ function buildBatchPrompt(reqs) {
2102
2253
  // Wrap in braces so the model sees "{site}" not "site" — makes the visual
2103
2254
  // connection to the source string obvious and reduces rename errors.
2104
2255
  placeholders: r.placeholders.map((p) => `{${p}}`),
2256
+ ...r.literals?.length ? { literals: r.literals } : {},
2105
2257
  ...r.glossary?.length ? { glossary: r.glossary } : {},
2106
2258
  hasScreenshot: r.image !== void 0
2107
2259
  };
@@ -3127,17 +3279,6 @@ var init_glossary = __esm({
3127
3279
  }
3128
3280
  });
3129
3281
 
3130
- // src/server/glob.ts
3131
- function globToRegExp(glob) {
3132
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
3133
- return new RegExp(`^${escaped}$`);
3134
- }
3135
- var init_glob = __esm({
3136
- "src/server/glob.ts"() {
3137
- "use strict";
3138
- }
3139
- });
3140
-
3141
3282
  // src/server/ai/run.ts
3142
3283
  import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
3143
3284
  import { resolve as resolve4, extname } from "path";
@@ -3145,6 +3286,8 @@ function selectRequests(state, opts) {
3145
3286
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
3146
3287
  const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
3147
3288
  const keySet = opts.keys ? new Set(opts.keys) : null;
3289
+ const stateSet = opts.states ? new Set(opts.states) : null;
3290
+ const skip = (st) => stateSet ? !stateSet.has(st) : !!opts.onlyMissing && st !== "missing";
3148
3291
  const reqs = [];
3149
3292
  let id = 0;
3150
3293
  for (const key of Object.keys(state.keys).sort()) {
@@ -3158,10 +3301,9 @@ function selectRequests(state, opts) {
3158
3301
  const other = sourceForms?.other;
3159
3302
  if (!sourceForms || !other) continue;
3160
3303
  for (const locale of targets) {
3161
- const have = entry.values[locale]?.forms ?? {};
3162
- const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
3163
- if (opts.onlyMissing && complete) continue;
3304
+ if (skip(cellState(entry, locale, state.config.sourceLocale))) continue;
3164
3305
  const glossary = relevantGlossary(other, locale, state.glossary);
3306
+ const literals = quotedLiterals(other);
3165
3307
  reqs.push({
3166
3308
  id: String(id++),
3167
3309
  key,
@@ -3171,6 +3313,7 @@ function selectRequests(state, opts) {
3171
3313
  targetLocale: locale,
3172
3314
  maxLength: entry.maxLength,
3173
3315
  placeholders: extractPlaceholders(other),
3316
+ ...literals.length ? { literals } : {},
3174
3317
  ...glossary.length ? { glossary } : {},
3175
3318
  plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
3176
3319
  });
@@ -3180,9 +3323,9 @@ function selectRequests(state, opts) {
3180
3323
  const source = sourceLv?.value;
3181
3324
  if (!source) continue;
3182
3325
  for (const locale of targets) {
3183
- const existing = entry.values[locale]?.value;
3184
- if (opts.onlyMissing && existing) continue;
3326
+ if (skip(cellState(entry, locale, state.config.sourceLocale))) continue;
3185
3327
  const glossary = relevantGlossary(source, locale, state.glossary);
3328
+ const literals = quotedLiterals(source);
3186
3329
  reqs.push({
3187
3330
  id: String(id++),
3188
3331
  key,
@@ -3192,6 +3335,7 @@ function selectRequests(state, opts) {
3192
3335
  targetLocale: locale,
3193
3336
  maxLength: entry.maxLength,
3194
3337
  placeholders: extractPlaceholders(source),
3338
+ ...literals.length ? { literals } : {},
3195
3339
  ...glossary.length ? { glossary } : {}
3196
3340
  });
3197
3341
  }
@@ -3340,6 +3484,7 @@ var init_run = __esm({
3340
3484
  init_placeholders();
3341
3485
  init_plurals();
3342
3486
  init_state();
3487
+ init_cell_state();
3343
3488
  init_glob();
3344
3489
  init_batch();
3345
3490
  MEDIA_TYPES = {
@@ -3683,7 +3828,8 @@ function buildContextSystemPrompt() {
3683
3828
  "- Do NOT restate the source string itself.",
3684
3829
  "- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
3685
3830
  "- Keep it under 500 characters.",
3686
- "- If no code snippets are available, infer from the key path and source value."
3831
+ "- If no code snippets are available, infer from the key path and source value.",
3832
+ "- Tokens: a source may contain interpolation placeholders ({name}, {{name}}, :name, %s) and ICU-apostrophe-quoted LITERAL tokens (e.g. '{{visitor}}', '{name}') that the app fills at runtime. Any provided `literals` are literal tokens, NOT plain placeholders. If you reference a token, write it EXACTLY as it appears in the source \u2014 keep apostrophe-quoted literals quoted, and never relabel a quoted literal as a placeholder or strip its quotes. The translation engine needs these to survive verbatim, so a note may simply remind translators to reproduce them exactly."
3687
3833
  ].join("\n");
3688
3834
  }
3689
3835
  function buildContextBatchPrompt(reqs) {
@@ -3695,7 +3841,14 @@ function buildContextBatchPrompt(reqs) {
3695
3841
  ${s.lines}
3696
3842
  \`\`\``;
3697
3843
  }).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
3698
- return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText };
3844
+ const literals = quotedLiterals(r.source);
3845
+ return {
3846
+ id: r.id,
3847
+ key: r.key,
3848
+ source: r.source,
3849
+ ...literals.length ? { literals } : {},
3850
+ codeSnippets: snippetText
3851
+ };
3699
3852
  });
3700
3853
  return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
3701
3854
  }
@@ -3733,6 +3886,7 @@ var init_context = __esm({
3733
3886
  "src/server/ai/context.ts"() {
3734
3887
  "use strict";
3735
3888
  init_state();
3889
+ init_placeholders();
3736
3890
  MAX_CONTEXT_LENGTH = 500;
3737
3891
  SNIPPET_WINDOW = 15;
3738
3892
  MAX_SNIPPETS = 3;
@@ -6583,121 +6737,6 @@ var init_run3 = __esm({
6583
6737
  }
6584
6738
  });
6585
6739
 
6586
- // src/server/stats.ts
6587
- function countWords(text) {
6588
- const t = text.trim();
6589
- return t === "" ? 0 : t.split(/\s+/).length;
6590
- }
6591
- function namespaceOf(key) {
6592
- const i = key.indexOf(".");
6593
- return i === -1 ? "(root)" : key.slice(0, i);
6594
- }
6595
- function pct(n, d) {
6596
- return d === 0 ? 0 : Math.round(n / d * 1e3) / 10;
6597
- }
6598
- function sourceText(entry, sourceLocale) {
6599
- const lv = entry.values[sourceLocale];
6600
- if (!lv) return "";
6601
- return entry.plural ? lv.forms?.other ?? "" : lv.value ?? "";
6602
- }
6603
- function isPresent(entry, locale) {
6604
- const lv = entry.values[locale];
6605
- if (!lv) return false;
6606
- return entry.plural ? (lv.forms?.other ?? "").trim() !== "" : (lv.value ?? "").trim() !== "";
6607
- }
6608
- function classify(entry, locale) {
6609
- if (!isPresent(entry, locale)) return "missing";
6610
- const st = entry.values[locale].state;
6611
- if (st === "reviewed") return "reviewed";
6612
- if (st === "needs-review") return "needsReview";
6613
- return "machine";
6614
- }
6615
- function groupCompletion(state, keys, targets, name) {
6616
- let translated = 0;
6617
- let reviewed = 0;
6618
- for (const k of keys) {
6619
- const entry = state.keys[k];
6620
- for (const locale of targets) {
6621
- const bucket = classify(entry, locale);
6622
- if (bucket !== "missing") translated++;
6623
- if (bucket === "reviewed") reviewed++;
6624
- }
6625
- }
6626
- const cells = keys.length * targets.length;
6627
- return { name, total: keys.length, translatedPct: pct(translated, cells), reviewedPct: pct(reviewed, cells) };
6628
- }
6629
- function worstFirst(a, b) {
6630
- return a.translatedPct - b.translatedPct || a.name.localeCompare(b.name);
6631
- }
6632
- function computeStats(state) {
6633
- const { sourceLocale, locales } = state.config;
6634
- const targets = locales.filter((l) => l !== sourceLocale);
6635
- const allKeys = Object.keys(state.keys);
6636
- const expected = allKeys.filter((k) => !state.keys[k].skipTranslate);
6637
- const locales_ = targets.map((locale) => {
6638
- const counts = { reviewed: 0, needsReview: 0, machine: 0, missing: 0 };
6639
- let sourceWords = 0;
6640
- let missingWords = 0;
6641
- for (const k of expected) {
6642
- const entry = state.keys[k];
6643
- const w = countWords(sourceText(entry, sourceLocale));
6644
- sourceWords += w;
6645
- const bucket = classify(entry, locale);
6646
- counts[bucket]++;
6647
- if (bucket === "missing") missingWords += w;
6648
- }
6649
- const total = expected.length;
6650
- const translated = counts.reviewed + counts.needsReview + counts.machine;
6651
- return {
6652
- locale,
6653
- total,
6654
- counts,
6655
- translated,
6656
- reviewed: counts.reviewed,
6657
- translatedPct: pct(translated, total),
6658
- reviewedPct: pct(counts.reviewed, total),
6659
- words: { source: sourceWords, missing: missingWords }
6660
- };
6661
- });
6662
- const cells = expected.length * targets.length;
6663
- let translatedCells = 0;
6664
- let reviewedCells = 0;
6665
- for (const ls of locales_) {
6666
- translatedCells += ls.translated;
6667
- reviewedCells += ls.reviewed;
6668
- }
6669
- const nsMap = /* @__PURE__ */ new Map();
6670
- for (const k of expected) {
6671
- const ns = namespaceOf(k);
6672
- (nsMap.get(ns) ?? nsMap.set(ns, []).get(ns)).push(k);
6673
- }
6674
- const byNamespace = [...nsMap.entries()].map(([name, keys]) => groupCompletion(state, keys, targets, name)).sort(worstFirst);
6675
- const tagMap = /* @__PURE__ */ new Map();
6676
- for (const k of expected) {
6677
- for (const tag of state.keys[k].tags ?? []) {
6678
- (tagMap.get(tag) ?? tagMap.set(tag, []).get(tag)).push(k);
6679
- }
6680
- }
6681
- const byTag = [...tagMap.entries()].map(([name, keys]) => groupCompletion(state, keys, targets, name)).sort(worstFirst);
6682
- return {
6683
- totals: {
6684
- keys: allKeys.length,
6685
- locales: targets.length,
6686
- translatedPct: pct(translatedCells, cells),
6687
- reviewedPct: pct(reviewedCells, cells),
6688
- sourceWords: expected.reduce((sum, k) => sum + countWords(sourceText(state.keys[k], sourceLocale)), 0)
6689
- },
6690
- locales: locales_,
6691
- byNamespace,
6692
- byTag
6693
- };
6694
- }
6695
- var init_stats = __esm({
6696
- "src/server/stats.ts"() {
6697
- "use strict";
6698
- }
6699
- });
6700
-
6701
6740
  // src/server/checks.ts
6702
6741
  function runChecks(state, opts = {}) {
6703
6742
  const ruleOff = (id) => state.config.lint?.rules?.[CHECK_RULE[id]] === "off";
@@ -8388,6 +8427,129 @@ var init_server = __esm({
8388
8427
 
8389
8428
  // src/server/cli.ts
8390
8429
  init_state();
8430
+ init_stats();
8431
+ import { resolve as resolve11, dirname as dirname5, join as join19, basename as basename2 } from "path";
8432
+ import { readFileSync as readFileSync24, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
8433
+ import { fileURLToPath as fileURLToPath2 } from "url";
8434
+
8435
+ // src/server/agent-cli.ts
8436
+ init_state();
8437
+ init_cell_state();
8438
+ init_glob();
8439
+ function projectCell(cell, fields) {
8440
+ const out = {};
8441
+ for (const f of fields) {
8442
+ if (f === "value" && cell.value !== void 0) out.value = cell.value;
8443
+ else if (f === "value" && cell.forms !== void 0) out.forms = cell.forms;
8444
+ else if (f === "state") out.state = cell.state;
8445
+ else if (f === "updatedAt" && cell.updatedAt !== void 0) out.updatedAt = cell.updatedAt;
8446
+ }
8447
+ return out;
8448
+ }
8449
+ function runGet(state, opts) {
8450
+ const { sourceLocale } = state.config;
8451
+ const shown = (opts.locales?.length ? opts.locales : state.config.locales).map(canonLocale);
8452
+ const targetsShown = shown.filter((l) => l !== sourceLocale);
8453
+ const res = opts.keyGlobs?.length ? opts.keyGlobs.map(globToRegExp) : null;
8454
+ const stateSet = opts.states?.length ? new Set(opts.states) : null;
8455
+ const fields = opts.fields?.length ? opts.fields : ["value", "state"];
8456
+ const fullEntry = fields.includes("all");
8457
+ const keys = [];
8458
+ const json = {};
8459
+ const ndjson = [];
8460
+ for (const key of Object.keys(state.keys).sort()) {
8461
+ if (res && !res.some((re) => re.test(key))) continue;
8462
+ const entry = state.keys[key];
8463
+ if (stateSet && !targetsShown.some((l) => stateSet.has(cellState(entry, l, sourceLocale)))) continue;
8464
+ keys.push(key);
8465
+ if (fullEntry) {
8466
+ const values = {};
8467
+ for (const l of shown) if (entry.values[l]) values[l] = entry.values[l];
8468
+ json[key] = { ...entry, values };
8469
+ ndjson.push({ key, ...entry, values });
8470
+ continue;
8471
+ }
8472
+ const cells = {};
8473
+ for (const locale of shown) {
8474
+ const st = cellState(entry, locale, sourceLocale);
8475
+ if (stateSet && locale !== sourceLocale && !stateSet.has(st)) continue;
8476
+ const lv = entry.values[locale];
8477
+ const cell = entry.plural ? { forms: lv?.forms ?? {}, state: st, updatedAt: lv?.updatedAt } : { value: lv?.value ?? "", state: st, updatedAt: lv?.updatedAt };
8478
+ const projected = projectCell(cell, fields);
8479
+ cells[locale] = projected;
8480
+ ndjson.push({ key, locale, ...projected });
8481
+ }
8482
+ json[key] = cells;
8483
+ }
8484
+ return { keys, json, ndjson };
8485
+ }
8486
+ function applyOne(state, op, clock) {
8487
+ switch (op.op) {
8488
+ case "create":
8489
+ createKey(state, op.key, op.value, clock);
8490
+ return;
8491
+ case "set-source":
8492
+ setSourceValue(state, op.key, op.value);
8493
+ return;
8494
+ case "set-target":
8495
+ setTargetValue(state, op.key, op.locale, op.value, clock);
8496
+ if (op.state && op.state !== "reviewed") setKeyState(state, op.key, op.locale, op.state);
8497
+ return;
8498
+ case "set-source-forms":
8499
+ setSourcePluralForms(state, op.key, op.forms);
8500
+ return;
8501
+ case "set-forms":
8502
+ setPluralForms(state, op.key, op.locale, op.forms, clock);
8503
+ if (op.state && op.state !== "reviewed") setKeyState(state, op.key, op.locale, op.state);
8504
+ return;
8505
+ case "set-state":
8506
+ setKeyState(state, op.key, op.locale, op.state);
8507
+ return;
8508
+ case "clear":
8509
+ clearValue(state, op.key, op.locale);
8510
+ return;
8511
+ default:
8512
+ throw new Error(`Unknown op: ${op.op ?? "(missing)"}`);
8513
+ }
8514
+ }
8515
+ function parseOps(raw) {
8516
+ let data;
8517
+ try {
8518
+ data = JSON.parse(raw);
8519
+ } catch (e) {
8520
+ throw new Error(`apply expects a JSON array of operations on stdin: ${e.message}`);
8521
+ }
8522
+ if (!Array.isArray(data)) throw new Error("apply expects a JSON array of operations on stdin");
8523
+ return data.map((o, i) => {
8524
+ if (!o || typeof o !== "object" || typeof o.op !== "string") {
8525
+ throw new Error(`operation ${i} is not an { "op": ... } object`);
8526
+ }
8527
+ return o;
8528
+ });
8529
+ }
8530
+ function applyOps(state, ops, opts = {}) {
8531
+ const clock = opts.clock ?? systemClock;
8532
+ const touched = /* @__PURE__ */ new Set();
8533
+ const errors = [];
8534
+ let applied = 0;
8535
+ for (let i = 0; i < ops.length; i++) {
8536
+ const op = ops[i];
8537
+ try {
8538
+ applyOne(state, op, clock);
8539
+ applied++;
8540
+ if (op.key) touched.add(op.key);
8541
+ } catch (e) {
8542
+ errors.push({ index: i, op: op.op, key: op.key, error: e instanceof Error ? e.message : String(e) });
8543
+ if (!opts.continueOnError) break;
8544
+ }
8545
+ }
8546
+ return { applied, keysTouched: [...touched].sort(), errors };
8547
+ }
8548
+
8549
+ // src/server/cli.ts
8550
+ init_cell_state();
8551
+ init_glob();
8552
+ init_schema();
8391
8553
  init_export_run();
8392
8554
  init_storage();
8393
8555
  init_ai();
@@ -8407,9 +8569,6 @@ init_usage();
8407
8569
  init_context();
8408
8570
  init_run2();
8409
8571
  init_outputs();
8410
- import { resolve as resolve11, dirname as dirname5, join as join19, basename as basename2 } from "path";
8411
- import { readFileSync as readFileSync24, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
8412
- import { fileURLToPath as fileURLToPath2 } from "url";
8413
8572
 
8414
8573
  // src/server/lint/locate.ts
8415
8574
  function locate(rawText, key) {
@@ -8485,7 +8644,7 @@ function formatSarif(report, ctx) {
8485
8644
  }
8486
8645
 
8487
8646
  // src/server/cli.ts
8488
- var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "scan", "prune", "split", "skill", "batch"];
8647
+ var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "scan", "prune", "split", "skill", "batch", "get", "stats", "set", "set-state", "clear", "apply"];
8489
8648
  var isCommand = (s) => s != null && COMMANDS.includes(s);
8490
8649
  function parseArgs(argv) {
8491
8650
  const statePath = resolve11(process.cwd(), "glotfile.json");
@@ -8561,9 +8720,21 @@ function parseArgs(argv) {
8561
8720
  else if (flag === "--batch") args.batch = true;
8562
8721
  else if (flag === "--wait") args.wait = true;
8563
8722
  else if (flag === "--print") args.print = true;
8723
+ else if (flag === "--state" && next) {
8724
+ args.states = next.split(",");
8725
+ i++;
8726
+ } else if (flag === "--fields" && next) {
8727
+ args.fields = next.split(",");
8728
+ i++;
8729
+ } else if (flag === "--keys-only") args.keysOnly = true;
8730
+ else if (flag === "--value" && next) {
8731
+ args.value = next;
8732
+ i++;
8733
+ } else if (flag === "--create") args.create = true;
8734
+ else if (flag === "--continue-on-error") args.continueOnError = true;
8564
8735
  else if (args.command === "batch" && (flag === "status" || flag === "apply" || flag === "cancel")) {
8565
8736
  args.batchAction = flag;
8566
- }
8737
+ } else if (!flag.startsWith("-")) (args.positionals ??= []).push(flag);
8567
8738
  }
8568
8739
  return args;
8569
8740
  }
@@ -8623,16 +8794,42 @@ function makeProviderOrExit(ai) {
8623
8794
  return null;
8624
8795
  }
8625
8796
  }
8797
+ function parseStates(args, allowSource) {
8798
+ if (!args.states?.length) return void 0;
8799
+ const allowed = allowSource ? EFFECTIVE_STATES : EFFECTIVE_STATES.filter((s) => s !== "source");
8800
+ for (const s of args.states) {
8801
+ if (!allowed.includes(s)) {
8802
+ console.error(`Unknown --state '${s}'. Expected one of: ${allowed.join(", ")}.`);
8803
+ process.exit(1);
8804
+ }
8805
+ }
8806
+ return args.states;
8807
+ }
8808
+ function translateSelection(args) {
8809
+ const states = parseStates(args, false);
8810
+ return {
8811
+ locales: args.locales,
8812
+ keyGlob: args.keyGlob,
8813
+ ...states ? { states } : { onlyMissing: args.all ? false : args.onlyMissing ?? true }
8814
+ };
8815
+ }
8816
+ function readStdin() {
8817
+ try {
8818
+ return readFileSync24(0, "utf8");
8819
+ } catch {
8820
+ return "";
8821
+ }
8822
+ }
8823
+ function matchKeys(state, glob) {
8824
+ const re = globToRegExp(glob);
8825
+ return Object.keys(state.keys).filter((k) => re.test(k)).sort();
8826
+ }
8626
8827
  async function runTranslate(args) {
8627
8828
  const state = loadState(args.statePath);
8628
8829
  const projectRoot = dirname5(resolve11(args.statePath));
8629
8830
  if (args.estimate) {
8630
8831
  const ai = loadLocalSettings(projectRoot).ai;
8631
- const est = estimateTranslation(state, ai, {
8632
- onlyMissing: args.all ? false : args.onlyMissing ?? true,
8633
- locales: args.locales,
8634
- keyGlob: args.keyGlob
8635
- });
8832
+ const est = estimateTranslation(state, ai, translateSelection(args));
8636
8833
  if (!est.requests) {
8637
8834
  console.log("Nothing to translate.");
8638
8835
  return;
@@ -8651,13 +8848,7 @@ async function runTranslate(args) {
8651
8848
  }
8652
8849
  return;
8653
8850
  }
8654
- const reqs = selectRequests(state, {
8655
- // Default to translating only empty values; --all forces a full re-translate
8656
- // (overwriting existing translations). --only missing stays as a no-op alias.
8657
- onlyMissing: args.all ? false : args.onlyMissing ?? true,
8658
- locales: args.locales,
8659
- keyGlob: args.keyGlob
8660
- });
8851
+ const reqs = selectRequests(state, translateSelection(args));
8661
8852
  const toTranslate = [...reqs];
8662
8853
  if (args.batch) {
8663
8854
  if (!toTranslate.length) {
@@ -9217,6 +9408,172 @@ function runSkill(args) {
9217
9408
  cpSync(SKILL_SRC, dest, { recursive: true });
9218
9409
  console.log(`Installed the glotfile skill to ${dest}. Restart Claude Code to pick it up.`);
9219
9410
  }
9411
+ function runGetCmd(args) {
9412
+ const state = loadState(args.statePath);
9413
+ const keyGlobs = [...args.positionals ?? [], ...args.keyGlob ? [args.keyGlob] : []];
9414
+ const out = runGet(state, {
9415
+ keyGlobs: keyGlobs.length ? keyGlobs : void 0,
9416
+ locales: args.locales,
9417
+ states: parseStates(args, true),
9418
+ fields: args.fields
9419
+ });
9420
+ if (args.keysOnly) {
9421
+ for (const k of out.keys) console.log(k);
9422
+ return;
9423
+ }
9424
+ if (args.format === "ndjson") {
9425
+ for (const row of out.ndjson) console.log(JSON.stringify(row));
9426
+ return;
9427
+ }
9428
+ console.log(JSON.stringify(out.json, null, 2));
9429
+ }
9430
+ function runStatsCmd(args) {
9431
+ const state = loadState(args.statePath);
9432
+ const stats = computeStats(state);
9433
+ let locales = stats.locales;
9434
+ if (args.locales?.length) {
9435
+ const want = new Set(args.locales.map(canonLocale));
9436
+ locales = locales.filter((l) => want.has(l.locale));
9437
+ }
9438
+ if (args.format === "text") {
9439
+ console.log(`${stats.totals.keys} key(s) \xB7 ${stats.totals.locales} target locale(s) \xB7 ${stats.totals.translatedPct}% translated, ${stats.totals.reviewedPct}% reviewed`);
9440
+ for (const l of locales) {
9441
+ const c = l.counts;
9442
+ console.log(` ${l.locale.padEnd(8)} ${String(l.translatedPct).padStart(5)}% translated (reviewed ${c.reviewed}, machine ${c.machine}, needs-review ${c.needsReview}, missing ${c.missing})`);
9443
+ }
9444
+ return;
9445
+ }
9446
+ console.log(JSON.stringify({ totals: stats.totals, locales }, null, 2));
9447
+ }
9448
+ function countNeedsReview(state, key) {
9449
+ const entry = state.keys[key];
9450
+ if (!entry) return 0;
9451
+ let n = 0;
9452
+ for (const [loc, lv] of Object.entries(entry.values)) {
9453
+ if (loc !== state.config.sourceLocale && lv.state === "needs-review") n++;
9454
+ }
9455
+ return n;
9456
+ }
9457
+ function runSet(args) {
9458
+ const pos = args.positionals ?? [];
9459
+ const key = pos[0];
9460
+ if (!key) {
9461
+ console.error("Usage: glotfile set <key> [value] [--locale <code>] [--state <state>] [--create]");
9462
+ process.exitCode = 1;
9463
+ return;
9464
+ }
9465
+ let value = args.value ?? pos[1];
9466
+ if (value === void 0) {
9467
+ const piped = readStdin();
9468
+ value = piped.length ? piped.replace(/\r?\n$/, "") : void 0;
9469
+ }
9470
+ if (value === void 0) {
9471
+ console.error('set requires a value (positional, --value, or piped on stdin). Use --value "" to set an empty value.');
9472
+ process.exitCode = 1;
9473
+ return;
9474
+ }
9475
+ const state = loadState(args.statePath);
9476
+ const sl = state.config.sourceLocale;
9477
+ const locale = args.locales?.[0] ? canonLocale(args.locales[0]) : sl;
9478
+ try {
9479
+ if (locale === sl) {
9480
+ if (args.create && !state.keys[key]) createKey(state, key, value);
9481
+ const before = countNeedsReview(state, key);
9482
+ setSourceValue(state, key, value);
9483
+ saveState(args.statePath, state);
9484
+ const flipped = countNeedsReview(state, key) - before;
9485
+ console.log(`set ${key} (${sl})${flipped > 0 ? ` \u2014 ${flipped} translation(s) now need re-translation (run \`glotfile translate --state needs-review\`)` : ""}`);
9486
+ } else {
9487
+ setTargetValue(state, key, locale, value);
9488
+ const override = args.states?.[0];
9489
+ if (override && override !== "reviewed") setKeyState(state, key, locale, override);
9490
+ saveState(args.statePath, state);
9491
+ console.log(`set ${key} (${locale})`);
9492
+ }
9493
+ } catch (e) {
9494
+ console.error(e.message);
9495
+ process.exitCode = 1;
9496
+ }
9497
+ }
9498
+ function runSetStateCmd(args) {
9499
+ const pos = args.positionals ?? [];
9500
+ const sel = args.keyGlob ?? pos[0];
9501
+ const stateName = args.keyGlob ? pos[0] : pos[1];
9502
+ if (!sel || !stateName) {
9503
+ console.error("Usage: glotfile set-state <key|glob> <state> [--locale <list>] (state: machine | needs-review | reviewed)");
9504
+ process.exitCode = 1;
9505
+ return;
9506
+ }
9507
+ if (!STATES.includes(stateName)) {
9508
+ console.error(`Unknown state '${stateName}'. Expected one of: ${STATES.join(", ")}.`);
9509
+ process.exitCode = 1;
9510
+ return;
9511
+ }
9512
+ const state = loadState(args.statePath);
9513
+ const sl = state.config.sourceLocale;
9514
+ const locales = (args.locales?.length ? args.locales : state.config.locales.filter((l) => l !== sl)).map(canonLocale);
9515
+ const keys = matchKeys(state, sel);
9516
+ let n = 0;
9517
+ for (const key of keys) {
9518
+ for (const loc of locales) {
9519
+ if (state.keys[key].values[loc]) {
9520
+ setKeyState(state, key, loc, stateName);
9521
+ n++;
9522
+ }
9523
+ }
9524
+ }
9525
+ saveState(args.statePath, state);
9526
+ console.log(`Set ${n} cell(s) to ${stateName} across ${keys.length} key(s).`);
9527
+ }
9528
+ function runClearCmd(args) {
9529
+ const sel = args.keyGlob ?? args.positionals?.[0];
9530
+ if (!sel) {
9531
+ console.error("Usage: glotfile clear <key|glob> --locale <list>");
9532
+ process.exitCode = 1;
9533
+ return;
9534
+ }
9535
+ if (!args.locales?.length) {
9536
+ console.error("clear requires --locale <list> (the locale(s) to empty).");
9537
+ process.exitCode = 1;
9538
+ return;
9539
+ }
9540
+ const state = loadState(args.statePath);
9541
+ const sl = state.config.sourceLocale;
9542
+ const locales = args.locales.map(canonLocale);
9543
+ if (locales.includes(sl)) {
9544
+ console.error(`Cannot clear the source locale (${sl}); edit it with \`glotfile set\` instead.`);
9545
+ process.exitCode = 1;
9546
+ return;
9547
+ }
9548
+ const keys = matchKeys(state, sel);
9549
+ let n = 0;
9550
+ for (const key of keys) {
9551
+ for (const loc of locales) {
9552
+ if (state.keys[key].values[loc]) {
9553
+ clearValue(state, key, loc);
9554
+ n++;
9555
+ }
9556
+ }
9557
+ }
9558
+ saveState(args.statePath, state);
9559
+ console.log(`Cleared ${n} value(s) \u2192 untranslated.`);
9560
+ }
9561
+ function runApply(args) {
9562
+ let ops;
9563
+ try {
9564
+ ops = parseOps(readStdin());
9565
+ } catch (e) {
9566
+ console.error(e.message);
9567
+ process.exitCode = 1;
9568
+ return;
9569
+ }
9570
+ const state = loadState(args.statePath);
9571
+ const r = applyOps(state, ops, { continueOnError: args.continueOnError });
9572
+ const saved = (args.continueOnError || r.errors.length === 0) && !args.dryRun;
9573
+ if (saved) saveState(args.statePath, state);
9574
+ console.log(JSON.stringify({ applied: r.applied, keysTouched: r.keysTouched, saved, dryRun: !!args.dryRun, errors: r.errors }, null, 2));
9575
+ if (r.errors.length) process.exitCode = 1;
9576
+ }
9220
9577
  var GLOBAL_OPTS = [
9221
9578
  ["-f, --file <path>", "State file to use (default: ./glotfile.json)"],
9222
9579
  ["-h, --help", "Show this help"]
@@ -9240,9 +9597,10 @@ var COMMAND_HELP = {
9240
9597
  },
9241
9598
  translate: {
9242
9599
  summary: "AI-translate missing strings into your target locales (writes back to the state file).",
9243
- usage: "glotfile translate [--all] [--estimate] [--locale <list>] [--key <glob>]",
9600
+ usage: "glotfile translate [--all] [--state <list>] [--estimate] [--locale <list>] [--key <glob>]",
9244
9601
  options: [
9245
9602
  ["--all", "Re-translate every string, not just empty values"],
9603
+ ["--state <list>", "Re-translate only targets in these states: missing|machine|needs-review|reviewed (e.g. needs-review = strings a source edit invalidated)"],
9246
9604
  ["--estimate", "Print batches, tokens and estimated cost without translating"],
9247
9605
  ["--locale <list>", "Comma-separated target locales (alias: --locales)"],
9248
9606
  ["--key <glob>", "Only keys matching this glob"],
@@ -9337,6 +9695,64 @@ var COMMAND_HELP = {
9337
9695
  ["apply", "Fetch results and write translations (auto-runs when finished)"],
9338
9696
  ["cancel", "Cancel the pending batch and discard the handle"]
9339
9697
  ]
9698
+ },
9699
+ get: {
9700
+ summary: "Extract values from the catalog (filtered) without loading the whole file. Prints JSON.",
9701
+ usage: "glotfile get [<key-glob>\u2026] [--key <glob>] [--locale <list>] [--state <list>] [--fields <list>] [--keys-only] [--format json|ndjson]",
9702
+ options: [
9703
+ ["<key-glob>\u2026", "Key globs to include (e.g. auth.*); positional, repeatable. Default: all keys"],
9704
+ ["--key <glob>", "Additional key glob (merged with positionals)"],
9705
+ ["--locale <list>", "Locales to show (default: all configured locales, source included)"],
9706
+ ["--state <list>", "Only keys whose shown target locales are in these states: source|missing|machine|needs-review|reviewed"],
9707
+ ["--fields <list>", "Cell fields to project: value,state,updatedAt (default value,state); 'all' = the full key entry"],
9708
+ ["--keys-only", "Print just the matched key names, one per line"],
9709
+ ["--format <fmt>", "json (default, nested) or ndjson (one row per cell)"]
9710
+ ]
9711
+ },
9712
+ stats: {
9713
+ summary: "Per-locale progress counts (translated / reviewed / machine / needs-review / missing).",
9714
+ usage: "glotfile stats [--locale <list>] [--format json|text]",
9715
+ options: [
9716
+ ["--locale <list>", "Restrict to these comma-separated locales"],
9717
+ ["--format <fmt>", "json (default) or text"]
9718
+ ]
9719
+ },
9720
+ set: {
9721
+ summary: "Set one value: the source string (default \u2014 flips downstream translations to needs-review) or a target (--locale).",
9722
+ usage: "glotfile set <key> [value] [--locale <code>] [--state <state>] [--create]",
9723
+ options: [
9724
+ ["<key> [value]", "Key, then the value (or pass --value, or pipe it on stdin)"],
9725
+ ["--locale <code>", "Set this target locale instead of the source"],
9726
+ ["--value <v>", "The value (alternative to the positional / stdin)"],
9727
+ ["--state <state>", "Resulting state for a target write (default reviewed): machine|needs-review|reviewed"],
9728
+ ["--create", "Create the key (scalar) if it does not exist yet"]
9729
+ ]
9730
+ },
9731
+ "set-state": {
9732
+ summary: "Flip the review state of one key \u2014 or many via a glob \u2014 across locales.",
9733
+ usage: "glotfile set-state <key|glob> <state> [--locale <list>]",
9734
+ options: [
9735
+ ["<key|glob> <state>", "Key/glob, then machine | needs-review | reviewed"],
9736
+ ["--key <glob>", "Glob selecting keys (alternative to the positional key)"],
9737
+ ["--locale <list>", "Locales to affect (default: every target locale)"]
9738
+ ]
9739
+ },
9740
+ clear: {
9741
+ summary: "Empty target value(s) so they read as untranslated (and get refilled by a plain translate).",
9742
+ usage: "glotfile clear <key|glob> --locale <list>",
9743
+ options: [
9744
+ ["<key|glob>", "Key or glob to clear"],
9745
+ ["--key <glob>", "Glob selecting keys (alternative to the positional key)"],
9746
+ ["--locale <list>", "Required: the locale(s) to empty (cannot be the source)"]
9747
+ ]
9748
+ },
9749
+ apply: {
9750
+ summary: "Apply a JSON batch of write operations from stdin in one load \u2192 save (atomic by default).",
9751
+ usage: "glotfile apply [--dry-run] [--continue-on-error] < ops.json",
9752
+ options: [
9753
+ ["--dry-run", "Report what would change without writing"],
9754
+ ["--continue-on-error", "Apply the survivors past a failing op instead of stopping (and saving nothing)"]
9755
+ ]
9340
9756
  }
9341
9757
  };
9342
9758
  function formatOpts(opts) {
@@ -9395,6 +9811,12 @@ async function main(argv) {
9395
9811
  if (args.command === "split") return runSplit(args);
9396
9812
  if (args.command === "skill") return runSkill(args);
9397
9813
  if (args.command === "batch") return runBatch(args);
9814
+ if (args.command === "get") return runGetCmd(args);
9815
+ if (args.command === "stats") return runStatsCmd(args);
9816
+ if (args.command === "set") return runSet(args);
9817
+ if (args.command === "set-state") return runSetStateCmd(args);
9818
+ if (args.command === "clear") return runClearCmd(args);
9819
+ if (args.command === "apply") return runApply(args);
9398
9820
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
9399
9821
  const { url } = await startServer2({ statePath: args.statePath, dev: args.dev, open: !args.noOpen });
9400
9822
  if (args.dev) console.log(`Glotfile dev API on ${url} \u2014 open the UI at the Vite "Local:" URL above`);