glotfile 0.4.3 → 0.4.5

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.
@@ -901,6 +901,10 @@ function toI18next(value) {
901
901
  if (isIcuPluralOrSelect(value)) return value;
902
902
  return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
903
903
  }
904
+ function toRuby(value) {
905
+ if (isIcuPluralOrSelect(value)) return value;
906
+ return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
907
+ }
904
908
  function placeholdersMatch(source, translation) {
905
909
  const a = extractPlaceholders(source).sort();
906
910
  const b = extractPlaceholders(translation).sort();
@@ -1565,6 +1569,95 @@ var init_angular_xliff = __esm({
1565
1569
  }
1566
1570
  });
1567
1571
 
1572
+ // src/server/adapters/rails-yaml.ts
1573
+ function yamlString(s) {
1574
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
1575
+ }
1576
+ function yamlKey(k) {
1577
+ if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
1578
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
1579
+ }
1580
+ function yamlMap(node, indent, level) {
1581
+ const pad = " ".repeat(indent * level);
1582
+ const lines = [];
1583
+ for (const key of Object.keys(node).sort()) {
1584
+ const v = node[key];
1585
+ if (v && typeof v === "object") {
1586
+ lines.push(`${pad}${yamlKey(key)}:`);
1587
+ lines.push(...yamlMap(v, indent, level + 1));
1588
+ } else {
1589
+ lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
1590
+ }
1591
+ }
1592
+ return lines;
1593
+ }
1594
+ var RESERVED_KEYS, DEFAULT_LOCALE_CASE8, railsYaml;
1595
+ var init_rails_yaml = __esm({
1596
+ "src/server/adapters/rails-yaml.ts"() {
1597
+ "use strict";
1598
+ init_adapters();
1599
+ init_shared();
1600
+ init_options();
1601
+ init_placeholders();
1602
+ init_schema();
1603
+ RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
1604
+ DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
1605
+ railsYaml = {
1606
+ name: "rails-yaml",
1607
+ capabilities: {
1608
+ plural: "native",
1609
+ select: "lossy",
1610
+ nesting: "nested",
1611
+ metadata: false,
1612
+ placeholderStyle: "named",
1613
+ fileGrouping: "per-locale"
1614
+ },
1615
+ defaultLocaleCase: DEFAULT_LOCALE_CASE8,
1616
+ export(state, output) {
1617
+ const warnings = [];
1618
+ warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
1619
+ const { indent, finalNewline } = resolveFormat(state, output);
1620
+ const emptyAs = resolveEmptyAs(output, "omit");
1621
+ const files = [];
1622
+ for (const locale of state.config.locales) {
1623
+ const flat = {};
1624
+ for (const [key, entry] of Object.entries(state.keys)) {
1625
+ if (entry.plural) {
1626
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1627
+ if (!forms) continue;
1628
+ for (const cat of PLURAL_CATEGORIES) {
1629
+ const body2 = forms[cat];
1630
+ if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
1631
+ }
1632
+ } else {
1633
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1634
+ if (raw === null) continue;
1635
+ if (raw && isIcuPluralOrSelect(raw)) {
1636
+ warnings.push({
1637
+ code: "lossy-plural",
1638
+ key,
1639
+ locale,
1640
+ message: "rails-yaml cannot represent ICU plural/select; written unconverted"
1641
+ });
1642
+ }
1643
+ flat[key] = toRuby(raw);
1644
+ }
1645
+ }
1646
+ const { tree: nested, collisions } = nestKeys(flat);
1647
+ for (const c of collisions) {
1648
+ warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
1649
+ }
1650
+ const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
1651
+ const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
1652
+ files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
1653
+ }
1654
+ files.sort((a, b) => a.path.localeCompare(b.path));
1655
+ return { files, warnings };
1656
+ }
1657
+ };
1658
+ }
1659
+ });
1660
+
1568
1661
  // src/server/adapters/index.ts
