glotfile 1.1.0 → 1.1.1

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.
@@ -4369,6 +4369,142 @@ var init_glossary_suggest = __esm({
4369
4369
  }
4370
4370
  });
4371
4371
 
4372
+ // src/server/ai/pending-glossary-batch.ts
4373
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync5, rmSync as rmSync6 } from "fs";
4374
+ import { join as join6 } from "path";
4375
+ function pendingGlossaryBatchPath(projectRoot) {
4376
+ return join6(projectRoot, ".glotfile", "glossary-suggest-batch.json");
4377
+ }
4378
+ function loadPendingGlossaryBatch(projectRoot) {
4379
+ const path = pendingGlossaryBatchPath(projectRoot);
4380
+ if (!existsSync10(path)) return void 0;
4381
+ try {
4382
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
4383
+ if (parsed?.version !== 1) return void 0;
4384
+ return parsed;
4385
+ } catch {
4386
+ return void 0;
4387
+ }
4388
+ }
4389
+ function savePendingGlossaryBatch(projectRoot, pending) {
4390
+ const dir = join6(projectRoot, ".glotfile");
4391
+ mkdirSync6(dir, { recursive: true });
4392
+ const gitignore = join6(dir, ".gitignore");
4393
+ if (!existsSync10(gitignore)) writeFileSync5(gitignore, "*\n");
4394
+ writeFileSync5(pendingGlossaryBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
4395
+ }
4396
+ function clearPendingGlossaryBatch(projectRoot) {
4397
+ rmSync6(pendingGlossaryBatchPath(projectRoot), { force: true });
4398
+ }
4399
+ var init_pending_glossary_batch = __esm({
4400
+ "src/server/ai/pending-glossary-batch.ts"() {
4401
+ "use strict";
4402
+ }
4403
+ });
4404
+
4405
+ // src/server/ai/glossary-batch-run.ts
4406
+ function completionRequestFor2(chunk2, knownTerms) {
4407
+ return {
4408
+ system: buildGlossarySuggestSystemPrompt(),
4409
+ content: [{ type: "text", text: buildGlossarySuggestBatchPrompt(chunk2, knownTerms) }],
4410
+ schema: GLOSSARY_SUGGEST_SCHEMA
4411
+ };
4412
+ }
4413
+ async function submitGlossarySuggestBatch(provider, sources, knownTerms, batchSize, model, projectRoot) {
4414
+ if (loadPendingGlossaryBatch(projectRoot)) {
4415
+ throw new Error("A glossary suggestion batch is already pending. Apply or cancel it first.");
4416
+ }
4417
+ const chunks = [];
4418
+ const size = Math.max(1, batchSize);
4419
+ for (let i = 0; i < sources.length; i += size) chunks.push(sources.slice(i, i + size));
4420
+ const jobs = chunks.map((chunk2, i) => ({ customId: `gloss_${i}`, chunk: chunk2 }));
4421
+ const batchId = await provider.submitCompletionBatch(
4422
+ jobs.map((j) => ({ customId: j.customId, request: completionRequestFor2(j.chunk, knownTerms) }))
4423
+ );
4424
+ const pending = {
4425
+ version: 1,
4426
+ // Only Anthropic implements completion batches today.
4427
+ provider: "anthropic",
4428
+ model,
4429
+ batchId,
4430
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4431
+ total: sources.length,
4432
+ knownTerms,
4433
+ jobs: jobs.map((j) => ({
4434
+ customId: j.customId,
4435
+ requests: j.chunk
4436
+ }))
4437
+ };
4438
+ savePendingGlossaryBatch(projectRoot, pending);
4439
+ return pending;
4440
+ }
4441
+ async function applyGlossarySuggestBatchResults(load, persist, provider, pending, projectRoot, ai) {
4442
+ provider.takeUsage?.();
4443
+ const outcomes = await provider.completionBatchResults(pending.batchId);
4444
+ const batchUsage = provider.takeUsage?.();
4445
+ const allTerms = [];
4446
+ const errors = [];
4447
+ const jobFailures = [];
4448
+ const retryChunks = [];
4449
+ for (const job of pending.jobs) {
4450
+ const outcome = outcomes.get(job.customId);
4451
+ if (outcome?.type === "json") {
4452
+ const batch = outcome.value;
4453
+ allTerms.push(...batch.terms ?? []);
4454
+ continue;
4455
+ }
4456
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: "", type: "missing" });
4457
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: "", type: "malformed", raw: outcome.raw });
4458
+ else jobFailures.push({ customId: job.customId, locale: "", type: "failed", error: outcome.error });
4459
+ retryChunks.push(job.requests);
4460
+ }
4461
+ for (const chunk2 of retryChunks) {
4462
+ try {
4463
+ const raw = await provider.complete(completionRequestFor2(chunk2, pending.knownTerms));
4464
+ const batch = raw;
4465
+ allTerms.push(...batch.terms ?? []);
4466
+ } catch (e) {
4467
+ errors.push({ error: e.message });
4468
+ }
4469
+ }
4470
+ const retryUsage = provider.takeUsage?.();
4471
+ const pricing = resolvePricing({ ...ai, model: pending.model });
4472
+ let estimatedCostUsd;
4473
+ if (pricing && (batchUsage || retryUsage)) {
4474
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
4475
+ }
4476
+ let usage;
4477
+ if (batchUsage || retryUsage) {
4478
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
4479
+ if (retryUsage) addUsage(usage, retryUsage);
4480
+ }
4481
+ const fresh = load();
4482
+ const added = mergeGlossarySuggestions(fresh, dedupeTerms(allTerms));
4483
+ persist(fresh);
4484
+ clearPendingGlossaryBatch(projectRoot);
4485
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
4486
+ appendLog(projectRoot, {
4487
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4488
+ kind: "glossary",
4489
+ summary: `Applied glossary suggestion batch ${pending.batchId}: ${added.length} new term(s), ${errors.length} error(s), ${retryChunks.length} job(s) retried${costSuffix}`,
4490
+ model: pending.model,
4491
+ jobFailures: jobFailures.length ? jobFailures : void 0,
4492
+ usage,
4493
+ estimatedCostUsd
4494
+ });
4495
+ return { added: added.length, errors, retried: retryChunks.length };
4496
+ }
4497
+ var init_glossary_batch_run = __esm({
4498
+ "src/server/ai/glossary-batch-run.ts"() {
4499
+ "use strict";
4500
+ init_glossary_suggest();
4501
+ init_pending_glossary_batch();
4502
+ init_state();
4503
+ init_log();
4504
+ init_pricing();
4505
+ }
4506
+ });
4507
+
4372
4508
  // src/server/ai/estimate.ts
