glotfile 1.0.1 → 1.1.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.
@@ -2,7 +2,7 @@
2
2
  import { Hono as Hono2 } from "hono";
3
3
  import { serve } from "@hono/node-server";
4
4
  import { fileURLToPath } from "url";
5
- import { dirname as dirname4, join as join19, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5
+ import { dirname as dirname4, join as join20, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
6
6
  import { readFile, stat } from "fs/promises";
7
7
  import { createServer } from "net";
8
8
  import open from "open";
@@ -270,7 +270,8 @@ function validate(raw) {
270
270
  }
271
271
  }
272
272
  if (raw.glossary !== void 0 && !Array.isArray(raw.glossary)) fail("glossary must be an array");
273
- const state = { glossary: [], ...raw };
273
+ if (raw.glossarySuggestions !== void 0 && !Array.isArray(raw.glossarySuggestions)) fail("glossarySuggestions must be an array");
274
+ const state = { glossary: [], glossarySuggestions: [], ...raw };
274
275
  return state;
275
276
  }
276
277
  function defaultState() {
@@ -289,6 +290,7 @@ function defaultState() {
289
290
  autoExport: true
290
291
  },
291
292
  glossary: [],
293
+ glossarySuggestions: [],
292
294
  keys: {}
293
295
  };
294
296
  }