1569
1662
  function resolvePath(template, locale, namespace = "") {
1570
1663
  return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
@@ -1597,7 +1690,8 @@ function getRegistry() {
1597
1690
  [gettextPo.name]: gettextPo,
1598
1691
  [appleStringsdict.name]: appleStringsdict,
1599
1692
  [vueI18nJson.name]: vueI18nJson,
1600
- [angularXliff.name]: angularXliff
1693
+ [angularXliff.name]: angularXliff,
1694
+ [railsYaml.name]: railsYaml
1601
1695
  };
1602
1696
  }
1603
1697
  function getAdapter(name) {
@@ -1617,6 +1711,7 @@ var init_adapters = __esm({
1617
1711
  init_apple_stringsdict();
1618
1712
  init_vue_i18n_json();
1619
1713
  init_angular_xliff();
1714
+ init_rails_yaml();
1620
1715
  }
1621
1716
  });
1622
1717
 
@@ -1758,6 +1853,7 @@ function buildSystemPrompt(hasPluralItems) {
1758
1853
  "- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
1759
1854
  "- 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.",
1760
1855
  "- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
1856
+ `- Quotation marks inside a translation MUST use the target language's typographic quote characters (e.g. \u201EGerman\u201C, \xABFrench\xBB, \u201CEnglish\u201D, \u2019 for apostrophes). Never emit a raw ASCII double-quote (") inside a translated string \u2014 it corrupts the JSON reply. If the source uses ASCII quotes, convert them to the target language's typographic quotes.`,
1761
1857
  "- Match the register and capitalization conventions of the target language and of UI microcopy.",
1762
1858
  "- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
1763
1859
  ];
@@ -1847,6 +1943,16 @@ var init_provider = __esm({
1847
1943
  });
1848
1944
 
1849
1945
  // src/server/ai/batch.ts
1946
+ function parseReplyItems(text) {
1947
+ let parsed;
1948
+ try {
1949
+ parsed = JSON.parse(text);
1950
+ } catch {
1951
+ throw new MalformedReplyError(text);
1952
+ }
1953
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
1954
+ return parsed.items;
1955
+ }
1850
1956
  function chunk(items, size) {
1851
1957
  const out = [];
1852
1958
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -1882,24 +1988,46 @@ function validatePlural(req, forms) {
1882
1988
  function validateReply(req, item) {
1883
1989
  return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
1884
1990
  }
1885
- async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
1991
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
1992
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
1993
+ async function resolveBatch(batch, isRetry = false) {
1994
+ let reply;
1995
+ try {
1996
+ reply = await callBatch(batch, signal);
1997
+ } catch (err) {
1998
+ if (!(err instanceof MalformedReplyError)) throw err;
1999
+ onMalformedReply?.(err.raw, batch.length);
2000
+ if (signal?.aborted) return failBatch(batch);
2001
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
2002
+ const mid = Math.ceil(batch.length / 2);
2003
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
2004
+ }
2005
+ const byId = new Map(reply.map((r) => [r.id, r]));
2006
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
2007
+ }
1886
2008
  const results = [];
1887
2009
  const total = reqs.length;
1888
2010
  for (const batch of chunk(reqs, Math.max(1, batchSize))) {
1889
2011
  if (signal?.aborted) break;
1890
- const reply = await callBatch(batch, signal);
1891
- const byId = new Map(reply.map((r) => [r.id, r]));
1892
- const batchResults = [];
1893
- for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
2012
+ const batchResults = await resolveBatch(batch);
1894
2013
  results.push(...batchResults);
1895
2014
  onBatchComplete?.(results.length, total, batchResults);
1896
2015
  }
1897
2016
  return results;
1898
2017
  }
2018
+ var MalformedReplyError;
1899
2019
  var init_batch = __esm({
1900
2020
  "src/server/ai/batch.ts"() {
1901
2021
  "use strict";
1902
2022
  init_placeholders();
2023
+ MalformedReplyError = class extends Error {
2024
+ constructor(raw) {
2025
+ super("Model reply was not valid translation JSON.");
2026
+ this.raw = raw;
2027
+ this.name = "MalformedReplyError";
2028
+ }
2029
+ raw;
2030
+ };
1903
2031
  }
1904
2032
  });
1905
2033
 
@@ -1928,8 +2056,8 @@ var init_anthropic = __esm({
1928
2056
  supportsVision() {
1929
2057
  return true;
1930
2058
  }
1931
- translate(reqs, onBatchComplete, signal) {
1932
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2059
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2060
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
1933
2061
  }
1934
2062
  // Build the user message as content blocks: each unique key's screenshot is
1935
2063
  // sent once (a key recurs once per target locale in a batch — dedupe by key),
@@ -1976,13 +2104,8 @@ var init_anthropic = __esm({
1976
2104
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
1977
2105
  messages: [{ role: "user", content }]
1978
2106
  }, { signal });
1979
- const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
1980
- try {
1981
- const parsed = JSON.parse(text);
1982
- return parsed.items ?? [];
1983
- } catch {
1984
- return [];
1985
- }
2107
+ const text = res.content.find((b) => b.type === "text")?.text ?? "";
2108
+ return parseReplyItems(text);
1986
2109
  }
1987
2110
  };
1988
2111
  }