4373
4509
  function estimateTokens(text) {
4374
4510
  const cjk = text.match(CJK_RE)?.length ?? 0;
@@ -4535,13 +4671,13 @@ var init_price_fetch = __esm({
4535
4671
  });
4536
4672
 
4537
4673
  // src/server/scan.ts
4538
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
4674
+ import { existsSync as existsSync11, readFileSync as readFileSync12 } from "fs";
4539
4675
  import { resolve as resolve7 } from "path";
4540
4676
  function loadUsageCache(projectRoot) {
4541
4677
  const path = resolve7(projectRoot, ".glotfile", "usage.json");
4542
- if (!existsSync10(path)) return null;
4678
+ if (!existsSync11(path)) return null;
4543
4679
  try {
4544
- return JSON.parse(readFileSync11(path, "utf8"));
4680
+ return JSON.parse(readFileSync12(path, "utf8"));
4545
4681
  } catch {
4546
4682
  return null;
4547
4683
  }
@@ -4606,8 +4742,8 @@ var init_scan = __esm({
4606
4742
  });
4607
4743
 
4608
4744
  // src/server/scanner.ts
4609
- import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync12 } from "fs";
4610
- import { join as join6, extname as extname2, relative } from "path";
4745
+ import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync13 } from "fs";
4746
+ import { join as join7, extname as extname2, relative } from "path";
4611
4747
  function scannerForExt(ext) {
4612
4748
  return EXT_SCANNER[ext] ?? null;
4613
4749
  }
@@ -4829,7 +4965,7 @@ function* walkFiles(dir, root, exclude) {
4829
4965
  }
