glotfile 0.6.4 → 0.7.0

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