@@ -756,6 +758,39 @@ function upsertGlossaryEntry(state, entry) {
756
758
  function deleteGlossaryEntry(state, term) {
757
759
  state.glossary = state.glossary.filter((e) => e.term !== term);
758
760
  }
761
+ function normGlossaryTerm(term) {
762
+ return term.trim().toLowerCase();
763
+ }
764
+ function mergeGlossarySuggestions(state, found) {
765
+ const known = /* @__PURE__ */ new Set();
766
+ for (const g of state.glossary) known.add(normGlossaryTerm(g.term));
767
+ for (const s of state.glossarySuggestions) known.add(normGlossaryTerm(s.term));
768
+ const added = [];
769
+ for (const f of found) {
770
+ const term = f.term.trim();
771
+ if (!term) continue;
772
+ const key = normGlossaryTerm(term);
773
+ if (known.has(key)) continue;
774
+ known.add(key);
775
+ const sug = { term, status: "pending" };
776
+ if (f.note?.trim()) sug.note = f.note.trim();
777
+ if (f.doNotTranslate) sug.doNotTranslate = true;
778
+ if (f.caseSensitive) sug.caseSensitive = true;
779
+ if (f.wholeWord === false) sug.wholeWord = false;
780
+ state.glossarySuggestions.push(sug);
781
+ added.push(sug);
782
+ }
783
+ return added;
784
+ }
785
+ function dismissGlossarySuggestion(state, term) {
786
+ const key = normGlossaryTerm(term);
787
+ const s = state.glossarySuggestions.find((x) => normGlossaryTerm(x.term) === key);
788
+ if (s) s.status = "dismissed";
789
+ }
790
+ function removeGlossarySuggestion(state, term) {
791
+ const key = normGlossaryTerm(term);
792
+ state.glossarySuggestions = state.glossarySuggestions.filter((x) => normGlossaryTerm(x.term) !== key);
793
+ }
759
794
  function addCustomWord(state, word) {
760
795
  const w = word.trim();
761
796
  if (!w) return;
@@ -786,6 +821,158 @@ function applyMachineTranslationForms(state, key, locale, forms, clock = systemC
786
821
  return true;
787
822
  }
788
823
 
824
+ // src/server/ai/glossary-suggest.ts
825
+ function globToRegExp(glob) {
826
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
827
+ return new RegExp(`^${escaped}$`);
828
+ }
829
+ function selectGlossarySources(state, opts) {
830
+ const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
831
+ let rows = [];
832
+ for (const key of Object.keys(state.keys)) {
833
+ if (keyRe && !keyRe.test(key)) continue;
834
+ const entry = state.keys[key];
835
+ if (opts.since) {
836
+ if (!entry.createdAt || entry.createdAt < opts.since) continue;
837
+ }
838
+ const lv = entry.values[state.config.sourceLocale];
839
+ const source = (lv?.value ?? lv?.forms?.other ?? "").trim();
840
+ if (!source) continue;
841
+ rows.push({ key, source });
842
+ }
843
+ rows.sort((a, b) => {
844
+ const ta = state.keys[a.key].createdAt ?? "";
845
+ const tb = state.keys[b.key].createdAt ?? "";
846
+ return tb.localeCompare(ta) || a.key.localeCompare(b.key);
847
+ });
848
+ if (opts.limit !== void 0) rows = rows.slice(0, opts.limit);
849
+ return rows;
850
+ }
851
+ function knownTermList(state) {
852
+ const out = /* @__PURE__ */ new Set();
853
+ for (const g of state.glossary) out.add(g.term);
854
+ for (const s of state.glossarySuggestions) out.add(s.term);
855
+ return [...out];
856
+ }
857
+ function buildGlossarySuggestSystemPrompt() {
858
+ return [
859
+ "You identify GLOSSARY-CANDIDATE terms in a UI string catalog so they translate consistently.",
860
+ "A glossary term is a brand or product name, a feature or module name, an acronym, a piece of domain/industry jargon, or any noun phrase that should translate the SAME way everywhere (or stay verbatim).",
861
+ "You are given source strings (the app's original language). Return the candidate terms you find.",
862
+ "Rules:",
863
+ "- Only surface terms a translator would benefit from pinning. IGNORE ordinary words, verbs, and generic UI labels (e.g. 'Save', 'Cancel', 'Welcome').",
864
+ "- Prefer terms that recur or are clearly proper nouns / product names / acronyms.",
865
+ "- Set doNotTranslate: true for brand/product names, code identifiers, and acronyms that must stay verbatim in every language.",
866
+ "- Set caseSensitive: true only when casing is meaningful (e.g. an all-caps acronym that must not match a lowercase common word).",
867
+ "- Set wholeWord: false ONLY if the term should also match inside larger words; otherwise omit it (whole-word is the default).",
868
+ "- note: one short phrase on why it's a term (e.g. 'product name', 'industry acronym', 'recurring UI concept'). Keep it under 80 characters.",
869
+ "- Do NOT return any term in the provided 'Already known' list.",
870
+ "- Return the term exactly as it appears in the source (preserve casing)."
871
+ ].join("\n");
872
+ }
873
+ function buildGlossarySuggestBatchPrompt(sources, knownTerms) {
874
+ const known = knownTerms.length ? knownTerms.join(", ") : "(none yet)";
875
+ const lines = sources.map((s) => `- [${s.key}] ${s.source}`).join("\n");
876
+ return [
877
+ `Already known (do NOT return these): ${known}`,
878
+ "",
879
+ "Source strings:",
880
+ lines,
881
+ "",
882
+ 'Return JSON {"terms":[{"term","note?","doNotTranslate?","caseSensitive?","wholeWord?"}]}. Return an empty array if you find no good candidates.'
883
+ ].join("\n");
884
+ }
885
+ var GLOSSARY_SUGGEST_SCHEMA = {
886
+ type: "object",
887
+ properties: {
888
+ terms: {
889
+ type: "array",
890
+ items: {
891
+ type: "object",
892
+ properties: {
893
+ term: { type: "string" },
894
+ note: { type: "string" },
895
+ doNotTranslate: { type: "boolean" },
896
+ caseSensitive: { type: "boolean" },
897
+ wholeWord: { type: "boolean" }
898
+ },
899
+ required: ["term"],
900
+ additionalProperties: false
901
+ }
902
+ }
903
+ },
904
+ required: ["terms"],
905
+ additionalProperties: false
906
+ };
907
+ function dedupeTerms(terms) {
908
+ const seen = /* @__PURE__ */ new Set();
909
+ const out = [];
910
+ for (const t of terms) {
911
+ const term = t.term?.trim();
912
+ if (!term) continue;
913
+ const key = term.toLowerCase();
914
+ if (seen.has(key)) continue;
915
+ seen.add(key);
916
+ out.push({ ...t, term });
917
+ }
918
+ return out;
919
+ }
920
+
921
+ // src/server/glossary.ts
922
+ function escapeRegExp(s) {
923
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
924
+ }
925
+ function contains(haystack, needle, caseSensitive, wholeWord) {
926
+ if (!wholeWord) {
927
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
928
+ }
929
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(needle)}(?![\\p{L}\\p{N}])`, caseSensitive ? "u" : "iu");
930
+ return re.test(haystack);
931
+ }
932
+ function termInSource(source, entry) {
933
+ return contains(source, entry.term, entry.caseSensitive, entry.wholeWord ?? true);
934
+ }
935
+ function relevantGlossary(source, targetLocale, glossary) {
936
+ const hints = [];
937
+ for (const entry of glossary) {
938
+ if (!termInSource(source, entry)) continue;
939
+ hints.push({
940
+ term: entry.term,
941
+ doNotTranslate: entry.doNotTranslate,
942
+ forced: entry.translations?.[targetLocale],
943
+ notes: entry.notes
944
+ });
945
+ }
946
+ return hints;
947
+ }
948
+ function sourceKeysForTerm(state, term, opts = {}) {
949
+ const pseudo = { term, caseSensitive: opts.caseSensitive, wholeWord: opts.wholeWord };
950
+ const out = [];
951
+ for (const [key, entry] of Object.entries(state.keys)) {
952
+ const lv = entry.values[state.config.sourceLocale];
953
+ const text = lv?.value ?? lv?.forms?.other ?? "";
954
+ if (text && termInSource(text, pseudo)) out.push(key);
955
+ }
956
+ return out;
957
+ }
958
+ function glossaryViolations(source, value, targetLocale, glossary) {
959
+ const out = [];
960
+ for (const entry of glossary) {
961
+ if (!termInSource(source, entry)) continue;
962
+ if (entry.doNotTranslate) {
963
+ if (!contains(value, entry.term, entry.caseSensitive)) {
964
+ out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
965
+ }
966
+ continue;
967
+ }
968
+ const forced = entry.translations?.[targetLocale];
969
+ if (forced && !contains(value, forced, entry.caseSensitive)) {
970
+ out.push({ term: entry.term, expected: forced, kind: "forced" });
971
+ }
972
+ }
973
+ return out;
974
+ }
975
+
789
976
  // src/server/lint/accept.ts
790
977
  function acceptFindings(state, findings, opts = {}, clock = systemClock) {
791
978
  const byRule = {};
@@ -824,7 +1011,7 @@ function ensureGlotfileDir(projectRoot) {
824
1011
  }
825
1012
 
826
1013
  // src/server/glob.ts
827
- function globToRegExp(glob) {
1014
+ function globToRegExp2(glob) {
828
1015
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
829
1016
  return new RegExp(`^${escaped}$`);
830
1017
  }
@@ -875,7 +1062,7 @@ function computeUsedKeys(state, cache2) {
875
1062
  matchers.push(literalMatcher(l.literal));
876
1063
  }
877
1064
  }
878
- const keep = (state.config.scan?.keep ?? []).map(globToRegExp);
1065
+ const keep = (state.config.scan?.keep ?? []).map(globToRegExp2);
879
1066
  return Object.keys(state.keys).filter((key) => exact.has(key) || prefixes.some((p) => key.startsWith(p)) || matchers.some((matches) => matches(key)) || keep.some((re) => re.test(key))).sort();
880
1067
  }
881
1068
  function escapeRe(s) {
@@ -1403,7 +1590,7 @@ var MAX_CONTEXT_LENGTH = 500;
1403
1590
  var SNIPPET_WINDOW = 15;
1404
1591
  var MAX_SNIPPETS = 3;
1405
1592
  var EXCLUDED_DIRS = ["node_modules/", "vendor/", "dist/", ".git/", ".glotfile/"];
1406
- function globToRegExp2(glob) {
1593
+ function globToRegExp3(glob) {
1407
1594
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1408
1595
  return new RegExp(`^${escaped}$`);
1409
1596
  }
@@ -1433,6 +1620,21 @@ function extractSnippets(refs, projectRoot, fileCache) {
1433
1620
  }
1434
1621
  return snippets;
1435
1622
  }
1623
+ function attachUsageSnippets(targets, cache2, projectRoot) {
1624
+ const fileCache = /* @__PURE__ */ new Map();
1625
+ for (const target of targets) {
1626
+ const allRefs = Object.entries(cache2.files).flatMap(
1627
+ ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
1628
+ key: r.key,
1629
+ file,
1630
+ line: r.line,
1631
+ col: r.col,
1632
+ scanner: r.scanner
1633
+ }))
1634
+ );
1635
+ target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
1636
+ }
1637
+ }
1436
1638
  function buildUsageIndex(cache2) {
1437
1639
  const index = /* @__PURE__ */ new Map();
1438
1640
  for (const [file, entry] of Object.entries(cache2.files)) {
@@ -1446,7 +1648,7 @@ function buildUsageIndex(cache2) {
1446
1648
  }
1447
1649
  function selectContextTargets(state, opts, cache2, lastRunAt) {
1448
1650
  const cutoff = opts.all ? void 0 : opts.since ?? lastRunAt;
1449
- const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
1651
+ const keyRe = opts.keyGlob ? globToRegExp3(opts.keyGlob) : null;
1450
1652
  const keySet = opts.keys ? new Set(opts.keys) : null;
1451
1653
  const usageIndex = buildUsageIndex(cache2);
1452
1654
  let candidates = [];
@@ -1668,41 +1870,6 @@ function computeStats(state) {
1668
1870
  };
1669
1871
  }
1670
1872
 
1671
- // src/server/glossary.ts
1672
- function contains(haystack, needle, caseSensitive) {
1673
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1674
- }
1675
- function relevantGlossary(source, targetLocale, glossary) {
1676
- const hints = [];
1677
- for (const entry of glossary) {
1678
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
1679
- hints.push({
1680
- term: entry.term,
1681
- doNotTranslate: entry.doNotTranslate,
1682
- forced: entry.translations?.[targetLocale],
1683
- notes: entry.notes
1684
- });
1685
- }
1686
- return hints;
1687
- }
1688
- function glossaryViolations(source, value, targetLocale, glossary) {
1689
- const out = [];
1690
- for (const entry of glossary) {
1691
- if (!contains(source, entry.term, entry.caseSensitive)) continue;
1692
- if (entry.doNotTranslate) {
1693
- if (!contains(value, entry.term, entry.caseSensitive)) {
1694
- out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
1695
- }
1696
- continue;
1697
- }
1698
- const forced = entry.translations?.[targetLocale];
1699
- if (forced && !contains(value, forced, entry.caseSensitive)) {
1700
- out.push({ term: entry.term, expected: forced, kind: "forced" });
1701
- }
1702
- }
1703
- return out;
1704
- }
1705
-
1706
1873
  // src/server/spell.ts
1707
1874
  var instances = /* @__PURE__ */ new Map();
1708
1875
  var loading = /* @__PURE__ */ new Set();
@@ -2134,7 +2301,7 @@ async function runLint(state, options = {}) {
2134
2301
  spellers,
2135
2302
  allowWords
2136
2303
  };
2137
- const ignoreRes = (config.ignore ?? []).map(globToRegExp);
2304
+ const ignoreRes = (config.ignore ?? []).map(globToRegExp2);
2138
2305
  const localeFilter = options.locales ? new Set(options.locales) : null;
2139
2306
  const findings = [];
2140
2307
  let suppressed = 0;
@@ -3084,7 +3251,7 @@ function checkOutputs(state, root) {
3084
3251
  }
3085
3252
 
3086
3253
  // src/server/api.ts
3087
- import { readFileSync as readFileSync24, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
3254
+ import { readFileSync as readFileSync25, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
3088
3255
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
3089
3256
 
3090
3257
  // src/server/ai/anthropic.ts
@@ -3330,6 +3497,48 @@ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, o
3330
3497
  return results;
3331
3498
  }
3332
3499
 
3500
+ // src/server/ai/price-cache.ts
3501
+ import { readFileSync as readFileSync7 } from "fs";
3502
+ import { homedir } from "os";
3503
+ import { join as join4 } from "path";
3504
+ var defaultPriceCachePath = () => process.env.GLOTFILE_PRICES_PATH || join4(homedir(), ".glotfile", "model-prices.json");
3505
+ function isModelPrice(v) {
3506
+ if (!v || typeof v !== "object") return false;
3507
+ const p = v;
3508
+ return typeof p.inputPerMTok === "number" && typeof p.outputPerMTok === "number";
3509
+ }
3510
+ function loadPriceCache(path = defaultPriceCachePath()) {
3511
+ let parsed;
3512
+ try {
3513
+ parsed = JSON.parse(readFileSync7(path, "utf8"));
3514
+ } catch {
3515
+ return null;
3516
+ }
3517
+ if (!parsed || typeof parsed !== "object") return null;
3518
+ const raw = parsed;
3519
+ if (!raw.models || typeof raw.models !== "object") return null;
3520
+ const models = {};
3521
+ for (const [id, price] of Object.entries(raw.models)) {
3522
+ if (isModelPrice(price)) models[id] = price;
3523
+ }
3524
+ return {
3525
+ source: typeof raw.source === "string" ? raw.source : "unknown",
3526
+ fetchedAt: typeof raw.fetchedAt === "string" ? raw.fetchedAt : "",
3527
+ models
3528
+ };
3529
+ }
3530
+ function savePriceCache(cache2, path = defaultPriceCachePath()) {
3531
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
3532
+ }
3533
+ var memo;
3534
+ function getPriceCache() {
3535
+ if (memo === void 0) memo = loadPriceCache();
3536
+ return memo;
3537
+ }
3538
+ function invalidatePriceCache() {
3539
+ memo = void 0;
3540
+ }
3541
+
3333
3542
  // src/server/ai/pricing.ts
3334
3543
  function addUsage(into, add) {
3335
3544
  into.inputTokens += add.inputTokens;
@@ -3346,7 +3555,9 @@ function usageCostUsd(usage, ai, multiplier = 1) {
3346
3555
  return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
3347
3556
  }
3348
3557
  function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
3349
- const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
3558
+ const writeRate = pricing.cacheWritePerMTok ?? pricing.inputPerMTok * CACHE_WRITE_MULTIPLIER;
3559
+ const readRate = pricing.cacheReadPerMTok ?? pricing.inputPerMTok * CACHE_READ_MULTIPLIER;
3560
+ const inputCost = usage.inputTokens * pricing.inputPerMTok + usage.cacheCreationInputTokens * writeRate + usage.cacheReadInputTokens * readRate;
3350
3561
  return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
3351
3562
  }
3352
3563
  var PRICE_TABLE = [
@@ -3378,12 +3589,27 @@ function bareModelId(model) {
3378
3589
  if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3379
3590
  return id;
3380
3591
  }
3381
- function resolvePricing(ai) {
3592
+ function lookupCachePrice(cache2, id) {
3593
+ const exact = cache2.models[id];
3594
+ if (exact) return { source: "cache", ...exact };
3595
+ let best;
3596
+ for (const [cid, price] of Object.entries(cache2.models)) {
3597
+ if (id.startsWith(cid) && (!best || cid.length > best.id.length)) {
3598
+ best = { id: cid, price: { source: "cache", ...price } };
3599
+ }
3600
+ }
3601
+ return best ? best.price : null;
3602
+ }
3603
+ function resolvePricing(ai, cache2 = getPriceCache()) {
3382
3604
  if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3383
3605
  return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3384
3606
  }
3385
3607
  if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3386
3608
  const id = bareModelId(ai.model);
3609
+ if (cache2) {
3610
+ const cached = lookupCachePrice(cache2, id);
3611
+ if (cached) return cached;
3612
+ }
3387
3613
  let best;
3388
3614
  for (const row of PRICE_TABLE) {
3389
3615
  if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
@@ -3931,7 +4157,7 @@ function makeProvider(ai) {
3931
4157
  }
3932
4158
 
3933
4159
  // src/server/ai/run.ts
3934
- import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
4160
+ import { readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
3935
4161
  import { resolve as resolve5, extname as extname2 } from "path";
3936
4162
 
3937
4163
  // src/server/cell-state.ts
@@ -3950,7 +4176,7 @@ function cellState(entry, locale, sourceLocale) {
3950
4176
  // src/server/ai/run.ts
3951
4177
  function selectRequests(state, opts) {
3952
4178
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
3953
- const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
4179
+ const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
3954
4180
  const keySet = opts.keys ? new Set(opts.keys) : null;
3955
4181
  const stateSet = opts.states ? new Set(opts.states) : null;
3956
4182
  const skip = (st) => stateSet ? !stateSet.has(st) : !!opts.onlyMissing && st !== "missing";
@@ -4028,7 +4254,7 @@ function attachScreenshots(reqs, state, projectRoot) {
4028
4254
  if (!existsSync7(abs)) {
4029
4255
  cache2.set(screenshot, null);
4030
4256
  } else {
4031
- const buf = readFileSync7(abs);
4257
+ const buf = readFileSync8(abs);
4032
4258
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
4033
4259
  }
4034
4260
  }
@@ -4152,17 +4378,61 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
4152
4378
  return { written, errors };
4153
4379
  }
4154
4380
 
4381
+ // src/server/ai/explain-error.ts
4382
+ var KEY_ENV = {
4383
+ anthropic: "ANTHROPIC_API_KEY",
4384
+ openai: "OPENAI_API_KEY",
4385
+ openrouter: "OPENROUTER_API_KEY"
4386
+ };
4387
+ function rawMessage(err) {
4388
+ if (err instanceof Error && err.message) return err.message;
4389
+ if (typeof err === "string") return err;
4390
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string") {
4391
+ return err.message;
4392
+ }
4393
+ return String(err ?? "Unknown error");
4394
+ }
4395
+ function explainProviderError(provider, err) {
4396
+ const raw = rawMessage(err);
4397
+ const m = raw.toLowerCase();
4398
+ if (provider === "bedrock") {
4399
+ if (/could not load credentials|unable to locate credentials|credentialsprovider|credentials from any providers/.test(m)) {
4400
+ return "No AWS credentials found. Set AWS_PROFILE (or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) in your shell or a .env file in the directory you started glotfile from, or use an SSO / instance role. If you just edited .env, restart glotfile so it reloads. For SSO, run `aws sso login`.";
4401
+ }
4402
+ if (/on-demand throughput isn.?t supported/.test(m) || /inference profile/.test(m)) {
4403
+ return 'This Bedrock model needs an inference profile for on-demand use. Prefix the model id with your region group \u2014 e.g. "eu.anthropic.claude-3-5-sonnet-20241022-v2:0" (or "us." / "apac." for your region).';
4404
+ }
4405
+ if (/not authorized to perform/.test(m) || /no identity-based policy/.test(m) || /bedrock:invoke/.test(m)) {
4406
+ return "Your AWS credentials authenticated, but their IAM policy doesn't allow this action. Add bedrock:InvokeModel (and bedrock:InvokeModelWithResponseStream) for this model to the IAM policy on this user/role.";
4407
+ }
4408
+ if (/access to the model|don.?t have access to the model/.test(m)) {
4409
+ return "Your account doesn't have access to this model in this region. Enable it in the Bedrock console under Model access, for the region you configured.";
4410
+ }
4411
+ if (/access ?denied/.test(m)) {
4412
+ return "Bedrock denied access. Either the model isn't enabled for your account/region (enable it in the Bedrock console under Model access) or your IAM policy is missing bedrock:InvokeModel for this model.";
4413
+ }
4414
+ if (/region/.test(m)) {
4415
+ return "No AWS region set for Bedrock. Set the Region in AI settings, or AWS_REGION in your environment.";
4416
+ }
4417
+ }
4418
+ const keyEnv = KEY_ENV[provider];
4419
+ if (keyEnv && /api key|unauthorized|\b401\b|authentication|incorrect api key|invalid x-api-key/.test(m)) {
4420
+ return `${provider} rejected the request \u2014 check ${keyEnv}. Set it in your environment or a .env file in the directory you started glotfile from.`;
4421
+ }
4422
+ return raw;
4423
+ }
4424
+
4155
4425
  // src/server/ai/pending-batch.ts
4156
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
4157
- import { join as join4 } from "path";
4426
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
4427
+ import { join as join5 } from "path";
4158
4428
  function pendingBatchPath(projectRoot) {
4159
- return join4(projectRoot, ".glotfile", "batch.json");
4429
+ return join5(projectRoot, ".glotfile", "batch.json");
4160
4430
  }
4161
4431
  function loadPendingBatch(projectRoot) {
4162
4432
  const path = pendingBatchPath(projectRoot);
4163
4433
  if (!existsSync8(path)) return void 0;
4164
4434
  try {
4165
- const parsed = JSON.parse(readFileSync8(path, "utf8"));
4435
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
4166
4436
  if (parsed?.version !== 1) return void 0;
4167
4437
  return parsed;
4168
4438
  } catch {
@@ -4170,9 +4440,9 @@ function loadPendingBatch(projectRoot) {
4170
4440
  }
4171
4441
  }
4172
4442
  function savePendingBatch(projectRoot, pending) {
4173
- const dir = join4(projectRoot, ".glotfile");
4443
+ const dir = join5(projectRoot, ".glotfile");
4174
4444
  mkdirSync4(dir, { recursive: true });
4175
- const gitignore = join4(dir, ".gitignore");
4445
+ const gitignore = join5(dir, ".gitignore");
4176
4446
  if (!existsSync8(gitignore)) writeFileSync3(gitignore, "*\n");
4177
4447
  writeFileSync3(pendingBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4178
4448
  }
@@ -4181,7 +4451,7 @@ function clearPendingBatch(projectRoot) {
4181
4451
  }
4182
4452
 
4183
4453
  // src/server/log.ts
4184
- import { appendFileSync, existsSync as existsSync9, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync9 } from "fs";
4454
+ import { appendFileSync, existsSync as existsSync9, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync10 } from "fs";
4185
4455
  import { resolve as resolve6 } from "path";
4186
4456
  function logPath(projectRoot) {
4187
4457
  return resolve6(projectRoot, ".glotfile", "log.jsonl");
@@ -4196,7 +4466,7 @@ function appendLog(projectRoot, entry) {
4196
4466
  }
4197
4467
  function trimLog(path, maxBytes = MAX_LOG_BYTES, targetBytes = TRIM_LOG_TO_BYTES) {
4198
4468
  if (!existsSync9(path) || statSync2(path).size <= maxBytes) return;
4199
- const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
4469
+ const lines = readFileSync10(path, "utf8").split("\n").filter((l) => l.trim() !== "");
4200
4470
  const kept = [];
4201
4471
  let bytes = 0;
4202
4472
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -4374,16 +4644,16 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
4374
4644
  }
4375
4645
 
4376
4646
  // src/server/ai/pending-context-batch.ts
4377
- import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4378
- import { join as join5 } from "path";
4647
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync4, rmSync as rmSync5 } from "fs";
4648
+ import { join as join6 } from "path";
4379
4649
  function pendingContextBatchPath(projectRoot) {
4380
- return join5(projectRoot, ".glotfile", "context-batch.json");
4650
+ return join6(projectRoot, ".glotfile", "context-batch.json");
4381
4651
  }
4382
4652
  function loadPendingContextBatch(projectRoot) {
4383
4653
  const path = pendingContextBatchPath(projectRoot);
4384
4654
  if (!existsSync10(path)) return void 0;
4385
4655
  try {
4386
- const parsed = JSON.parse(readFileSync10(path, "utf8"));
4656
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
4387
4657
  if (parsed?.version !== 1) return void 0;
4388
4658
  return parsed;
4389
4659
  } catch {
@@ -4391,9 +4661,9 @@ function loadPendingContextBatch(projectRoot) {
4391
4661
  }
4392
4662
  }
4393
4663
  function savePendingContextBatch(projectRoot, pending) {
4394
- const dir = join5(projectRoot, ".glotfile");
4664
+ const dir = join6(projectRoot, ".glotfile");
4395
4665
  mkdirSync5(dir, { recursive: true });
4396
- const gitignore = join5(dir, ".gitignore");
4666
+ const gitignore = join6(dir, ".gitignore");
4397
4667
  if (!existsSync10(gitignore)) writeFileSync4(gitignore, "*\n");
4398
4668
  writeFileSync4(pendingContextBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4399
4669
  }
@@ -4552,13 +4822,83 @@ function estimateTranslation(state, ai, opts) {
4552
4822
  estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4553
4823
  };
4554
4824
  }
4825
+ var CONTEXT_REPLY_OVERHEAD = 16;
4826
+ var TYPICAL_CONTEXT_TOKENS = 35;
4827
+ function estimateContext(targets, ai) {
4828
+ const batchSize = Math.max(1, ai.contextBatchSize ?? ai.batchSize ?? 10);
4829
+ const batches = chunk(targets, batchSize);
4830
+ const system = buildContextSystemPrompt();
4831
+ let inputTokens = 0;
4832
+ let outputTokens = 0;
4833
+ for (const batch of batches) {
4834
+ inputTokens += estimateTokens(system) + estimateTokens(buildContextBatchPrompt(batch));
4835
+ outputTokens += batch.length * (CONTEXT_REPLY_OVERHEAD + TYPICAL_CONTEXT_TOKENS);
4836
+ }
4837
+ const pricing = resolvePricing(ai);
4838
+ return {
4839
+ keys: targets.length,
4840
+ batches: batches.length,
4841
+ inputTokens,
4842
+ outputTokens,
4843
+ pricing,
4844
+ estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
4845
+ };
4846
+ }
4847
+
4848
+ // src/server/ai/price-fetch.ts
4849
+ var MODELS_DEV_URL = "https://models.dev/api.json";
4850
+ var priceUrl = () => process.env.GLOTFILE_PRICES_URL || MODELS_DEV_URL;
4851
+ var PROVIDER_PREFERENCE = ["anthropic", "openai", "google", "meta-llama", "mistral", "deepseek", "xai", "groq"];
4852
+ var providerRank = (provId) => {
4853
+ const i = PROVIDER_PREFERENCE.indexOf(provId);
4854
+ return i === -1 ? PROVIDER_PREFERENCE.length : i;
4855
+ };
4856
+ function normalizeModelsDevPrices(api) {
4857
+ const out = {};
4858
+ const ranks = {};
4859
+ if (!api || typeof api !== "object") return out;
4860
+ for (const [provId, prov] of Object.entries(api)) {
4861
+ const models = prov?.models;
4862
+ if (!models || typeof models !== "object") continue;
4863
+ const rank = providerRank(provId);
4864
+ for (const [modelKey, model] of Object.entries(models)) {
4865
+ const cost = model?.cost;
4866
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
4867
+ const bareId = bareModelId(modelKey);
4868
+ if (!bareId) continue;
4869
+ const existingRank = ranks[bareId];
4870
+ if (existingRank !== void 0 && existingRank <= rank) continue;
4871
+ const price = { inputPerMTok: cost.input, outputPerMTok: cost.output };
4872
+ if (typeof cost.cache_read === "number") price.cacheReadPerMTok = cost.cache_read;
4873
+ if (typeof cost.cache_write === "number") price.cacheWritePerMTok = cost.cache_write;
4874
+ out[bareId] = price;
4875
+ ranks[bareId] = rank;
4876
+ }
4877
+ }
4878
+ return out;
4879
+ }
4880
+ var defaultNow = () => (/* @__PURE__ */ new Date()).toISOString();
4881
+ async function refreshPrices(opts = {}) {
4882
+ const url = opts.url ?? priceUrl();
4883
+ const doFetch = opts.fetchImpl ?? fetch;
4884
+ const res = await doFetch(url);
4885
+ if (!res.ok) throw new Error(`Failed to fetch prices from ${url}: HTTP ${res.status}`);
4886
+ const api = await res.json();
4887
+ const models = normalizeModelsDevPrices(api);
4888
+ const modelCount = Object.keys(models).length;
4889
+ if (modelCount === 0) throw new Error(`No model prices found in response from ${url}`);
4890
+ const cache2 = { source: "models.dev", fetchedAt: (opts.now ?? defaultNow)(), models };
4891
+ const path = opts.path ?? defaultPriceCachePath();
4892
+ savePriceCache(cache2, path);
4893
+ return { source: cache2.source, fetchedAt: cache2.fetchedAt, modelCount, path };
4894
+ }
4555
4895
 
4556
4896
  // src/server/import/run.ts
4557
4897
  import { relative as relative3 } from "path";
4558
4898
 
4559
4899
  // src/server/import/detect.ts
4560
- import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync11, statSync as statSync3 } from "fs";
4561
- import { join as join6 } from "path";
4900
+ import { existsSync as existsSync11, readdirSync as readdirSync3, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
4901
+ import { join as join7 } from "path";
4562
4902
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4563
4903
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
4564
4904
  function safeIsDir(p) {
@@ -4569,7 +4909,7 @@ function safeIsDir(p) {
4569
4909
  }
4570
4910
  }
4571
4911
  function listDirs(dir) {
4572
- return readdirSync3(dir).filter((e) => safeIsDir(join6(dir, e)));
4912
+ return readdirSync3(dir).filter((e) => safeIsDir(join7(dir, e)));
4573
4913
  }
4574
4914
  function fileCount(dir) {
4575
4915
  try {
@@ -4583,23 +4923,23 @@ function pickSource(locales, sizeOf) {
4583
4923
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4584
4924
  }
4585
4925
  function detectLaravel(root) {
4586
- const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
4926
+ const localeRoot = [join7(root, "resources", "lang"), join7(root, "lang")].find(safeIsDir);
4587
4927
  if (!localeRoot) return null;
4588
4928
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4589
4929
  if (locales.length === 0) return null;
4590
- const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
4930
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join7(localeRoot, loc)));
4591
4931
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4592
4932
  }
4593
4933
  function detectVue(root, forced = false) {
4594
4934
  for (const rel of VUE_DIR_CANDIDATES) {
4595
- const localeRoot = join6(root, rel);
4935
+ const localeRoot = join7(root, rel);
4596
4936
  if (!safeIsDir(localeRoot)) continue;
4597
4937
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4598
4938
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4599
4939
  if (enough) {
4600
4940
  const sourceLocale = pickSource(locales, (loc) => {
4601
4941
  try {
4602
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
4942
+ return statSync3(join7(localeRoot, `${loc}.json`)).size;
4603
4943
  } catch {
4604
4944
  return 0;
4605
4945
  }
@@ -4613,9 +4953,9 @@ var NEXT_INTL_CONFIG_CANDIDATES = ["src/i18n/request.ts", "i18n/request.ts", "sr
4613
4953
  var NEXT_INTL_ROUTING_CANDIDATES = ["src/i18n/routing.ts", "i18n/routing.ts", "src/i18n/routing.js", "i18n/routing.js"];
4614
4954
  var NEXT_INTL_DIR_CANDIDATES = ["messages", "src/messages", "locales", "src/locales", "src/i18n/messages"];
4615
4955
  function hasNextIntlSignal(root) {
4616
- if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join6(root, rel)))) return true;
4956
+ if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join7(root, rel)))) return true;
4617
4957
  try {
4618
- const pkg = JSON.parse(readFileSync11(join6(root, "package.json"), "utf8"));
4958
+ const pkg = JSON.parse(readFileSync12(join7(root, "package.json"), "utf8"));
4619
4959
  if (pkg.dependencies?.["next-intl"] || pkg.devDependencies?.["next-intl"]) return true;
4620
4960
  } catch {
4621
4961
  }
@@ -4624,7 +4964,7 @@ function hasNextIntlSignal(root) {
4624
4964
  function nextIntlDefaultLocale(root) {
4625
4965
  for (const rel of NEXT_INTL_ROUTING_CANDIDATES) {
4626
4966
  try {
4627
- const m = readFileSync11(join6(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
4967
+ const m = readFileSync12(join7(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
4628
4968
  if (m) return m[1];
4629
4969
  } catch {
4630
4970
  }
@@ -4634,14 +4974,14 @@ function nextIntlDefaultLocale(root) {
4634
4974
  function detectNextIntl(root, forced = false) {
4635
4975
  if (!forced && !hasNextIntlSignal(root)) return null;
4636
4976
  for (const rel of NEXT_INTL_DIR_CANDIDATES) {
4637
- const localeRoot = join6(root, rel);
4977
+ const localeRoot = join7(root, rel);
4638
4978
  if (!safeIsDir(localeRoot)) continue;
4639
4979
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4640
4980
  if (locales.length === 0) continue;
4641
4981
  const def = nextIntlDefaultLocale(root);
4642
4982
  const sourceLocale = def && locales.includes(def) ? def : pickSource(locales, (loc) => {
4643
4983
  try {
4644
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
4984
+ return statSync3(join7(localeRoot, `${loc}.json`)).size;
4645
4985
  } catch {
4646
4986
  return 0;
4647
4987
  }
@@ -4652,7 +4992,7 @@ function detectNextIntl(root, forced = false) {
4652
4992
  }
4653
4993
  function detectArb(root) {
4654
4994
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4655
- const localeRoot = join6(root, rel);
4995
+ const localeRoot = join7(root, rel);
4656
4996
  if (!safeIsDir(localeRoot)) continue;
4657
4997
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4658
4998
  if (locales.length >= 1) {
@@ -4662,10 +5002,10 @@ function detectArb(root) {
4662
5002
  return null;
4663
5003
  }
4664
5004
  function lprojLocales(dir) {
4665
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.strings")));
5005
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.strings")));
4666
5006
  }
4667
5007
  function detectApple(root) {
4668
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5008
+ const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
4669
5009
  let best = null;
4670
5010
  for (const dir of candidates) {
4671
5011
  const locales = lprojLocales(dir);
@@ -4677,7 +5017,7 @@ function detectApple(root) {
4677
5017
  locales,
4678
5018
  sourceLocale: pickSource(locales, (loc) => {
4679
5019
  try {
4680
- return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
5020
+ return statSync3(join7(dir, `${loc}.lproj`, "Localizable.strings")).size;
4681
5021
  } catch {
4682
5022
  return 0;
4683
5023
  }
@@ -4690,7 +5030,7 @@ function detectApple(root) {
4690
5030
  var ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
4691
5031
  function detectAngularXliff(root) {
4692
5032
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4693
- const localeRoot = rel === "." ? root : join6(root, rel);
5033
+ const localeRoot = rel === "." ? root : join7(root, rel);
4694
5034
  if (!safeIsDir(localeRoot)) continue;
4695
5035
  const files = readdirSync3(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4696
5036
  if (files.length === 0) continue;
@@ -4698,7 +5038,7 @@ function detectAngularXliff(root) {
4698
5038
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4699
5039
  let sourceLocale;
4700
5040
  try {
4701
- sourceLocale = readFileSync11(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5041
+ sourceLocale = readFileSync12(join7(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4702
5042
  } catch {
4703
5043
  }
4704
5044
  if (!sourceLocale && locales.length === 0) continue;
@@ -4709,14 +5049,14 @@ function detectAngularXliff(root) {
4709
5049
  return null;
4710
5050
  }
4711
5051
  function detectRails(root) {
4712
- const localeRoot = join6(root, "config", "locales");
5052
+ const localeRoot = join7(root, "config", "locales");
4713
5053
  if (!safeIsDir(localeRoot)) return null;
4714
5054
  const locales = [];
4715
5055
  for (const file of readdirSync3(localeRoot).sort()) {
4716
5056
  if (!/\.ya?ml$/.test(file)) continue;
4717
5057
  let text;
4718
5058
  try {
4719
- text = readFileSync11(join6(localeRoot, file), "utf8");
5059
+ text = readFileSync12(join7(localeRoot, file), "utf8");
4720
5060
  } catch {
4721
5061
  continue;
4722
5062
  }
@@ -4731,15 +5071,15 @@ function detectRails(root) {
4731
5071
  var I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
4732
5072
  function detectI18next(root) {
4733
5073
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4734
- const localeRoot = join6(root, rel);
5074
+ const localeRoot = join7(root, rel);
4735
5075
  if (!safeIsDir(localeRoot)) continue;
4736
5076
  const locales = listDirs(localeRoot).filter(
4737
- (d) => LOCALE_RE.test(d) && readdirSync3(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
5077
+ (d) => LOCALE_RE.test(d) && readdirSync3(join7(localeRoot, d)).some((f) => f.endsWith(".json"))
4738
5078
  );
4739
5079
  if (locales.length === 0) continue;
4740
5080
  const sourceLocale = pickSource(locales, (loc) => {
4741
5081
  try {
4742
- return readdirSync3(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
5082
+ return readdirSync3(join7(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join7(localeRoot, loc, f)).size, 0);
4743
5083
  } catch {
4744
5084
  return 0;
4745
5085
  }
@@ -4756,8 +5096,8 @@ function gettextLocales(dir) {
4756
5096
  if (!locales.includes(flat)) locales.push(flat);
4757
5097
  continue;
4758
5098
  }
4759
- if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4760
- const sub = join6(dir, entry);
5099
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join7(dir, entry))) continue;
5100
+ const sub = join7(dir, entry);
4761
5101
  const hasPo = (d) => {
4762
5102
  try {
4763
5103
  return readdirSync3(d).some((f) => f.endsWith(".po"));
@@ -4765,7 +5105,7 @@ function gettextLocales(dir) {
4765
5105
  return false;
4766
5106
  }
4767
5107
  };
4768
- if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
5108
+ if (hasPo(join7(sub, "LC_MESSAGES")) || hasPo(sub)) {
4769
5109
  if (!locales.includes(entry)) locales.push(entry);
4770
5110
  }
4771
5111
  }
@@ -4774,7 +5114,7 @@ function gettextLocales(dir) {
4774
5114
  var GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
4775
5115
  function detectGettext(root) {
4776
5116
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4777
- const localeRoot = join6(root, rel);
5117
+ const localeRoot = join7(root, rel);
4778
5118
  if (!safeIsDir(localeRoot)) continue;
4779
5119
  const locales = gettextLocales(localeRoot);
4780
5120
  if (locales.length === 0) continue;
@@ -4783,10 +5123,10 @@ function detectGettext(root) {
4783
5123
  return null;
4784
5124
  }
4785
5125
  function detectAppleStringsdict(root) {
4786
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
5126
+ const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
4787
5127
  let best = null;
4788
5128
  for (const dir of candidates) {
4789
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
5129
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.stringsdict")));
4790
5130
  if (locales.length === 0) continue;
4791
5131
  if (!best || locales.length > best.locales.length) {
4792
5132
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4833,8 +5173,8 @@ function detect(root, formatOverride) {
4833
5173
  }
4834
5174
 
4835
5175
  // src/server/import/parsers/vue-i18n-json.ts
4836
- import { readdirSync as readdirSync4, readFileSync as readFileSync12 } from "fs";
4837
- import { join as join7 } from "path";
5176
+ import { readdirSync as readdirSync4, readFileSync as readFileSync13 } from "fs";
5177
+ import { join as join8 } from "path";
4838
5178
 
4839
5179
  // src/server/import/flatten.ts
4840
5180
  function flattenObject(value, prefix, warnings) {
@@ -4875,7 +5215,7 @@ var vueI18nJson2 = {
4875
5215
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4876
5216
  let data;
4877
5217
  try {
4878
- data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
5218
+ data = JSON.parse(readFileSync13(join8(localeRoot, file), "utf8"));
4879
5219
  } catch (e) {
4880
5220
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4881
5221
  continue;
@@ -4890,8 +5230,8 @@ var vueI18nJson2 = {
4890
5230
  };
4891
5231
 
4892
5232
  // src/server/import/parsers/next-intl-json.ts
4893
- import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4894
- import { join as join8 } from "path";
5233
+ import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
5234
+ import { join as join9 } from "path";
4895
5235
  var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4896
5236
  var nextIntlJson2 = {
4897
5237
  name: "next-intl-json",
@@ -4906,7 +5246,7 @@ var nextIntlJson2 = {
4906
5246
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4907
5247
  let data;
4908
5248
  try {
4909
- data = JSON.parse(readFileSync13(join8(localeRoot, file), "utf8"));
5249
+ data = JSON.parse(readFileSync14(join9(localeRoot, file), "utf8"));
4910
5250
  } catch (e) {
4911
5251
  warnings.push(`next-intl-json: failed to parse ${file}: ${e.message}`);
4912
5252
  continue;
@@ -4922,7 +5262,7 @@ var nextIntlJson2 = {
4922
5262
 
4923
5263
  // src/server/import/parsers/laravel-php.ts
4924
5264
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4925
- import { join as join9, relative as relative2 } from "path";
5265
+ import { join as join10, relative as relative2 } from "path";
4926
5266
  import { execFileSync } from "child_process";
4927
5267
 
4928
5268
  // src/server/import/placeholders.ts
@@ -4938,13 +5278,13 @@ function railsToCanonical(value) {
4938
5278
 
4939
5279
  // src/server/import/parsers/laravel-php.ts
4940
5280
  function listDirs2(dir) {
4941
- return readdirSync6(dir).filter((e) => statSync4(join9(dir, e)).isDirectory());
5281
+ return readdirSync6(dir).filter((e) => statSync4(join10(dir, e)).isDirectory());
4942
5282
  }
4943
5283
  function listPhpFiles(dir) {
4944
5284
  const out = [];
4945
5285
  const walk = (d) => {
4946
5286
  for (const e of readdirSync6(d)) {
4947
- const full = join9(d, e);
5287
+ const full = join10(d, e);
4948
5288
  if (statSync4(full).isDirectory()) walk(full);
4949
5289
  else if (e.endsWith(".php")) out.push(full);
4950
5290
  }
@@ -4981,7 +5321,7 @@ var laravelPhp2 = {
4981
5321
  for (const locale of listDirs2(localeRoot).sort()) {
4982
5322
  if (locale === "vendor") continue;
4983
5323
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4984
- const localeDir = join9(localeRoot, locale);
5324
+ const localeDir = join10(localeRoot, locale);
4985
5325
  locales.push(locale);
4986
5326
  for (const file of listPhpFiles(localeDir)) {
4987
5327
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -5004,8 +5344,8 @@ var laravelPhp2 = {
5004
5344
  };
5005
5345
 
5006
5346
  // src/server/import/parsers/flutter-arb.ts
5007
- import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
5008
- import { join as join10 } from "path";
5347
+ import { readdirSync as readdirSync7, readFileSync as readFileSync15 } from "fs";
5348
+ import { join as join11 } from "path";
5009
5349
  var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5010
5350
  function localeFromArbName(file) {
5011
5351
  const m = file.match(/^(.+)\.arb$/);
@@ -5041,7 +5381,7 @@ var flutterArb2 = {
5041
5381
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5042
5382
  let data;
5043
5383
  try {
5044
- data = JSON.parse(readFileSync14(join10(localeRoot, file), "utf8"));
5384
+ data = JSON.parse(readFileSync15(join11(localeRoot, file), "utf8"));
5045
5385
  } catch (e) {
5046
5386
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
5047
5387
  continue;
@@ -5066,8 +5406,8 @@ var flutterArb2 = {
5066
5406
  };
5067
5407
 
5068
5408
  // src/server/import/parsers/apple-strings.ts
5069
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
5070
- import { join as join11 } from "path";
5409
+ import { readdirSync as readdirSync8, readFileSync as readFileSync16, statSync as statSync5 } from "fs";
5410
+ import { join as join12 } from "path";
5071
5411
  var LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5072
5412
  var TABLE = "Localizable.strings";
5073
5413
  function localeFromLproj(dir) {
@@ -5183,16 +5523,16 @@ var appleStrings2 = {
5183
5523
  const locale = localeFromLproj(dir);
5184
5524
  if (!locale) continue;
5185
5525
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5186
- const file = join11(localeRoot, dir, TABLE);
5526
+ const file = join12(localeRoot, dir, TABLE);
5187
5527
  let text;
5188
5528
  try {
5189
5529
  if (!statSync5(file).isFile()) continue;
5190
- text = readFileSync15(file, "utf8");
5530
+ text = readFileSync16(file, "utf8");
5191
5531
  } catch {
5192
5532
  continue;
5193
5533
  }
5194
5534
  locales.push(locale);
5195
- const others = readdirSync8(join11(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5535
+ const others = readdirSync8(join12(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5196
5536
  if (others.length) {
5197
5537
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5198
5538
  }
@@ -5205,8 +5545,8 @@ var appleStrings2 = {
5205
5545
  };
5206
5546
 
5207
5547
  // src/server/import/parsers/angular-xliff.ts
5208
- import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
5209
- import { join as join12 } from "path";
5548
+ import { readdirSync as readdirSync9, readFileSync as readFileSync17 } from "fs";
5549
+ import { join as join13 } from "path";
5210
5550
  var LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5211
5551
  var FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
5212
5552
  function decodeEntities(s) {
@@ -5273,7 +5613,7 @@ var angularXliff2 = {
5273
5613
  if (fnameLocale !== void 0 && !LOCALE_RE6.test(fnameLocale)) continue;
5274
5614
  let xml;
5275
5615
  try {
5276
- xml = readFileSync16(join12(localeRoot, file), "utf8");
5616
+ xml = readFileSync17(join13(localeRoot, file), "utf8");
5277
5617
  } catch (e) {
5278
5618
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5279
5619
  continue;
@@ -5318,8 +5658,8 @@ var angularXliff2 = {
5318
5658
  };
5319
5659
 
5320
5660
  // src/server/import/parsers/gettext-po.ts
5321
- import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5322
- import { join as join13 } from "path";
5661
+ import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5662
+ import { join as join14 } from "path";
5323
5663
  var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5324
5664
  var DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
5325
5665
  var CONT_RE = /^[ \t]*"(.*)"\s*$/;
@@ -5411,17 +5751,17 @@ function discoverPoFiles(root) {
5411
5751
  for (const e of entries) {
5412
5752
  if (e.isFile() && e.name.endsWith(".po")) {
5413
5753
  const base = e.name.slice(0, -3);
5414
- found.push({ path: join13(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5754
+ found.push({ path: join14(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5415
5755
  } else if (e.isDirectory() && LOCALE_RE7.test(e.name)) {
5416
- for (const sub of [join13(e.name, "LC_MESSAGES"), e.name]) {
5756
+ for (const sub of [join14(e.name, "LC_MESSAGES"), e.name]) {
5417
5757
  let names;
5418
5758
  try {
5419
- names = readdirSync10(join13(root, sub)).sort();
5759
+ names = readdirSync10(join14(root, sub)).sort();
5420
5760
  } catch {
5421
5761
  continue;
5422
5762
  }
5423
5763
  for (const f of names) {
5424
- if (f.endsWith(".po")) found.push({ path: join13(root, sub, f), rel: join13(sub, f), locale: e.name });
5764
+ if (f.endsWith(".po")) found.push({ path: join14(root, sub, f), rel: join14(sub, f), locale: e.name });
5425
5765
  }
5426
5766
  }
5427
5767
  }
@@ -5437,7 +5777,7 @@ var gettextPo2 = {
5437
5777
  for (const file of discoverPoFiles(localeRoot)) {
5438
5778
  let entries;
5439
5779
  try {
5440
- entries = parseEntries(readFileSync17(file.path, "utf8"));
5780
+ entries = parseEntries(readFileSync18(file.path, "utf8"));
5441
5781
  } catch (e) {
5442
5782
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5443
5783
  continue;
@@ -5482,8 +5822,8 @@ var gettextPo2 = {
5482
5822
  };
5483
5823
 
5484
5824
  // src/server/import/parsers/i18next-json.ts
5485
- import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5486
- import { join as join14 } from "path";
5825
+ import { readdirSync as readdirSync11, readFileSync as readFileSync19, statSync as statSync6 } from "fs";
5826
+ import { join as join15 } from "path";
5487
5827
  var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5488
5828
  var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
5489
5829
  var PLURAL_ARG = "count";
@@ -5502,7 +5842,7 @@ function fromI18next(value) {
5502
5842
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5503
5843
  let data;
5504
5844
  try {
5505
- data = JSON.parse(readFileSync18(path, "utf8"));
5845
+ data = JSON.parse(readFileSync19(path, "utf8"));
5506
5846
  } catch (e) {
5507
5847
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5508
5848
  return false;
@@ -5544,7 +5884,7 @@ var i18nextJson2 = {
5544
5884
  const keys = {};
5545
5885
  const locales = [];
5546
5886
  for (const entry of readdirSync11(localeRoot).sort()) {
5547
- const full = join14(localeRoot, entry);
5887
+ const full = join15(localeRoot, entry);
5548
5888
  if (safeIsDir2(full)) {
5549
5889
  if (!LOCALE_RE8.test(entry)) continue;
5550
5890
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5553,7 +5893,7 @@ var i18nextJson2 = {
5553
5893
  if (!file.endsWith(".json")) continue;
5554
5894
  const ns = file.slice(0, -".json".length);
5555
5895
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5556
- if (ingestFile(join14(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5896
+ if (ingestFile(join15(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5557
5897
  }
5558
5898
  if (any && !locales.includes(entry)) locales.push(entry);
5559
5899
  } else if (entry.endsWith(".json")) {
@@ -5570,8 +5910,8 @@ var i18nextJson2 = {
5570
5910
  };
5571
5911
 
5572
5912
  // src/server/import/parsers/rails-yaml.ts
5573
- import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5574
- import { join as join15 } from "path";
5913
+ import { readdirSync as readdirSync12, readFileSync as readFileSync20 } from "fs";
5914
+ import { join as join16 } from "path";
5575
5915
  var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5576
5916
  var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5577
5917
  function makeNode() {
@@ -5789,7 +6129,7 @@ var railsYaml2 = {
5789
6129
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5790
6130
  let text;
5791
6131
  try {
5792
- text = readFileSync19(join15(localeRoot, file), "utf8");
6132
+ text = readFileSync20(join16(localeRoot, file), "utf8");
5793
6133
  } catch (e) {
5794
6134
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5795
6135
  continue;
@@ -5810,8 +6150,8 @@ var railsYaml2 = {
5810
6150
  };
5811
6151
 
5812
6152
  // src/server/import/parsers/apple-stringsdict.ts
5813
- import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5814
- import { join as join16 } from "path";
6153
+ import { readdirSync as readdirSync13, readFileSync as readFileSync21, statSync as statSync7 } from "fs";
6154
+ import { join as join17 } from "path";
5815
6155
  var LOCALE_RE10 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5816
6156
  var TABLE2 = "Localizable.stringsdict";
5817
6157
  function localeFromLproj2(dir) {
@@ -5965,16 +6305,16 @@ var appleStringsdict2 = {
5965
6305
  const locale = localeFromLproj2(dir);
5966
6306
  if (!locale) continue;
5967
6307
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5968
- const file = join16(localeRoot, dir, TABLE2);
6308
+ const file = join17(localeRoot, dir, TABLE2);
5969
6309
  let text;
5970
6310
  try {
5971
6311
  if (!statSync7(file).isFile()) continue;
5972
- text = readFileSync20(file, "utf8");
6312
+ text = readFileSync21(file, "utf8");
5973
6313
  } catch {
5974
6314
  continue;
5975
6315
  }
5976
6316
  locales.push(locale);
5977
- const others = readdirSync13(join16(localeRoot, dir)).filter(
6317
+ const others = readdirSync13(join17(localeRoot, dir)).filter(
5978
6318
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5979
6319
  );
5980
6320
  if (others.length) {
@@ -6098,6 +6438,7 @@ function assemble2(parsed, opts) {
6098
6438
  spelling: { customWords: [] }
6099
6439
  },
6100
6440
  glossary: [],
6441
+ glossarySuggestions: [],
6101
6442
  keys,
6102
6443
  warnings
6103
6444
  };
@@ -6294,7 +6635,7 @@ function refreshLocationUsage(projectRoot, format) {
6294
6635
  }
6295
6636
 
6296
6637
  // src/server/export-run.ts
6297
- import { existsSync as existsSync12, readFileSync as readFileSync21, readdirSync as readdirSync14, rmdirSync, statSync as statSync8, unlinkSync } from "fs";
6638
+ import { existsSync as existsSync12, readFileSync as readFileSync22, readdirSync as readdirSync14, rmdirSync, statSync as statSync8, unlinkSync } from "fs";
6298
6639
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
6299
6640
  function effectiveLocales(config) {
6300
6641
  const limit = config.exportLocales;
@@ -6307,11 +6648,11 @@ function narrowForExport(state) {
6307
6648
  return { ...state, config: { ...state.config, locales } };
6308
6649
  }
6309
6650
  var LOCALE_TOKEN = /^[a-z]{2,3}([_-][a-z0-9]+)*$/i;
6310
- function escapeRegExp(s) {
6651
+ function escapeRegExp2(s) {
6311
6652
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6312
6653
  }
6313
6654
  function segmentRegExp(segment) {
6314
- const pattern = escapeRegExp(segment).replaceAll("\\{locale\\}", "(?<locale>[A-Za-z0-9_-]+)").replaceAll("\\{namespace\\}", "[^/]*");
6655
+ const pattern = escapeRegExp2(segment).replaceAll("\\{locale\\}", "(?<locale>[A-Za-z0-9_-]+)").replaceAll("\\{namespace\\}", "[^/]*");
6315
6656
  return new RegExp(`^${pattern}$`);
6316
6657
  }
6317
6658
  function removeEmptyDirs(dir, stopAt) {
@@ -6393,7 +6734,7 @@ function exportToDisk(state, projectRoot, opts) {
6393
6734
  writtenPaths.add(abs);
6394
6735
  let current = null;
6395
6736
  try {
6396
- current = readFileSync21(abs, "utf8");
6737
+ current = readFileSync22(abs, "utf8");
6397
6738
  } catch {
6398
6739
  }
6399
6740
  if (current === f.contents) {
@@ -6410,17 +6751,17 @@ function exportToDisk(state, projectRoot, opts) {
6410
6751
  }
6411
6752
 
6412
6753
  // src/server/ui-prefs.ts
6413
- import { readFileSync as readFileSync22 } from "fs";
6414
- import { homedir } from "os";
6415
- import { join as join17 } from "path";
6754
+ import { readFileSync as readFileSync23 } from "fs";
6755
+ import { homedir as homedir2 } from "os";
6756
+ import { join as join18 } from "path";
6416
6757
  var THEMES = ["system", "light", "dark"];
6417
6758
  var isThemeMode = (v) => THEMES.includes(v);
6418
6759
  var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
6419
- var defaultUiPrefsPath = () => join17(homedir(), ".glotfile", "ui.json");
6760
+ var defaultUiPrefsPath = () => join18(homedir2(), ".glotfile", "ui.json");
6420
6761
  var DEFAULTS = { theme: "system" };
6421
6762
  function readJson(path) {
6422
6763
  try {
6423
- const parsed = JSON.parse(readFileSync22(path, "utf8"));
6764
+ const parsed = JSON.parse(readFileSync23(path, "utf8"));
6424
6765
  return parsed && typeof parsed === "object" ? parsed : {};
6425
6766
  } catch {
6426
6767
  return {};
@@ -6439,7 +6780,7 @@ function saveUiPrefs(path, prefs) {
6439
6780
  }
6440
6781
 
6441
6782
  // src/server/local-settings.ts
6442
- import { readFileSync as readFileSync23 } from "fs";
6783
+ import { readFileSync as readFileSync24 } from "fs";
6443
6784
  import { resolve as resolve8 } from "path";
6444
6785
  var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
6445
6786
  var isEditorId = (v) => EDITOR_IDS.includes(v);
@@ -6454,7 +6795,7 @@ var DEFAULT_EDITOR = "vscode";
6454
6795
  var settingsPath = (projectRoot) => resolve8(projectRoot, ".glotfile", "settings.json");
6455
6796
  function readJson2(path) {
6456
6797
  try {
6457
- const parsed = JSON.parse(readFileSync23(path, "utf8"));
6798
+ const parsed = JSON.parse(readFileSync24(path, "utf8"));
6458
6799
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
6459
6800
  } catch {
6460
6801
  return {};
@@ -6544,7 +6885,7 @@ function createEventHub() {
6544
6885
 
6545
6886
  // src/server/watch.ts
6546
6887
  import { statSync as statSync9, readdirSync as readdirSync15 } from "fs";
6547
- import { join as join18 } from "path";
6888
+ import { join as join19 } from "path";
6548
6889
  import { createHash as createHash2 } from "crypto";
6549
6890
  function hashState(state) {
6550
6891
  return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
@@ -6560,15 +6901,15 @@ function signature(statePath) {
6560
6901
  const parts = [];
6561
6902
  for (const rel of ["config.json", "keys.json"]) {
6562
6903
  try {
6563
- const s = statSync9(join18(dir, rel));
6904
+ const s = statSync9(join19(dir, rel));
6564
6905
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6565
6906
  } catch {
6566
6907
  }
6567
6908
  }
6568
6909
  try {
6569
- for (const name of readdirSync15(join18(dir, "locales")).sort()) {
6910
+ for (const name of readdirSync15(join19(dir, "locales")).sort()) {
6570
6911
  if (!name.endsWith(".json")) continue;
6571
- const s = statSync9(join18(dir, "locales", name));
6912
+ const s = statSync9(join19(dir, "locales", name));
6572
6913
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6573
6914
  }
6574
6915
  } catch {
@@ -6643,28 +6984,13 @@ function projectName(root) {
6643
6984
  const nameFile = resolve9(root, ".idea", ".name");
6644
6985
  if (existsSync13(nameFile)) {
6645
6986
  try {
6646
- const name = readFileSync24(nameFile, "utf8").trim();
6987
+ const name = readFileSync25(nameFile, "utf8").trim();
6647
6988
  if (name) return name;
6648
6989
  } catch {
6649
6990
  }
6650
6991
  }
6651
6992
  return basename(root);
6652
6993
  }
6653
- function attachUsageSnippets(targets, cache2, projectRoot) {
6654
- const fileCache = /* @__PURE__ */ new Map();
6655
- for (const target of targets) {
6656
- const allRefs = Object.entries(cache2.files).flatMap(
6657
- ([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
6658
- key: r.key,
6659
- file,
6660
- line: r.line,
6661
- col: r.col,
6662
- scanner: r.scanner
6663
- }))
6664
- );
6665
- target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
6666
- }
6667
- }
6668
6994
  function createApi(deps) {
6669
6995
  const app = new Hono();
6670
6996
  const load = () => loadState(deps.statePath);
@@ -6796,6 +7122,61 @@ function createApi(deps) {
6796
7122
  }
6797
7123
  return c.json({ ok: true });
6798
7124
  });
7125
+ app.post("/ai-test", async (c) => {
7126
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7127
+ const meta = { provider: aiCfg.provider, model: aiCfg.model };
7128
+ let provider;
7129
+ try {
7130
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7131
+ } catch (e) {
7132
+ return c.json({ ok: false, ...meta, error: explainProviderError(aiCfg.provider, e) });
7133
+ }
7134
+ const controller = new AbortController();
7135
+ const timer = setTimeout(() => controller.abort(), 3e4);
7136
+ try {
7137
+ const probe = {
7138
+ id: "probe",
7139
+ key: "glotfile.connection-test",
7140
+ source: "Hello",
7141
+ sourceLocale: "en",
7142
+ targetLocale: "es",
7143
+ placeholders: []
7144
+ };
7145
+ await provider.translate([probe], void 0, controller.signal);
7146
+ return c.json({ ok: true, ...meta });
7147
+ } catch (e) {
7148
+ const error = controller.signal.aborted ? "Connection test timed out after 30s \u2014 the provider didn't respond." : explainProviderError(aiCfg.provider, e);
7149
+ return c.json({ ok: false, ...meta, error });
7150
+ } finally {
7151
+ clearTimeout(timer);
7152
+ }
7153
+ });
7154
+ app.get("/prices", (c) => {
7155
+ const cache2 = loadPriceCache();
7156
+ const ai = loadLocalSettings(projectRoot).ai;
7157
+ const pricing = resolvePricing(ai, cache2);
7158
+ return c.json({
7159
+ source: cache2?.source ?? null,
7160
+ fetchedAt: cache2?.fetchedAt ?? null,
7161
+ modelCount: cache2 ? Object.keys(cache2.models).length : 0,
7162
+ path: defaultPriceCachePath(),
7163
+ resolved: pricing ? { provider: ai.provider, model: ai.model, ...pricing } : null
7164
+ });
7165
+ });
7166
+ app.get("/prices/list", (c) => {
7167
+ const cache2 = loadPriceCache();
7168
+ const models = cache2 ? Object.entries(cache2.models).map(([id, p]) => ({ id, ...p })).sort((a, b) => a.id.localeCompare(b.id)) : [];
7169
+ return c.json({ source: cache2?.source ?? null, fetchedAt: cache2?.fetchedAt ?? null, models });
7170
+ });
7171
+ app.post("/prices/refresh", async (c) => {
7172
+ try {
7173
+ const res = await refreshPrices();
7174
+ invalidatePriceCache();
7175
+ return c.json({ ok: true, ...res });
7176
+ } catch (e) {
7177
+ return c.json({ error: e.message }, 502);
7178
+ }
7179
+ });
6799
7180
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
6800
7181
  app.get("/files", (c) => {
6801
7182
  const found = /* @__PURE__ */ new Map();
@@ -7160,6 +7541,90 @@ function createApi(deps) {
7160
7541
  logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
7161
7542
  return c.json({ ok: true });
7162
7543
  });
7544
+ app.get("/glossary/suggestions", (c) => {
7545
+ const s = load();
7546
+ const pending = s.glossarySuggestions.filter((x) => x.status === "pending");
7547
+ return c.json(pending.map((x) => ({
7548
+ ...x,
7549
+ occurrences: sourceKeysForTerm(s, x.term, { caseSensitive: x.caseSensitive, wholeWord: x.wholeWord }).length
7550
+ })));
7551
+ });
7552
+ app.post("/glossary/suggestions/dismiss", async (c) => {
7553
+ const { term } = await c.req.json();
7554
+ if (typeof term !== "string") return c.json({ error: "term must be a string" }, 400);
7555
+ const s = load();
7556
+ dismissGlossarySuggestion(s, term);
7557
+ persist(s);
7558
+ logChange({ kind: "glossary", summary: `Dismissed suggested term "${term}"` });
7559
+ return c.json({ ok: true });
7560
+ });
7561
+ app.delete("/glossary/suggestions/:term", (c) => {
7562
+ const s = load();
7563
+ const term = decodeURIComponent(c.req.param("term"));
7564
+ removeGlossarySuggestion(s, term);
7565
+ persist(s);
7566
+ return c.json({ ok: true });
7567
+ });
7568
+ app.post("/glossary/suggest", async (c) => {
7569
+ const signal = c.req.raw.signal;
7570
+ const body = await c.req.json().catch(() => ({}));
7571
+ return streamSSE(c, async (stream) => {
7572
+ const s0 = load();
7573
+ const sources = selectGlossarySources(s0, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
7574
+ if (!sources.length) {
7575
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: 0, terms: [] }) });
7576
+ return;
7577
+ }
7578
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7579
+ let provider;
7580
+ try {
7581
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7582
+ } catch (e) {
7583
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
7584
+ return;
7585
+ }
7586
+ const known = knownTermList(s0);
7587
+ await stream.writeSSE({ event: "start", data: JSON.stringify({ total: sources.length }) });
7588
+ const system = buildGlossarySuggestSystemPrompt();
7589
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
7590
+ const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
7591
+ const chunks = [];
7592
+ for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
7593
+ const all = [];
7594
+ let done = 0;
7595
+ let next = 0;
7596
+ async function worker() {
7597
+ while (next < chunks.length) {
7598
+ if (signal?.aborted) break;
7599
+ const chunkRows = chunks[next++];
7600
+ try {
7601
+ const raw = await provider.complete({ system, content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunkRows, known) }], schema: GLOSSARY_SUGGEST_SCHEMA });
7602
+ all.push(...raw.terms ?? []);
7603
+ } catch (e) {
7604
+ void stream.writeSSE({ event: "warn", data: JSON.stringify({ error: e.message }) });
7605
+ }
7606
+ done += chunkRows.length;
7607
+ void stream.writeSSE({ event: "progress", data: JSON.stringify({ done, total: sources.length }) });
7608
+ }
7609
+ }
7610
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, worker));
7611
+ if (signal?.aborted) return;
7612
+ const fresh = load();
7613
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(all));
7614
+ const usage = provider.takeUsage?.();
7615
+ persist(fresh);
7616
+ appendLog(projectRoot, {
7617
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7618
+ kind: "glossary",
7619
+ summary: `Suggested ${added.length} glossary term(s)`,
7620
+ model: aiCfg.model,
7621
+ system,
7622
+ usage,
7623
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
7624
+ });
7625
+ await stream.writeSSE({ event: "done", data: JSON.stringify({ added: added.length, terms: added }) });
7626
+ });
7627
+ });
7163
7628
  app.post("/keys/:key/screenshot", async (c) => {
7164
7629
  const key = c.req.param("key");
7165
7630
  const body = await c.req.parseBody();
@@ -7345,7 +7810,7 @@ function createApi(deps) {
7345
7810
  try {
7346
7811
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7347
7812
  } catch (e) {
7348
- await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
7813
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7349
7814
  return;
7350
7815
  }
7351
7816
  const { skipped } = attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
@@ -7362,58 +7827,65 @@ function createApi(deps) {
7362
7827
  event: "start",
7363
7828
  data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
7364
7829
  });
7365
- await runLocaleParallel(reqs, provider, {
7366
- // Announce a language the moment a worker picks it up — this is the
7367
- // signal that "something is happening" during the long first LLM call.
7368
- onLocaleStart: (locale) => {
7369
- void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
7370
- },
7371
- onBatchComplete: (done, total, batchResults, locale) => {
7372
- const fresh = load();
7373
- const { written, errors } = applyResults(fresh, reqs, batchResults);
7374
- persist(fresh);
7375
- totalWritten += written;
7376
- allErrors.push(...errors);
7377
- const usage = provider.takeUsage?.();
7378
- appendLog(projectRoot, {
7379
- at: (/* @__PURE__ */ new Date()).toISOString(),
7380
- kind: "translate",
7381
- summary: `Translated ${batchResults.length} item(s)`,
7382
- model: aiCfg.model,
7383
- system,
7384
- items: batchResults.map((r) => {
7385
- const req = reqById.get(r.id);
7386
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
7387
- }),
7388
- results: batchResults,
7389
- usage,
7390
- estimatedCostUsd: usageCostUsd(usage, aiCfg)
7391
- });
7392
- const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
7393
- localeDone.set(locale, ld);
7394
- console.log(`[translate] ${done}/${total}`);
7395
- void stream.writeSSE({
7396
- event: "progress",
7397
- data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
7398
- });
7399
- },
7400
- onLocaleDone: (locale) => {
7401
- void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
7402
- },
7403
- // Record the raw reply so an unparseable model response is diagnosable
7404
- // from the activity log instead of vanishing into per-item errors.
7405
- onMalformedReply: (raw, batchSize, locale) => {
7406
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7407
- appendLog(projectRoot, {
7408
- at: (/* @__PURE__ */ new Date()).toISOString(),
7409
- kind: "translate",
7410
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7411
- model: aiCfg.model,
7412
- locale,
7413
- raw
7414
- });
7830
+ try {
7831
+ await runLocaleParallel(reqs, provider, {
7832
+ // Announce a language the moment a worker picks it up — this is the
7833
+ // signal that "something is happening" during the long first LLM call.
7834
+ onLocaleStart: (locale) => {
7835
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
7836
+ },
7837
+ onBatchComplete: (done, total, batchResults, locale) => {
7838
+ const fresh = load();
7839
+ const { written, errors } = applyResults(fresh, reqs, batchResults);
7840
+ persist(fresh);
7841
+ totalWritten += written;
7842
+ allErrors.push(...errors);
7843
+ const usage = provider.takeUsage?.();
7844
+ appendLog(projectRoot, {
7845
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7846
+ kind: "translate",
7847
+ summary: `Translated ${batchResults.length} item(s)`,
7848
+ model: aiCfg.model,
7849
+ system,
7850
+ items: batchResults.map((r) => {
7851
+ const req = reqById.get(r.id);
7852
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
7853
+ }),
7854
+ results: batchResults,
7855
+ usage,
7856
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
7857
+ });
7858
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
7859
+ localeDone.set(locale, ld);
7860
+ console.log(`[translate] ${done}/${total}`);
7861
+ void stream.writeSSE({
7862
+ event: "progress",
7863
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
7864
+ });
7865
+ },
7866
+ onLocaleDone: (locale) => {
7867
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
7868
+ },
7869
+ // Record the raw reply so an unparseable model response is diagnosable
7870
+ // from the activity log instead of vanishing into per-item errors.
7871
+ onMalformedReply: (raw, batchSize, locale) => {
7872
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7873
+ appendLog(projectRoot, {
7874
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7875
+ kind: "translate",
7876
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7877
+ model: aiCfg.model,
7878
+ locale,
7879
+ raw
7880
+ });
7881
+ }
7882
+ }, aiCfg.concurrency, signal, aiCfg.batchSize);
7883
+ } catch (e) {
7884
+ if (!signal?.aborted) {
7885
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ error: explainProviderError(aiCfg.provider, e) }) });
7415
7886
  }
7416
- }, aiCfg.concurrency, signal, aiCfg.batchSize);
7887
+ return;
7888
+ }
7417
7889
  if (!signal?.aborted) {
7418
7890
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
7419
7891
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -7440,23 +7912,28 @@ function createApi(deps) {
7440
7912
  try {
7441
7913
  provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7442
7914
  } catch (e) {
7443
- return c.json({ error: e.message }, 400);
7915
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 400);
7444
7916
  }
7445
7917
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
7446
7918
  if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
7447
- const results = await runLocaleParallel(toTranslate, provider, {
7448
- onMalformedReply: (raw, batchSize, locale) => {
7449
- console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7450
- appendLog(projectRoot, {
7451
- at: (/* @__PURE__ */ new Date()).toISOString(),
7452
- kind: "translate",
7453
- summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7454
- model: aiCfg.model,
7455
- locale,
7456
- raw
7457
- });
7458
- }
7459
- }, aiCfg.concurrency, void 0, aiCfg.batchSize);
7919
+ let results;
7920
+ try {
7921
+ results = await runLocaleParallel(toTranslate, provider, {
7922
+ onMalformedReply: (raw, batchSize, locale) => {
7923
+ console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
7924
+ appendLog(projectRoot, {
7925
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7926
+ kind: "translate",
7927
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
7928
+ model: aiCfg.model,
7929
+ locale,
7930
+ raw
7931
+ });
7932
+ }
7933
+ }, aiCfg.concurrency, void 0, aiCfg.batchSize);
7934
+ } catch (e) {
7935
+ return c.json({ error: explainProviderError(aiCfg.provider, e) }, 502);
7936
+ }
7460
7937
  const latest = load();
7461
7938
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
7462
7939
  const usage = provider.takeUsage?.();
@@ -7727,6 +8204,22 @@ function createApi(deps) {
7727
8204
  await stream.writeSSE({ event: "done", data: JSON.stringify({ requested: targets.length, written: totalWritten, errors: allErrors }) });
7728
8205
  });
7729
8206
  });
8207
+ app.post("/context/estimate", async (c) => {
8208
+ const body = await c.req.json().catch(() => ({}));
8209
+ const cache2 = loadUsageCache(projectRoot);
8210
+ if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
8211
+ const targets = selectContextTargets(load(), {
8212
+ all: body.all,
8213
+ keyGlob: body.keyGlob,
8214
+ limit: body.limit,
8215
+ since: body.since,
8216
+ keys: body.keys,
8217
+ force: body.force
8218
+ }, cache2, body.lastRunAt);
8219
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8220
+ attachUsageSnippets(targets, cache2, projectRoot);
8221
+ return c.json(estimateContext(targets, aiCfg));
8222
+ });
7730
8223
  app.get("/context/batch/status", async (c) => {
7731
8224
  const aiCfg = loadLocalSettings(projectRoot).ai;
7732
8225
  let supported = false;
@@ -7829,7 +8322,7 @@ function createApi(deps) {
7829
8322
 
7830
8323
  // src/server/server.ts
7831
8324
  var here = dirname4(fileURLToPath(import.meta.url));
7832
- var DEFAULT_UI_DIR = join19(here, "..", "ui");
8325
+ var DEFAULT_UI_DIR = join20(here, "..", "ui");
7833
8326
  var MIME = {
7834
8327
  ".html": "text/html; charset=utf-8",
7835
8328
  ".js": "text/javascript; charset=utf-8",
@@ -7896,7 +8389,7 @@ function buildApp(opts) {
7896
8389
  const file = await readFileResponse(target);
7897
8390
  if (file) return file;
7898
8391
  }
7899
- const index = await readFileResponse(join19(root, "index.html"));
8392
+ const index = await readFileResponse(join20(root, "index.html"));
7900
8393
  if (index) return index;
7901
8394
  return c.notFound();
7902
8395
  });