4830
4966
  for (const name of entries) {
4831
4967
  if (ALWAYS_EXCLUDE.has(name)) continue;
4832
- const abs = join6(dir, name);
4968
+ const abs = join7(dir, name);
4833
4969
  const rel = relative(root, abs);
4834
4970
  let st;
4835
4971
  try {
@@ -4859,7 +4995,7 @@ function runScan(projectRoot, opts, existing) {
4859
4995
  const ext = extname2(relPath);
4860
4996
  const scanner = scannerForExt(ext);
4861
4997
  if (!scanner) continue;
4862
- const abs = join6(projectRoot, relPath);
4998
+ const abs = join7(projectRoot, relPath);
4863
4999
  let st;
4864
5000
  try {
4865
5001
  st = statSync3(abs);
@@ -4875,7 +5011,7 @@ function runScan(projectRoot, opts, existing) {
4875
5011
  }
4876
5012
  let content;
4877
5013
  try {
4878
- content = readFileSync12(abs, "utf8");
5014
+ content = readFileSync13(abs, "utf8");
4879
5015
  } catch {
4880
5016
  continue;
4881
5017
  }
@@ -5009,8 +5145,8 @@ var init_scanner = __esm({
5009
5145
  });
5010
5146
 
5011
5147
  // src/server/import/detect.ts
5012
- import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync13, statSync as statSync4 } from "fs";
5013
- import { join as join7 } from "path";
5148
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync14, statSync as statSync4 } from "fs";
5149
+ import { join as join8 } from "path";
5014
5150
  function safeIsDir(p) {
5015
5151
  try {
5016
5152
  return statSync4(p).isDirectory();
@@ -5019,7 +5155,7 @@ function safeIsDir(p) {
5019
5155
  }
5020
5156
  }
5021
5157
  function listDirs(dir) {
5022
- return readdirSync4(dir).filter((e) => safeIsDir(join7(dir, e)));
5158
+ return readdirSync4(dir).filter((e) => safeIsDir(join8(dir, e)));
5023
5159
  }
5024
5160
  function fileCount(dir) {
5025
5161
  try {
@@ -5033,23 +5169,23 @@ function pickSource(locales, sizeOf) {
5033
5169
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
5034
5170
  }
5035
5171
  function detectLaravel(root) {
5036
- const localeRoot = [join7(root, "resources", "lang"), join7(root, "lang")].find(safeIsDir);
5172
+ const localeRoot = [join8(root, "resources", "lang"), join8(root, "lang")].find(safeIsDir);
5037
5173
  if (!localeRoot) return null;
5038
5174
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
5039
5175
  if (locales.length === 0) return null;
5040
- const sourceLocale = pickSource(locales, (loc) => fileCount(join7(localeRoot, loc)));
5176
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join8(localeRoot, loc)));
5041
5177
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
5042
5178
  }
5043
5179
  function detectVue(root, forced = false) {
5044
5180
  for (const rel of VUE_DIR_CANDIDATES) {
5045
- const localeRoot = join7(root, rel);
5181
+ const localeRoot = join8(root, rel);
5046
5182
  if (!safeIsDir(localeRoot)) continue;
5047
5183
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
5048
5184
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
5049
5185
  if (enough) {
5050
5186
  const sourceLocale = pickSource(locales, (loc) => {
5051
5187
  try {
5052
- return statSync4(join7(localeRoot, `${loc}.json`)).size;
5188
+ return statSync4(join8(localeRoot, `${loc}.json`)).size;
5053
5189
  } catch {
5054
5190
  return 0;
5055
5191
  }
@@ -5060,9 +5196,9 @@ function detectVue(root, forced = false) {
5060
5196
  return null;
5061
5197
  }
5062
5198
  function hasNextIntlSignal(root) {
5063
- if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync11(join7(root, rel)))) return true;
5199
+ if (NEXT_INTL_CONFIG_CANDIDATES.some((rel) => existsSync12(join8(root, rel)))) return true;
5064
5200
  try {
5065
- const pkg = JSON.parse(readFileSync13(join7(root, "package.json"), "utf8"));
5201
+ const pkg = JSON.parse(readFileSync14(join8(root, "package.json"), "utf8"));
5066
5202
  if (pkg.dependencies?.["next-intl"] || pkg.devDependencies?.["next-intl"]) return true;
5067
5203
  } catch {
5068
5204
  }
@@ -5071,7 +5207,7 @@ function hasNextIntlSignal(root) {
5071
5207
  function nextIntlDefaultLocale(root) {
5072
5208
  for (const rel of NEXT_INTL_ROUTING_CANDIDATES) {
5073
5209
  try {
5074
- const m = readFileSync13(join7(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
5210
+ const m = readFileSync14(join8(root, rel), "utf8").match(/defaultLocale\s*:\s*['"]([^'"]+)['"]/);
5075
5211
  if (m) return m[1];
5076
5212
  } catch {
5077
5213
  }
@@ -5081,14 +5217,14 @@ function nextIntlDefaultLocale(root) {
5081
5217
  function detectNextIntl(root, forced = false) {
5082
5218
  if (!forced && !hasNextIntlSignal(root)) return null;
5083
5219
  for (const rel of NEXT_INTL_DIR_CANDIDATES) {
5084
- const localeRoot = join7(root, rel);
5220
+ const localeRoot = join8(root, rel);
5085
5221
  if (!safeIsDir(localeRoot)) continue;
5086
5222
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
5087
5223
  if (locales.length === 0) continue;
5088
5224
  const def = nextIntlDefaultLocale(root);
5089
5225
  const sourceLocale = def && locales.includes(def) ? def : pickSource(locales, (loc) => {
5090
5226
  try {
5091
- return statSync4(join7(localeRoot, `${loc}.json`)).size;
5227
+ return statSync4(join8(localeRoot, `${loc}.json`)).size;
5092
5228
  } catch {
5093
5229
  return 0;
5094
5230
  }
@@ -5099,7 +5235,7 @@ function detectNextIntl(root, forced = false) {
5099
5235
  }
5100
5236
  function detectArb(root) {
5101
5237
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
5102
- const localeRoot = join7(root, rel);
5238
+ const localeRoot = join8(root, rel);
5103
5239
  if (!safeIsDir(localeRoot)) continue;
5104
5240
  const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
5105
5241
  if (locales.length >= 1) {
@@ -5109,10 +5245,10 @@ function detectArb(root) {
5109
5245
  return null;
5110
5246
  }
5111
5247
  function lprojLocales(dir) {
5112
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.strings")));
5248
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join8(dir, `${l}.lproj`, "Localizable.strings")));
5113
5249
  }
5114
5250
  function detectApple(root) {
5115
- const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
5251
+ const candidates = [root, ...listDirs(root).map((d) => join8(root, d))];
5116
5252
  let best = null;
5117
5253
  for (const dir of candidates) {
5118
5254
  const locales = lprojLocales(dir);
@@ -5124,7 +5260,7 @@ function detectApple(root) {
5124
5260
  locales,
5125
5261
  sourceLocale: pickSource(locales, (loc) => {
5126
5262
  try {
5127
- return statSync4(join7(dir, `${loc}.lproj`, "Localizable.strings")).size;
5263
+ return statSync4(join8(dir, `${loc}.lproj`, "Localizable.strings")).size;
5128
5264
  } catch {
5129
5265
  return 0;
5130
5266
  }
@@ -5136,7 +5272,7 @@ function detectApple(root) {
5136
5272
  }
5137
5273
  function detectAngularXliff(root) {
5138
5274
  for (const rel of ANGULAR_DIR_CANDIDATES) {
5139
- const localeRoot = rel === "." ? root : join7(root, rel);
5275
+ const localeRoot = rel === "." ? root : join8(root, rel);
5140
5276
  if (!safeIsDir(localeRoot)) continue;
5141
5277
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
5142
5278
  if (files.length === 0) continue;
@@ -5144,7 +5280,7 @@ function detectAngularXliff(root) {
5144
5280
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
5145
5281
  let sourceLocale;
5146
5282
  try {
5147
- sourceLocale = readFileSync13(join7(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5283
+ sourceLocale = readFileSync14(join8(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
5148
5284
  } catch {
5149
5285
  }
5150
5286
  if (!sourceLocale && locales.length === 0) continue;
@@ -5155,14 +5291,14 @@ function detectAngularXliff(root) {
5155
5291
  return null;
5156
5292
  }
5157
5293
  function detectRails(root) {
5158
- const localeRoot = join7(root, "config", "locales");
5294
+ const localeRoot = join8(root, "config", "locales");
5159
5295
  if (!safeIsDir(localeRoot)) return null;
5160
5296
  const locales = [];
5161
5297
  for (const file of readdirSync4(localeRoot).sort()) {
5162
5298
  if (!/\.ya?ml$/.test(file)) continue;
5163
5299
  let text;
5164
5300
  try {
5165
- text = readFileSync13(join7(localeRoot, file), "utf8");
5301
+ text = readFileSync14(join8(localeRoot, file), "utf8");
5166
5302
  } catch {
5167
5303
  continue;
5168
5304
  }
@@ -5176,15 +5312,15 @@ function detectRails(root) {
5176
5312
  }
5177
5313
  function detectI18next(root) {
5178
5314
  for (const rel of I18NEXT_DIR_CANDIDATES) {
5179
- const localeRoot = join7(root, rel);
5315
+ const localeRoot = join8(root, rel);
5180
5316
  if (!safeIsDir(localeRoot)) continue;
5181
5317
  const locales = listDirs(localeRoot).filter(
5182
- (d) => LOCALE_RE.test(d) && readdirSync4(join7(localeRoot, d)).some((f) => f.endsWith(".json"))
5318
+ (d) => LOCALE_RE.test(d) && readdirSync4(join8(localeRoot, d)).some((f) => f.endsWith(".json"))
5183
5319
  );
5184
5320
  if (locales.length === 0) continue;
5185
5321
  const sourceLocale = pickSource(locales, (loc) => {
5186
5322
  try {
5187
- return readdirSync4(join7(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join7(localeRoot, loc, f)).size, 0);
5323
+ return readdirSync4(join8(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join8(localeRoot, loc, f)).size, 0);
5188
5324
  } catch {
5189
5325
  return 0;
5190
5326
  }
@@ -5201,8 +5337,8 @@ function gettextLocales(dir) {
5201
5337
  if (!locales.includes(flat)) locales.push(flat);
5202
5338
  continue;
5203
5339
  }
5204
- if (!LOCALE_RE.test(entry) || !safeIsDir(join7(dir, entry))) continue;
5205
- const sub = join7(dir, entry);
5340
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join8(dir, entry))) continue;
5341
+ const sub = join8(dir, entry);
5206
5342
  const hasPo = (d) => {
5207
5343
  try {
5208
5344
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -5210,7 +5346,7 @@ function gettextLocales(dir) {
5210
5346
  return false;
5211
5347
  }
5212
5348
  };
5213
- if (hasPo(join7(sub, "LC_MESSAGES")) || hasPo(sub)) {
5349
+ if (hasPo(join8(sub, "LC_MESSAGES")) || hasPo(sub)) {
5214
5350
  if (!locales.includes(entry)) locales.push(entry);
5215
5351
  }
5216
5352
  }
@@ -5218,7 +5354,7 @@ function gettextLocales(dir) {
5218
5354
  }
5219
5355
  function detectGettext(root) {
5220
5356
  for (const rel of GETTEXT_DIR_CANDIDATES) {
5221
- const localeRoot = join7(root, rel);
5357
+ const localeRoot = join8(root, rel);
5222
5358
  if (!safeIsDir(localeRoot)) continue;
5223
5359
  const locales = gettextLocales(localeRoot);
5224
5360
  if (locales.length === 0) continue;
@@ -5227,10 +5363,10 @@ function detectGettext(root) {
5227
5363
  return null;
5228
5364
  }
5229
5365
  function detectAppleStringsdict(root) {
5230
- const candidates = [root, ...listDirs(root).map((d) => join7(root, d))];
5366
+ const candidates = [root, ...listDirs(root).map((d) => join8(root, d))];
5231
5367
  let best = null;
5232
5368
  for (const dir of candidates) {
5233
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join7(dir, `${l}.lproj`, "Localizable.stringsdict")));
5369
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join8(dir, `${l}.lproj`, "Localizable.stringsdict")));
5234
5370
  if (locales.length === 0) continue;
5235
5371
  if (!best || locales.length > best.locales.length) {
5236
5372
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -5239,7 +5375,7 @@ function detectAppleStringsdict(root) {
5239
5375
  return best;
5240
5376
  }
5241
5377
  function detect(root, formatOverride) {
5242
- if (!existsSync11(root)) return null;
5378
+ if (!existsSync12(root)) return null;
5243
5379
  if (formatOverride) {
5244
5380
  const fn = BY_FORMAT[formatOverride];
5245
5381
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -5317,8 +5453,8 @@ var init_flatten = __esm({
5317
5453
  });
5318
5454
 
5319
5455
  // src/server/import/parsers/vue-i18n-json.ts
5320
- import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
5321
- import { join as join8 } from "path";
5456
+ import { readdirSync as readdirSync5, readFileSync as readFileSync15 } from "fs";
5457
+ import { join as join9 } from "path";
5322
5458
  function fromVueI18n(value) {
5323
5459
  return value.replace(/\{'([^']*)'\}/g, "'$1'");
5324
5460
  }
@@ -5341,7 +5477,7 @@ var init_vue_i18n_json2 = __esm({
5341
5477
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5342
5478
  let data;
5343
5479
  try {
5344
- data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
5480
+ data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
5345
5481
  } catch (e) {
5346
5482
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
5347
5483
  continue;
@@ -5358,8 +5494,8 @@ var init_vue_i18n_json2 = __esm({
5358
5494
  });
5359
5495
 
5360
5496
  // src/server/import/parsers/next-intl-json.ts
5361
- import { readdirSync as readdirSync6, readFileSync as readFileSync15 } from "fs";
5362
- import { join as join9 } from "path";
5497
+ import { readdirSync as readdirSync6, readFileSync as readFileSync16 } from "fs";
5498
+ import { join as join10 } from "path";
5363
5499
  var LOCALE_RE3, nextIntlJson2;
5364
5500
  var init_next_intl_json2 = __esm({
5365
5501
  "src/server/import/parsers/next-intl-json.ts"() {
@@ -5379,7 +5515,7 @@ var init_next_intl_json2 = __esm({
5379
5515
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5380
5516
  let data;
5381
5517
  try {
5382
- data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
5518
+ data = JSON.parse(readFileSync16(join10(localeRoot, file), "utf8"));
5383
5519
  } catch (e) {
5384
5520
  warnings.push(`next-intl-json: failed to parse ${file}: ${e.message}`);
5385
5521
  continue;
@@ -5413,16 +5549,16 @@ var init_placeholders2 = __esm({
5413
5549
 
5414
5550
  // src/server/import/parsers/laravel-php.ts
5415
5551
  import { readdirSync as readdirSync7, statSync as statSync5 } from "fs";
5416
- import { join as join10, relative as relative2 } from "path";
5552
+ import { join as join11, relative as relative2 } from "path";
5417
5553
  import { execFileSync } from "child_process";
5418
5554
  function listDirs2(dir) {
5419
- return readdirSync7(dir).filter((e) => statSync5(join10(dir, e)).isDirectory());
5555
+ return readdirSync7(dir).filter((e) => statSync5(join11(dir, e)).isDirectory());
5420
5556
  }
5421
5557
  function listPhpFiles(dir) {
5422
5558
  const out = [];
5423
5559
  const walk = (d) => {
5424
5560
  for (const e of readdirSync7(d)) {
5425
- const full = join10(d, e);
5561
+ const full = join11(d, e);
5426
5562
  if (statSync5(full).isDirectory()) walk(full);
5427
5563
  else if (e.endsWith(".php")) out.push(full);
5428
5564
  }
@@ -5465,7 +5601,7 @@ var init_laravel_php2 = __esm({
5465
5601
  for (const locale of listDirs2(localeRoot).sort()) {
5466
5602
  if (locale === "vendor") continue;
5467
5603
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5468
- const localeDir = join10(localeRoot, locale);
5604
+ const localeDir = join11(localeRoot, locale);
5469
5605
  locales.push(locale);
5470
5606
  for (const file of listPhpFiles(localeDir)) {
5471
5607
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -5490,8 +5626,8 @@ var init_laravel_php2 = __esm({
5490
5626
  });
5491
5627
 
5492
5628
  // src/server/import/parsers/flutter-arb.ts
5493
- import { readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
5494
- import { join as join11 } from "path";
5629
+ import { readdirSync as readdirSync8, readFileSync as readFileSync17 } from "fs";
5630
+ import { join as join12 } from "path";
5495
5631
  function localeFromArbName(file) {
5496
5632
  const m = file.match(/^(.+)\.arb$/);
5497
5633
  if (!m) return null;
@@ -5531,7 +5667,7 @@ var init_flutter_arb2 = __esm({
5531
5667
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5532
5668
  let data;
5533
5669
  try {
5534
- data = JSON.parse(readFileSync16(join11(localeRoot, file), "utf8"));
5670
+ data = JSON.parse(readFileSync17(join12(localeRoot, file), "utf8"));
5535
5671
  } catch (e) {
5536
5672
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
5537
5673
  continue;
@@ -5558,8 +5694,8 @@ var init_flutter_arb2 = __esm({
5558
5694
  });
5559
5695
 
5560
5696
  // src/server/import/parsers/apple-strings.ts
5561
- import { readdirSync as readdirSync9, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
5562
- import { join as join12 } from "path";
5697
+ import { readdirSync as readdirSync9, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5698
+ import { join as join13 } from "path";
5563
5699
  function localeFromLproj(dir) {
5564
5700
  const m = dir.match(/^(.+)\.lproj$/);
5565
5701
  if (!m) return null;
@@ -5679,16 +5815,16 @@ var init_apple_strings2 = __esm({
5679
5815
  const locale = localeFromLproj(dir);
5680
5816
  if (!locale) continue;
5681
5817
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5682
- const file = join12(localeRoot, dir, TABLE);
5818
+ const file = join13(localeRoot, dir, TABLE);
5683
5819
  let text;
5684
5820
  try {
5685
5821
  if (!statSync6(file).isFile()) continue;
5686
- text = readFileSync17(file, "utf8");
5822
+ text = readFileSync18(file, "utf8");
5687
5823
  } catch {
5688
5824
  continue;
5689
5825
  }
5690
5826
  locales.push(locale);
5691
- const others = readdirSync9(join12(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5827
+ const others = readdirSync9(join13(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
5692
5828
  if (others.length) {
5693
5829
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
5694
5830
  }
@@ -5703,8 +5839,8 @@ var init_apple_strings2 = __esm({
5703
5839
  });
5704
5840
 
5705
5841
  // src/server/import/parsers/angular-xliff.ts
5706
- import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5707
- import { join as join13 } from "path";
5842
+ import { readdirSync as readdirSync10, readFileSync as readFileSync19 } from "fs";
5843
+ import { join as join14 } from "path";
5708
5844
  function decodeEntities(s) {
5709
5845
  return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5710
5846
  }
@@ -5775,7 +5911,7 @@ var init_angular_xliff2 = __esm({
5775
5911
  if (fnameLocale !== void 0 && !LOCALE_RE6.test(fnameLocale)) continue;
5776
5912
  let xml;
5777
5913
  try {
5778
- xml = readFileSync18(join13(localeRoot, file), "utf8");
5914
+ xml = readFileSync19(join14(localeRoot, file), "utf8");
5779
5915
  } catch (e) {
5780
5916
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5781
5917
  continue;
@@ -5822,8 +5958,8 @@ var init_angular_xliff2 = __esm({
5822
5958
  });
5823
5959
 
5824
5960
  // src/server/import/parsers/gettext-po.ts
5825
- import { readdirSync as readdirSync11, readFileSync as readFileSync19 } from "fs";
5826
- import { join as join14 } from "path";
5961
+ import { readdirSync as readdirSync11, readFileSync as readFileSync20 } from "fs";
5962
+ import { join as join15 } from "path";
5827
5963
  function unescapePo(s) {
5828
5964
  return s.replace(
5829
5965
  /\\([\\"ntr])/g,
@@ -5912,17 +6048,17 @@ function discoverPoFiles(root) {
5912
6048
  for (const e of entries) {
5913
6049
  if (e.isFile() && e.name.endsWith(".po")) {
5914
6050
  const base = e.name.slice(0, -3);
5915
- found.push({ path: join14(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
6051
+ found.push({ path: join15(root, e.name), rel: e.name, locale: LOCALE_RE7.test(base) ? base : null });
5916
6052
  } else if (e.isDirectory() && LOCALE_RE7.test(e.name)) {
5917
- for (const sub of [join14(e.name, "LC_MESSAGES"), e.name]) {
6053
+ for (const sub of [join15(e.name, "LC_MESSAGES"), e.name]) {
5918
6054
  let names;
5919
6055
  try {
5920
- names = readdirSync11(join14(root, sub)).sort();
6056
+ names = readdirSync11(join15(root, sub)).sort();
5921
6057
  } catch {
5922
6058
  continue;
5923
6059
  }
5924
6060
  for (const f of names) {
5925
- if (f.endsWith(".po")) found.push({ path: join14(root, sub, f), rel: join14(sub, f), locale: e.name });
6061
+ if (f.endsWith(".po")) found.push({ path: join15(root, sub, f), rel: join15(sub, f), locale: e.name });
5926
6062
  }
5927
6063
  }
5928
6064
  }
@@ -5946,7 +6082,7 @@ var init_gettext_po2 = __esm({
5946
6082
  for (const file of discoverPoFiles(localeRoot)) {
5947
6083
  let entries;
5948
6084
  try {
5949
- entries = parseEntries(readFileSync19(file.path, "utf8"));
6085
+ entries = parseEntries(readFileSync20(file.path, "utf8"));
5950
6086
  } catch (e) {
5951
6087
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5952
6088
  continue;
@@ -5993,8 +6129,8 @@ var init_gettext_po2 = __esm({
5993
6129
  });
5994
6130
 
5995
6131
  // src/server/import/parsers/i18next-json.ts
5996
- import { readdirSync as readdirSync12, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5997
- import { join as join15 } from "path";
6132
+ import { readdirSync as readdirSync12, readFileSync as readFileSync21, statSync as statSync7 } from "fs";
6133
+ import { join as join16 } from "path";
5998
6134
  function safeIsDir2(p) {
5999
6135
  try {
6000
6136
  return statSync7(p).isDirectory();
@@ -6009,7 +6145,7 @@ function fromI18next(value) {
6009
6145
  function ingestFile(path, label, prefix, locale, keys, warnings) {
6010
6146
  let data;
6011
6147
  try {
6012
- data = JSON.parse(readFileSync20(path, "utf8"));
6148
+ data = JSON.parse(readFileSync21(path, "utf8"));
6013
6149
  } catch (e) {
6014
6150
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
6015
6151
  return false;
@@ -6062,7 +6198,7 @@ var init_i18next_json2 = __esm({
6062
6198
  const keys = {};
6063
6199
  const locales = [];
6064
6200
  for (const entry of readdirSync12(localeRoot).sort()) {
6065
- const full = join15(localeRoot, entry);
6201
+ const full = join16(localeRoot, entry);
6066
6202
  if (safeIsDir2(full)) {
6067
6203
  if (!LOCALE_RE8.test(entry)) continue;
6068
6204
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -6071,7 +6207,7 @@ var init_i18next_json2 = __esm({
6071
6207
  if (!file.endsWith(".json")) continue;
6072
6208
  const ns = file.slice(0, -".json".length);
6073
6209
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
6074
- if (ingestFile(join15(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
6210
+ if (ingestFile(join16(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
6075
6211
  }
6076
6212
  if (any && !locales.includes(entry)) locales.push(entry);
6077
6213
  } else if (entry.endsWith(".json")) {
@@ -6090,8 +6226,8 @@ var init_i18next_json2 = __esm({
6090
6226
  });
6091
6227
 
6092
6228
  // src/server/import/parsers/rails-yaml.ts
6093
- import { readdirSync as readdirSync13, readFileSync as readFileSync21 } from "fs";
6094
- import { join as join16 } from "path";
6229
+ import { readdirSync as readdirSync13, readFileSync as readFileSync22 } from "fs";
6230
+ import { join as join17 } from "path";
6095
6231
  function makeNode() {
6096
6232
  return /* @__PURE__ */ Object.create(null);
6097
6233
  }
@@ -6315,7 +6451,7 @@ var init_rails_yaml2 = __esm({
6315
6451
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
6316
6452
  let text;
6317
6453
  try {
6318
- text = readFileSync21(join16(localeRoot, file), "utf8");
6454
+ text = readFileSync22(join17(localeRoot, file), "utf8");
6319
6455
  } catch (e) {
6320
6456
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
6321
6457
  continue;
@@ -6338,8 +6474,8 @@ var init_rails_yaml2 = __esm({
6338
6474
  });
6339
6475
 
6340
6476
  // src/server/import/parsers/apple-stringsdict.ts
6341
- import { readdirSync as readdirSync14, readFileSync as readFileSync22, statSync as statSync8 } from "fs";
6342
- import { join as join17 } from "path";
6477
+ import { readdirSync as readdirSync14, readFileSync as readFileSync23, statSync as statSync8 } from "fs";
6478
+ import { join as join18 } from "path";
6343
6479
  function localeFromLproj2(dir) {
6344
6480
  const m = dir.match(/^(.+)\.lproj$/);
6345
6481
  if (!m) return null;
@@ -6499,16 +6635,16 @@ var init_apple_stringsdict2 = __esm({
6499
6635
  const locale = localeFromLproj2(dir);
6500
6636
  if (!locale) continue;
6501
6637
  if (opts?.locales && !opts.locales.includes(locale)) continue;
6502
- const file = join17(localeRoot, dir, TABLE2);
6638
+ const file = join18(localeRoot, dir, TABLE2);
6503
6639
  let text;
6504
6640
  try {
6505
6641
  if (!statSync8(file).isFile()) continue;
6506
- text = readFileSync22(file, "utf8");
6642
+ text = readFileSync23(file, "utf8");
6507
6643
  } catch {
6508
6644
  continue;
6509
6645
  }
6510
6646
  locales.push(locale);
6511
- const others = readdirSync14(join17(localeRoot, dir)).filter(
6647
+ const others = readdirSync14(join18(localeRoot, dir)).filter(
6512
6648
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
6513
6649
  );
6514
6650
  if (others.length) {
@@ -6982,7 +7118,7 @@ var init_run2 = __esm({
6982
7118
  });
6983
7119
 
6984
7120
  // src/server/lint/outputs.ts
6985
- import { readFileSync as readFileSync23, existsSync as existsSync12 } from "fs";
7121
+ import { readFileSync as readFileSync24, existsSync as existsSync13 } from "fs";
6986
7122
  import { resolve as resolve8 } from "path";
6987
7123
  function checkOutputs(state, root) {
6988
7124
  const out = [];
@@ -6990,7 +7126,7 @@ function checkOutputs(state, root) {
6990
7126
  const result = getAdapter(output.adapter).export(state, output);
6991
7127
  for (const file of result.files) {
6992
7128
  const abs = resolve8(root, file.path);
6993
- const current = existsSync12(abs) ? readFileSync23(abs, "utf8") : null;
7129
+ const current = existsSync13(abs) ? readFileSync24(abs, "utf8") : null;
6994
7130
  if (current === null) {
6995
7131
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
6996
7132
  } else if (current !== file.contents) {
@@ -7493,12 +7629,12 @@ var init_explain_error = __esm({
7493
7629
  });
7494
7630
 
7495
7631
  // src/server/ui-prefs.ts
7496
- import { readFileSync as readFileSync24 } from "fs";
7632
+ import { readFileSync as readFileSync25 } from "fs";
7497
7633
  import { homedir as homedir2 } from "os";
7498
- import { join as join18 } from "path";
7634
+ import { join as join19 } from "path";
7499
7635
  function readJson2(path) {
7500
7636
  try {
7501
- const parsed = JSON.parse(readFileSync24(path, "utf8"));
7637
+ const parsed = JSON.parse(readFileSync25(path, "utf8"));
7502
7638
  return parsed && typeof parsed === "object" ? parsed : {};
7503
7639
  } catch {
7504
7640
  return {};
@@ -7523,7 +7659,7 @@ var init_ui_prefs = __esm({
7523
7659
  THEMES = ["system", "light", "dark"];
7524
7660
  isThemeMode = (v) => THEMES.includes(v);
7525
7661
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
7526
- defaultUiPrefsPath = () => join18(homedir2(), ".glotfile", "ui.json");
7662
+ defaultUiPrefsPath = () => join19(homedir2(), ".glotfile", "ui.json");
7527
7663
  DEFAULTS = { theme: "system" };
7528
7664
  }
7529
7665
  });
@@ -7557,7 +7693,7 @@ var init_events = __esm({
7557
7693
 
7558
7694
  // src/server/watch.ts
7559
7695
  import { statSync as statSync9, readdirSync as readdirSync15 } from "fs";
7560
- import { join as join19 } from "path";
7696
+ import { join as join20 } from "path";
7561
7697
  import { createHash as createHash2 } from "crypto";
7562
7698
  function hashState(state) {
7563
7699
  return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
@@ -7573,15 +7709,15 @@ function signature(statePath) {
7573
7709
  const parts = [];
7574
7710
  for (const rel of ["config.json", "keys.json"]) {
7575
7711
  try {
7576
- const s = statSync9(join19(dir, rel));
7712
+ const s = statSync9(join20(dir, rel));
7577
7713
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
7578
7714
  } catch {
7579
7715
  }
7580
7716
  }
7581
7717
  try {
7582
- for (const name of readdirSync15(join19(dir, "locales")).sort()) {
7718
+ for (const name of readdirSync15(join20(dir, "locales")).sort()) {
7583
7719
  if (!name.endsWith(".json")) continue;
7584
- const s = statSync9(join19(dir, "locales", name));
7720
+ const s = statSync9(join20(dir, "locales", name));
7585
7721
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
7586
7722
  }
7587
7723
  } catch {
@@ -7660,13 +7796,13 @@ var init_watch = __esm({
7660
7796
  // src/server/api.ts
7661
7797
  import { Hono } from "hono";
7662
7798
  import { streamSSE } from "hono/streaming";
7663
- import { readFileSync as readFileSync25, existsSync as existsSync13, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync6 } from "fs";
7799
+ import { readFileSync as readFileSync26, existsSync as existsSync14, readdirSync as readdirSync16, statSync as statSync10, rmSync as rmSync7 } from "fs";
7664
7800
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
7665
7801
  function projectName(root) {
7666
7802
  const nameFile = resolve9(root, ".idea", ".name");
7667
- if (existsSync13(nameFile)) {
7803
+ if (existsSync14(nameFile)) {
7668
7804
  try {
7669
- const name = readFileSync25(nameFile, "utf8").trim();
7805
+ const name = readFileSync26(nameFile, "utf8").trim();
7670
7806
  if (name) return name;
7671
7807
  } catch {
7672
7808
  }
@@ -7880,7 +8016,7 @@ function createApi(deps) {
7880
8016
  if (name.startsWith(".") || name === "node_modules") continue;
7881
8017
  const abs = resolve9(dir, name);
7882
8018
  let filePath = null;
7883
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync13(resolve9(abs, "config.json"))) {
8019
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync14(resolve9(abs, "config.json"))) {
7884
8020
  filePath = resolve9(dir, `${name}.json`);
7885
8021
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
7886
8022
  filePath = abs;
@@ -7914,7 +8050,7 @@ function createApi(deps) {
7914
8050
  const resolved = resolve9(projectRoot, path);
7915
8051
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
7916
8052
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
7917
- if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
8053
+ if (!existsSync14(resolved)) return c.json({ error: "file not found" }, 400);
7918
8054
  loadState(resolved);
7919
8055
  deps.statePath = resolved;
7920
8056
  watcher.retarget(resolved);
@@ -7976,9 +8112,9 @@ function createApi(deps) {
7976
8112
  const abs = resolve9(root, screenshot);
7977
8113
  const rel = relative4(root, abs);
7978
8114
  const seg0 = rel.split(sep2)[0] ?? "";
7979
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync13(abs)) {
8115
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync14(abs)) {
7980
8116
  try {
7981
- rmSync6(abs);
8117
+ rmSync7(abs);
7982
8118
  } catch {
7983
8119
  }
7984
8120
  }
@@ -8307,6 +8443,93 @@ function createApi(deps) {
8307
8443
  await stream.writeSSE({ event: "done", data: JSON.stringify({ added: added.length, terms: added }) });
8308
8444
  });
8309
8445
  });
8446
+ app.post("/glossary/suggest/estimate", async (c) => {
8447
+ const body = await c.req.json().catch(() => ({}));
8448
+ const s = load();
8449
+ const sources = selectGlossarySources(s, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8450
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8451
+ return c.json(estimateGlossarySuggest(sources, knownTermList(s), aiCfg));
8452
+ });
8453
+ app.get("/glossary/suggest/batch/status", async (c) => {
8454
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8455
+ let supported = false;
8456
+ let provider;
8457
+ try {
8458
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8459
+ supported = supportsBatchComplete(provider);
8460
+ } catch {
8461
+ }
8462
+ const pending = loadPendingGlossaryBatch(projectRoot);
8463
+ if (!pending) return c.json({ supported, pending: null });
8464
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
8465
+ if (!provider || !supportsBatchComplete(provider)) {
8466
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
8467
+ }
8468
+ try {
8469
+ const status = await provider.translationBatchStatus(pending.batchId);
8470
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
8471
+ } catch (e) {
8472
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
8473
+ }
8474
+ });
8475
+ app.post("/glossary/suggest/batch", (c) => withTranslateLock(async () => {
8476
+ const body = await c.req.json().catch(() => ({}));
8477
+ const s = load();
8478
+ const sources = selectGlossarySources(s, { keyGlob: body.keyGlob, limit: body.limit, since: body.since });
8479
+ if (!sources.length) return c.json({ error: "No source strings to scan." }, 400);
8480
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8481
+ let provider;
8482
+ try {
8483
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8484
+ } catch (e) {
8485
+ return c.json({ error: e.message }, 400);
8486
+ }
8487
+ if (!supportsBatchComplete(provider)) {
8488
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
8489
+ }
8490
+ const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
8491
+ let pending;
8492
+ try {
8493
+ pending = await submitGlossarySuggestBatch(provider, sources, knownTermList(s), batchSize, aiCfg.model, projectRoot);
8494
+ } catch (e) {
8495
+ return c.json({ error: e.message }, 409);
8496
+ }
8497
+ appendLog(projectRoot, {
8498
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8499
+ kind: "glossary",
8500
+ summary: `Submitted glossary suggestion batch ${pending.batchId} (${pending.total} sources)`,
8501
+ model: aiCfg.model
8502
+ });
8503
+ return c.json({ batchId: pending.batchId, total: pending.total });
8504
+ }));
8505
+ app.post("/glossary/suggest/batch/apply", (c) => withTranslateLock(async () => {
8506
+ const pending = loadPendingGlossaryBatch(projectRoot);
8507
+ if (!pending) return c.json({ error: "No pending glossary suggestion batch." }, 404);
8508
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8509
+ let provider;
8510
+ try {
8511
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8512
+ } catch (e) {
8513
+ return c.json({ error: e.message }, 400);
8514
+ }
8515
+ if (!supportsBatchComplete(provider)) {
8516
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
8517
+ }
8518
+ const outcome = await applyGlossarySuggestBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
8519
+ return c.json(outcome);
8520
+ }));
8521
+ app.post("/glossary/suggest/batch/cancel", async (c) => {
8522
+ const pending = loadPendingGlossaryBatch(projectRoot);
8523
+ if (!pending) return c.json({ error: "No pending glossary suggestion batch." }, 404);
8524
+ const aiCfg = loadLocalSettings(projectRoot).ai;
8525
+ try {
8526
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
8527
+ if (supportsBatchComplete(provider)) await provider.cancelTranslationBatch(pending.batchId);
8528
+ } catch {
8529
+ }
8530
+ clearPendingGlossaryBatch(projectRoot);
8531
+ return c.json({ canceled: pending.batchId });
8532
+ });
8310
8533
  app.post("/keys/:key/screenshot", async (c) => {
8311
8534
  const key = c.req.param("key");
8312
8535
  const body = await c.req.parseBody();
@@ -9026,6 +9249,8 @@ var init_api = __esm({
9026
9249
  init_pending_batch();
9027
9250
  init_context_batch_run();
9028
9251
  init_pending_context_batch();
9252
+ init_glossary_batch_run();
9253
+ init_pending_glossary_batch();
9029
9254
  init_estimate();
9030
9255
  init_pricing();
9031
9256
  init_price_fetch();
@@ -9054,7 +9279,7 @@ __export(server_exports, {
9054
9279
  import { Hono as Hono2 } from "hono";
9055
9280
  import { serve } from "@hono/node-server";
9056
9281
  import { fileURLToPath } from "url";
9057
- import { dirname as dirname4, join as join20, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
9282
+ import { dirname as dirname4, join as join21, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
9058
9283
  import { readFile, stat } from "fs/promises";
9059
9284
  import { createServer } from "net";
9060
9285
  import open from "open";
@@ -9110,7 +9335,7 @@ function buildApp(opts) {
9110
9335
  const file = await readFileResponse(target);
9111
9336
  if (file) return file;
9112
9337
  }
9113
- const index = await readFileResponse(join20(root, "index.html"));
9338
+ const index = await readFileResponse(join21(root, "index.html"));
9114
9339
  if (index) return index;
9115
9340
  return c.notFound();
9116
9341
  });
@@ -9180,7 +9405,7 @@ var init_server = __esm({
9180
9405
  init_scanner();
9181
9406
  init_usage();
9182
9407
  here = dirname4(fileURLToPath(import.meta.url));
9183
- DEFAULT_UI_DIR = join20(here, "..", "ui");
9408
+ DEFAULT_UI_DIR = join21(here, "..", "ui");
9184
9409
  MIME = {
9185
9410
  ".html": "text/html; charset=utf-8",
9186
9411
  ".js": "text/javascript; charset=utf-8",
@@ -9212,8 +9437,8 @@ var init_server = __esm({
9212
9437
  // src/server/cli.ts
9213
9438
  init_state();
9214
9439
  init_stats();
9215
- import { resolve as resolve11, dirname as dirname5, join as join21, basename as basename2 } from "path";
9216
- import { readFileSync as readFileSync26, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
9440
+ import { resolve as resolve11, dirname as dirname5, join as join22, basename as basename2 } from "path";
9441
+ import { readFileSync as readFileSync27, existsSync as existsSync15, mkdirSync as mkdirSync7, cpSync } from "fs";
9217
9442
  import { fileURLToPath as fileURLToPath2 } from "url";
9218
9443
 
9219
9444
  // src/server/agent-cli.ts
@@ -9344,6 +9569,8 @@ init_batch_run();
9344
9569
  init_pending_batch();
9345
9570
  init_context_batch_run();
9346
9571
  init_pending_context_batch();
9572
+ init_glossary_batch_run();
9573
+ init_pending_glossary_batch();
9347
9574
  init_estimate();
9348
9575
  init_glossary_suggest();
9349
9576
  init_pricing();
@@ -9604,7 +9831,7 @@ function translateSelection(args) {
9604
9831
  }
9605
9832
  function readStdin() {
9606
9833
  try {
9607
- return readFileSync26(0, "utf8");
9834
+ return readFileSync27(0, "utf8");
9608
9835
  } catch {
9609
9836
  return "";
9610
9837
  }
@@ -9777,13 +10004,15 @@ async function runBatch(args) {
9777
10004
  const projectRoot = dirname5(resolve11(args.statePath));
9778
10005
  const pending = loadPendingBatch(projectRoot);
9779
10006
  const ctxPending = loadPendingContextBatch(projectRoot);
9780
- if (!pending && !ctxPending) {
9781
- console.log("No pending batch. Start one with `glotfile translate --batch` or `glotfile build-context --batch`.");
10007
+ const glossPending = loadPendingGlossaryBatch(projectRoot);
10008
+ if (!pending && !ctxPending && !glossPending) {
10009
+ console.log("No pending batch. Start one with `glotfile translate --batch`, `glotfile build-context --batch`, or `glotfile suggest-glossary --batch`.");
9782
10010
  return;
9783
10011
  }
9784
10012
  const action = args.batchAction ?? "status";
9785
10013
  if (pending) await runTranslationBatchAction(args, pending, action, projectRoot);
9786
10014
  if (ctxPending) await runContextBatchAction(args, ctxPending, action, projectRoot);
10015
+ if (glossPending) await runGlossaryBatchAction(args, glossPending, action, projectRoot);
9787
10016
  }
9788
10017
  async function runTranslationBatchAction(args, pending, action, projectRoot) {
9789
10018
  if (action === "cancel") {
@@ -9869,18 +10098,65 @@ async function runContextBatchAction(args, pending, action, projectRoot) {
9869
10098
  if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
9870
10099
  for (const e of outcome.errors) console.warn(`skip ${e.key}: ${e.error}`);
9871
10100
  }
10101
+ async function runGlossaryBatchAction(args, pending, action, projectRoot) {
10102
+ if (action === "cancel") {
10103
+ let remoteFailed = false;
10104
+ try {
10105
+ const ai2 = loadLocalSettings(projectRoot).ai;
10106
+ const provider2 = makeProvider(ai2);
10107
+ if (supportsBatchComplete(provider2)) {
10108
+ await provider2.cancelTranslationBatch(pending.batchId);
10109
+ } else {
10110
+ remoteFailed = true;
10111
+ }
10112
+ } catch {
10113
+ remoteFailed = true;
10114
+ }
10115
+ clearPendingGlossaryBatch(projectRoot);
10116
+ const suffix = remoteFailed ? " (remote cancel failed \u2014 it will expire server-side)" : "";
10117
+ console.log(`Canceled glossary suggestion batch ${pending.batchId}.${suffix}`);
10118
+ return;
10119
+ }
10120
+ const ai = loadLocalSettings(projectRoot).ai;
10121
+ const provider = makeProviderOrExit(ai);
10122
+ if (!provider) return;
10123
+ if (!supportsBatchComplete(provider)) {
10124
+ console.error(`Pending glossary batch was submitted via anthropic, but the configured provider "${ai.provider}" has no batch support.`);
10125
+ process.exitCode = 1;
10126
+ return;
10127
+ }
10128
+ const status = await provider.translationBatchStatus(pending.batchId);
10129
+ const c = status.counts;
10130
+ console.log(`Glossary suggestion batch ${pending.batchId} (${pending.total} source(s), submitted ${pending.createdAt})`);
10131
+ console.log(` ${status.status} \u2014 ${c.succeeded} succeeded, ${c.processing} processing, ${c.errored} errored, ${c.expired} expired, ${c.canceled} canceled`);
10132
+ if (status.status !== "ended") {
10133
+ if (action === "apply") console.log("Not finished yet \u2014 try again later.");
10134
+ return;
10135
+ }
10136
+ const outcome = await applyGlossarySuggestBatchResults(
10137
+ () => loadState(args.statePath),
10138
+ (s) => saveState(args.statePath, s),
10139
+ provider,
10140
+ pending,
10141
+ projectRoot,
10142
+ ai
10143
+ );
10144
+ console.log(`Found ${outcome.added} new candidate term(s).`);
10145
+ if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
10146
+ for (const e of outcome.errors) console.warn(`batch job failed: ${e.error}`);
10147
+ }
9872
10148
  function sarifContextFor(statePath) {
9873
10149
  if (detectFormat(statePath) === "split") {
9874
10150
  const dir = splitDirFor(statePath);
9875
- const keysPath = join21(dir, "keys.json");
10151
+ const keysPath = join22(dir, "keys.json");
9876
10152
  return {
9877
10153
  keysUri: `${basename2(dir)}/keys.json`,
9878
- keysRawText: existsSync14(keysPath) ? readFileSync26(keysPath, "utf8") : ""
10154
+ keysRawText: existsSync15(keysPath) ? readFileSync27(keysPath, "utf8") : ""
9879
10155
  };
9880
10156
  }
9881
10157
  return {
9882
10158
  keysUri: basename2(statePath),
9883
- keysRawText: existsSync14(statePath) ? readFileSync26(statePath, "utf8") : ""
10159
+ keysRawText: existsSync15(statePath) ? readFileSync27(statePath, "utf8") : ""
9884
10160
  };
9885
10161
  }
9886
10162
  function printReport(report, format, statePath) {
@@ -9949,7 +10225,7 @@ async function runImportCmd(args) {
9949
10225
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
9950
10226
  const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
9951
10227
  const out = resolve11(projectRoot, "glotfile.json");
9952
- if (existsSync14(out) && !args.importForce) {
10228
+ if (existsSync15(out) && !args.importForce) {
9953
10229
  console.error(`${out} already exists; pass --force to overwrite`);
9954
10230
  process.exitCode = 1;
9955
10231
  return;
@@ -10155,6 +10431,31 @@ async function runSuggestGlossary(args) {
10155
10431
  const system = buildGlossarySuggestSystemPrompt();
10156
10432
  const batchSize = aiCfg.contextBatchSize ?? aiCfg.batchSize ?? 10;
10157
10433
  const concurrency = aiCfg.contextConcurrency ?? aiCfg.concurrency ?? 3;
10434
+ if (args.batch) {
10435
+ if (!supportsBatchComplete(provider)) {
10436
+ console.error(`Provider "${aiCfg.provider}" does not support batch mode. Currently anthropic only.`);
10437
+ process.exitCode = 1;
10438
+ return;
10439
+ }
10440
+ let pending;
10441
+ try {
10442
+ pending = await submitGlossarySuggestBatch(provider, sources, known, batchSize, aiCfg.model, projectRoot);
10443
+ } catch (e) {
10444
+ console.error(e.message);
10445
+ process.exitCode = 1;
10446
+ return;
10447
+ }
10448
+ appendLog(projectRoot, {
10449
+ at: (/* @__PURE__ */ new Date()).toISOString(),
10450
+ kind: "glossary",
10451
+ summary: `Submitted glossary suggestion batch ${pending.batchId} (${pending.total} sources)`,
10452
+ model: aiCfg.model,
10453
+ system
10454
+ });
10455
+ console.log(`Submitted glossary suggestion batch ${pending.batchId} \u2014 ${pending.total} source string(s) at 50% batch pricing.`);
10456
+ console.log("Check progress with `glotfile batch`; it applies results automatically when finished.");
10457
+ return;
10458
+ }
10158
10459
  const chunks = [];
10159
10460
  for (let i = 0; i < sources.length; i += batchSize) chunks.push(sources.slice(i, i + batchSize));
10160
10461
  const all = [];
@@ -10259,19 +10560,19 @@ function runSplit(args) {
10259
10560
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
10260
10561
  );
10261
10562
  }
10262
- var SKILL_SRC = join21(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
10563
+ var SKILL_SRC = join22(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
10263
10564
  function runSkill(args) {
10264
10565
  if (args.print) {
10265
- console.log(readFileSync26(join21(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
10566
+ console.log(readFileSync27(join22(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
10266
10567
  return;
10267
10568
  }
10268
10569
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
10269
- if (existsSync14(dest) && !args.importForce) {
10570
+ if (existsSync15(dest) && !args.importForce) {
10270
10571
  console.error(`${dest} already exists; pass --force to overwrite`);
10271
10572
  process.exitCode = 1;
10272
10573
  return;
10273
10574
  }
10274
- mkdirSync6(dirname5(dest), { recursive: true });
10575
+ mkdirSync7(dirname5(dest), { recursive: true });
10275
10576
  cpSync(SKILL_SRC, dest, { recursive: true });
10276
10577
  console.log(`Installed the glotfile skill to ${dest}. Restart Claude Code to pick it up.`);
10277
10578
  }
@@ -10564,12 +10865,13 @@ var COMMAND_HELP = {
10564
10865
  },
10565
10866
  "suggest-glossary": {
10566
10867
  summary: "AI-scan source strings for candidate glossary terms (adds a review queue; existing terms are skipped).",
10567
- usage: "glotfile suggest-glossary [--key <glob>] [--limit <n>] [--since <date>] [--estimate]",
10868
+ usage: "glotfile suggest-glossary [--key <glob>] [--limit <n>] [--since <date>] [--estimate] [--batch]",
10568
10869
  options: [
10569
10870
  ["--key <glob>", "Only scan keys matching this glob"],
10570
10871
  ["--limit <n>", "Scan at most n source strings"],
10571
10872
  ["--since <date>", "Only keys added since this date"],
10572
- ["--estimate", "Print batches, tokens and estimated cost without scanning"]
10873
+ ["--estimate", "Print batches, tokens and estimated cost without scanning"],
10874
+ ["--batch", "Submit via the provider's batch API (50% cost, async; anthropic only)"]
10573
10875
  ]
10574
10876
  },
10575
10877
  scan: {
@@ -10704,8 +11006,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
10704
11006
  );
10705
11007
  }
10706
11008
  function printVersion() {
10707
- const pkgPath = join21(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
10708
- console.log(JSON.parse(readFileSync26(pkgPath, "utf8")).version);
11009
+ const pkgPath = join22(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
11010
+ console.log(JSON.parse(readFileSync27(pkgPath, "utf8")).version);
10709
11011
  }
10710
11012
  async function main(argv) {
10711
11013
  const args = parseArgs(argv);