glotfile 0.6.4 → 0.7.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.
@@ -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 join15, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5
+ import { dirname as dirname4, join as join16, 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";
@@ -2841,13 +2841,16 @@ function checkOutputs(state, root) {
2841
2841
  }
2842
2842
 
2843
2843
  // src/server/api.ts
2844
- import { readFileSync as readFileSync21, existsSync as existsSync11, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync4 } from "fs";
2844
+ import { readFileSync as readFileSync22, existsSync as existsSync12, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync5 } from "fs";
2845
2845
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
2846
2846
 
2847
2847
  // src/server/ai/anthropic.ts
2848
2848
  import Anthropic from "@anthropic-ai/sdk";
2849
2849
 
2850
2850
  // src/server/ai/provider.ts
2851
+ function supportsBatchTranslate(p) {
2852
+ return typeof p.submitTranslationBatch === "function";
2853
+ }
2851
2854
  function buildSystemPrompt(hasPluralItems) {
2852
2855
  const lines = [
2853
2856
  "You are a professional software localization engine for a UI string catalog.",
@@ -3136,6 +3139,53 @@ var AnthropicProvider = class {
3136
3139
  return {};
3137
3140
  }
3138
3141
  }
3142
+ batchesClient() {
3143
+ const b = this.client.messages.batches;
3144
+ if (!b) throw new Error("Anthropic client has no batches support.");
3145
+ return b;
3146
+ }
3147
+ // Each job becomes one batch entry whose params mirror callBatch exactly —
3148
+ // same prompts, schema, and vision blocks — so batch and sync replies are
3149
+ // interchangeable downstream.
3150
+ async submitTranslationBatch(jobs) {
3151
+ const requests = jobs.map((job) => ({
3152
+ custom_id: job.customId,
3153
+ params: {
3154
+ model: this.config.model,
3155
+ max_tokens: 8192,
3156
+ // Batch entries don't share a live cache window, so cache_control is omitted here.
3157
+ system: [{ type: "text", text: buildSystemPrompt(job.requests.some((r) => r.plural !== void 0)) }],
3158
+ output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
3159
+ messages: [{ role: "user", content: this.buildUserContent(job.requests) }]
3160
+ }
3161
+ }));
3162
+ const res = await this.batchesClient().create({ requests });
3163
+ return res.id;
3164
+ }
3165
+ async translationBatchStatus(batchId) {
3166
+ const r = await this.batchesClient().retrieve(batchId);
3167
+ return { status: r.processing_status, counts: r.request_counts };
3168
+ }
3169
+ async translationBatchResults(batchId) {
3170
+ const out = /* @__PURE__ */ new Map();
3171
+ for await (const entry of await this.batchesClient().results(batchId)) {
3172
+ if (entry.result.type !== "succeeded") {
3173
+ out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
3174
+ continue;
3175
+ }
3176
+ const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
3177
+ try {
3178
+ out.set(entry.custom_id, { type: "items", items: parseReplyItems(text) });
3179
+ } catch (err) {
3180
+ if (!(err instanceof MalformedReplyError)) throw err;
3181
+ out.set(entry.custom_id, { type: "malformed", raw: err.raw });
3182
+ }
3183
+ }
3184
+ return out;
3185
+ }
3186
+ async cancelTranslationBatch(batchId) {
3187
+ await this.batchesClient().cancel(batchId);
3188
+ }
3139
3189
  async callBatch(batch, signal) {
3140
3190
  const content = this.buildUserContent(batch);
3141
3191
  const res = await this.client.messages.create({
@@ -3682,6 +3732,129 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
3682
3732
  return { written, errors };
3683
3733
  }
3684
3734
 
3735
+ // src/server/ai/pending-batch.ts
3736
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
3737
+ import { join as join4 } from "path";
3738
+ function pendingBatchPath(projectRoot) {
3739
+ return join4(projectRoot, ".glotfile", "batch.json");
3740
+ }
3741
+ function loadPendingBatch(projectRoot) {
3742
+ const path = pendingBatchPath(projectRoot);
3743
+ if (!existsSync8(path)) return void 0;
3744
+ try {
3745
+ const parsed = JSON.parse(readFileSync8(path, "utf8"));
3746
+ if (parsed?.version !== 1) return void 0;
3747
+ return parsed;
3748
+ } catch {
3749
+ return void 0;
3750
+ }
3751
+ }
3752
+ function savePendingBatch(projectRoot, pending) {
3753
+ const dir = join4(projectRoot, ".glotfile");
3754
+ mkdirSync4(dir, { recursive: true });
3755
+ const gitignore = join4(dir, ".gitignore");
3756
+ if (!existsSync8(gitignore)) writeFileSync3(gitignore, "*\n");
3757
+ writeFileSync3(pendingBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
3758
+ }
3759
+ function clearPendingBatch(projectRoot) {
3760
+ rmSync4(pendingBatchPath(projectRoot), { force: true });
3761
+ }
3762
+
3763
+ // src/server/ai/batch-run.ts
3764
+ function buildBatchJobs(reqs, batchSize) {
3765
+ const byLocale = /* @__PURE__ */ new Map();
3766
+ for (const req of reqs) {
3767
+ let group = byLocale.get(req.targetLocale);
3768
+ if (!group) {
3769
+ group = [];
3770
+ byLocale.set(req.targetLocale, group);
3771
+ }
3772
+ group.push(req);
3773
+ }
3774
+ const jobs = [];
3775
+ for (const [locale, group] of byLocale) {
3776
+ chunk(group, Math.max(1, batchSize)).forEach((batch, i) => {
3777
+ const safeLocale = locale.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 56);
3778
+ jobs.push({ customId: `${safeLocale}_${i}`, locale, requests: batch });
3779
+ });
3780
+ }
3781
+ return jobs;
3782
+ }
3783
+ async function submitBatchTranslation(state, provider, reqs, batchSize, model, projectRoot) {
3784
+ if (loadPendingBatch(projectRoot)) {
3785
+ throw new Error("A translation batch is already pending. Apply or cancel it first (`glotfile batch`).");
3786
+ }
3787
+ const jobs = buildBatchJobs(reqs, batchSize);
3788
+ const batchId = await provider.submitTranslationBatch(jobs);
3789
+ const pending = {
3790
+ version: 1,
3791
+ // Only Anthropic implements batch translation today.
3792
+ provider: "anthropic",
3793
+ model,
3794
+ batchId,
3795
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3796
+ total: reqs.length,
3797
+ jobs: jobs.map((j) => ({
3798
+ customId: j.customId,
3799
+ locale: j.locale,
3800
+ requests: j.requests.map((r) => {
3801
+ const { image: _image, ...rest } = r;
3802
+ return { ...rest, sourceHash: sourceHash(state.keys[r.key], state.config.sourceLocale) };
3803
+ })
3804
+ }))
3805
+ };
3806
+ savePendingBatch(projectRoot, pending);
3807
+ return pending;
3808
+ }
3809
+ async function applyBatchResults(load, persist, provider, pending, projectRoot, ai) {
3810
+ const outcomes = await provider.translationBatchResults(pending.batchId);
3811
+ const fresh = load();
3812
+ const isStale = (r) => {
3813
+ const entry = fresh.keys[r.key];
3814
+ return !entry || sourceHash(entry, fresh.config.sourceLocale) !== r.sourceHash;
3815
+ };
3816
+ const applied = [];
3817
+ const results = [];
3818
+ const retryReqs = [];
3819
+ let staleSkipped = 0;
3820
+ for (const job of pending.jobs) {
3821
+ const outcome = outcomes.get(job.customId);
3822
+ const itemsById = outcome?.type === "items" ? new Map(outcome.items.map((i) => [i.id, i])) : null;
3823
+ for (const stored of job.requests) {
3824
+ if (isStale(stored)) {
3825
+ staleSkipped++;
3826
+ continue;
3827
+ }
3828
+ const { sourceHash: _hash, ...req } = stored;
3829
+ if (!itemsById) {
3830
+ retryReqs.push(req);
3831
+ continue;
3832
+ }
3833
+ applied.push(req);
3834
+ results.push(validateReply(req, itemsById.get(req.id)));
3835
+ }
3836
+ }
3837
+ let screenshotsSkipped = 0;
3838
+ if (retryReqs.length) {
3839
+ const { skipped } = attachScreenshotsForProvider(retryReqs, fresh, projectRoot, provider.supportsVision());
3840
+ screenshotsSkipped = skipped;
3841
+ const retryResults = await runLocaleParallel(
3842
+ retryReqs,
3843
+ provider,
3844
+ {},
3845
+ ai.concurrency,
3846
+ void 0,
3847
+ ai.batchSize
3848
+ );
3849
+ applied.push(...retryReqs);
3850
+ results.push(...retryResults);
3851
+ }
3852
+ const { written, errors } = applyResults(fresh, applied, results);
3853
+ persist(fresh);
3854
+ clearPendingBatch(projectRoot);
3855
+ return { written, errors, staleSkipped, retried: retryReqs.length, screenshotsSkipped };
3856
+ }
3857
+
3685
3858
  // src/server/ai/pricing.ts
3686
3859
  var PRICE_TABLE = [
3687
3860
  ["claude-fable-5", 10, 50],
@@ -3779,7 +3952,7 @@ function estimateTranslation(state, ai, opts) {
3779
3952
  }
3780
3953
 
3781
3954
  // src/server/log.ts
3782
- import { appendFileSync, readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
3955
+ import { appendFileSync, readFileSync as readFileSync9, existsSync as existsSync9 } from "fs";
3783
3956
  import { resolve as resolve6 } from "path";
3784
3957
  function logPath(projectRoot) {
3785
3958
  return resolve6(projectRoot, ".glotfile", "log.jsonl");
@@ -3790,8 +3963,8 @@ function appendLog(projectRoot, entry) {
3790
3963
  }
3791
3964
  function readLog(projectRoot, limit = 100) {
3792
3965
  const path = logPath(projectRoot);
3793
- if (!existsSync8(path)) return [];
3794
- const lines = readFileSync8(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3966
+ if (!existsSync9(path)) return [];
3967
+ const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3795
3968
  const entries = lines.map((l) => JSON.parse(l));
3796
3969
  return entries.reverse().slice(0, limit);
3797
3970
  }
@@ -3800,8 +3973,8 @@ function readLog(projectRoot, limit = 100) {
3800
3973
  import { relative as relative3 } from "path";
3801
3974
 
3802
3975
  // src/server/import/detect.ts
3803
- import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
3804
- import { join as join4 } from "path";
3976
+ import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync10, statSync as statSync2 } from "fs";
3977
+ import { join as join5 } from "path";
3805
3978
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
3806
3979
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
3807
3980
  function safeIsDir(p) {
@@ -3812,7 +3985,7 @@ function safeIsDir(p) {
3812
3985
  }
3813
3986
  }
3814
3987
  function listDirs(dir) {
3815
- return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
3988
+ return readdirSync3(dir).filter((e) => safeIsDir(join5(dir, e)));
3816
3989
  }
3817
3990
  function fileCount(dir) {
3818
3991
  try {
@@ -3826,23 +3999,23 @@ function pickSource(locales, sizeOf) {
3826
3999
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
3827
4000
  }
3828
4001
  function detectLaravel(root) {
3829
- const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
4002
+ const localeRoot = [join5(root, "resources", "lang"), join5(root, "lang")].find(safeIsDir);
3830
4003
  if (!localeRoot) return null;
3831
4004
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
3832
4005
  if (locales.length === 0) return null;
3833
- const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
4006
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join5(localeRoot, loc)));
3834
4007
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
3835
4008
  }
3836
4009
  function detectVue(root, forced = false) {
3837
4010
  for (const rel of VUE_DIR_CANDIDATES) {
3838
- const localeRoot = join4(root, rel);
4011
+ const localeRoot = join5(root, rel);
3839
4012
  if (!safeIsDir(localeRoot)) continue;
3840
4013
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
3841
4014
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
3842
4015
  if (enough) {
3843
4016
  const sourceLocale = pickSource(locales, (loc) => {
3844
4017
  try {
3845
- return statSync2(join4(localeRoot, `${loc}.json`)).size;
4018
+ return statSync2(join5(localeRoot, `${loc}.json`)).size;
3846
4019
  } catch {
3847
4020
  return 0;
3848
4021
  }
@@ -3854,7 +4027,7 @@ function detectVue(root, forced = false) {
3854
4027
  }
3855
4028
  function detectArb(root) {
3856
4029
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
3857
- const localeRoot = join4(root, rel);
4030
+ const localeRoot = join5(root, rel);
3858
4031
  if (!safeIsDir(localeRoot)) continue;
3859
4032
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
3860
4033
  if (locales.length >= 1) {
@@ -3864,10 +4037,10 @@ function detectArb(root) {
3864
4037
  return null;
3865
4038
  }
3866
4039
  function lprojLocales(dir) {
3867
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync9(join4(dir, `${l}.lproj`, "Localizable.strings")));
4040
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join5(dir, `${l}.lproj`, "Localizable.strings")));
3868
4041
  }
3869
4042
  function detectApple(root) {
3870
- const candidates = [root, ...listDirs(root).map((d) => join4(root, d))];
4043
+ const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
3871
4044
  let best = null;
3872
4045
  for (const dir of candidates) {
3873
4046
  const locales = lprojLocales(dir);
@@ -3879,7 +4052,7 @@ function detectApple(root) {
3879
4052
  locales,
3880
4053
  sourceLocale: pickSource(locales, (loc) => {
3881
4054
  try {
3882
- return statSync2(join4(dir, `${loc}.lproj`, "Localizable.strings")).size;
4055
+ return statSync2(join5(dir, `${loc}.lproj`, "Localizable.strings")).size;
3883
4056
  } catch {
3884
4057
  return 0;
3885
4058
  }
@@ -3892,7 +4065,7 @@ function detectApple(root) {
3892
4065
  var ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
3893
4066
  function detectAngularXliff(root) {
3894
4067
  for (const rel of ANGULAR_DIR_CANDIDATES) {
3895
- const localeRoot = rel === "." ? root : join4(root, rel);
4068
+ const localeRoot = rel === "." ? root : join5(root, rel);
3896
4069
  if (!safeIsDir(localeRoot)) continue;
3897
4070
  const files = readdirSync3(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
3898
4071
  if (files.length === 0) continue;
@@ -3900,7 +4073,7 @@ function detectAngularXliff(root) {
3900
4073
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
3901
4074
  let sourceLocale;
3902
4075
  try {
3903
- sourceLocale = readFileSync9(join4(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4076
+ sourceLocale = readFileSync10(join5(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
3904
4077
  } catch {
3905
4078
  }
3906
4079
  if (!sourceLocale && locales.length === 0) continue;
@@ -3911,14 +4084,14 @@ function detectAngularXliff(root) {
3911
4084
  return null;
3912
4085
  }
3913
4086
  function detectRails(root) {
3914
- const localeRoot = join4(root, "config", "locales");
4087
+ const localeRoot = join5(root, "config", "locales");
3915
4088
  if (!safeIsDir(localeRoot)) return null;
3916
4089
  const locales = [];
3917
4090
  for (const file of readdirSync3(localeRoot).sort()) {
3918
4091
  if (!/\.ya?ml$/.test(file)) continue;
3919
4092
  let text;
3920
4093
  try {
3921
- text = readFileSync9(join4(localeRoot, file), "utf8");
4094
+ text = readFileSync10(join5(localeRoot, file), "utf8");
3922
4095
  } catch {
3923
4096
  continue;
3924
4097
  }
@@ -3933,15 +4106,15 @@ function detectRails(root) {
3933
4106
  var I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
3934
4107
  function detectI18next(root) {
3935
4108
  for (const rel of I18NEXT_DIR_CANDIDATES) {
3936
- const localeRoot = join4(root, rel);
4109
+ const localeRoot = join5(root, rel);
3937
4110
  if (!safeIsDir(localeRoot)) continue;
3938
4111
  const locales = listDirs(localeRoot).filter(
3939
- (d) => LOCALE_RE.test(d) && readdirSync3(join4(localeRoot, d)).some((f) => f.endsWith(".json"))
4112
+ (d) => LOCALE_RE.test(d) && readdirSync3(join5(localeRoot, d)).some((f) => f.endsWith(".json"))
3940
4113
  );
3941
4114
  if (locales.length === 0) continue;
3942
4115
  const sourceLocale = pickSource(locales, (loc) => {
3943
4116
  try {
3944
- return readdirSync3(join4(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync2(join4(localeRoot, loc, f)).size, 0);
4117
+ return readdirSync3(join5(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync2(join5(localeRoot, loc, f)).size, 0);
3945
4118
  } catch {
3946
4119
  return 0;
3947
4120
  }
@@ -3958,8 +4131,8 @@ function gettextLocales(dir) {
3958
4131
  if (!locales.includes(flat)) locales.push(flat);
3959
4132
  continue;
3960
4133
  }
3961
- if (!LOCALE_RE.test(entry) || !safeIsDir(join4(dir, entry))) continue;
3962
- const sub = join4(dir, entry);
4134
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join5(dir, entry))) continue;
4135
+ const sub = join5(dir, entry);
3963
4136
  const hasPo = (d) => {
3964
4137
  try {
3965
4138
  return readdirSync3(d).some((f) => f.endsWith(".po"));
@@ -3967,7 +4140,7 @@ function gettextLocales(dir) {
3967
4140
  return false;
3968
4141
  }
3969
4142
  };
3970
- if (hasPo(join4(sub, "LC_MESSAGES")) || hasPo(sub)) {
4143
+ if (hasPo(join5(sub, "LC_MESSAGES")) || hasPo(sub)) {
3971
4144
  if (!locales.includes(entry)) locales.push(entry);
3972
4145
  }
3973
4146
  }
@@ -3976,7 +4149,7 @@ function gettextLocales(dir) {
3976
4149
  var GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
3977
4150
  function detectGettext(root) {
3978
4151
  for (const rel of GETTEXT_DIR_CANDIDATES) {
3979
- const localeRoot = join4(root, rel);
4152
+ const localeRoot = join5(root, rel);
3980
4153
  if (!safeIsDir(localeRoot)) continue;
3981
4154
  const locales = gettextLocales(localeRoot);
3982
4155
  if (locales.length === 0) continue;
@@ -3985,10 +4158,10 @@ function detectGettext(root) {
3985
4158
  return null;
3986
4159
  }
3987
4160
  function detectAppleStringsdict(root) {
3988
- const candidates = [root, ...listDirs(root).map((d) => join4(root, d))];
4161
+ const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
3989
4162
  let best = null;
3990
4163
  for (const dir of candidates) {
3991
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync9(join4(dir, `${l}.lproj`, "Localizable.stringsdict")));
4164
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join5(dir, `${l}.lproj`, "Localizable.stringsdict")));
3992
4165
  if (locales.length === 0) continue;
3993
4166
  if (!best || locales.length > best.locales.length) {
3994
4167
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4019,7 +4192,7 @@ var BY_FORMAT = {
4019
4192
  "apple-stringsdict": detectAppleStringsdict
4020
4193
  };
4021
4194
  function detect(root, formatOverride) {
4022
- if (!existsSync9(root)) return null;
4195
+ if (!existsSync10(root)) return null;
4023
4196
  if (formatOverride) {
4024
4197
  const fn = BY_FORMAT[formatOverride];
4025
4198
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4033,8 +4206,8 @@ function detect(root, formatOverride) {
4033
4206
  }
4034
4207
 
4035
4208
  // src/server/import/parsers/vue-i18n-json.ts
4036
- import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "fs";
4037
- import { join as join5 } from "path";
4209
+ import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
4210
+ import { join as join6 } from "path";
4038
4211
 
4039
4212
  // src/server/import/flatten.ts
4040
4213
  function flattenObject(value, prefix, warnings) {
@@ -4073,7 +4246,7 @@ var vueI18nJson2 = {
4073
4246
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4074
4247
  let data;
4075
4248
  try {
4076
- data = JSON.parse(readFileSync10(join5(localeRoot, file), "utf8"));
4249
+ data = JSON.parse(readFileSync11(join6(localeRoot, file), "utf8"));
4077
4250
  } catch (e) {
4078
4251
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4079
4252
  continue;
@@ -4089,7 +4262,7 @@ var vueI18nJson2 = {
4089
4262
 
4090
4263
  // src/server/import/parsers/laravel-php.ts
4091
4264
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
4092
- import { join as join6, relative as relative2 } from "path";
4265
+ import { join as join7, relative as relative2 } from "path";
4093
4266
  import { execFileSync } from "child_process";
4094
4267
 
4095
4268
  // src/server/import/placeholders.ts
@@ -4099,13 +4272,13 @@ function laravelToCanonical(value) {
4099
4272
 
4100
4273
  // src/server/import/parsers/laravel-php.ts
4101
4274
  function listDirs2(dir) {
4102
- return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
4275
+ return readdirSync5(dir).filter((e) => statSync3(join7(dir, e)).isDirectory());
4103
4276
  }
4104
4277
  function listPhpFiles(dir) {
4105
4278
  const out = [];
4106
4279
  const walk = (d) => {
4107
4280
  for (const e of readdirSync5(d)) {
4108
- const full = join6(d, e);
4281
+ const full = join7(d, e);
4109
4282
  if (statSync3(full).isDirectory()) walk(full);
4110
4283
  else if (e.endsWith(".php")) out.push(full);
4111
4284
  }
@@ -4142,7 +4315,7 @@ var laravelPhp2 = {
4142
4315
  for (const locale of listDirs2(localeRoot).sort()) {
4143
4316
  if (locale === "vendor") continue;
4144
4317
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4145
- const localeDir = join6(localeRoot, locale);
4318
+ const localeDir = join7(localeRoot, locale);
4146
4319
  locales.push(locale);
4147
4320
  for (const file of listPhpFiles(localeDir)) {
4148
4321
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -4165,8 +4338,8 @@ var laravelPhp2 = {
4165
4338
  };
4166
4339
 
4167
4340
  // src/server/import/parsers/flutter-arb.ts
4168
- import { readdirSync as readdirSync6, readFileSync as readFileSync11 } from "fs";
4169
- import { join as join7 } from "path";
4341
+ import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
4342
+ import { join as join8 } from "path";
4170
4343
  var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4171
4344
  function localeFromArbName(file) {
4172
4345
  const m = file.match(/^(.+)\.arb$/);
@@ -4202,7 +4375,7 @@ var flutterArb2 = {
4202
4375
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4203
4376
  let data;
4204
4377
  try {
4205
- data = JSON.parse(readFileSync11(join7(localeRoot, file), "utf8"));
4378
+ data = JSON.parse(readFileSync12(join8(localeRoot, file), "utf8"));
4206
4379
  } catch (e) {
4207
4380
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4208
4381
  continue;
@@ -4227,8 +4400,8 @@ var flutterArb2 = {
4227
4400
  };
4228
4401
 
4229
4402
  // src/server/import/parsers/apple-strings.ts
4230
- import { readdirSync as readdirSync7, readFileSync as readFileSync12, statSync as statSync4 } from "fs";
4231
- import { join as join8 } from "path";
4403
+ import { readdirSync as readdirSync7, readFileSync as readFileSync13, statSync as statSync4 } from "fs";
4404
+ import { join as join9 } from "path";
4232
4405
  var LOCALE_RE4 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4233
4406
  var TABLE = "Localizable.strings";
4234
4407
  function localeFromLproj(dir) {
@@ -4332,16 +4505,16 @@ var appleStrings2 = {
4332
4505
  const locale = localeFromLproj(dir);
4333
4506
  if (!locale) continue;
4334
4507
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4335
- const file = join8(localeRoot, dir, TABLE);
4508
+ const file = join9(localeRoot, dir, TABLE);
4336
4509
  let text;
4337
4510
  try {
4338
4511
  if (!statSync4(file).isFile()) continue;
4339
- text = readFileSync12(file, "utf8");
4512
+ text = readFileSync13(file, "utf8");
4340
4513
  } catch {
4341
4514
  continue;
4342
4515
  }
4343
4516
  locales.push(locale);
4344
- const others = readdirSync7(join8(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4517
+ const others = readdirSync7(join9(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4345
4518
  if (others.length) {
4346
4519
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4347
4520
  }
@@ -4354,8 +4527,8 @@ var appleStrings2 = {
4354
4527
  };
4355
4528
 
4356
4529
  // src/server/import/parsers/angular-xliff.ts
4357
- import { readdirSync as readdirSync8, readFileSync as readFileSync13 } from "fs";
4358
- import { join as join9 } from "path";
4530
+ import { readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
4531
+ import { join as join10 } from "path";
4359
4532
  var LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4360
4533
  var FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
4361
4534
  function decodeEntities(s) {
@@ -4403,7 +4576,7 @@ var angularXliff2 = {
4403
4576
  if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
4404
4577
  let xml;
4405
4578
  try {
4406
- xml = readFileSync13(join9(localeRoot, file), "utf8");
4579
+ xml = readFileSync14(join10(localeRoot, file), "utf8");
4407
4580
  } catch (e) {
4408
4581
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
4409
4582
  continue;
@@ -4444,8 +4617,8 @@ var angularXliff2 = {
4444
4617
  };
4445
4618
 
4446
4619
  // src/server/import/parsers/gettext-po.ts
4447
- import { readdirSync as readdirSync9, readFileSync as readFileSync14 } from "fs";
4448
- import { join as join10 } from "path";
4620
+ import { readdirSync as readdirSync9, readFileSync as readFileSync15 } from "fs";
4621
+ import { join as join11 } from "path";
4449
4622
  var LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4450
4623
  var DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
4451
4624
  var CONT_RE = /^[ \t]*"(.*)"\s*$/;
@@ -4519,17 +4692,17 @@ function discoverPoFiles(root) {
4519
4692
  for (const e of entries) {
4520
4693
  if (e.isFile() && e.name.endsWith(".po")) {
4521
4694
  const base = e.name.slice(0, -3);
4522
- found.push({ path: join10(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4695
+ found.push({ path: join11(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4523
4696
  } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
4524
- for (const sub of [join10(e.name, "LC_MESSAGES"), e.name]) {
4697
+ for (const sub of [join11(e.name, "LC_MESSAGES"), e.name]) {
4525
4698
  let names;
4526
4699
  try {
4527
- names = readdirSync9(join10(root, sub)).sort();
4700
+ names = readdirSync9(join11(root, sub)).sort();
4528
4701
  } catch {
4529
4702
  continue;
4530
4703
  }
4531
4704
  for (const f of names) {
4532
- if (f.endsWith(".po")) found.push({ path: join10(root, sub, f), rel: join10(sub, f), locale: e.name });
4705
+ if (f.endsWith(".po")) found.push({ path: join11(root, sub, f), rel: join11(sub, f), locale: e.name });
4533
4706
  }
4534
4707
  }
4535
4708
  }
@@ -4545,7 +4718,7 @@ var gettextPo2 = {
4545
4718
  for (const file of discoverPoFiles(localeRoot)) {
4546
4719
  let entries;
4547
4720
  try {
4548
- entries = parseEntries(readFileSync14(file.path, "utf8"));
4721
+ entries = parseEntries(readFileSync15(file.path, "utf8"));
4549
4722
  } catch (e) {
4550
4723
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
4551
4724
  continue;
@@ -4590,8 +4763,8 @@ var gettextPo2 = {
4590
4763
  };
4591
4764
 
4592
4765
  // src/server/import/parsers/i18next-json.ts
4593
- import { readdirSync as readdirSync10, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
4594
- import { join as join11 } from "path";
4766
+ import { readdirSync as readdirSync10, readFileSync as readFileSync16, statSync as statSync5 } from "fs";
4767
+ import { join as join12 } from "path";
4595
4768
  var LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4596
4769
  var PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
4597
4770
  var PLURAL_ARG = "count";
@@ -4610,7 +4783,7 @@ function fromI18next(value) {
4610
4783
  function ingestFile(path, label, prefix, locale, keys, warnings) {
4611
4784
  let data;
4612
4785
  try {
4613
- data = JSON.parse(readFileSync15(path, "utf8"));
4786
+ data = JSON.parse(readFileSync16(path, "utf8"));
4614
4787
  } catch (e) {
4615
4788
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
4616
4789
  return false;
@@ -4652,7 +4825,7 @@ var i18nextJson2 = {
4652
4825
  const keys = {};
4653
4826
  const locales = [];
4654
4827
  for (const entry of readdirSync10(localeRoot).sort()) {
4655
- const full = join11(localeRoot, entry);
4828
+ const full = join12(localeRoot, entry);
4656
4829
  if (safeIsDir2(full)) {
4657
4830
  if (!LOCALE_RE7.test(entry)) continue;
4658
4831
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -4661,7 +4834,7 @@ var i18nextJson2 = {
4661
4834
  if (!file.endsWith(".json")) continue;
4662
4835
  const ns = file.slice(0, -".json".length);
4663
4836
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
4664
- if (ingestFile(join11(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
4837
+ if (ingestFile(join12(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
4665
4838
  }
4666
4839
  if (any && !locales.includes(entry)) locales.push(entry);
4667
4840
  } else if (entry.endsWith(".json")) {
@@ -4678,8 +4851,8 @@ var i18nextJson2 = {
4678
4851
  };
4679
4852
 
4680
4853
  // src/server/import/parsers/rails-yaml.ts
4681
- import { readdirSync as readdirSync11, readFileSync as readFileSync16 } from "fs";
4682
- import { join as join12 } from "path";
4854
+ import { readdirSync as readdirSync11, readFileSync as readFileSync17 } from "fs";
4855
+ import { join as join13 } from "path";
4683
4856
  var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
4684
4857
  var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
4685
4858
  function fromRuby(value) {
@@ -4891,7 +5064,7 @@ var railsYaml2 = {
4891
5064
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
4892
5065
  let text;
4893
5066
  try {
4894
- text = readFileSync16(join12(localeRoot, file), "utf8");
5067
+ text = readFileSync17(join13(localeRoot, file), "utf8");
4895
5068
  } catch (e) {
4896
5069
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
4897
5070
  continue;
@@ -4912,8 +5085,8 @@ var railsYaml2 = {
4912
5085
  };
4913
5086
 
4914
5087
  // src/server/import/parsers/apple-stringsdict.ts
4915
- import { readdirSync as readdirSync12, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
4916
- import { join as join13 } from "path";
5088
+ import { readdirSync as readdirSync12, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5089
+ import { join as join14 } from "path";
4917
5090
  var LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4918
5091
  var TABLE2 = "Localizable.stringsdict";
4919
5092
  function localeFromLproj2(dir) {
@@ -5049,16 +5222,16 @@ var appleStringsdict2 = {
5049
5222
  const locale = localeFromLproj2(dir);
5050
5223
  if (!locale) continue;
5051
5224
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5052
- const file = join13(localeRoot, dir, TABLE2);
5225
+ const file = join14(localeRoot, dir, TABLE2);
5053
5226
  let text;
5054
5227
  try {
5055
5228
  if (!statSync6(file).isFile()) continue;
5056
- text = readFileSync17(file, "utf8");
5229
+ text = readFileSync18(file, "utf8");
5057
5230
  } catch {
5058
5231
  continue;
5059
5232
  }
5060
5233
  locales.push(locale);
5061
- const others = readdirSync12(join13(localeRoot, dir)).filter(
5234
+ const others = readdirSync12(join14(localeRoot, dir)).filter(
5062
5235
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5063
5236
  );
5064
5237
  if (others.length) {
@@ -5231,7 +5404,7 @@ function runImport(opts) {
5231
5404
  }
5232
5405
 
5233
5406
  // src/server/export-run.ts
5234
- import { existsSync as existsSync10, readFileSync as readFileSync18, readdirSync as readdirSync13, rmdirSync, statSync as statSync7, unlinkSync } from "fs";
5407
+ import { existsSync as existsSync11, readFileSync as readFileSync19, readdirSync as readdirSync13, rmdirSync, statSync as statSync7, unlinkSync } from "fs";
5235
5408
  import { dirname as dirname2, resolve as resolve7, sep } from "path";
5236
5409
  function effectiveLocales(config) {
5237
5410
  const limit = config.exportLocales;
@@ -5274,7 +5447,7 @@ function pruneStaleLocaleFiles(output, validTokens, projectRoot) {
5274
5447
  if (!segment.includes("{locale}") && !segment.includes("{namespace}")) {
5275
5448
  const next = resolve7(dir, segment);
5276
5449
  if (isLast) {
5277
- if (stale(locale) && existsSync10(next) && statSync7(next).isFile()) {
5450
+ if (stale(locale) && existsSync11(next) && statSync7(next).isFile()) {
5278
5451
  unlinkSync(next);
5279
5452
  deleted++;
5280
5453
  removeEmptyDirs(dir, root);
@@ -5330,7 +5503,7 @@ function exportToDisk(state, projectRoot, opts) {
5330
5503
  writtenPaths.add(abs);
5331
5504
  let current = null;
5332
5505
  try {
5333
- current = readFileSync18(abs, "utf8");
5506
+ current = readFileSync19(abs, "utf8");
5334
5507
  } catch {
5335
5508
  }
5336
5509
  if (current === f.contents) {
@@ -5347,17 +5520,17 @@ function exportToDisk(state, projectRoot, opts) {
5347
5520
  }
5348
5521
 
5349
5522
  // src/server/ui-prefs.ts
5350
- import { readFileSync as readFileSync19 } from "fs";
5523
+ import { readFileSync as readFileSync20 } from "fs";
5351
5524
  import { homedir } from "os";
5352
- import { join as join14 } from "path";
5525
+ import { join as join15 } from "path";
5353
5526
  var THEMES = ["system", "light", "dark"];
5354
5527
  var isThemeMode = (v) => THEMES.includes(v);
5355
5528
  var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
5356
- var defaultUiPrefsPath = () => join14(homedir(), ".glotfile", "ui.json");
5529
+ var defaultUiPrefsPath = () => join15(homedir(), ".glotfile", "ui.json");
5357
5530
  var DEFAULTS = { theme: "system" };
5358
5531
  function readJson(path) {
5359
5532
  try {
5360
- const parsed = JSON.parse(readFileSync19(path, "utf8"));
5533
+ const parsed = JSON.parse(readFileSync20(path, "utf8"));
5361
5534
  return parsed && typeof parsed === "object" ? parsed : {};
5362
5535
  } catch {
5363
5536
  return {};
@@ -5376,7 +5549,7 @@ function saveUiPrefs(path, prefs) {
5376
5549
  }
5377
5550
 
5378
5551
  // src/server/local-settings.ts
5379
- import { readFileSync as readFileSync20 } from "fs";
5552
+ import { readFileSync as readFileSync21 } from "fs";
5380
5553
  import { resolve as resolve8 } from "path";
5381
5554
  var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
5382
5555
  var isEditorId = (v) => EDITOR_IDS.includes(v);
@@ -5391,7 +5564,7 @@ var DEFAULT_EDITOR = "vscode";
5391
5564
  var settingsPath = (projectRoot) => resolve8(projectRoot, ".glotfile", "settings.json");
5392
5565
  function readJson2(path) {
5393
5566
  try {
5394
- const parsed = JSON.parse(readFileSync20(path, "utf8"));
5567
+ const parsed = JSON.parse(readFileSync21(path, "utf8"));
5395
5568
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
5396
5569
  } catch {
5397
5570
  return {};
@@ -5462,9 +5635,9 @@ var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
5462
5635
  var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
5463
5636
  function projectName(root) {
5464
5637
  const nameFile = resolve9(root, ".idea", ".name");
5465
- if (existsSync11(nameFile)) {
5638
+ if (existsSync12(nameFile)) {
5466
5639
  try {
5467
- const name = readFileSync21(nameFile, "utf8").trim();
5640
+ const name = readFileSync22(nameFile, "utf8").trim();
5468
5641
  if (name) return name;
5469
5642
  } catch {
5470
5643
  }
@@ -5597,7 +5770,7 @@ function createApi(deps) {
5597
5770
  if (name.startsWith(".") || name === "node_modules") continue;
5598
5771
  const abs = resolve9(dir, name);
5599
5772
  let filePath = null;
5600
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync11(resolve9(abs, "config.json"))) {
5773
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync12(resolve9(abs, "config.json"))) {
5601
5774
  filePath = resolve9(dir, `${name}.json`);
5602
5775
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
5603
5776
  filePath = abs;
@@ -5631,7 +5804,7 @@ function createApi(deps) {
5631
5804
  const resolved = resolve9(projectRoot, path);
5632
5805
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
5633
5806
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
5634
- if (!existsSync11(resolved)) return c.json({ error: "file not found" }, 400);
5807
+ if (!existsSync12(resolved)) return c.json({ error: "file not found" }, 400);
5635
5808
  loadState(resolved);
5636
5809
  deps.statePath = resolved;
5637
5810
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -5692,9 +5865,9 @@ function createApi(deps) {
5692
5865
  const abs = resolve9(root, screenshot);
5693
5866
  const rel = relative4(root, abs);
5694
5867
  const seg0 = rel.split(sep2)[0] ?? "";
5695
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync11(abs)) {
5868
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync12(abs)) {
5696
5869
  try {
5697
- rmSync4(abs);
5870
+ rmSync5(abs);
5698
5871
  } catch {
5699
5872
  }
5700
5873
  }
@@ -6232,6 +6405,104 @@ function createApi(deps) {
6232
6405
  const ai = loadLocalSettings(projectRoot).ai;
6233
6406
  return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
6234
6407
  });
6408
+ app.get("/batch/status", async (c) => {
6409
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6410
+ let supported = false;
6411
+ let provider;
6412
+ try {
6413
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6414
+ supported = supportsBatchTranslate(provider);
6415
+ } catch {
6416
+ }
6417
+ const pending = loadPendingBatch(projectRoot);
6418
+ if (!pending) return c.json({ supported, pending: null });
6419
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
6420
+ if (!provider || !supportsBatchTranslate(provider)) {
6421
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
6422
+ }
6423
+ try {
6424
+ const status = await provider.translationBatchStatus(pending.batchId);
6425
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
6426
+ } catch (e) {
6427
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
6428
+ }
6429
+ });
6430
+ app.post("/batch/translate", (c) => withTranslateLock(async () => {
6431
+ const body = await c.req.json().catch(() => ({}));
6432
+ const s = load();
6433
+ const reqs = selectRequests(s, {
6434
+ onlyMissing: body.onlyMissing ?? true,
6435
+ keys: Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0,
6436
+ locales: Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0
6437
+ });
6438
+ if (!reqs.length) return c.json({ error: "Nothing to translate." }, 400);
6439
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6440
+ let provider;
6441
+ try {
6442
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6443
+ } catch (e) {
6444
+ return c.json({ error: e.message }, 400);
6445
+ }
6446
+ if (!supportsBatchTranslate(provider)) {
6447
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6448
+ }
6449
+ attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
6450
+ let pending;
6451
+ try {
6452
+ pending = await submitBatchTranslation(s, provider, reqs, aiCfg.batchSize, aiCfg.model, projectRoot);
6453
+ } catch (e) {
6454
+ return c.json({ error: e.message }, 409);
6455
+ }
6456
+ appendLog(projectRoot, {
6457
+ at: (/* @__PURE__ */ new Date()).toISOString(),
6458
+ kind: "translate",
6459
+ summary: `Submitted batch ${pending.batchId} (${pending.total} items)`,
6460
+ model: aiCfg.model,
6461
+ system: buildSystemPrompt(reqs.some((r) => r.plural !== void 0)),
6462
+ items: reqs.map((r) => ({ id: r.id, key: r.key, source: r.source, targetLocale: r.targetLocale, context: r.context, glossary: r.glossary, screenshot: s.keys[r.key]?.screenshot }))
6463
+ });
6464
+ console.log(`[batch] submitted ${pending.batchId} \u2014 ${pending.total} string(s)`);
6465
+ return c.json({ batchId: pending.batchId, total: pending.total });
6466
+ }));
6467
+ app.post("/batch/apply", (c) => withTranslateLock(async () => {
6468
+ const pending = loadPendingBatch(projectRoot);
6469
+ if (!pending) return c.json({ error: "No pending batch." }, 404);
6470
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6471
+ let provider;
6472
+ try {
6473
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6474
+ } catch (e) {
6475
+ return c.json({ error: e.message }, 400);
6476
+ }
6477
+ if (!supportsBatchTranslate(provider)) {
6478
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6479
+ }
6480
+ const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, {
6481
+ batchSize: aiCfg.batchSize,
6482
+ concurrency: aiCfg.concurrency
6483
+ });
6484
+ appendLog(projectRoot, {
6485
+ at: (/* @__PURE__ */ new Date()).toISOString(),
6486
+ kind: "translate",
6487
+ summary: `Applied batch ${pending.batchId}: wrote ${outcome.written}, ${outcome.retried} retried, ${outcome.staleSkipped} stale`,
6488
+ model: aiCfg.model,
6489
+ results: []
6490
+ });
6491
+ console.log(`[batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
6492
+ return c.json(outcome);
6493
+ }));
6494
+ app.post("/batch/cancel", async (c) => {
6495
+ const pending = loadPendingBatch(projectRoot);
6496
+ if (!pending) return c.json({ error: "No pending batch." }, 404);
6497
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6498
+ try {
6499
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6500
+ if (supportsBatchTranslate(provider)) await provider.cancelTranslationBatch(pending.batchId);
6501
+ } catch {
6502
+ }
6503
+ clearPendingBatch(projectRoot);
6504
+ return c.json({ canceled: pending.batchId });
6505
+ });
6235
6506
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
6236
6507
  app.post("/scan", async (c) => {
6237
6508
  const s = load();
@@ -6397,7 +6668,7 @@ function createApi(deps) {
6397
6668
 
6398
6669
  // src/server/server.ts
6399
6670
  var here = dirname4(fileURLToPath(import.meta.url));
6400
- var DEFAULT_UI_DIR = join15(here, "..", "ui");
6671
+ var DEFAULT_UI_DIR = join16(here, "..", "ui");
6401
6672
  var MIME = {
6402
6673
  ".html": "text/html; charset=utf-8",
6403
6674
  ".js": "text/javascript; charset=utf-8",
@@ -6451,7 +6722,7 @@ function buildApp(opts) {
6451
6722
  const file = await readFileResponse(target);
6452
6723
  if (file) return file;
6453
6724
  }
6454
- const index = await readFileResponse(join15(root, "index.html"));
6725
+ const index = await readFileResponse(join16(root, "index.html"));
6455
6726
  if (index) return index;
6456
6727
  return c.notFound();
6457
6728
  });