@@ -2024,8 +2147,8 @@ var init_openai = __esm({
2024
2147
  supportsVision() {
2025
2148
  return true;
2026
2149
  }
2027
- translate(reqs, onBatchComplete, signal) {
2028
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2150
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2151
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2029
2152
  }
2030
2153
  // User content as an array of parts: each unique key's screenshot once (as an
2031
2154
  // image_url data URL), then the batch prompt text describing every item.
@@ -2074,13 +2197,8 @@ var init_openai = __esm({
2074
2197
  { role: "user", content: this.buildUserContent(batch) }
2075
2198
  ]
2076
2199
  }, { signal });
2077
- const text = res.choices?.[0]?.message?.content ?? "{}";
2078
- try {
2079
- const parsed = JSON.parse(text);
2080
- return parsed.items ?? [];
2081
- } catch {
2082
- return [];
2083
- }
2200
+ const text = res.choices?.[0]?.message?.content ?? "";
2201
+ return parseReplyItems(text);
2084
2202
  }
2085
2203
  };
2086
2204
  }
@@ -2133,8 +2251,8 @@ var init_bedrock = __esm({
2133
2251
  supportsVision() {
2134
2252
  return !this.isMeta();
2135
2253
  }
2136
- translate(reqs, onBatchComplete, signal) {
2137
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2254
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2255
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2138
2256
  }
2139
2257
  buildContentBlocks(batch) {
2140
2258
  const blocks = [];
@@ -2198,13 +2316,8 @@ var init_bedrock = __esm({
2198
2316
  const blocks = res.output?.message?.content ?? [];
2199
2317
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
2200
2318
  if (tool?.input?.items) return tool.input.items;
2201
- const text = blocks.find((b) => b.text)?.text ?? "{}";
2202
- try {
2203
- const parsed = JSON.parse(text);
2204
- return parsed.items ?? [];
2205
- } catch {
2206
- return [];
2207
- }
2319
+ const text = blocks.find((b) => b.text)?.text ?? "";
2320
+ return parseReplyItems(text);
2208
2321
  }
2209
2322
  };
2210
2323
  }
@@ -2347,8 +2460,8 @@ var init_claudecode = __esm({
2347
2460
  supportsVision() {
2348
2461
  return false;
2349
2462
  }
2350
- translate(reqs, onBatchComplete, signal) {
2351
- return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2463
+ translate(reqs, onBatchComplete, signal, onMalformedReply) {
2464
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
2352
2465
  }
2353
2466
  async complete(req) {
2354
2467
  const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
@@ -2371,12 +2484,7 @@ var init_claudecode = __esm({
2371
2484
  throw err;
2372
2485
  }
2373
2486
  if (signal?.aborted) return [];
2374
- try {
2375
- const parsed = JSON.parse(stripFences(result));
2376
- return parsed.items ?? [];
2377
- } catch {
2378
- return [];
2379
- }
2487
+ return parseReplyItems(stripFences(result));
2380
2488
  }
2381
2489
  };
2382
2490
  }
@@ -2457,7 +2565,9 @@ function coerceAi(raw) {
2457
2565
  contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
2458
2566
  contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
2459
2567
  vision: typeof a.vision === "boolean" ? a.vision : void 0,
2460
- promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
2568
+ promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
2569
+ inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
2570
+ outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
2461
2571
  };
2462
2572
  }
2463
2573
  function coerceProfiles(raw) {
@@ -2496,6 +2606,10 @@ function aiConfigError(ai) {
2496
2606
  if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
2497
2607
  if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
2498
2608
  if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
2609
+ for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
2610
+ const v = a[f];
2611
+ if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
2612
+ }
2499
2613
  return null;
2500
2614
  }
2501
2615
  var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
@@ -2658,7 +2772,7 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
2658
2772
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
2659
2773
  done += batchResults.length;
2660
2774
  hooks.onBatchComplete?.(done, total, batchResults, locale);
2661
- }, signal);
2775
+ }, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
2662
2776
  allResults.push(...localeResults);
