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