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.
@@ -1969,6 +1969,9 @@ var init_export_run = __esm({
1969
1969
  });
1970
1970
 
1971
1971
  // src/server/ai/provider.ts
1972
+ function supportsBatchTranslate(p) {
1973
+ return typeof p.submitTranslationBatch === "function";
1974
+ }
1972
1975
  function buildSystemPrompt(hasPluralItems) {
1973
1976
  const lines = [
1974
1977
  "You are a professional software localization engine for a UI string catalog.",
@@ -2277,6 +2280,53 @@ var init_anthropic = __esm({
2277
2280
  return {};
2278
2281
  }
2279
2282
  }
2283
+ batchesClient() {
2284
+ const b = this.client.messages.batches;
2285
+ if (!b) throw new Error("Anthropic client has no batches support.");
2286
+ return b;
2287
+ }
2288
+ // Each job becomes one batch entry whose params mirror callBatch exactly —
2289
+ // same prompts, schema, and vision blocks — so batch and sync replies are
2290
+ // interchangeable downstream.
2291
+ async submitTranslationBatch(jobs) {
2292
+ const requests = jobs.map((job) => ({
2293
+ custom_id: job.customId,
2294
+ params: {
2295
+ model: this.config.model,
2296
+ max_tokens: 8192,
2297
+ // Batch entries don't share a live cache window, so cache_control is omitted here.
2298
+ system: [{ type: "text", text: buildSystemPrompt(job.requests.some((r) => r.plural !== void 0)) }],
2299
+ output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
2300
+ messages: [{ role: "user", content: this.buildUserContent(job.requests) }]
2301
+ }
2302
+ }));
2303
+ const res = await this.batchesClient().create({ requests });
2304
+ return res.id;
2305
+ }
2306
+ async translationBatchStatus(batchId) {
2307
+ const r = await this.batchesClient().retrieve(batchId);
2308
+ return { status: r.processing_status, counts: r.request_counts };
2309
+ }
2310
+ async translationBatchResults(batchId) {
2311
+ const out = /* @__PURE__ */ new Map();
2312
+ for await (const entry of await this.batchesClient().results(batchId)) {
2313
+ if (entry.result.type !== "succeeded") {
2314
+ out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
2315
+ continue;
2316
+ }
2317
+ const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
2318
+ try {
2319
+ out.set(entry.custom_id, { type: "items", items: parseReplyItems(text) });
2320
+ } catch (err) {
2321
+ if (!(err instanceof MalformedReplyError)) throw err;
2322
+ out.set(entry.custom_id, { type: "malformed", raw: err.raw });
2323
+ }
2324
+ }
2325
+ return out;
2326
+ }
2327
+ async cancelTranslationBatch(batchId) {
2328
+ await this.batchesClient().cancel(batchId);
2329
+ }
2280
2330
  async callBatch(batch, signal) {
2281
2331
  const content = this.buildUserContent(batch);
2282
2332
  const res = await this.client.messages.create({
@@ -3050,6 +3100,143 @@ var init_run = __esm({
3050
3100
  }
3051
3101
  });
3052
3102
 
3103
+ // src/server/ai/pending-batch.ts
3104
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync3, rmSync as rmSync4 } from "fs";
3105
+ import { join as join3 } from "path";
3106
+ function pendingBatchPath(projectRoot) {
3107
+ return join3(projectRoot, ".glotfile", "batch.json");
3108
+ }
3109
+ function loadPendingBatch(projectRoot) {
3110
+ const path = pendingBatchPath(projectRoot);
3111
+ if (!existsSync6(path)) return void 0;
3112
+ try {
3113
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
3114
+ if (parsed?.version !== 1) return void 0;
3115
+ return parsed;
3116
+ } catch {
3117
+ return void 0;
3118
+ }
3119
+ }
3120
+ function savePendingBatch(projectRoot, pending) {
3121
+ const dir = join3(projectRoot, ".glotfile");
3122
+ mkdirSync4(dir, { recursive: true });
3123
+ const gitignore = join3(dir, ".gitignore");
3124
+ if (!existsSync6(gitignore)) writeFileSync3(gitignore, "*\n");
3125
+ writeFileSync3(pendingBatchPath(projectRoot), JSON.stringify(pending, null, 2) + "\n");
3126
+ }
3127
+ function clearPendingBatch(projectRoot) {
3128
+ rmSync4(pendingBatchPath(projectRoot), { force: true });
3129
+ }
3130
+ var init_pending_batch = __esm({
3131
+ "src/server/ai/pending-batch.ts"() {
3132
+ "use strict";
3133
+ }
3134
+ });
3135
+
3136
+ // src/server/ai/batch-run.ts
3137
+ function buildBatchJobs(reqs, batchSize) {
3138
+ const byLocale = /* @__PURE__ */ new Map();
3139
+ for (const req of reqs) {
3140
+ let group = byLocale.get(req.targetLocale);
3141
+ if (!group) {
3142
+ group = [];
3143
+ byLocale.set(req.targetLocale, group);
3144
+ }
3145
+ group.push(req);
3146
+ }
3147
+ const jobs = [];
3148
+ for (const [locale, group] of byLocale) {
3149
+ chunk(group, Math.max(1, batchSize)).forEach((batch, i) => {
3150
+ const safeLocale = locale.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 56);
3151
+ jobs.push({ customId: `${safeLocale}_${i}`, locale, requests: batch });
3152
+ });
3153
+ }
3154
+ return jobs;
3155
+ }
3156
+ async function submitBatchTranslation(state, provider, reqs, batchSize, model, projectRoot) {
3157
+ if (loadPendingBatch(projectRoot)) {
3158
+ throw new Error("A translation batch is already pending. Apply or cancel it first (`glotfile batch`).");
3159
+ }
3160
+ const jobs = buildBatchJobs(reqs, batchSize);
3161
+ const batchId = await provider.submitTranslationBatch(jobs);
3162
+ const pending = {
3163
+ version: 1,
3164
+ // Only Anthropic implements batch translation today.
3165
+ provider: "anthropic",
3166
+ model,
3167
+ batchId,
3168
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3169
+ total: reqs.length,
3170
+ jobs: jobs.map((j) => ({
3171
+ customId: j.customId,
3172
+ locale: j.locale,
3173
+ requests: j.requests.map((r) => {
3174
+ const { image: _image, ...rest } = r;
3175
+ return { ...rest, sourceHash: sourceHash(state.keys[r.key], state.config.sourceLocale) };
3176
+ })
3177
+ }))
3178
+ };
3179
+ savePendingBatch(projectRoot, pending);
3180
+ return pending;
3181
+ }
3182
+ async function applyBatchResults(load, persist, provider, pending, projectRoot, ai) {
3183
+ const outcomes = await provider.translationBatchResults(pending.batchId);
3184
+ const fresh = load();
3185
+ const isStale = (r) => {
3186
+ const entry = fresh.keys[r.key];
3187
+ return !entry || sourceHash(entry, fresh.config.sourceLocale) !== r.sourceHash;
3188
+ };
3189
+ const applied = [];
3190
+ const results = [];
3191
+ const retryReqs = [];
3192
+ let staleSkipped = 0;
3193
+ for (const job of pending.jobs) {
3194
+ const outcome = outcomes.get(job.customId);
3195
+ const itemsById = outcome?.type === "items" ? new Map(outcome.items.map((i) => [i.id, i])) : null;
3196
+ for (const stored of job.requests) {
3197
+ if (isStale(stored)) {
3198
+ staleSkipped++;
3199
+ continue;
3200
+ }
3201
+ const { sourceHash: _hash, ...req } = stored;
3202
+ if (!itemsById) {
3203
+ retryReqs.push(req);
3204
+ continue;
3205
+ }
3206
+ applied.push(req);
3207
+ results.push(validateReply(req, itemsById.get(req.id)));
3208
+ }
3209
+ }
3210
+ let screenshotsSkipped = 0;
3211
+ if (retryReqs.length) {
3212
+ const { skipped } = attachScreenshotsForProvider(retryReqs, fresh, projectRoot, provider.supportsVision());
3213
+ screenshotsSkipped = skipped;
3214
+ const retryResults = await runLocaleParallel(
3215
+ retryReqs,
3216
+ provider,
3217
+ {},
3218
+ ai.concurrency,
3219
+ void 0,
3220
+ ai.batchSize
3221
+ );
3222
+ applied.push(...retryReqs);
3223
+ results.push(...retryResults);
3224
+ }
3225
+ const { written, errors } = applyResults(fresh, applied, results);
3226
+ persist(fresh);
3227
+ clearPendingBatch(projectRoot);
3228
+ return { written, errors, staleSkipped, retried: retryReqs.length, screenshotsSkipped };
3229
+ }
3230
+ var init_batch_run = __esm({
3231
+ "src/server/ai/batch-run.ts"() {
3232
+ "use strict";
3233
+ init_suppress();
3234
+ init_batch();
3235
+ init_run();
3236
+ init_pending_batch();
3237
+ }
3238
+ });
3239
+
3053
3240
  // src/server/ai/pricing.ts
3054
3241
  function bareModelId(model) {
3055
3242
  let id = model.trim().toLowerCase();
@@ -3163,7 +3350,7 @@ var init_estimate = __esm({
3163
3350
  });
3164
3351
 
3165
3352
  // src/server/log.ts
3166
- import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
3353
+ import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
3167
3354
  import { resolve as resolve5 } from "path";
3168
3355
  function logPath(projectRoot) {
3169
3356
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
@@ -3174,8 +3361,8 @@ function appendLog(projectRoot, entry) {
3174
3361
  }
3175
3362
  function readLog(projectRoot, limit = 100) {
3176
3363
  const path = logPath(projectRoot);
3177
- if (!existsSync6(path)) return [];
3178
- const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3364
+ if (!existsSync7(path)) return [];
3365
+ const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3179
3366
  const entries = lines.map((l) => JSON.parse(l));
3180
3367
  return entries.reverse().slice(0, limit);
3181
3368
  }
@@ -3187,13 +3374,13 @@ var init_log = __esm({
3187
3374
  });
3188
3375
 
3189
3376
  // src/server/scan.ts
3190
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
3377
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
3191
3378
  import { resolve as resolve6 } from "path";
3192
3379
  function loadUsageCache(projectRoot) {
3193
3380
  const path = resolve6(projectRoot, ".glotfile", "usage.json");
3194
- if (!existsSync7(path)) return null;
3381
+ if (!existsSync8(path)) return null;
3195
3382
  try {
3196
- return JSON.parse(readFileSync7(path, "utf8"));
3383
+ return JSON.parse(readFileSync8(path, "utf8"));
3197
3384
  } catch {
3198
3385
  return null;
3199
3386
  }
@@ -3258,8 +3445,8 @@ var init_scan = __esm({
3258
3445
  });
3259
3446
 
3260
3447
  // src/server/scanner.ts
3261
- import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync8 } from "fs";
3262
- import { join as join3, extname as extname2, relative } from "path";
3448
+ import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync9 } from "fs";
3449
+ import { join as join4, extname as extname2, relative } from "path";
3263
3450
  function scannerForExt(ext) {
3264
3451
  return EXT_SCANNER[ext] ?? null;
3265
3452
  }
@@ -3411,7 +3598,7 @@ function* walkFiles(dir, root, exclude) {
3411
3598
  }
3412
3599
  for (const name of entries) {
3413
3600
  if (ALWAYS_EXCLUDE.has(name)) continue;
3414
- const abs = join3(dir, name);
3601
+ const abs = join4(dir, name);
3415
3602
  const rel = relative(root, abs);
3416
3603
  let st;
3417
3604
  try {
@@ -3441,7 +3628,7 @@ function runScan(projectRoot, opts, existing) {
3441
3628
  const ext = extname2(relPath);
3442
3629
  const scanner = scannerForExt(ext);
3443
3630
  if (!scanner) continue;
3444
- const abs = join3(projectRoot, relPath);
3631
+ const abs = join4(projectRoot, relPath);
3445
3632
  let st;
3446
3633
  try {
3447
3634
  st = statSync2(abs);
@@ -3457,7 +3644,7 @@ function runScan(projectRoot, opts, existing) {
3457
3644
  }
3458
3645
  let content;
3459
3646
  try {
3460
- content = readFileSync8(abs, "utf8");
3647
+ content = readFileSync9(abs, "utf8");
3461
3648
  } catch {
3462
3649
  continue;
3463
3650
  }
@@ -3573,7 +3760,7 @@ var init_scanner = __esm({
3573
3760
  });
3574
3761
 
3575
3762
  // src/server/ai/context.ts
3576
- import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
3763
+ import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
3577
3764
  import { resolve as resolve7 } from "path";
3578
3765
  function globToRegExp2(glob) {
3579
3766
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -3588,8 +3775,8 @@ function extractSnippets(refs, projectRoot, fileCache) {
3588
3775
  for (const ref of selected) {
3589
3776
  const absPath = resolve7(projectRoot, ref.file);
3590
3777
  if (!fileCache.has(ref.file)) {
3591
- if (!existsSync8(absPath)) continue;
3592
- const content = readFileSync9(absPath, "utf8");
3778
+ if (!existsSync9(absPath)) continue;
3779
+ const content = readFileSync10(absPath, "utf8");
3593
3780
  fileCache.set(ref.file, content.split("\n"));
3594
3781
  }
3595
3782
  const lines = fileCache.get(ref.file);
@@ -4105,7 +4292,7 @@ var init_run2 = __esm({
4105
4292
  });
4106
4293
 
4107
4294
  // src/server/lint/outputs.ts
4108
- import { readFileSync as readFileSync10, existsSync as existsSync9 } from "fs";
4295
+ import { readFileSync as readFileSync11, existsSync as existsSync10 } from "fs";
4109
4296
  import { resolve as resolve8 } from "path";
4110
4297
  function checkOutputs(state, root) {
4111
4298
  const out = [];
@@ -4113,7 +4300,7 @@ function checkOutputs(state, root) {
4113
4300
  const result = getAdapter(output.adapter).export(state, output);
4114
4301
  for (const file of result.files) {
4115
4302
  const abs = resolve8(root, file.path);
4116
- const current = existsSync9(abs) ? readFileSync10(abs, "utf8") : null;
4303
+ const current = existsSync10(abs) ? readFileSync11(abs, "utf8") : null;
4117
4304
  if (current === null) {
4118
4305
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4119
4306
  } else if (current !== file.contents) {
@@ -4158,8 +4345,8 @@ var init_accept = __esm({
4158
4345
  });
4159
4346
 
4160
4347
  // src/server/import/detect.ts
4161
- import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync11, statSync as statSync3 } from "fs";
4162
- import { join as join4 } from "path";
4348
+ import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
4349
+ import { join as join5 } from "path";
4163
4350
  function safeIsDir(p) {
4164
4351
  try {
4165
4352
  return statSync3(p).isDirectory();
@@ -4168,7 +4355,7 @@ function safeIsDir(p) {
4168
4355
  }
4169
4356
  }
4170
4357
  function listDirs(dir) {
4171
- return readdirSync4(dir).filter((e) => safeIsDir(join4(dir, e)));
4358
+ return readdirSync4(dir).filter((e) => safeIsDir(join5(dir, e)));
4172
4359
  }
4173
4360
  function fileCount(dir) {
4174
4361
  try {
@@ -4182,23 +4369,23 @@ function pickSource(locales, sizeOf) {
4182
4369
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4183
4370
  }
4184
4371
  function detectLaravel(root) {
4185
- const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
4372
+ const localeRoot = [join5(root, "resources", "lang"), join5(root, "lang")].find(safeIsDir);
4186
4373
  if (!localeRoot) return null;
4187
4374
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4188
4375
  if (locales.length === 0) return null;
4189
- const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
4376
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join5(localeRoot, loc)));
4190
4377
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
4191
4378
  }
4192
4379
  function detectVue(root, forced = false) {
4193
4380
  for (const rel of VUE_DIR_CANDIDATES) {
4194
- const localeRoot = join4(root, rel);
4381
+ const localeRoot = join5(root, rel);
4195
4382
  if (!safeIsDir(localeRoot)) continue;
4196
4383
  const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4197
4384
  const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4198
4385
  if (enough) {
4199
4386
  const sourceLocale = pickSource(locales, (loc) => {
4200
4387
  try {
4201
- return statSync3(join4(localeRoot, `${loc}.json`)).size;
4388
+ return statSync3(join5(localeRoot, `${loc}.json`)).size;
4202
4389
  } catch {
4203
4390
  return 0;
4204
4391
  }
@@ -4210,7 +4397,7 @@ function detectVue(root, forced = false) {
4210
4397
  }
4211
4398
  function detectArb(root) {
4212
4399
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4213
- const localeRoot = join4(root, rel);
4400
+ const localeRoot = join5(root, rel);
4214
4401
  if (!safeIsDir(localeRoot)) continue;
4215
4402
  const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4216
4403
  if (locales.length >= 1) {
@@ -4220,10 +4407,10 @@ function detectArb(root) {
4220
4407
  return null;
4221
4408
  }
4222
4409
  function lprojLocales(dir) {
4223
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join4(dir, `${l}.lproj`, "Localizable.strings")));
4410
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join5(dir, `${l}.lproj`, "Localizable.strings")));
4224
4411
  }
4225
4412
  function detectApple(root) {
4226
- const candidates = [root, ...listDirs(root).map((d) => join4(root, d))];
4413
+ const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4227
4414
  let best = null;
4228
4415
  for (const dir of candidates) {
4229
4416
  const locales = lprojLocales(dir);
@@ -4235,7 +4422,7 @@ function detectApple(root) {
4235
4422
  locales,
4236
4423
  sourceLocale: pickSource(locales, (loc) => {
4237
4424
  try {
4238
- return statSync3(join4(dir, `${loc}.lproj`, "Localizable.strings")).size;
4425
+ return statSync3(join5(dir, `${loc}.lproj`, "Localizable.strings")).size;
4239
4426
  } catch {
4240
4427
  return 0;
4241
4428
  }
@@ -4247,7 +4434,7 @@ function detectApple(root) {
4247
4434
  }
4248
4435
  function detectAngularXliff(root) {
4249
4436
  for (const rel of ANGULAR_DIR_CANDIDATES) {
4250
- const localeRoot = rel === "." ? root : join4(root, rel);
4437
+ const localeRoot = rel === "." ? root : join5(root, rel);
4251
4438
  if (!safeIsDir(localeRoot)) continue;
4252
4439
  const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4253
4440
  if (files.length === 0) continue;
@@ -4255,7 +4442,7 @@ function detectAngularXliff(root) {
4255
4442
  const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4256
4443
  let sourceLocale;
4257
4444
  try {
4258
- sourceLocale = readFileSync11(join4(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4445
+ sourceLocale = readFileSync12(join5(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4259
4446
  } catch {
4260
4447
  }
4261
4448
  if (!sourceLocale && locales.length === 0) continue;
@@ -4266,14 +4453,14 @@ function detectAngularXliff(root) {
4266
4453
  return null;
4267
4454
  }
4268
4455
  function detectRails(root) {
4269
- const localeRoot = join4(root, "config", "locales");
4456
+ const localeRoot = join5(root, "config", "locales");
4270
4457
  if (!safeIsDir(localeRoot)) return null;
4271
4458
  const locales = [];
4272
4459
  for (const file of readdirSync4(localeRoot).sort()) {
4273
4460
  if (!/\.ya?ml$/.test(file)) continue;
4274
4461
  let text;
4275
4462
  try {
4276
- text = readFileSync11(join4(localeRoot, file), "utf8");
4463
+ text = readFileSync12(join5(localeRoot, file), "utf8");
4277
4464
  } catch {
4278
4465
  continue;
4279
4466
  }
@@ -4287,15 +4474,15 @@ function detectRails(root) {
4287
4474
  }
4288
4475
  function detectI18next(root) {
4289
4476
  for (const rel of I18NEXT_DIR_CANDIDATES) {
4290
- const localeRoot = join4(root, rel);
4477
+ const localeRoot = join5(root, rel);
4291
4478
  if (!safeIsDir(localeRoot)) continue;
4292
4479
  const locales = listDirs(localeRoot).filter(
4293
- (d) => LOCALE_RE.test(d) && readdirSync4(join4(localeRoot, d)).some((f) => f.endsWith(".json"))
4480
+ (d) => LOCALE_RE.test(d) && readdirSync4(join5(localeRoot, d)).some((f) => f.endsWith(".json"))
4294
4481
  );
4295
4482
  if (locales.length === 0) continue;
4296
4483
  const sourceLocale = pickSource(locales, (loc) => {
4297
4484
  try {
4298
- return readdirSync4(join4(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join4(localeRoot, loc, f)).size, 0);
4485
+ return readdirSync4(join5(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join5(localeRoot, loc, f)).size, 0);
4299
4486
  } catch {
4300
4487
  return 0;
4301
4488
  }
@@ -4312,8 +4499,8 @@ function gettextLocales(dir) {
4312
4499
  if (!locales.includes(flat)) locales.push(flat);
4313
4500
  continue;
4314
4501
  }
4315
- if (!LOCALE_RE.test(entry) || !safeIsDir(join4(dir, entry))) continue;
4316
- const sub = join4(dir, entry);
4502
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join5(dir, entry))) continue;
4503
+ const sub = join5(dir, entry);
4317
4504
  const hasPo = (d) => {
4318
4505
  try {
4319
4506
  return readdirSync4(d).some((f) => f.endsWith(".po"));
@@ -4321,7 +4508,7 @@ function gettextLocales(dir) {
4321
4508
  return false;
4322
4509
  }
4323
4510
  };
4324
- if (hasPo(join4(sub, "LC_MESSAGES")) || hasPo(sub)) {
4511
+ if (hasPo(join5(sub, "LC_MESSAGES")) || hasPo(sub)) {
4325
4512
  if (!locales.includes(entry)) locales.push(entry);
4326
4513
  }
4327
4514
  }
@@ -4329,7 +4516,7 @@ function gettextLocales(dir) {
4329
4516
  }
4330
4517
  function detectGettext(root) {
4331
4518
  for (const rel of GETTEXT_DIR_CANDIDATES) {
4332
- const localeRoot = join4(root, rel);
4519
+ const localeRoot = join5(root, rel);
4333
4520
  if (!safeIsDir(localeRoot)) continue;
4334
4521
  const locales = gettextLocales(localeRoot);
4335
4522
  if (locales.length === 0) continue;
@@ -4338,10 +4525,10 @@ function detectGettext(root) {
4338
4525
  return null;
4339
4526
  }
4340
4527
  function detectAppleStringsdict(root) {
4341
- const candidates = [root, ...listDirs(root).map((d) => join4(root, d))];
4528
+ const candidates = [root, ...listDirs(root).map((d) => join5(root, d))];
4342
4529
  let best = null;
4343
4530
  for (const dir of candidates) {
4344
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join4(dir, `${l}.lproj`, "Localizable.stringsdict")));
4531
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join5(dir, `${l}.lproj`, "Localizable.stringsdict")));
4345
4532
  if (locales.length === 0) continue;
4346
4533
  if (!best || locales.length > best.locales.length) {
4347
4534
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4350,7 +4537,7 @@ function detectAppleStringsdict(root) {
4350
4537
  return best;
4351
4538
  }
4352
4539
  function detect(root, formatOverride) {
4353
- if (!existsSync10(root)) return null;
4540
+ if (!existsSync11(root)) return null;
4354
4541
  if (formatOverride) {
4355
4542
  const fn = BY_FORMAT[formatOverride];
4356
4543
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4424,8 +4611,8 @@ var init_flatten = __esm({
4424
4611
  });
4425
4612
 
4426
4613
  // src/server/import/parsers/vue-i18n-json.ts
4427
- import { readdirSync as readdirSync5, readFileSync as readFileSync12 } from "fs";
4428
- import { join as join5 } from "path";
4614
+ import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4615
+ import { join as join6 } from "path";
4429
4616
  var LOCALE_RE2, vueI18nJson2;
4430
4617
  var init_vue_i18n_json2 = __esm({
4431
4618
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -4445,7 +4632,7 @@ var init_vue_i18n_json2 = __esm({
4445
4632
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4446
4633
  let data;
4447
4634
  try {
4448
- data = JSON.parse(readFileSync12(join5(localeRoot, file), "utf8"));
4635
+ data = JSON.parse(readFileSync13(join6(localeRoot, file), "utf8"));
4449
4636
  } catch (e) {
4450
4637
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4451
4638
  continue;
@@ -4473,16 +4660,16 @@ var init_placeholders2 = __esm({
4473
4660
 
4474
4661
  // src/server/import/parsers/laravel-php.ts
4475
4662
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4476
- import { join as join6, relative as relative2 } from "path";
4663
+ import { join as join7, relative as relative2 } from "path";
4477
4664
  import { execFileSync } from "child_process";
4478
4665
  function listDirs2(dir) {
4479
- return readdirSync6(dir).filter((e) => statSync4(join6(dir, e)).isDirectory());
4666
+ return readdirSync6(dir).filter((e) => statSync4(join7(dir, e)).isDirectory());
4480
4667
  }
4481
4668
  function listPhpFiles(dir) {
4482
4669
  const out = [];
4483
4670
  const walk = (d) => {
4484
4671
  for (const e of readdirSync6(d)) {
4485
- const full = join6(d, e);
4672
+ const full = join7(d, e);
4486
4673
  if (statSync4(full).isDirectory()) walk(full);
4487
4674
  else if (e.endsWith(".php")) out.push(full);
4488
4675
  }
@@ -4525,7 +4712,7 @@ var init_laravel_php2 = __esm({
4525
4712
  for (const locale of listDirs2(localeRoot).sort()) {
4526
4713
  if (locale === "vendor") continue;
4527
4714
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4528
- const localeDir = join6(localeRoot, locale);
4715
+ const localeDir = join7(localeRoot, locale);
4529
4716
  locales.push(locale);
4530
4717
  for (const file of listPhpFiles(localeDir)) {
4531
4718
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -4550,8 +4737,8 @@ var init_laravel_php2 = __esm({
4550
4737
  });
4551
4738
 
4552
4739
  // src/server/import/parsers/flutter-arb.ts
4553
- import { readdirSync as readdirSync7, readFileSync as readFileSync13 } from "fs";
4554
- import { join as join7 } from "path";
4740
+ import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
4741
+ import { join as join8 } from "path";
4555
4742
  function localeFromArbName(file) {
4556
4743
  const m = file.match(/^(.+)\.arb$/);
4557
4744
  if (!m) return null;
@@ -4591,7 +4778,7 @@ var init_flutter_arb2 = __esm({
4591
4778
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4592
4779
  let data;
4593
4780
  try {
4594
- data = JSON.parse(readFileSync13(join7(localeRoot, file), "utf8"));
4781
+ data = JSON.parse(readFileSync14(join8(localeRoot, file), "utf8"));
4595
4782
  } catch (e) {
4596
4783
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4597
4784
  continue;
@@ -4618,8 +4805,8 @@ var init_flutter_arb2 = __esm({
4618
4805
  });
4619
4806
 
4620
4807
  // src/server/import/parsers/apple-strings.ts
4621
- import { readdirSync as readdirSync8, readFileSync as readFileSync14, statSync as statSync5 } from "fs";
4622
- import { join as join8 } from "path";
4808
+ import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
4809
+ import { join as join9 } from "path";
4623
4810
  function localeFromLproj(dir) {
4624
4811
  const m = dir.match(/^(.+)\.lproj$/);
4625
4812
  if (!m) return null;
@@ -4727,16 +4914,16 @@ var init_apple_strings2 = __esm({
4727
4914
  const locale = localeFromLproj(dir);
4728
4915
  if (!locale) continue;
4729
4916
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4730
- const file = join8(localeRoot, dir, TABLE);
4917
+ const file = join9(localeRoot, dir, TABLE);
4731
4918
  let text;
4732
4919
  try {
4733
4920
  if (!statSync5(file).isFile()) continue;
4734
- text = readFileSync14(file, "utf8");
4921
+ text = readFileSync15(file, "utf8");
4735
4922
  } catch {
4736
4923
  continue;
4737
4924
  }
4738
4925
  locales.push(locale);
4739
- const others = readdirSync8(join8(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4926
+ const others = readdirSync8(join9(localeRoot, dir)).filter((f) => f.endsWith(".strings") && f !== TABLE);
4740
4927
  if (others.length) {
4741
4928
  warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
4742
4929
  }
@@ -4751,8 +4938,8 @@ var init_apple_strings2 = __esm({
4751
4938
  });
4752
4939
 
4753
4940
  // src/server/import/parsers/angular-xliff.ts
4754
- import { readdirSync as readdirSync9, readFileSync as readFileSync15 } from "fs";
4755
- import { join as join9 } from "path";
4941
+ import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
4942
+ import { join as join10 } from "path";
4756
4943
  function decodeEntities(s) {
4757
4944
  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, "&");
4758
4945
  }
@@ -4804,7 +4991,7 @@ var init_angular_xliff2 = __esm({
4804
4991
  if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
4805
4992
  let xml;
4806
4993
  try {
4807
- xml = readFileSync15(join9(localeRoot, file), "utf8");
4994
+ xml = readFileSync16(join10(localeRoot, file), "utf8");
4808
4995
  } catch (e) {
4809
4996
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
4810
4997
  continue;
@@ -4847,8 +5034,8 @@ var init_angular_xliff2 = __esm({
4847
5034
  });
4848
5035
 
4849
5036
  // src/server/import/parsers/gettext-po.ts
4850
- import { readdirSync as readdirSync10, readFileSync as readFileSync16 } from "fs";
4851
- import { join as join10 } from "path";
5037
+ import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
5038
+ import { join as join11 } from "path";
4852
5039
  function unescapePo(s) {
4853
5040
  return s.replace(
4854
5041
  /\\([\\"ntr])/g,
@@ -4919,17 +5106,17 @@ function discoverPoFiles(root) {
4919
5106
  for (const e of entries) {
4920
5107
  if (e.isFile() && e.name.endsWith(".po")) {
4921
5108
  const base = e.name.slice(0, -3);
4922
- found.push({ path: join10(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
5109
+ found.push({ path: join11(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4923
5110
  } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
4924
- for (const sub of [join10(e.name, "LC_MESSAGES"), e.name]) {
5111
+ for (const sub of [join11(e.name, "LC_MESSAGES"), e.name]) {
4925
5112
  let names;
4926
5113
  try {
4927
- names = readdirSync10(join10(root, sub)).sort();
5114
+ names = readdirSync10(join11(root, sub)).sort();
4928
5115
  } catch {
4929
5116
  continue;
4930
5117
  }
4931
5118
  for (const f of names) {
4932
- if (f.endsWith(".po")) found.push({ path: join10(root, sub, f), rel: join10(sub, f), locale: e.name });
5119
+ if (f.endsWith(".po")) found.push({ path: join11(root, sub, f), rel: join11(sub, f), locale: e.name });
4933
5120
  }
4934
5121
  }
4935
5122
  }
@@ -4953,7 +5140,7 @@ var init_gettext_po2 = __esm({
4953
5140
  for (const file of discoverPoFiles(localeRoot)) {
4954
5141
  let entries;
4955
5142
  try {
4956
- entries = parseEntries(readFileSync16(file.path, "utf8"));
5143
+ entries = parseEntries(readFileSync17(file.path, "utf8"));
4957
5144
  } catch (e) {
4958
5145
  warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
4959
5146
  continue;
@@ -5000,8 +5187,8 @@ var init_gettext_po2 = __esm({
5000
5187
  });
5001
5188
 
5002
5189
  // src/server/import/parsers/i18next-json.ts
5003
- import { readdirSync as readdirSync11, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
5004
- import { join as join11 } from "path";
5190
+ import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5191
+ import { join as join12 } from "path";
5005
5192
  function safeIsDir2(p) {
5006
5193
  try {
5007
5194
  return statSync6(p).isDirectory();
@@ -5016,7 +5203,7 @@ function fromI18next(value) {
5016
5203
  function ingestFile(path, label, prefix, locale, keys, warnings) {
5017
5204
  let data;
5018
5205
  try {
5019
- data = JSON.parse(readFileSync17(path, "utf8"));
5206
+ data = JSON.parse(readFileSync18(path, "utf8"));
5020
5207
  } catch (e) {
5021
5208
  warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5022
5209
  return false;
@@ -5069,7 +5256,7 @@ var init_i18next_json2 = __esm({
5069
5256
  const keys = {};
5070
5257
  const locales = [];
5071
5258
  for (const entry of readdirSync11(localeRoot).sort()) {
5072
- const full = join11(localeRoot, entry);
5259
+ const full = join12(localeRoot, entry);
5073
5260
  if (safeIsDir2(full)) {
5074
5261
  if (!LOCALE_RE7.test(entry)) continue;
5075
5262
  if (opts?.locales && !opts.locales.includes(entry)) continue;
@@ -5078,7 +5265,7 @@ var init_i18next_json2 = __esm({
5078
5265
  if (!file.endsWith(".json")) continue;
5079
5266
  const ns = file.slice(0, -".json".length);
5080
5267
  const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5081
- if (ingestFile(join11(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5268
+ if (ingestFile(join12(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5082
5269
  }
5083
5270
  if (any && !locales.includes(entry)) locales.push(entry);
5084
5271
  } else if (entry.endsWith(".json")) {
@@ -5097,8 +5284,8 @@ var init_i18next_json2 = __esm({
5097
5284
  });
5098
5285
 
5099
5286
  // src/server/import/parsers/rails-yaml.ts
5100
- import { readdirSync as readdirSync12, readFileSync as readFileSync18 } from "fs";
5101
- import { join as join12 } from "path";
5287
+ import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5288
+ import { join as join13 } from "path";
5102
5289
  function fromRuby(value) {
5103
5290
  return value.replace(/%\{(\w+)\}/g, "{$1}");
5104
5291
  }
@@ -5315,7 +5502,7 @@ var init_rails_yaml2 = __esm({
5315
5502
  if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5316
5503
  let text;
5317
5504
  try {
5318
- text = readFileSync18(join12(localeRoot, file), "utf8");
5505
+ text = readFileSync19(join13(localeRoot, file), "utf8");
5319
5506
  } catch (e) {
5320
5507
  warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5321
5508
  continue;
@@ -5338,8 +5525,8 @@ var init_rails_yaml2 = __esm({
5338
5525
  });
5339
5526
 
5340
5527
  // src/server/import/parsers/apple-stringsdict.ts
5341
- import { readdirSync as readdirSync13, readFileSync as readFileSync19, statSync as statSync7 } from "fs";
5342
- import { join as join13 } from "path";
5528
+ import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5529
+ import { join as join14 } from "path";
5343
5530
  function localeFromLproj2(dir) {
5344
5531
  const m = dir.match(/^(.+)\.lproj$/);
5345
5532
  if (!m) return null;
@@ -5481,16 +5668,16 @@ var init_apple_stringsdict2 = __esm({
5481
5668
  const locale = localeFromLproj2(dir);
5482
5669
  if (!locale) continue;
5483
5670
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5484
- const file = join13(localeRoot, dir, TABLE2);
5671
+ const file = join14(localeRoot, dir, TABLE2);
5485
5672
  let text;
5486
5673
  try {
5487
5674
  if (!statSync7(file).isFile()) continue;
5488
- text = readFileSync19(file, "utf8");
5675
+ text = readFileSync20(file, "utf8");
5489
5676
  } catch {
5490
5677
  continue;
5491
5678
  }
5492
5679
  locales.push(locale);
5493
- const others = readdirSync13(join13(localeRoot, dir)).filter(
5680
+ const others = readdirSync13(join14(localeRoot, dir)).filter(
5494
5681
  (f) => f.endsWith(".stringsdict") && f !== TABLE2
5495
5682
  );
5496
5683
  if (others.length) {
@@ -5949,12 +6136,12 @@ var init_checks = __esm({
5949
6136
  });
5950
6137
 
5951
6138
  // src/server/ui-prefs.ts
5952
- import { readFileSync as readFileSync20 } from "fs";
6139
+ import { readFileSync as readFileSync21 } from "fs";
5953
6140
  import { homedir } from "os";
5954
- import { join as join14 } from "path";
6141
+ import { join as join15 } from "path";
5955
6142
  function readJson2(path) {
5956
6143
  try {
5957
- const parsed = JSON.parse(readFileSync20(path, "utf8"));
6144
+ const parsed = JSON.parse(readFileSync21(path, "utf8"));
5958
6145
  return parsed && typeof parsed === "object" ? parsed : {};
5959
6146
  } catch {
5960
6147
  return {};
@@ -5979,7 +6166,7 @@ var init_ui_prefs = __esm({
5979
6166
  THEMES = ["system", "light", "dark"];
5980
6167
  isThemeMode = (v) => THEMES.includes(v);
5981
6168
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
5982
- defaultUiPrefsPath = () => join14(homedir(), ".glotfile", "ui.json");
6169
+ defaultUiPrefsPath = () => join15(homedir(), ".glotfile", "ui.json");
5983
6170
  DEFAULTS = { theme: "system" };
5984
6171
  }
5985
6172
  });
@@ -5987,13 +6174,13 @@ var init_ui_prefs = __esm({
5987
6174
  // src/server/api.ts
5988
6175
  import { Hono } from "hono";
5989
6176
  import { streamSSE } from "hono/streaming";
5990
- import { readFileSync as readFileSync21, existsSync as existsSync11, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync4 } from "fs";
6177
+ import { readFileSync as readFileSync22, existsSync as existsSync12, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync5 } from "fs";
5991
6178
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
5992
6179
  function projectName(root) {
5993
6180
  const nameFile = resolve9(root, ".idea", ".name");
5994
- if (existsSync11(nameFile)) {
6181
+ if (existsSync12(nameFile)) {
5995
6182
  try {
5996
- const name = readFileSync21(nameFile, "utf8").trim();
6183
+ const name = readFileSync22(nameFile, "utf8").trim();
5997
6184
  if (name) return name;
5998
6185
  } catch {
5999
6186
  }
@@ -6126,7 +6313,7 @@ function createApi(deps) {
6126
6313
  if (name.startsWith(".") || name === "node_modules") continue;
6127
6314
  const abs = resolve9(dir, name);
6128
6315
  let filePath = null;
6129
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync11(resolve9(abs, "config.json"))) {
6316
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync12(resolve9(abs, "config.json"))) {
6130
6317
  filePath = resolve9(dir, `${name}.json`);
6131
6318
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
6132
6319
  filePath = abs;
@@ -6160,7 +6347,7 @@ function createApi(deps) {
6160
6347
  const resolved = resolve9(projectRoot, path);
6161
6348
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep2);
6162
6349
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
6163
- if (!existsSync11(resolved)) return c.json({ error: "file not found" }, 400);
6350
+ if (!existsSync12(resolved)) return c.json({ error: "file not found" }, 400);
6164
6351
  loadState(resolved);
6165
6352
  deps.statePath = resolved;
6166
6353
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -6221,9 +6408,9 @@ function createApi(deps) {
6221
6408
  const abs = resolve9(root, screenshot);
6222
6409
  const rel = relative4(root, abs);
6223
6410
  const seg0 = rel.split(sep2)[0] ?? "";
6224
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync11(abs)) {
6411
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync12(abs)) {
6225
6412
  try {
6226
- rmSync4(abs);
6413
+ rmSync5(abs);
6227
6414
  } catch {
6228
6415
  }
6229
6416
  }
@@ -6761,6 +6948,104 @@ function createApi(deps) {
6761
6948
  const ai = loadLocalSettings(projectRoot).ai;
6762
6949
  return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
6763
6950
  });
6951
+ app.get("/batch/status", async (c) => {
6952
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6953
+ let supported = false;
6954
+ let provider;
6955
+ try {
6956
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6957
+ supported = supportsBatchTranslate(provider);
6958
+ } catch {
6959
+ }
6960
+ const pending = loadPendingBatch(projectRoot);
6961
+ if (!pending) return c.json({ supported, pending: null });
6962
+ const base = { batchId: pending.batchId, createdAt: pending.createdAt, model: pending.model, total: pending.total };
6963
+ if (!provider || !supportsBatchTranslate(provider)) {
6964
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null } });
6965
+ }
6966
+ try {
6967
+ const status = await provider.translationBatchStatus(pending.batchId);
6968
+ return c.json({ supported, pending: { ...base, status: status.status, counts: status.counts } });
6969
+ } catch (e) {
6970
+ return c.json({ supported, pending: { ...base, status: "unknown", counts: null, error: e.message } });
6971
+ }
6972
+ });
6973
+ app.post("/batch/translate", (c) => withTranslateLock(async () => {
6974
+ const body = await c.req.json().catch(() => ({}));
6975
+ const s = load();
6976
+ const reqs = selectRequests(s, {
6977
+ onlyMissing: body.onlyMissing ?? true,
6978
+ keys: Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0,
6979
+ locales: Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0
6980
+ });
6981
+ if (!reqs.length) return c.json({ error: "Nothing to translate." }, 400);
6982
+ const aiCfg = loadLocalSettings(projectRoot).ai;
6983
+ let provider;
6984
+ try {
6985
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
6986
+ } catch (e) {
6987
+ return c.json({ error: e.message }, 400);
6988
+ }
6989
+ if (!supportsBatchTranslate(provider)) {
6990
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6991
+ }
6992
+ attachScreenshotsForProvider(reqs, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
6993
+ let pending;
6994
+ try {
6995
+ pending = await submitBatchTranslation(s, provider, reqs, aiCfg.batchSize, aiCfg.model, projectRoot);
6996
+ } catch (e) {
6997
+ return c.json({ error: e.message }, 409);
6998
+ }
6999
+ appendLog(projectRoot, {
7000
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7001
+ kind: "translate",
7002
+ summary: `Submitted batch ${pending.batchId} (${pending.total} items)`,
7003
+ model: aiCfg.model,
7004
+ system: buildSystemPrompt(reqs.some((r) => r.plural !== void 0)),
7005
+ 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 }))
7006
+ });
7007
+ console.log(`[batch] submitted ${pending.batchId} \u2014 ${pending.total} string(s)`);
7008
+ return c.json({ batchId: pending.batchId, total: pending.total });
7009
+ }));
7010
+ app.post("/batch/apply", (c) => withTranslateLock(async () => {
7011
+ const pending = loadPendingBatch(projectRoot);
7012
+ if (!pending) return c.json({ error: "No pending batch." }, 404);
7013
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7014
+ let provider;
7015
+ try {
7016
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7017
+ } catch (e) {
7018
+ return c.json({ error: e.message }, 400);
7019
+ }
7020
+ if (!supportsBatchTranslate(provider)) {
7021
+ return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
7022
+ }
7023
+ const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, {
7024
+ batchSize: aiCfg.batchSize,
7025
+ concurrency: aiCfg.concurrency
7026
+ });
7027
+ appendLog(projectRoot, {
7028
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7029
+ kind: "translate",
7030
+ summary: `Applied batch ${pending.batchId}: wrote ${outcome.written}, ${outcome.retried} retried, ${outcome.staleSkipped} stale`,
7031
+ model: aiCfg.model,
7032
+ results: []
7033
+ });
7034
+ console.log(`[batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
7035
+ return c.json(outcome);
7036
+ }));
7037
+ app.post("/batch/cancel", async (c) => {
7038
+ const pending = loadPendingBatch(projectRoot);
7039
+ if (!pending) return c.json({ error: "No pending batch." }, 404);
7040
+ const aiCfg = loadLocalSettings(projectRoot).ai;
7041
+ try {
7042
+ const provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
7043
+ if (supportsBatchTranslate(provider)) await provider.cancelTranslationBatch(pending.batchId);
7044
+ } catch {
7045
+ }
7046
+ clearPendingBatch(projectRoot);
7047
+ return c.json({ canceled: pending.batchId });
7048
+ });
6764
7049
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
6765
7050
  app.post("/scan", async (c) => {
6766
7051
  const s = load();
@@ -6941,6 +7226,8 @@ var init_api = __esm({
6941
7226
  init_ai();
6942
7227
  init_run();
6943
7228
  init_provider();
7229
+ init_batch_run();
7230
+ init_pending_batch();
6944
7231
  init_estimate();
6945
7232
  init_log();
6946
7233
  init_schema();
@@ -6963,7 +7250,7 @@ __export(server_exports, {
6963
7250
  import { Hono as Hono2 } from "hono";
6964
7251
  import { serve } from "@hono/node-server";
6965
7252
  import { fileURLToPath } from "url";
6966
- import { dirname as dirname4, join as join15, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
7253
+ import { dirname as dirname4, join as join16, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
6967
7254
  import { readFile, stat } from "fs/promises";
6968
7255
  import { createServer } from "net";
6969
7256
  import open from "open";
@@ -7006,7 +7293,7 @@ function buildApp(opts) {
7006
7293
  const file = await readFileResponse(target);
7007
7294
  if (file) return file;
7008
7295
  }
7009
- const index = await readFileResponse(join15(root, "index.html"));
7296
+ const index = await readFileResponse(join16(root, "index.html"));
7010
7297
  if (index) return index;
7011
7298
  return c.notFound();
7012
7299
  });
@@ -7064,7 +7351,7 @@ var init_server = __esm({
7064
7351
  init_scan();
7065
7352
  init_scanner();
7066
7353
  here = dirname4(fileURLToPath(import.meta.url));
7067
- DEFAULT_UI_DIR = join15(here, "..", "ui");
7354
+ DEFAULT_UI_DIR = join16(here, "..", "ui");
7068
7355
  MIME = {
7069
7356
  ".html": "text/html; charset=utf-8",
7070
7357
  ".js": "text/javascript; charset=utf-8",
@@ -7101,6 +7388,8 @@ init_ai();
7101
7388
  init_local_settings();
7102
7389
  init_run();
7103
7390
  init_provider();
7391
+ init_batch_run();
7392
+ init_pending_batch();
7104
7393
  init_estimate();
7105
7394
  init_log();
7106
7395
  init_scan();
@@ -7108,8 +7397,8 @@ init_scanner();
7108
7397
  init_context();
7109
7398
  init_run2();
7110
7399
  init_outputs();
7111
- import { resolve as resolve11, dirname as dirname5, join as join16 } from "path";
7112
- import { readFileSync as readFileSync22, existsSync as existsSync12, mkdirSync as mkdirSync4, cpSync } from "fs";
7400
+ import { resolve as resolve11, dirname as dirname5, join as join17 } from "path";
7401
+ import { readFileSync as readFileSync23, existsSync as existsSync13, mkdirSync as mkdirSync5, cpSync } from "fs";
7113
7402
  import { fileURLToPath as fileURLToPath2 } from "url";
7114
7403
 
7115
7404
  // src/server/lint/locate.ts
@@ -7176,7 +7465,7 @@ function formatSarif(report, rawText) {
7176
7465
  }
7177
7466
 
7178
7467
  // src/server/cli.ts
7179
- var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "build-context", "scan", "prune", "split", "skill"];
7468
+ var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "build-context", "scan", "prune", "split", "skill", "batch"];
7180
7469
  var isCommand = (s) => s != null && COMMANDS.includes(s);
7181
7470
  function parseArgs(argv) {
7182
7471
  const statePath = resolve11(process.cwd(), "glotfile.json");
@@ -7246,7 +7535,12 @@ function parseArgs(argv) {
7246
7535
  else if (flag === "--unused") args.unused = true;
7247
7536
  else if (flag === "--write") args.write = true;
7248
7537
  else if (flag === "--estimate") args.estimate = true;
7538
+ else if (flag === "--batch") args.batch = true;
7539
+ else if (flag === "--wait") args.wait = true;
7249
7540
  else if (flag === "--print") args.print = true;
7541
+ else if (args.command === "batch" && (flag === "status" || flag === "apply" || flag === "cancel")) {
7542
+ args.batchAction = flag;
7543
+ }
7250
7544
  }
7251
7545
  return args;
7252
7546
  }
@@ -7297,6 +7591,15 @@ async function runExport(args) {
7297
7591
  await new Promise(() => {
7298
7592
  });
7299
7593
  }
7594
+ function makeProviderOrExit(ai) {
7595
+ try {
7596
+ return makeProvider(ai);
7597
+ } catch (e) {
7598
+ console.error(e.message);
7599
+ process.exitCode = 1;
7600
+ return null;
7601
+ }
7602
+ }
7300
7603
  async function runTranslate(args) {
7301
7604
  const state = loadState(args.statePath);
7302
7605
  const projectRoot = dirname5(resolve11(args.statePath));
@@ -7333,18 +7636,51 @@ async function runTranslate(args) {
7333
7636
  keyGlob: args.keyGlob
7334
7637
  });
7335
7638
  const toTranslate = [...reqs];
7336
- let written = 0;
7337
- let errors = [];
7338
- if (toTranslate.length) {
7639
+ if (args.batch) {
7640
+ if (!toTranslate.length) {
7641
+ console.log("Nothing to translate.");
7642
+ return;
7643
+ }
7339
7644
  const ai = loadLocalSettings(projectRoot).ai;
7340
- let provider;
7645
+ const provider = makeProviderOrExit(ai);
7646
+ if (!provider) return;
7647
+ if (!supportsBatchTranslate(provider)) {
7648
+ console.error(`Provider "${ai.provider}" does not support batch mode. Currently anthropic only.`);
7649
+ process.exitCode = 1;
7650
+ return;
7651
+ }
7652
+ const { skipped } = attachScreenshotsForProvider(toTranslate, state, projectRoot, provider.supportsVision());
7653
+ if (skipped) console.warn(`Model "${ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
7654
+ let pending;
7341
7655
  try {
7342
- provider = makeProvider(ai);
7656
+ pending = await submitBatchTranslation(state, provider, toTranslate, ai.batchSize, ai.model, projectRoot);
7343
7657
  } catch (e) {
7344
7658
  console.error(e.message);
7345
7659
  process.exitCode = 1;
7346
7660
  return;
7347
7661
  }
7662
+ appendLog(projectRoot, {
7663
+ at: (/* @__PURE__ */ new Date()).toISOString(),
7664
+ kind: "translate",
7665
+ summary: `Submitted batch ${pending.batchId} (${pending.total} items)`,
7666
+ model: ai.model,
7667
+ system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
7668
+ items: toTranslate.map((r) => ({ id: r.id, key: r.key, source: r.source, targetLocale: r.targetLocale, context: r.context, glossary: r.glossary, screenshot: state.keys[r.key]?.screenshot }))
7669
+ });
7670
+ console.log(`Submitted batch ${pending.batchId} \u2014 ${pending.total} string(s) at 50% batch pricing.`);
7671
+ if (!args.wait) {
7672
+ console.log("Check progress with `glotfile batch`; it applies results automatically when finished.");
7673
+ return;
7674
+ }
7675
+ await waitAndApply(args, provider, pending, ai);
7676
+ return;
7677
+ }
7678
+ let written = 0;
7679
+ let errors = [];
7680
+ if (toTranslate.length) {
7681
+ const ai = loadLocalSettings(projectRoot).ai;
7682
+ const provider = makeProviderOrExit(ai);
7683
+ if (!provider) return;
7348
7684
  const { skipped } = attachScreenshotsForProvider(toTranslate, state, projectRoot, provider.supportsVision());
7349
7685
  if (skipped) console.warn(`Model "${ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
7350
7686
  console.log(`Translating ${toTranslate.length} string(s)\u2026`);
@@ -7401,6 +7737,80 @@ async function runTranslate(args) {
7401
7737
  console.log(`Wrote ${written} machine translation(s).`);
7402
7738
  for (const e of errors) console.warn(`skip ${e.key} @ ${e.locale}: ${e.error}`);
7403
7739
  }
7740
+ function reportApply(outcome) {
7741
+ console.log(`Wrote ${outcome.written} machine translation(s).`);
7742
+ if (outcome.retried) console.log(`${outcome.retried} item(s) re-run synchronously (batch entries failed or were malformed).`);
7743
+ if (outcome.staleSkipped) console.log(`${outcome.staleSkipped} result(s) skipped \u2014 source changed since submission.`);
7744
+ if (outcome.screenshotsSkipped) console.log(`${outcome.screenshotsSkipped} screenshot(s) ignored on retry (model has no vision support).`);
7745
+ for (const e of outcome.errors) console.warn(`skip ${e.key} @ ${e.locale}: ${e.error}`);
7746
+ }
7747
+ async function applyPending(args, provider, pending, ai) {
7748
+ const projectRoot = dirname5(resolve11(args.statePath));
7749
+ const outcome = await applyBatchResults(
7750
+ () => loadState(args.statePath),
7751
+ (s) => saveState(args.statePath, s),
7752
+ provider,
7753
+ pending,
7754
+ projectRoot,
7755
+ { batchSize: ai.batchSize, concurrency: ai.concurrency }
7756
+ );
7757
+ reportApply(outcome);
7758
+ }
7759
+ async function waitAndApply(args, provider, pending, ai) {
7760
+ for (; ; ) {
7761
+ const status = await provider.translationBatchStatus(pending.batchId);
7762
+ const c = status.counts;
7763
+ process.stdout.write(`\r ${c.succeeded + c.errored + c.expired + c.canceled}/${pending.jobs.length} entries done (${c.processing} processing)`);
7764
+ if (status.status === "ended") break;
7765
+ await new Promise((r) => setTimeout(r, 6e4));
7766
+ }
7767
+ process.stdout.write("\n");
7768
+ await applyPending(args, provider, pending, ai);
7769
+ }
7770
+ async function runBatch(args) {
7771
+ const projectRoot = dirname5(resolve11(args.statePath));
7772
+ const pending = loadPendingBatch(projectRoot);
7773
+ if (!pending) {
7774
+ console.log("No pending batch. Start one with `glotfile translate --batch`.");
7775
+ return;
7776
+ }
7777
+ const action = args.batchAction ?? "status";
7778
+ if (action === "cancel") {
7779
+ let remoteFailed = false;
7780
+ try {
7781
+ const ai2 = loadLocalSettings(projectRoot).ai;
7782
+ const provider2 = makeProvider(ai2);
7783
+ if (supportsBatchTranslate(provider2)) {
7784
+ await provider2.cancelTranslationBatch(pending.batchId);
7785
+ } else {
7786
+ remoteFailed = true;
7787
+ }
7788
+ } catch {
7789
+ remoteFailed = true;
7790
+ }
7791
+ clearPendingBatch(projectRoot);
7792
+ const suffix = remoteFailed ? " (remote cancel failed \u2014 it will expire server-side)" : "";
7793
+ console.log(`Canceled batch ${pending.batchId}.${suffix}`);
7794
+ return;
7795
+ }
7796
+ const ai = loadLocalSettings(projectRoot).ai;
7797
+ const provider = makeProviderOrExit(ai);
7798
+ if (!provider) return;
7799
+ if (!supportsBatchTranslate(provider)) {
7800
+ console.error(`Pending batch was submitted via anthropic, but the configured provider "${ai.provider}" has no batch support.`);
7801
+ process.exitCode = 1;
7802
+ return;
7803
+ }
7804
+ const status = await provider.translationBatchStatus(pending.batchId);
7805
+ const c = status.counts;
7806
+ console.log(`Batch ${pending.batchId} (${pending.total} string(s), submitted ${pending.createdAt})`);
7807
+ console.log(` ${status.status} \u2014 ${c.succeeded} succeeded, ${c.processing} processing, ${c.errored} errored, ${c.expired} expired, ${c.canceled} canceled`);
7808
+ if (status.status !== "ended") {
7809
+ if (action === "apply") console.log("Not finished yet \u2014 try again later.");
7810
+ return;
7811
+ }
7812
+ await applyPending(args, provider, pending, ai);
7813
+ }
7404
7814
  function printReport(report, format, rawText) {
7405
7815
  if (format === "json") console.log(formatJson(report).trimEnd());
7406
7816
  else if (format === "sarif") console.log(formatSarif(report, rawText).trimEnd());
@@ -7420,7 +7830,7 @@ async function runLintCmd(args) {
7420
7830
  }
7421
7831
  return;
7422
7832
  }
7423
- const rawText = existsSync12(args.statePath) ? readFileSync22(args.statePath, "utf8") : "";
7833
+ const rawText = existsSync13(args.statePath) ? readFileSync23(args.statePath, "utf8") : "";
7424
7834
  const report = await runLint(state, {
7425
7835
  locales: args.locales,
7426
7836
  ruleIds: args.ruleIds,
@@ -7444,7 +7854,7 @@ async function runCheck(args) {
7444
7854
  process.exitCode = 1;
7445
7855
  return;
7446
7856
  }
7447
- const rawText = existsSync12(args.statePath) ? readFileSync22(args.statePath, "utf8") : "";
7857
+ const rawText = existsSync13(args.statePath) ? readFileSync23(args.statePath, "utf8") : "";
7448
7858
  const root = dirname5(resolve11(args.statePath));
7449
7859
  const lint = await runLint(state, {});
7450
7860
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
@@ -7457,7 +7867,7 @@ async function runImportCmd(args) {
7457
7867
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
7458
7868
  const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
7459
7869
  const out = resolve11(projectRoot, "glotfile.json");
7460
- if (existsSync12(out) && !args.importForce) {
7870
+ if (existsSync13(out) && !args.importForce) {
7461
7871
  console.error(`${out} already exists; pass --force to overwrite`);
7462
7872
  process.exitCode = 1;
7463
7873
  return;
@@ -7605,19 +8015,19 @@ function runSplit(args) {
7605
8015
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
7606
8016
  );
7607
8017
  }
7608
- var SKILL_SRC = join16(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
8018
+ var SKILL_SRC = join17(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
7609
8019
  function runSkill(args) {
7610
8020
  if (args.print) {
7611
- console.log(readFileSync22(join16(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
8021
+ console.log(readFileSync23(join17(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
7612
8022
  return;
7613
8023
  }
7614
8024
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
7615
- if (existsSync12(dest) && !args.importForce) {
8025
+ if (existsSync13(dest) && !args.importForce) {
7616
8026
  console.error(`${dest} already exists; pass --force to overwrite`);
7617
8027
  process.exitCode = 1;
7618
8028
  return;
7619
8029
  }
7620
- mkdirSync4(dirname5(dest), { recursive: true });
8030
+ mkdirSync5(dirname5(dest), { recursive: true });
7621
8031
  cpSync(SKILL_SRC, dest, { recursive: true });
7622
8032
  console.log(`Installed the glotfile skill to ${dest}. Restart Claude Code to pick it up.`);
7623
8033
  }
@@ -7646,7 +8056,9 @@ var COMMAND_HELP = {
7646
8056
  ["--all", "Re-translate every string, not just empty values"],
7647
8057
  ["--estimate", "Print batches, tokens and estimated cost without translating"],
7648
8058
  ["--locale <list>", "Comma-separated target locales (alias: --locales)"],
7649
- ["--key <glob>", "Only keys matching this glob"]
8059
+ ["--key <glob>", "Only keys matching this glob"],
8060
+ ["--batch", "Submit via the provider's batch API (50% cost, async; anthropic only)"],
8061
+ ["--wait", "With --batch: stay attached, poll until finished, then apply"]
7650
8062
  ]
7651
8063
  },
7652
8064
  lint: {
@@ -7714,6 +8126,15 @@ var COMMAND_HELP = {
7714
8126
  ["--print", "Write SKILL.md to stdout instead of installing"],
7715
8127
  ["--force", "Overwrite an existing installed skill"]
7716
8128
  ]
8129
+ },
8130
+ batch: {
8131
+ summary: "Check, apply, or cancel a pending batch translation.",
8132
+ usage: "glotfile batch [status|apply|cancel]",
8133
+ options: [
8134
+ ["status", "Show the pending batch's progress (default)"],
8135
+ ["apply", "Fetch results and write translations (auto-runs when finished)"],
8136
+ ["cancel", "Cancel the pending batch and discard the handle"]
8137
+ ]
7717
8138
  }
7718
8139
  };
7719
8140
  function formatOpts(opts) {
@@ -7746,8 +8167,8 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
7746
8167
  );
7747
8168
  }
7748
8169
  function printVersion() {
7749
- const pkgPath = join16(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
7750
- console.log(JSON.parse(readFileSync22(pkgPath, "utf8")).version);
8170
+ const pkgPath = join17(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8171
+ console.log(JSON.parse(readFileSync23(pkgPath, "utf8")).version);
7751
8172
  }
7752
8173
  async function main(argv) {
7753
8174
  const args = parseArgs(argv);
@@ -7770,6 +8191,7 @@ async function main(argv) {
7770
8191
  if (args.command === "prune") return runPrune(args);
7771
8192
  if (args.command === "split") return runSplit(args);
7772
8193
  if (args.command === "skill") return runSkill(args);
8194
+ if (args.command === "batch") return runBatch(args);
7773
8195
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
7774
8196
  const { url } = await startServer2({ statePath: args.statePath, dev: args.dev });
7775
8197
  if (args.dev) console.log(`Glotfile dev API on ${url} \u2014 open the UI at the Vite "Local:" URL above`);