2663
2777
  if (!signal?.aborted) hooks.onLocaleDone?.(locale);
2664
2778
  }
@@ -2711,6 +2825,118 @@ var init_run = __esm({
2711
2825
  }
2712
2826
  });
2713
2827
 
2828
+ // src/server/ai/pricing.ts
2829
+ function bareModelId(model) {
2830
+ let id = model.trim().toLowerCase();
2831
+ const slash = id.lastIndexOf("/");
2832
+ if (slash !== -1) id = id.slice(slash + 1);
2833
+ const anth = id.lastIndexOf("anthropic.");
2834
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
2835
+ return id;
2836
+ }
2837
+ function resolvePricing(ai) {
2838
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
2839
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
2840
+ }
2841
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
2842
+ const id = bareModelId(ai.model);
2843
+ let best;
2844
+ for (const row of PRICE_TABLE) {
2845
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
2846
+ }
2847
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
2848
+ }
2849
+ var PRICE_TABLE, FREE_PROVIDERS;
2850
+ var init_pricing = __esm({
2851
+ "src/server/ai/pricing.ts"() {
2852
+ "use strict";
2853
+ PRICE_TABLE = [
2854
+ ["claude-fable-5", 10, 50],
2855
+ ["claude-mythos-5", 10, 50],
2856
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
2857
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
2858
+ ["claude-opus-4-1", 15, 75],
2859
+ ["claude-opus-4-0", 15, 75],
2860
+ ["claude-opus-4-2025", 15, 75],
2861
+ ["claude-opus-4", 5, 25],
2862
+ ["claude-sonnet-4", 3, 15],
2863
+ ["claude-haiku-4", 1, 5],
2864
+ ["claude-3-5-haiku", 0.8, 4],
2865
+ ["gpt-5.5-pro", 30, 180],
2866
+ ["gpt-5.5", 5, 30],
2867
+ ["gpt-5.4-pro", 30, 180],
2868
+ ["gpt-5.4-mini", 0.75, 4.5],
2869
+ ["gpt-5.4-nano", 0.2, 1.25],
2870
+ ["gpt-5.4", 2.5, 15],
2871
+ ["gpt-5.3-codex", 1.75, 14]
2872
+ ];
2873
+ FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
2874
+ }
2875
+ });
2876
+
2877
+ // src/server/ai/estimate.ts
2878
+ function estimateTokens(text) {
2879
+ const cjk = text.match(CJK_RE)?.length ?? 0;
2880
+ return Math.ceil((text.length - cjk) / 4 + cjk / 2);
2881
+ }
2882
+ function estimateOutputTokens(req) {
2883
+ const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
2884
+ if (req.plural) {
2885
+ return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
2886
+ }
2887
+ return ITEM_REPLY_OVERHEAD + translated;
2888
+ }
2889
+ function estimateTranslation(state, ai, opts) {
2890
+ const reqs = selectRequests(state, opts);
2891
+ const byLocale = /* @__PURE__ */ new Map();
2892
+ for (const r of reqs) {
2893
+ let group = byLocale.get(r.targetLocale);
2894
+ if (!group) {
2895
+ group = [];
2896
+ byLocale.set(r.targetLocale, group);
2897
+ }
2898
+ group.push(r);
2899
+ }
2900
+ const perLocale = [];
2901
+ for (const [locale, group] of byLocale) {
2902
+ let inputTokens2 = 0;
2903
+ let outputTokens2 = 0;
2904
+ const batches = chunk(group, Math.max(1, ai.batchSize));
2905
+ for (const batch of batches) {
2906
+ const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
2907
+ inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
2908
+ for (const r of batch) outputTokens2 += estimateOutputTokens(r);
2909
+ }
2910
+ perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
2911
+ }
2912
+ const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
2913
+ const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
2914
+ const pricing = resolvePricing(ai);
2915
+ return {
2916
+ requests: reqs.length,
2917
+ batches: perLocale.reduce((n, l) => n + l.batches, 0),
2918
+ perLocale,
2919
+ inputTokens,
2920
+ outputTokens,
2921
+ pricing,
2922
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
2923
+ };
2924
+ }
2925
+ var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
2926
+ var init_estimate = __esm({
2927
+ "src/server/ai/estimate.ts"() {
2928
+ "use strict";
2929
+ init_run();
2930
+ init_provider();
2931
+ init_batch();
2932
+ init_pricing();
2933
+ CJK_RE = /[ -鿿가-힯豈-﫿]/g;
2934
+ EXPANSION = 1.2;
2935
+ ITEM_REPLY_OVERHEAD = 16;
2936
+ FORM_REPLY_OVERHEAD = 8;
2937
+ }
2938
+ });
2939
+
2714
2940
  // src/server/log.ts
2715
2941
  import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2716
2942
  import { resolve as resolve5 } from "path";
@@ -5053,6 +5279,19 @@ function createApi(deps) {
5053
5279
  },
5054
5280
  onLocaleDone: (locale) => {
5055
5281
  void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
5282
+ },
5283
+ // Record the raw reply so an unparseable model response is diagnosable
5284
+ // from the activity log instead of vanishing into per-item errors.
5285
+ onMalformedReply: (raw, batchSize, locale) => {
5286
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
5287
+ appendLog(projectRoot, {
5288
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5289
+ kind: "translate",
5290
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
5291
+ model: aiCfg.model,
5292
+ locale,
5293
+ raw
5294
+ });
5056
5295
  }
5057
5296
  }, aiCfg.concurrency, signal);
5058
5297
  if (!signal?.aborted) {
@@ -5085,7 +5324,19 @@ function createApi(deps) {
5085
5324
  }
5086
5325
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
5087
5326
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
5088
- const results = await runLocaleParallel(toTranslate, provider, {}, aiCfg.concurrency);
5327
+ const results = await runLocaleParallel(toTranslate, provider, {
5328
+ onMalformedReply: (raw, batchSize, locale) => {
5329
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
5330
+ appendLog(projectRoot, {
5331
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5332
+ kind: "translate",
5333
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
5334
+ model: aiCfg.model,
5335
+ locale,
5336
+ raw
5337
+ });
5338
+ }
5339
+ }, aiCfg.concurrency);
5089
5340
  const latest = load();
5090
5341
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
5091
5342
  const entry = {
@@ -5111,6 +5362,13 @@ function createApi(deps) {
5111
5362
  }
5112
5363
  return c.json({ requested: reqs.length, written, errors });
5113
5364
  }));
5365
+ app.post("/translate/estimate", async (c) => {
5366
+ const body = await c.req.json().catch(() => ({}));
5367
+ const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
5368
+ const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
5369
+ const ai = loadLocalSettings(projectRoot).ai;
5370
+ return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
5371
+ });
5114
5372
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
5115
5373
  app.post("/scan", async (c) => {
5116
5374
  const s = load();
@@ -5277,6 +5535,7 @@ var init_api = __esm({
5277
5535
  init_ai();
5278
5536
  init_run();
5279
5537
  init_provider();
5538
+ init_estimate();
5280
5539
  init_log();
5281
5540
  init_schema();
5282
5541
  init_run3();
@@ -5436,6 +5695,7 @@ init_ai();
5436
5695
  init_local_settings();
5437
5696
  init_run();
5438
5697
  init_provider();
5698
+ init_estimate();
5439
5699
  init_log();
5440
5700
  init_scan();
5441
5701
  init_scanner();
@@ -5573,6 +5833,7 @@ function parseArgs(argv) {
5573
5833
  } else if (flag === "--empty-source") args.emptySource = true;
5574
5834
  else if (flag === "--unused") args.unused = true;
5575
5835
  else if (flag === "--write") args.write = true;
5836
+ else if (flag === "--estimate") args.estimate = true;
5576
5837
  }
5577
5838
  return args;
5578
5839
  }
@@ -5626,6 +5887,31 @@ async function runExport(args) {
5626
5887
  async function runTranslate(args) {
5627
5888
  const state = loadState(args.statePath);
5628
5889
  const projectRoot = dirname5(resolve11(args.statePath));
5890
+ if (args.estimate) {
5891
+ const ai = loadLocalSettings(projectRoot).ai;
5892
+ const est = estimateTranslation(state, ai, {
5893
+ onlyMissing: args.all ? false : args.onlyMissing ?? true,
5894
+ locales: args.locales,
5895
+ keyGlob: args.keyGlob
5896
+ });
5897
+ if (!est.requests) {
5898
+ console.log("Nothing to translate.");
5899
+ return;
5900
+ }
5901
+ const fmt = (n) => n.toLocaleString("en-US");
5902
+ console.log(`Estimate for ${fmt(est.requests)} request(s) in ${fmt(est.batches)} batch(es) \u2014 ${ai.provider} \xB7 ${ai.model}`);
5903
+ for (const l of est.perLocale) {
5904
+ console.log(` ${l.locale.padEnd(8)} ${fmt(l.requests).padStart(7)} req ${fmt(l.batches).padStart(5)} batch(es) ~${fmt(l.inputTokens)} in / ~${fmt(l.outputTokens)} out tokens`);
5905
+ }
5906
+ console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
5907
+ if (est.pricing) {
5908
+ const cost = est.estimatedCost;
5909
+ console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
5910
+ } else {
5911
+ console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
5912
+ }
5913
+ return;
5914
+ }
5629
5915
  const reqs = selectRequests(state, {
5630
5916
  // Default to translating only empty values; --all forces a full re-translate
5631
5917
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -5658,6 +5944,20 @@ async function runTranslate(args) {
5658
5944
  errors.push(...batchApplied.errors);
5659
5945
  saveState(args.statePath, state);
5660
5946
  process.stdout.write(`\r ${done}/${total} translated`);
5947
+ },
5948
+ // Record the raw reply so an unparseable model response is diagnosable
5949
+ // from the activity log instead of vanishing into per-item errors.
5950
+ onMalformedReply: (raw, batchSize, locale) => {
5951
+ console.error(`
5952
+ malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
5953
+ appendLog(projectRoot, {
5954
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5955
+ kind: "translate",
5956
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
5957
+ model: ai.model,
5958
+ locale,
5959
+ raw
5960
+ });
5661
5961
  }
5662
5962
  });
5663
5963
  process.stdout.write("\n");
@@ -5896,9 +6196,10 @@ var COMMAND_HELP = {
5896
6196
  },
5897
6197
  translate: {
5898
6198
  summary: "AI-translate missing strings into your target locales (writes back to the state file).",
5899
- usage: "glotfile translate [--all] [--locale <list>] [--key <glob>]",
6199
+ usage: "glotfile translate [--all] [--estimate] [--locale <list>] [--key <glob>]",
5900
6200
  options: [
5901
6201
  ["--all", "Re-translate every string, not just empty values"],
6202
+ ["--estimate", "Print batches, tokens and estimated cost without translating"],
5902
6203
  ["--locale <list>", "Comma-separated target locales (alias: --locales)"],
5903
6204
  ["--key <glob>", "Only keys matching this glob"]
5904
6205
  ]