glotfile 0.1.1 → 0.3.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.
@@ -29,7 +29,7 @@ var init_dictionary_en = __esm({
29
29
  import { Hono as Hono2 } from "hono";
30
30
  import { serve } from "@hono/node-server";
31
31
  import { fileURLToPath } from "url";
32
- import { dirname as dirname6, join as join7, resolve as resolve7, extname as extname3, sep as sep2 } from "path";
32
+ import { dirname as dirname3, join as join9, resolve as resolve9, extname as extname3, sep as sep2 } from "path";
33
33
  import { readFile, stat } from "fs/promises";
34
34
  import { createServer } from "net";
35
35
  import open from "open";
@@ -39,8 +39,7 @@ import { Hono } from "hono";
39
39
  import { streamSSE } from "hono/streaming";
40
40
 
41
41
  // src/server/state.ts
42
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
43
- import { dirname as dirname2 } from "path";
42
+ import { readFileSync as readFileSync2, existsSync as existsSync2, rmSync as rmSync3 } from "fs";
44
43
  import { randomUUID } from "crypto";
45
44
 
46
45
  // src/server/format.ts
@@ -61,6 +60,26 @@ function serializeJson(value, opts) {
61
60
  return opts.finalNewline ? body + "\n" : body;
62
61
  }
63
62
 
63
+ // src/server/atomic-write.ts
64
+ import { writeFileSync, renameSync, mkdirSync, rmSync } from "fs";
65
+ import { dirname, join } from "path";
66
+ var counter = 0;
67
+ function writeFileAtomic(path, data) {
68
+ const dir = dirname(path);
69
+ mkdirSync(dir, { recursive: true });
70
+ const tmp = join(dir, `.${process.pid}.${counter++}.tmp`);
71
+ try {
72
+ writeFileSync(tmp, data);
73
+ renameSync(tmp, path);
74
+ } catch (e) {
75
+ try {
76
+ rmSync(tmp, { force: true });
77
+ } catch {
78
+ }
79
+ throw e;
80
+ }
81
+ }
82
+
64
83
  // src/server/lint/registry.ts
65
84
  var RULE_IDS = [
66
85
  "empty-source",
@@ -83,7 +102,7 @@ function isPluralForm(key) {
83
102
  return PLURAL_CATEGORIES.includes(key) || EXACT_SELECTOR_RE.test(key);
84
103
  }
85
104
  var LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
86
- var PROVIDERS = ["anthropic", "openai", "bedrock"];
105
+ var PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
87
106
  var GlotfileError = class extends Error {
88
107
  };
89
108
  function isObject(v) {
@@ -138,17 +157,6 @@ function validate(raw) {
138
157
  }
139
158
  }
140
159
  }
141
- if (!isObject(config.ai)) fail("config.ai must be an object");
142
- const ai = config.ai;
143
- if (typeof ai.provider !== "string" || !PROVIDERS.includes(ai.provider)) {
144
- fail(`config.ai.provider must be one of: ${PROVIDERS.join(", ")}`);
145
- }
146
- if (typeof ai.model !== "string") fail("config.ai.model must be a string");
147
- if (!(ai.endpoint === null || typeof ai.endpoint === "string")) fail("config.ai.endpoint must be a string or null");
148
- if (!(ai.region === void 0 || ai.region === null || typeof ai.region === "string")) {
149
- fail("config.ai.region must be a string or null");
150
- }
151
- if (typeof ai.batchSize !== "number") fail("config.ai.batchSize must be a number");
152
160
  if (!isObject(config.format)) fail("config.format must be an object");
153
161
  const fmt = config.format;
154
162
  if (typeof fmt.indent !== "number") fail("config.format.indent must be a number");
@@ -279,7 +287,6 @@ function defaultState() {
279
287
  { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
280
288
  { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" }
281
289
  ],
282
- ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", endpoint: null, region: null, batchSize: 25 },
283
290
  format: { indent: 2, sortKeys: true, finalNewline: true },
284
291
  spelling: { customWords: [] },
285
292
  autoExport: true
@@ -402,13 +409,13 @@ function gettextPluralForms(locale) {
402
409
  }
403
410
 
404
411
  // src/server/storage.ts
405
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "fs";
406
- import { join, dirname } from "path";
412
+ import { existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, rmSync as rmSync2 } from "fs";
413
+ import { join as join2 } from "path";
407
414
  function splitDirFor(statePath) {
408
415
  return statePath.replace(/\.json$/i, "");
409
416
  }
410
417
  function detectFormat(statePath) {
411
- if (existsSync(join(splitDirFor(statePath), "config.json"))) return "split";
418
+ if (existsSync(join2(splitDirFor(statePath), "config.json"))) return "split";
412
419
  if (existsSync(statePath)) return "single";
413
420
  return "none";
414
421
  }
@@ -438,15 +445,15 @@ function assemble(parts) {
438
445
  return { ...parts.manifest, keys };
439
446
  }
440
447
  function loadSplit(splitDir) {
441
- const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
442
- const manifest = readJson(join(splitDir, "config.json"));
443
- const keysPath = join(splitDir, "keys.json");
444
- const keys = existsSync(keysPath) ? readJson(keysPath) : {};
445
- const localesDir = join(splitDir, "locales");
448
+ const readJson3 = (p) => JSON.parse(readFileSync(p, "utf8"));
449
+ const manifest = readJson3(join2(splitDir, "config.json"));
450
+ const keysPath = join2(splitDir, "keys.json");
451
+ const keys = existsSync(keysPath) ? readJson3(keysPath) : {};
452
+ const localesDir = join2(splitDir, "locales");
446
453
  const locales = {};
447
454
  if (existsSync(localesDir)) {
448
455
  for (const name of readdirSync(localesDir)) {
449
- if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson(join(localesDir, name));
456
+ if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson3(join2(localesDir, name));
450
457
  }
451
458
  }
452
459
  return assemble({ manifest, keys, locales });
@@ -458,15 +465,14 @@ function writeIfChanged(path, contents) {
458
465
  } catch {
459
466
  }
460
467
  if (current === contents) return false;
461
- mkdirSync(dirname(path), { recursive: true });
462
- writeFileSync(path, contents, "utf8");
468
+ writeFileAtomic(path, contents);
463
469
  return true;
464
470
  }
465
471
  function saveSplit(splitDir, state) {
466
472
  const fmt = state.config.format;
467
473
  const parts = disassemble(state);
468
- const localesDir = join(splitDir, "locales");
469
- mkdirSync(localesDir, { recursive: true });
474
+ const localesDir = join2(splitDir, "locales");
475
+ mkdirSync2(localesDir, { recursive: true });
470
476
  let written = 0;
471
477
  let skipped = 0;
472
478
  let deleted = 0;
@@ -474,15 +480,15 @@ function saveSplit(splitDir, state) {
474
480
  if (changed) written++;
475
481
  else skipped++;
476
482
  };
477
- track(writeIfChanged(join(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
478
- track(writeIfChanged(join(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
483
+ track(writeIfChanged(join2(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
484
+ track(writeIfChanged(join2(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
479
485
  for (const [locale, entries] of Object.entries(parts.locales)) {
480
- track(writeIfChanged(join(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
486
+ track(writeIfChanged(join2(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
481
487
  }
482
488
  const live = new Set(Object.keys(parts.locales).map((l) => `${l}.json`));
483
489
  for (const name of readdirSync(localesDir)) {
484
490
  if (name.endsWith(".json") && !live.has(name)) {
485
- rmSync(join(localesDir, name));
491
+ rmSync2(join2(localesDir, name));
486
492
  deleted++;
487
493
  }
488
494
  }
@@ -539,10 +545,9 @@ function saveState(path, state) {
539
545
  normalizeState(state);
540
546
  if (state.config.storage === "split") {
541
547
  saveSplit(splitDirFor(path), state);
542
- if (existsSync2(path)) rmSync2(path);
548
+ if (existsSync2(path)) rmSync3(path);
543
549
  } else {
544
- mkdirSync2(dirname2(path), { recursive: true });
545
- writeFileSync2(path, serializeJson(state, state.config.format), "utf8");
550
+ writeFileAtomic(path, serializeJson(state, state.config.format));
546
551
  }
547
552
  }
548
553
  function requireKey(state, key) {
@@ -681,6 +686,9 @@ function setMetadata(state, key, partial) {
681
686
  delete entry.contextAt;
682
687
  }
683
688
  Object.assign(entry, safe);
689
+ if ("context" in safe && !entry.context) delete entry.context;
690
+ if ("tags" in safe && !entry.tags?.length) delete entry.tags;
691
+ if ("maxLength" in safe && !entry.maxLength) delete entry.maxLength;
684
692
  }
685
693
  function addNote(state, key, text, clock = systemClock) {
686
694
  const entry = requireKey(state, key);
@@ -736,11 +744,29 @@ function applyMachineTranslationForms(state, key, locale, forms, clock = systemC
736
744
  }
737
745
 
738
746
  // src/server/scan.ts
739
- import { existsSync as existsSync3, readFileSync as readFileSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
740
- import { resolve, dirname as dirname3 } from "path";
747
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
748
+ import { resolve as resolve2 } from "path";
749
+
750
+ // src/server/glotfile-dir.ts
751
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
752
+ import { resolve } from "path";
753
+ function ensureGlotfileDir(projectRoot) {
754
+ const dir = resolve(projectRoot, ".glotfile");
755
+ mkdirSync3(dir, { recursive: true });
756
+ const ignore = resolve(dir, ".gitignore");
757
+ if (!existsSync3(ignore)) {
758
+ try {
759
+ writeFileSync2(ignore, "*\n");
760
+ } catch {
761
+ }
762
+ }
763
+ return dir;
764
+ }
765
+
766
+ // src/server/scan.ts
741
767
  function loadUsageCache(projectRoot) {
742
- const path = resolve(projectRoot, ".glotfile", "usage.json");
743
- if (!existsSync3(path)) return null;
768
+ const path = resolve2(projectRoot, ".glotfile", "usage.json");
769
+ if (!existsSync4(path)) return null;
744
770
  try {
745
771
  return JSON.parse(readFileSync3(path, "utf8"));
746
772
  } catch {
@@ -748,9 +774,9 @@ function loadUsageCache(projectRoot) {
748
774
  }
749
775
  }
750
776
  function saveUsageCache(projectRoot, cache2) {
751
- const path = resolve(projectRoot, ".glotfile", "usage.json");
752
- mkdirSync3(dirname3(path), { recursive: true });
753
- writeFileSync3(path, JSON.stringify(cache2, null, 2) + "\n", "utf8");
777
+ ensureGlotfileDir(projectRoot);
778
+ const path = resolve2(projectRoot, ".glotfile", "usage.json");
779
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
754
780
  }
755
781
  function findMissing(state) {
756
782
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
@@ -777,7 +803,7 @@ function computeUsedKeys(state, cache2) {
777
803
 
778
804
  // src/server/scanner.ts
779
805
  import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync4 } from "fs";
780
- import { join as join2, extname, relative } from "path";
806
+ import { join as join3, extname, relative } from "path";
781
807
  var PATTERNS = {
782
808
  laravel: [
783
809
  /\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']+)'/g,
@@ -985,7 +1011,7 @@ function* walkFiles(dir, root, exclude) {
985
1011
  }
986
1012
  for (const name of entries) {
987
1013
  if (ALWAYS_EXCLUDE.has(name)) continue;
988
- const abs = join2(dir, name);
1014
+ const abs = join3(dir, name);
989
1015
  const rel = relative(root, abs);
990
1016
  let st;
991
1017
  try {
@@ -1015,7 +1041,7 @@ function runScan(projectRoot, opts, existing) {
1015
1041
  const ext = extname(relPath);
1016
1042
  const scanner = scannerForExt(ext);
1017
1043
  if (!scanner) continue;
1018
- const abs = join2(projectRoot, relPath);
1044
+ const abs = join3(projectRoot, relPath);
1019
1045
  let st;
1020
1046
  try {
1021
1047
  st = statSync(abs);
@@ -1047,8 +1073,8 @@ function runScan(projectRoot, opts, existing) {
1047
1073
  }
1048
1074
 
1049
1075
  // src/server/ai/context.ts
1050
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
1051
- import { resolve as resolve2 } from "path";
1076
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
1077
+ import { resolve as resolve3 } from "path";
1052
1078
  var MAX_CONTEXT_LENGTH = 500;
1053
1079
  var SNIPPET_WINDOW = 15;
1054
1080
  var MAX_SNIPPETS = 3;
@@ -1064,9 +1090,9 @@ function extractSnippets(refs, projectRoot, fileCache) {
1064
1090
  const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
1065
1091
  const snippets = [];
1066
1092
  for (const ref of selected) {
1067
- const absPath = resolve2(projectRoot, ref.file);
1093
+ const absPath = resolve3(projectRoot, ref.file);
1068
1094
  if (!fileCache.has(ref.file)) {
1069
- if (!existsSync4(absPath)) continue;
1095
+ if (!existsSync5(absPath)) continue;
1070
1096
  const content = readFileSync5(absPath, "utf8");
1071
1097
  fileCache.set(ref.file, content.split("\n"));
1072
1098
  }
@@ -1372,8 +1398,8 @@ function pluralFormPlaceholdersMatch(category, source, form) {
1372
1398
  }
1373
1399
 
1374
1400
  // src/server/ai/run.ts
1375
- import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
1376
- import { resolve as resolve3, extname as extname2 } from "path";
1401
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
1402
+ import { resolve as resolve4, extname as extname2 } from "path";
1377
1403
 
1378
1404
  // src/server/glob.ts
1379
1405
  function globToRegExp2(glob) {
@@ -1468,8 +1494,8 @@ function attachScreenshots(reqs, state, projectRoot) {
1468
1494
  const mediaType = MEDIA_TYPES[extname2(screenshot).toLowerCase()];
1469
1495
  if (!mediaType) continue;
1470
1496
  if (!cache2.has(screenshot)) {
1471
- const abs = resolve3(projectRoot, screenshot);
1472
- if (!existsSync5(abs)) {
1497
+ const abs = resolve4(projectRoot, screenshot);
1498
+ if (!existsSync6(abs)) {
1473
1499
  cache2.set(screenshot, null);
1474
1500
  } else {
1475
1501
  const buf = readFileSync6(abs);
@@ -1489,7 +1515,7 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
1489
1515
  return { skipped: keys.size };
1490
1516
  }
1491
1517
  var DEFAULT_LOCALE_CONCURRENCY = 3;
1492
- async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
1518
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
1493
1519
  if (!reqs.length) return [];
1494
1520
  const byLocale = /* @__PURE__ */ new Map();
1495
1521
  for (const req of reqs) {
@@ -1509,11 +1535,14 @@ async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency =
1509
1535
  while (next < groups.length) {
1510
1536
  if (signal?.aborted) break;
1511
1537
  const group = groups[next++];
1538
+ const locale = group[0].targetLocale;
1539
+ hooks.onLocaleStart?.(locale);
1512
1540
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
1513
1541
  done += batchResults.length;
1514
- onBatchComplete?.(done, total, batchResults);
1542
+ hooks.onBatchComplete?.(done, total, batchResults, locale);
1515
1543
  }, signal);
1516
1544
  allResults.push(...localeResults);
1545
+ if (!signal?.aborted) hooks.onLocaleDone?.(locale);
1517
1546
  }
1518
1547
  }
1519
1548
  const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
@@ -1535,10 +1564,11 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
1535
1564
  if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
1536
1565
  continue;
1537
1566
  }
1538
- if (res.error || res.translation === void 0) {
1567
+ if (res.translation === void 0) {
1539
1568
  errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
1540
1569
  continue;
1541
1570
  }
1571
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
1542
1572
  if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
1543
1573
  }
1544
1574
  return { written, errors };
@@ -2310,8 +2340,8 @@ function getAdapter(name) {
2310
2340
  }
2311
2341
 
2312
2342
  // src/server/api.ts
2313
- import { writeFileSync as writeFileSync5, readFileSync as readFileSync11, mkdirSync as mkdirSync6, existsSync as existsSync8, readdirSync as readdirSync7, rmSync as rmSync3 } from "fs";
2314
- import { dirname as dirname5, resolve as resolve6, basename, relative as relative3, sep } from "path";
2343
+ import { readFileSync as readFileSync13, existsSync as existsSync9, readdirSync as readdirSync7, rmSync as rmSync4 } from "fs";
2344
+ import { dirname as dirname2, resolve as resolve8, basename, relative as relative3, sep } from "path";
2315
2345
 
2316
2346
  // src/server/ai/anthropic.ts
2317
2347
  import Anthropic from "@anthropic-ai/sdk";
@@ -2400,7 +2430,7 @@ function validateTranslation(req, translation) {
2400
2430
  return { id: req.id, error: "Placeholder mismatch between source and translation." };
2401
2431
  }
2402
2432
  if (req.maxLength !== void 0 && translation.length > req.maxLength) {
2403
- return { id: req.id, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2433
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2404
2434
  }
2405
2435
  return { id: req.id, translation };
2406
2436
  }
@@ -2517,6 +2547,17 @@ var AnthropicProvider = class {
2517
2547
 
2518
2548
  // src/server/ai/openai.ts
2519
2549
  import { createRequire } from "module";
2550
+ function loadOpenAIClient(opts) {
2551
+ const require2 = createRequire(import.meta.url);
2552
+ let OpenAICtor;
2553
+ try {
2554
+ const mod = require2("openai");
2555
+ OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
2556
+ } catch {
2557
+ throw new Error("The OpenAI SDK is required for this provider. Install it: npm i openai");
2558
+ }
2559
+ return new OpenAICtor(opts);
2560
+ }
2520
2561
  var OpenAIProvider = class {
2521
2562
  constructor(config, client) {
2522
2563
  this.config = config;
@@ -2527,15 +2568,7 @@ var OpenAIProvider = class {
2527
2568
  if (!process.env.OPENAI_API_KEY) {
2528
2569
  throw new Error("OPENAI_API_KEY is not set. AI translation requires it; every other feature works offline.");
2529
2570
  }
2530
- const require2 = createRequire(import.meta.url);
2531
- let OpenAICtor;
2532
- try {
2533
- const mod = require2("openai");
2534
- OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
2535
- } catch {
2536
- throw new Error('Provider "openai" requires the OpenAI SDK. Install it: npm i openai');
2537
- }
2538
- this.client = new OpenAICtor({ baseURL: config.endpoint ?? void 0 });
2571
+ this.client = loadOpenAIClient({ baseURL: config.endpoint ?? void 0 });
2539
2572
  }
2540
2573
  config;
2541
2574
  client;
@@ -2620,7 +2653,7 @@ var BedrockProvider = class {
2620
2653
  }
2621
2654
  const region = config.region ?? process.env.AWS_REGION;
2622
2655
  if (!region) {
2623
- throw new Error("AWS region is not set. Set config.ai.region or the AWS_REGION environment variable for the bedrock provider.");
2656
+ throw new Error("AWS region is not set. Set the Region in your local AI settings (.glotfile/settings.json) or the AWS_REGION environment variable for the bedrock provider.");
2624
2657
  }
2625
2658
  const require2 = createRequire2(import.meta.url);
2626
2659
  let sdk;
@@ -2718,9 +2751,137 @@ var BedrockProvider = class {
2718
2751
  }
2719
2752
  };
2720
2753
 
2754
+ // src/server/ai/openrouter.ts
2755
+ var OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
2756
+ var OPENROUTER_REFERER = "https://www.npmjs.com/package/glotfile";
2757
+ var OPENROUTER_TITLE = "glotfile";
2758
+ function openRouterClientOptions(config) {
2759
+ const apiKey = process.env.OPENROUTER_API_KEY;
2760
+ if (!apiKey) {
2761
+ throw new Error("OPENROUTER_API_KEY is not set. AI translation requires it; every other feature works offline.");
2762
+ }
2763
+ return {
2764
+ apiKey,
2765
+ baseURL: config.endpoint ?? OPENROUTER_BASE_URL,
2766
+ defaultHeaders: { "HTTP-Referer": OPENROUTER_REFERER, "X-Title": OPENROUTER_TITLE }
2767
+ };
2768
+ }
2769
+ var OpenRouterProvider = class extends OpenAIProvider {
2770
+ constructor(config, client) {
2771
+ super(config, client ?? loadOpenAIClient(openRouterClientOptions(config)));
2772
+ }
2773
+ };
2774
+
2775
+ // src/server/ai/ollama.ts
2776
+ var OLLAMA_BASE_URL = "http://localhost:11434/v1";
2777
+ function ollamaClientOptions(config) {
2778
+ return {
2779
+ apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
2780
+ baseURL: config.endpoint ?? OLLAMA_BASE_URL
2781
+ };
2782
+ }
2783
+ var OllamaProvider = class extends OpenAIProvider {
2784
+ constructor(config, client) {
2785
+ super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2786
+ }
2787
+ supportsVision() {
2788
+ return false;
2789
+ }
2790
+ };
2791
+
2792
+ // src/server/ai/claudecode.ts
2793
+ import { spawn } from "child_process";
2794
+ function stripFences(s) {
2795
+ return s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
2796
+ }
2797
+ function defaultSpawn(prompt, systemPrompt, model) {
2798
+ return new Promise((resolve10, reject) => {
2799
+ const args = [
2800
+ "--print",
2801
+ "--output-format",
2802
+ "json",
2803
+ "--system-prompt",
2804
+ systemPrompt,
2805
+ // Only pass --model when explicitly configured; otherwise use the session default.
2806
+ ...model ? ["--model", model] : []
2807
+ ];
2808
+ const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
2809
+ let stdout = "";
2810
+ let stderr = "";
2811
+ child.stdout.on("data", (chunk2) => {
2812
+ stdout += chunk2.toString();
2813
+ });
2814
+ child.stderr.on("data", (chunk2) => {
2815
+ stderr += chunk2.toString();
2816
+ });
2817
+ child.stdin.write(prompt);
2818
+ child.stdin.end();
2819
+ child.on("close", (code) => {
2820
+ if (code !== 0) {
2821
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
2822
+ return;
2823
+ }
2824
+ try {
2825
+ const envelope = JSON.parse(stdout.trim());
2826
+ if (envelope.is_error) {
2827
+ reject(new Error(`claude error: ${envelope.result ?? "unknown error"}`));
2828
+ return;
2829
+ }
2830
+ resolve10(envelope.result ?? "");
2831
+ } catch {
2832
+ reject(new Error(`Failed to parse claude JSON output: ${stdout.slice(0, 200)}`));
2833
+ }
2834
+ });
2835
+ child.on("error", (err) => {
2836
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed?`));
2837
+ });
2838
+ });
2839
+ }
2840
+ var ClaudeCodeProvider = class {
2841
+ constructor(config, spawnFn) {
2842
+ this.config = config;
2843
+ this.spawnFn = spawnFn ?? defaultSpawn;
2844
+ }
2845
+ config;
2846
+ spawnFn;
2847
+ supportsVision() {
2848
+ return false;
2849
+ }
2850
+ translate(reqs, onBatchComplete, signal) {
2851
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2852
+ }
2853
+ async complete(req) {
2854
+ const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
2855
+ const textBlocks = req.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
2856
+ const result = await this.spawnFn(textBlocks, systemParts.join("\n\n"), this.config.model);
2857
+ try {
2858
+ return JSON.parse(stripFences(result));
2859
+ } catch {
2860
+ return {};
2861
+ }
2862
+ }
2863
+ async callBatch(batch, signal) {
2864
+ if (signal?.aborted) return [];
2865
+ const prompt = buildBatchPrompt(batch);
2866
+ let result;
2867
+ try {
2868
+ result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2869
+ } catch (err) {
2870
+ if (signal?.aborted) return [];
2871
+ throw err;
2872
+ }
2873
+ if (signal?.aborted) return [];
2874
+ try {
2875
+ const parsed = JSON.parse(stripFences(result));
2876
+ return parsed.items ?? [];
2877
+ } catch {
2878
+ return [];
2879
+ }
2880
+ }
2881
+ };
2882
+
2721
2883
  // src/server/ai/index.ts
2722
- function makeProvider(config) {
2723
- const ai = config.ai;
2884
+ function makeProvider(ai) {
2724
2885
  switch (ai.provider) {
2725
2886
  case "anthropic":
2726
2887
  return new AnthropicProvider(ai);
@@ -2728,36 +2889,38 @@ function makeProvider(config) {
2728
2889
  return new OpenAIProvider(ai);
2729
2890
  case "bedrock":
2730
2891
  return new BedrockProvider(ai);
2892
+ case "openrouter":
2893
+ return new OpenRouterProvider(ai);
2894
+ case "ollama":
2895
+ return new OllamaProvider(ai);
2896
+ case "claude-code":
2897
+ return new ClaudeCodeProvider(ai);
2731
2898
  default:
2732
- throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock.`);
2899
+ throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
2733
2900
  }
2734
2901
  }
2735
2902
 
2736
- // src/server/ai/log.ts
2737
- import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
2738
- import { resolve as resolve4 } from "path";
2903
+ // src/server/log.ts
2904
+ import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
2905
+ import { resolve as resolve5 } from "path";
2739
2906
  function logPath(projectRoot) {
2740
- return resolve4(projectRoot, ".glotfile", "ai-log.jsonl");
2907
+ return resolve5(projectRoot, ".glotfile", "log.jsonl");
2741
2908
  }
2742
- function appendAiLog(projectRoot, entry) {
2743
- mkdirSync4(resolve4(projectRoot, ".glotfile"), { recursive: true });
2909
+ function appendLog(projectRoot, entry) {
2910
+ ensureGlotfileDir(projectRoot);
2744
2911
  appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
2745
2912
  }
2746
- function readAiLog(projectRoot, limit = 100) {
2913
+ function readLog(projectRoot, limit = 100) {
2747
2914
  const path = logPath(projectRoot);
2748
- if (!existsSync6(path)) return [];
2915
+ if (!existsSync7(path)) return [];
2749
2916
  const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2750
- const entries = lines.map((l) => {
2751
- const e = JSON.parse(l);
2752
- e.kind ??= "translate";
2753
- return e;
2754
- });
2917
+ const entries = lines.map((l) => JSON.parse(l));
2755
2918
  return entries.reverse().slice(0, limit);
2756
2919
  }
2757
2920
 
2758
2921
  // src/server/import/detect.ts
2759
- import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2760
- import { join as join3 } from "path";
2922
+ import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2923
+ import { join as join4 } from "path";
2761
2924
  var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
2762
2925
  var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
2763
2926
  function safeIsDir(p) {
@@ -2768,7 +2931,7 @@ function safeIsDir(p) {
2768
2931
  }
2769
2932
  }
2770
2933
  function listDirs(dir) {
2771
- return readdirSync3(dir).filter((e) => safeIsDir(join3(dir, e)));
2934
+ return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
2772
2935
  }
2773
2936
  function fileCount(dir) {
2774
2937
  try {
@@ -2782,22 +2945,22 @@ function pickSource(locales, sizeOf) {
2782
2945
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
2783
2946
  }
2784
2947
  function detectLaravel(root) {
2785
- const localeRoot = [join3(root, "resources", "lang"), join3(root, "lang")].find(safeIsDir);
2948
+ const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
2786
2949
  if (!localeRoot) return null;
2787
2950
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
2788
2951
  if (locales.length === 0) return null;
2789
- const sourceLocale = pickSource(locales, (loc) => fileCount(join3(localeRoot, loc)));
2952
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
2790
2953
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
2791
2954
  }
2792
2955
  function detectVue(root) {
2793
2956
  for (const rel of VUE_DIR_CANDIDATES) {
2794
- const localeRoot = join3(root, rel);
2957
+ const localeRoot = join4(root, rel);
2795
2958
  if (!safeIsDir(localeRoot)) continue;
2796
2959
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
2797
2960
  if (locales.length >= 2) {
2798
2961
  const sourceLocale = pickSource(locales, (loc) => {
2799
2962
  try {
2800
- return statSync2(join3(localeRoot, `${loc}.json`)).size;
2963
+ return statSync2(join4(localeRoot, `${loc}.json`)).size;
2801
2964
  } catch {
2802
2965
  return 0;
2803
2966
  }
@@ -2809,7 +2972,7 @@ function detectVue(root) {
2809
2972
  }
2810
2973
  function detectArb(root) {
2811
2974
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
2812
- const localeRoot = join3(root, rel);
2975
+ const localeRoot = join4(root, rel);
2813
2976
  if (!safeIsDir(localeRoot)) continue;
2814
2977
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
2815
2978
  if (locales.length >= 1) {
@@ -2825,7 +2988,7 @@ var BY_FORMAT = {
2825
2988
  "flutter-arb": detectArb
2826
2989
  };
2827
2990
  function detect(root, formatOverride) {
2828
- if (!existsSync7(root)) return null;
2991
+ if (!existsSync8(root)) return null;
2829
2992
  if (formatOverride) {
2830
2993
  const fn = BY_FORMAT[formatOverride];
2831
2994
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -2840,7 +3003,7 @@ function detect(root, formatOverride) {
2840
3003
 
2841
3004
  // src/server/import/parsers/vue-i18n-json.ts
2842
3005
  import { readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
2843
- import { join as join4 } from "path";
3006
+ import { join as join5 } from "path";
2844
3007
 
2845
3008
  // src/server/import/flatten.ts
2846
3009
  function flattenObject(value, prefix, warnings) {
@@ -2879,7 +3042,7 @@ var vueI18nJson2 = {
2879
3042
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2880
3043
  let data;
2881
3044
  try {
2882
- data = JSON.parse(readFileSync8(join4(localeRoot, file), "utf8"));
3045
+ data = JSON.parse(readFileSync8(join5(localeRoot, file), "utf8"));
2883
3046
  } catch (e) {
2884
3047
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
2885
3048
  continue;
@@ -2895,7 +3058,7 @@ var vueI18nJson2 = {
2895
3058
 
2896
3059
  // src/server/import/parsers/laravel-php.ts
2897
3060
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2898
- import { join as join5, relative as relative2 } from "path";
3061
+ import { join as join6, relative as relative2 } from "path";
2899
3062
  import { execFileSync } from "child_process";
2900
3063
 
2901
3064
  // src/server/import/placeholders.ts
@@ -2905,13 +3068,13 @@ function laravelToCanonical(value) {
2905
3068
 
2906
3069
  // src/server/import/parsers/laravel-php.ts
2907
3070
  function listDirs2(dir) {
2908
- return readdirSync5(dir).filter((e) => statSync3(join5(dir, e)).isDirectory());
3071
+ return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
2909
3072
  }
2910
3073
  function listPhpFiles(dir) {
2911
3074
  const out = [];
2912
3075
  const walk = (d) => {
2913
3076
  for (const e of readdirSync5(d)) {
2914
- const full = join5(d, e);
3077
+ const full = join6(d, e);
2915
3078
  if (statSync3(full).isDirectory()) walk(full);
2916
3079
  else if (e.endsWith(".php")) out.push(full);
2917
3080
  }
@@ -2948,7 +3111,7 @@ var laravelPhp2 = {
2948
3111
  for (const locale of listDirs2(localeRoot).sort()) {
2949
3112
  if (locale === "vendor") continue;
2950
3113
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2951
- const localeDir = join5(localeRoot, locale);
3114
+ const localeDir = join6(localeRoot, locale);
2952
3115
  locales.push(locale);
2953
3116
  for (const file of listPhpFiles(localeDir)) {
2954
3117
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -2972,7 +3135,7 @@ var laravelPhp2 = {
2972
3135
 
2973
3136
  // src/server/import/parsers/flutter-arb.ts
2974
3137
  import { readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
2975
- import { join as join6 } from "path";
3138
+ import { join as join7 } from "path";
2976
3139
  var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
2977
3140
  function localeFromArbName(file) {
2978
3141
  const m = file.match(/^(.+)\.arb$/);
@@ -3008,7 +3171,7 @@ var flutterArb2 = {
3008
3171
  if (opts?.locales && !opts.locales.includes(locale)) continue;
3009
3172
  let data;
3010
3173
  try {
3011
- data = JSON.parse(readFileSync9(join6(localeRoot, file), "utf8"));
3174
+ data = JSON.parse(readFileSync9(join7(localeRoot, file), "utf8"));
3012
3175
  } catch (e) {
3013
3176
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
3014
3177
  continue;
@@ -3106,7 +3269,6 @@ function assemble2(parsed, opts) {
3106
3269
  sourceLocale,
3107
3270
  locales,
3108
3271
  outputs: [output],
3109
- ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
3110
3272
  format: { indent: 2, sortKeys: true, finalNewline: true },
3111
3273
  spelling: { customWords: [] }
3112
3274
  },
@@ -3163,8 +3325,8 @@ function runImport(opts) {
3163
3325
  }
3164
3326
 
3165
3327
  // src/server/export-run.ts
3166
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync10 } from "fs";
3167
- import { dirname as dirname4, resolve as resolve5 } from "path";
3328
+ import { readFileSync as readFileSync10 } from "fs";
3329
+ import { resolve as resolve6 } from "path";
3168
3330
  function effectiveLocales(config) {
3169
3331
  const limit = config.exportLocales;
3170
3332
  if (!limit || limit.length === 0) return config.locales;
@@ -3186,7 +3348,7 @@ function exportToDisk(state, projectRoot, opts) {
3186
3348
  warnings.push(...result.warnings);
3187
3349
  const writtenPaths = /* @__PURE__ */ new Set();
3188
3350
  for (const f of result.files) {
3189
- const abs = resolve5(projectRoot, f.path);
3351
+ const abs = resolve6(projectRoot, f.path);
3190
3352
  if (writtenPaths.has(abs)) {
3191
3353
  skipped++;
3192
3354
  continue;
@@ -3201,22 +3363,103 @@ function exportToDisk(state, projectRoot, opts) {
3201
3363
  skipped++;
3202
3364
  continue;
3203
3365
  }
3204
- mkdirSync5(dirname4(abs), { recursive: true });
3205
- writeFileSync4(abs, f.contents, "utf8");
3366
+ writeFileAtomic(abs, f.contents);
3206
3367
  written++;
3207
3368
  }
3208
3369
  }
3209
3370
  return { written, skipped, warnings };
3210
3371
  }
3211
3372
 
3373
+ // src/server/ui-prefs.ts
3374
+ import { readFileSync as readFileSync11 } from "fs";
3375
+ import { homedir } from "os";
3376
+ import { join as join8 } from "path";
3377
+ var THEMES = ["system", "light", "dark"];
3378
+ var isThemeMode = (v) => THEMES.includes(v);
3379
+ var defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
3380
+ var DEFAULTS = { theme: "system" };
3381
+ function readJson(path) {
3382
+ try {
3383
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
3384
+ return parsed && typeof parsed === "object" ? parsed : {};
3385
+ } catch {
3386
+ return {};
3387
+ }
3388
+ }
3389
+ function loadUiPrefs(path) {
3390
+ const raw = readJson(path);
3391
+ return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
3392
+ }
3393
+ function saveUiPrefs(path, prefs) {
3394
+ const merged = { ...readJson(path), ...prefs };
3395
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3396
+ }
3397
+
3398
+ // src/server/local-settings.ts
3399
+ import { readFileSync as readFileSync12 } from "fs";
3400
+ import { resolve as resolve7 } from "path";
3401
+ var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
3402
+ var isEditorId = (v) => EDITOR_IDS.includes(v);
3403
+ var DEFAULT_AI = {
3404
+ provider: "anthropic",
3405
+ model: "claude-haiku-4-5-20251001",
3406
+ endpoint: null,
3407
+ region: null,
3408
+ batchSize: 25
3409
+ };
3410
+ var DEFAULT_EDITOR = "vscode";
3411
+ var settingsPath = (projectRoot) => resolve7(projectRoot, ".glotfile", "settings.json");
3412
+ function readJson2(path) {
3413
+ try {
3414
+ const parsed = JSON.parse(readFileSync12(path, "utf8"));
3415
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3416
+ } catch {
3417
+ return {};
3418
+ }
3419
+ }
3420
+ function coerceAi(raw) {
3421
+ const a = raw && typeof raw === "object" ? raw : {};
3422
+ return {
3423
+ provider: PROVIDERS.includes(a.provider) ? a.provider : DEFAULT_AI.provider,
3424
+ model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
3425
+ endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
3426
+ region: typeof a.region === "string" ? a.region : null,
3427
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
3428
+ };
3429
+ }
3430
+ function loadLocalSettings(projectRoot) {
3431
+ const raw = readJson2(settingsPath(projectRoot));
3432
+ return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
3433
+ }
3434
+ function saveLocalSettings(projectRoot, patch) {
3435
+ const path = settingsPath(projectRoot);
3436
+ const merged = { ...readJson2(path) };
3437
+ if (patch.ai !== void 0) merged.ai = patch.ai;
3438
+ if (patch.editor !== void 0) merged.editor = patch.editor;
3439
+ ensureGlotfileDir(projectRoot);
3440
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3441
+ }
3442
+ function aiConfigError(ai) {
3443
+ if (!ai || typeof ai !== "object") return "ai must be an object";
3444
+ const a = ai;
3445
+ if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
3446
+ return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
3447
+ }
3448
+ if (typeof a.model !== "string") return "ai.model must be a string";
3449
+ if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
3450
+ if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
3451
+ if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
3452
+ return null;
3453
+ }
3454
+
3212
3455
  // src/server/api.ts
3213
3456
  var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
3214
3457
  var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
3215
3458
  function projectName(root) {
3216
- const nameFile = resolve6(root, ".idea", ".name");
3217
- if (existsSync8(nameFile)) {
3459
+ const nameFile = resolve8(root, ".idea", ".name");
3460
+ if (existsSync9(nameFile)) {
3218
3461
  try {
3219
- const name = readFileSync11(nameFile, "utf8").trim();
3462
+ const name = readFileSync13(nameFile, "utf8").trim();
3220
3463
  if (name) return name;
3221
3464
  } catch {
3222
3465
  }
@@ -3226,7 +3469,7 @@ function projectName(root) {
3226
3469
  function createApi(deps) {
3227
3470
  const app = new Hono();
3228
3471
  const load = () => loadState(deps.statePath);
3229
- const projectRoot = dirname5(resolve6(deps.statePath));
3472
+ const projectRoot = dirname2(resolve8(deps.statePath));
3230
3473
  let translateQueue = Promise.resolve();
3231
3474
  const withTranslateLock = (fn) => {
3232
3475
  const next = translateQueue.then(fn, fn);
@@ -3250,7 +3493,36 @@ function createApi(deps) {
3250
3493
  saveState(deps.statePath, s);
3251
3494
  scheduleAutoExport(s);
3252
3495
  };
3496
+ const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
3497
+ const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
3498
+ const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
3253
3499
  app.get("/state", (c) => c.json(load()));
3500
+ app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
3501
+ app.put("/ui-prefs", async (c) => {
3502
+ const { theme } = await c.req.json();
3503
+ if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
3504
+ saveUiPrefs(uiPrefsPath, { theme });
3505
+ return c.json({ ok: true });
3506
+ });
3507
+ app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
3508
+ app.put("/local-settings", async (c) => {
3509
+ const body = await c.req.json().catch(() => ({}));
3510
+ const patch = {};
3511
+ if (body.ai !== void 0) {
3512
+ const err = aiConfigError(body.ai);
3513
+ if (err) return c.json({ error: err }, 400);
3514
+ patch.ai = body.ai;
3515
+ }
3516
+ if (body.editor !== void 0) {
3517
+ if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
3518
+ patch.editor = body.editor;
3519
+ }
3520
+ if (patch.ai === void 0 && patch.editor === void 0) {
3521
+ return c.json({ error: "provide ai and/or editor" }, 400);
3522
+ }
3523
+ saveLocalSettings(projectRoot, patch);
3524
+ return c.json({ ok: true });
3525
+ });
3254
3526
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3255
3527
  app.get("/files", (c) => {
3256
3528
  const found = /* @__PURE__ */ new Map();
@@ -3262,10 +3534,10 @@ function createApi(deps) {
3262
3534
  }
3263
3535
  for (const name of entries) {
3264
3536
  let abs;
3265
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync8(resolve6(projectRoot, name, "config.json"))) {
3266
- abs = resolve6(projectRoot, `${name}.json`);
3537
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(projectRoot, name, "config.json"))) {
3538
+ abs = resolve8(projectRoot, `${name}.json`);
3267
3539
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3268
- abs = resolve6(projectRoot, name);
3540
+ abs = resolve8(projectRoot, name);
3269
3541
  } else {
3270
3542
  continue;
3271
3543
  }
@@ -3282,10 +3554,10 @@ function createApi(deps) {
3282
3554
  app.post("/file", async (c) => {
3283
3555
  const { path } = await c.req.json();
3284
3556
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
3285
- const resolved = resolve6(projectRoot, path);
3557
+ const resolved = resolve8(projectRoot, path);
3286
3558
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
3287
3559
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
3288
- if (!existsSync8(resolved)) return c.json({ error: "file not found" }, 400);
3560
+ if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
3289
3561
  loadState(resolved);
3290
3562
  deps.statePath = resolved;
3291
3563
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -3300,6 +3572,7 @@ function createApi(deps) {
3300
3572
  const s = load();
3301
3573
  createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
3302
3574
  persist(s);
3575
+ logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
3303
3576
  console.log(`[key] created ${key}`);
3304
3577
  return c.json({ ok: true });
3305
3578
  });
@@ -3309,37 +3582,45 @@ function createApi(deps) {
3309
3582
  const s = load();
3310
3583
  addCustomWord(s, word);
3311
3584
  persist(s);
3585
+ logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
3312
3586
  return c.json({ ok: true });
3313
3587
  });
3314
3588
  app.delete("/dictionary/:word", (c) => {
3315
3589
  const s = load();
3316
- removeCustomWord(s, c.req.param("word"));
3590
+ const word = c.req.param("word");
3591
+ removeCustomWord(s, word);
3317
3592
  persist(s);
3593
+ logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
3318
3594
  return c.json({ ok: true });
3319
3595
  });
3320
3596
  app.patch("/keys/:key", async (c) => {
3321
3597
  const key = c.req.param("key");
3322
3598
  const body = await c.req.json();
3323
3599
  const s = load();
3600
+ const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
3324
3601
  if (typeof body.rename === "string") renameKey(s, key, body.rename);
3325
3602
  const target = typeof body.rename === "string" ? body.rename : key;
3326
3603
  if (body.metadata) setMetadata(s, target, body.metadata);
3327
3604
  if (typeof body.source === "string") setSourceValue(s, target, body.source);
3328
3605
  if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
3329
3606
  persist(s);
3607
+ if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
3608
+ if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
3609
+ if (typeof body.source === "string") logChange({ kind: "translation", summary: `Set source value of ${target}`, key: target, locale: s.config.sourceLocale, before: beforeSource, after: body.source });
3610
+ if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
3330
3611
  if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
3331
3612
  return c.json({ ok: true });
3332
3613
  });
3333
3614
  function removeOrphanScreenshot(s, screenshot) {
3334
3615
  if (!screenshot) return;
3335
3616
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
3336
- const root = dirname5(resolve6(deps.statePath));
3337
- const abs = resolve6(root, screenshot);
3617
+ const root = dirname2(resolve8(deps.statePath));
3618
+ const abs = resolve8(root, screenshot);
3338
3619
  const rel = relative3(root, abs);
3339
3620
  const seg0 = rel.split(sep)[0] ?? "";
3340
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync8(abs)) {
3621
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
3341
3622
  try {
3342
- rmSync3(abs);
3623
+ rmSync4(abs);
3343
3624
  } catch {
3344
3625
  }
3345
3626
  }
@@ -3348,9 +3629,11 @@ function createApi(deps) {
3348
3629
  const s = load();
3349
3630
  const key = c.req.param("key");
3350
3631
  const shot = s.keys[key]?.screenshot;
3632
+ const before = valueText(s, key, s.config.sourceLocale);
3351
3633
  deleteKey(s, key);
3352
3634
  removeOrphanScreenshot(s, shot);
3353
3635
  persist(s);
3636
+ logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
3354
3637
  console.log(`[key] deleted ${key}`);
3355
3638
  return c.json({ ok: true });
3356
3639
  });
@@ -3374,6 +3657,7 @@ function createApi(deps) {
3374
3657
  }
3375
3658
  }
3376
3659
  persist(s);
3660
+ if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
3377
3661
  console.log(`[bulk] cleared ${cleared} value(s)`);
3378
3662
  return c.json({ cleared });
3379
3663
  });
@@ -3391,6 +3675,7 @@ function createApi(deps) {
3391
3675
  }
3392
3676
  for (const shot of shots) removeOrphanScreenshot(s, shot);
3393
3677
  persist(s);
3678
+ if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
3394
3679
  console.log(`[bulk] deleted ${removed.length} key(s)`);
3395
3680
  return c.json({ removed });
3396
3681
  });
@@ -3416,6 +3701,7 @@ function createApi(deps) {
3416
3701
  updated++;
3417
3702
  }
3418
3703
  persist(s);
3704
+ if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
3419
3705
  console.log(`[bulk] updated metadata on ${updated} key(s)`);
3420
3706
  return c.json({ updated });
3421
3707
  });
@@ -3439,6 +3725,7 @@ function createApi(deps) {
3439
3725
  }
3440
3726
  }
3441
3727
  persist(s);
3728
+ if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
3442
3729
  console.log(`[bulk] set state ${next} on ${updated} value(s)`);
3443
3730
  return c.json({ updated });
3444
3731
  });
@@ -3448,15 +3735,21 @@ function createApi(deps) {
3448
3735
  const s = load();
3449
3736
  const key = c.req.param("key");
3450
3737
  const locale = c.req.param("locale");
3738
+ const before = valueText(s, key, locale);
3451
3739
  if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
3452
3740
  else setTargetValue(s, key, locale, value);
3453
3741
  persist(s);
3742
+ logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
3454
3743
  return c.json({ ok: true });
3455
3744
  });
3456
3745
  app.delete("/keys/:key/values/:locale", (c) => {
3457
3746
  const s = load();
3458
- clearValue(s, c.req.param("key"), c.req.param("locale"));
3747
+ const key = c.req.param("key");
3748
+ const locale = c.req.param("locale");
3749
+ const before = valueText(s, key, locale);
3750
+ clearValue(s, key, locale);
3459
3751
  persist(s);
3752
+ logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
3460
3753
  return c.json({ ok: true });
3461
3754
  });
3462
3755
  app.put("/keys/:key/plural/:locale", async (c) => {
@@ -3465,52 +3758,68 @@ function createApi(deps) {
3465
3758
  const s = load();
3466
3759
  const key = c.req.param("key");
3467
3760
  const locale = c.req.param("locale");
3761
+ const before = s.keys[key]?.values[locale]?.forms;
3468
3762
  if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
3469
3763
  else setPluralForms(s, key, locale, forms);
3470
3764
  persist(s);
3765
+ logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
3471
3766
  return c.json({ ok: true });
3472
3767
  });
3473
3768
  app.post("/keys/:key/plural", async (c) => {
3474
3769
  const { arg } = await c.req.json();
3475
3770
  if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
3476
3771
  const s = load();
3477
- convertToPlural(s, c.req.param("key"), arg);
3772
+ const key = c.req.param("key");
3773
+ convertToPlural(s, key, arg);
3478
3774
  persist(s);
3775
+ logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
3479
3776
  return c.json({ ok: true });
3480
3777
  });
3481
3778
  app.delete("/keys/:key/plural", (c) => {
3482
3779
  const s = load();
3483
- convertToScalar(s, c.req.param("key"));
3780
+ const key = c.req.param("key");
3781
+ convertToScalar(s, key);
3484
3782
  persist(s);
3783
+ logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
3485
3784
  return c.json({ ok: true });
3486
3785
  });
3487
3786
  app.put("/keys/:key/values/:locale/state", async (c) => {
3488
3787
  const { state } = await c.req.json();
3489
3788
  const s = load();
3490
- setKeyState(s, c.req.param("key"), c.req.param("locale"), state);
3789
+ const key = c.req.param("key");
3790
+ const locale = c.req.param("locale");
3791
+ const before = s.keys[key]?.values[locale]?.state;
3792
+ setKeyState(s, key, locale, state);
3491
3793
  persist(s);
3794
+ logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
3492
3795
  return c.json({ ok: true });
3493
3796
  });
3494
3797
  app.post("/keys/:key/notes", async (c) => {
3495
3798
  const { text } = await c.req.json();
3496
3799
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3497
3800
  const s = load();
3498
- const note = addNote(s, c.req.param("key"), text);
3801
+ const key = c.req.param("key");
3802
+ const note = addNote(s, key, text);
3499
3803
  persist(s);
3804
+ logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
3500
3805
  return c.json(note);
3501
3806
  });
3502
3807
  app.put("/keys/:key/notes/:id", async (c) => {
3503
3808
  const { text } = await c.req.json();
3504
3809
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3505
3810
  const s = load();
3506
- editNote(s, c.req.param("key"), c.req.param("id"), text);
3811
+ const key = c.req.param("key");
3812
+ editNote(s, key, c.req.param("id"), text);
3507
3813
  persist(s);
3814
+ logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
3508
3815
  return c.json({ ok: true });
3509
3816
  });
3510
3817
  app.delete("/keys/:key/notes/:id", (c) => {
3511
3818
  const s = load();
3512
- deleteNote(s, c.req.param("key"), c.req.param("id"));
3819
+ const key = c.req.param("key");
3820
+ deleteNote(s, key, c.req.param("id"));
3513
3821
  persist(s);
3822
+ logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
3514
3823
  return c.json({ ok: true });
3515
3824
  });
3516
3825
  app.put("/config", async (c) => {
@@ -3519,6 +3828,7 @@ function createApi(deps) {
3519
3828
  return c.json({ error: "config.locales must be an array" }, 400);
3520
3829
  }
3521
3830
  const s = load();
3831
+ const beforeCfg = { locales: s.config.locales };
3522
3832
  const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
3523
3833
  for (const l of removed) {
3524
3834
  for (const e of Object.values(s.keys)) delete e.values[l];
@@ -3526,7 +3836,8 @@ function createApi(deps) {
3526
3836
  s.config = newConfig;
3527
3837
  validate(s);
3528
3838
  persist(s);
3529
- console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s), model ${newConfig.ai.model}`);
3839
+ logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
3840
+ console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
3530
3841
  return c.json({ ok: true });
3531
3842
  });
3532
3843
  app.get("/glossary", (c) => c.json(load().glossary));
@@ -3534,14 +3845,19 @@ function createApi(deps) {
3534
3845
  const entry = await c.req.json();
3535
3846
  if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
3536
3847
  const s = load();
3848
+ const before = s.glossary.find((g) => g.term === entry.term);
3537
3849
  upsertGlossaryEntry(s, entry);
3538
3850
  persist(s);
3851
+ logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
3539
3852
  return c.json({ ok: true });
3540
3853
  });
3541
3854
  app.delete("/glossary/:term", (c) => {
3542
3855
  const s = load();
3543
- deleteGlossaryEntry(s, decodeURIComponent(c.req.param("term")));
3856
+ const term = decodeURIComponent(c.req.param("term"));
3857
+ const before = s.glossary.find((g) => g.term === term);
3858
+ deleteGlossaryEntry(s, term);
3544
3859
  persist(s);
3860
+ logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
3545
3861
  return c.json({ ok: true });
3546
3862
  });
3547
3863
  app.post("/keys/:key/screenshot", async (c) => {
@@ -3549,18 +3865,18 @@ function createApi(deps) {
3549
3865
  const body = await c.req.parseBody();
3550
3866
  const file = body["file"];
3551
3867
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
3552
- const root = dirname5(resolve6(deps.statePath));
3868
+ const root = dirname2(resolve8(deps.statePath));
3553
3869
  const dirName = screenshotDirName(deps.statePath);
3554
- const dir = resolve6(root, dirName);
3555
- mkdirSync6(dir, { recursive: true });
3870
+ const dir = resolve8(root, dirName);
3556
3871
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
3557
- writeFileSync5(resolve6(dir, filename), Buffer.from(await file.arrayBuffer()));
3872
+ writeFileAtomic(resolve8(dir, filename), Buffer.from(await file.arrayBuffer()));
3558
3873
  const path = `${dirName}/${filename}`;
3559
3874
  const s = load();
3560
3875
  const prev = s.keys[key]?.screenshot;
3561
3876
  setMetadata(s, key, { screenshot: path });
3562
3877
  if (prev && prev !== path) removeOrphanScreenshot(s, prev);
3563
3878
  persist(s);
3879
+ logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
3564
3880
  return c.json({ path });
3565
3881
  });
3566
3882
  app.delete("/keys/:key/screenshot", (c) => {
@@ -3570,6 +3886,7 @@ function createApi(deps) {
3570
3886
  setMetadata(s, key, { screenshot: void 0 });
3571
3887
  removeOrphanScreenshot(s, shot);
3572
3888
  persist(s);
3889
+ logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
3573
3890
  return c.json({ ok: true });
3574
3891
  });
3575
3892
  app.get("/export/preview", (c) => {
@@ -3614,12 +3931,13 @@ function createApi(deps) {
3614
3931
  return c.json({ error: e.message }, 400);
3615
3932
  }
3616
3933
  persist(result.state);
3934
+ logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
3617
3935
  console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
3618
3936
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
3619
3937
  });
3620
3938
  app.post("/export", (c) => {
3621
3939
  const s = narrowForExport(load());
3622
- const root = dirname5(resolve6(deps.statePath));
3940
+ const root = dirname2(resolve8(deps.statePath));
3623
3941
  const warnings = [];
3624
3942
  let count = 0;
3625
3943
  for (const output of s.config.outputs) {
@@ -3627,9 +3945,8 @@ function createApi(deps) {
3627
3945
  const result = adapter.export(s, output);
3628
3946
  warnings.push(...result.warnings);
3629
3947
  for (const f of result.files) {
3630
- const abs = resolve6(root, f.path);
3631
- mkdirSync6(dirname5(abs), { recursive: true });
3632
- writeFileSync5(abs, f.contents, "utf8");
3948
+ const abs = resolve8(root, f.path);
3949
+ writeFileAtomic(abs, f.contents);
3633
3950
  count++;
3634
3951
  }
3635
3952
  }
@@ -3648,41 +3965,62 @@ function createApi(deps) {
3648
3965
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
3649
3966
  return;
3650
3967
  }
3968
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3651
3969
  let provider;
3652
3970
  try {
3653
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
3971
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3654
3972
  } catch (e) {
3655
3973
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
3656
3974
  return;
3657
3975
  }
3658
3976
  const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
3659
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3660
- console.log(`[translate] ${reqs.length} string(s) \u2192 ${s.config.ai.model}`);
3977
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3978
+ console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
3661
3979
  let totalWritten = 0;
3662
3980
  const allErrors = [];
3663
3981
  const system = buildSystemPrompt();
3664
3982
  const reqById = new Map(reqs.map((r) => [r.id, r]));
3665
- await runLocaleParallel(reqs, provider, (done, total, batchResults) => {
3666
- const { written, errors } = applyResults(s, reqs, batchResults);
3667
- persist(s);
3668
- totalWritten += written;
3669
- allErrors.push(...errors);
3670
- appendAiLog(projectRoot, {
3671
- at: (/* @__PURE__ */ new Date()).toISOString(),
3672
- kind: "translate",
3673
- model: s.config.ai.model,
3674
- system,
3675
- items: batchResults.map((r) => {
3676
- const req = reqById.get(r.id);
3677
- return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
3678
- }),
3679
- results: batchResults
3680
- });
3681
- console.log(`[translate] ${done}/${total}`);
3682
- void stream.writeSSE({
3683
- event: "progress",
3684
- data: JSON.stringify({ done, total, written: totalWritten, errors })
3685
- });
3983
+ const localeTotals = /* @__PURE__ */ new Map();
3984
+ for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
3985
+ const localeDone = /* @__PURE__ */ new Map();
3986
+ await stream.writeSSE({
3987
+ event: "start",
3988
+ data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
3989
+ });
3990
+ await runLocaleParallel(reqs, provider, {
3991
+ // Announce a language the moment a worker picks it up — this is the
3992
+ // signal that "something is happening" during the long first LLM call.
3993
+ onLocaleStart: (locale) => {
3994
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
3995
+ },
3996
+ onBatchComplete: (done, total, batchResults, locale) => {
3997
+ const { written, errors } = applyResults(s, reqs, batchResults);
3998
+ persist(s);
3999
+ totalWritten += written;
4000
+ allErrors.push(...errors);
4001
+ appendLog(projectRoot, {
4002
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4003
+ kind: "translate",
4004
+ summary: `Translated ${batchResults.length} item(s)`,
4005
+ model: aiCfg.model,
4006
+ system,
4007
+ items: batchResults.map((r) => {
4008
+ const req = reqById.get(r.id);
4009
+ return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
4010
+ }),
4011
+ results: batchResults
4012
+ });
4013
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
4014
+ localeDone.set(locale, ld);
4015
+ console.log(`[translate] ${done}/${total}`);
4016
+ void stream.writeSSE({
4017
+ event: "progress",
4018
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
4019
+ });
4020
+ },
4021
+ onLocaleDone: (locale) => {
4022
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4023
+ }
3686
4024
  }, void 0, signal);
3687
4025
  if (!signal?.aborted) {
3688
4026
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
@@ -3705,19 +4043,22 @@ function createApi(deps) {
3705
4043
  let written = 0;
3706
4044
  let errors = [];
3707
4045
  if (toTranslate.length) {
4046
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3708
4047
  let provider;
3709
4048
  try {
3710
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4049
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3711
4050
  } catch (e) {
3712
4051
  return c.json({ error: e.message }, 400);
3713
4052
  }
3714
4053
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
3715
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4054
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
3716
4055
  const results = await runLocaleParallel(toTranslate, provider);
3717
4056
  ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
3718
4057
  const entry = {
3719
4058
  at: (/* @__PURE__ */ new Date()).toISOString(),
3720
- model: s.config.ai.model,
4059
+ kind: "translate",
4060
+ summary: `Translated ${toTranslate.length} item(s)`,
4061
+ model: aiCfg.model,
3721
4062
  system: buildSystemPrompt(),
3722
4063
  // Log the screenshot PATH only — never the image bytes.
3723
4064
  items: toTranslate.map((r) => ({
@@ -3731,12 +4072,12 @@ function createApi(deps) {
3731
4072
  })),
3732
4073
  results
3733
4074
  };
3734
- appendAiLog(projectRoot, entry);
4075
+ appendLog(projectRoot, entry);
3735
4076
  }
3736
4077
  persist(s);
3737
4078
  return c.json({ requested: reqs.length, written, errors });
3738
4079
  }));
3739
- app.get("/ai-log", (c) => c.json(readAiLog(projectRoot, 100)));
4080
+ app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
3740
4081
  app.post("/scan", async (c) => {
3741
4082
  const s = load();
3742
4083
  const existing = loadUsageCache(projectRoot);
@@ -3760,7 +4101,7 @@ function createApi(deps) {
3760
4101
  const refs = [];
3761
4102
  const prefixRefs = [];
3762
4103
  for (const [file, entry] of Object.entries(cache2.files)) {
3763
- const abs = resolve6(projectRoot, file);
4104
+ const abs = resolve8(projectRoot, file);
3764
4105
  for (const r of entry.refs) {
3765
4106
  if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
3766
4107
  }
@@ -3801,9 +4142,10 @@ function createApi(deps) {
3801
4142
  keys: body.keys
3802
4143
  }, cache2, body.lastRunAt);
3803
4144
  if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4145
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3804
4146
  let provider;
3805
4147
  try {
3806
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4148
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
3807
4149
  } catch (e) {
3808
4150
  return c.json({ error: e.message }, 400);
3809
4151
  }
@@ -3825,10 +4167,11 @@ function createApi(deps) {
3825
4167
  const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
3826
4168
  const batch = raw;
3827
4169
  const { written, errors } = applyContext(s, targets, batch.items ?? []);
3828
- appendAiLog(projectRoot, {
4170
+ appendLog(projectRoot, {
3829
4171
  at: (/* @__PURE__ */ new Date()).toISOString(),
3830
4172
  kind: "context",
3831
- model: s.config.ai.model,
4173
+ summary: `Generated context for ${targets.length} key(s)`,
4174
+ model: aiCfg.model,
3832
4175
  system,
3833
4176
  items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
3834
4177
  results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
@@ -3844,8 +4187,8 @@ function createApi(deps) {
3844
4187
  }
3845
4188
 
3846
4189
  // src/server/server.ts
3847
- var here = dirname6(fileURLToPath(import.meta.url));
3848
- var DEFAULT_UI_DIR = join7(here, "..", "ui");
4190
+ var here = dirname3(fileURLToPath(import.meta.url));
4191
+ var DEFAULT_UI_DIR = join9(here, "..", "ui");
3849
4192
  var MIME = {
3850
4193
  ".html": "text/html; charset=utf-8",
3851
4194
  ".js": "text/javascript; charset=utf-8",
@@ -3874,14 +4217,14 @@ async function readFileResponse(absPath) {
3874
4217
  function buildApp(opts) {
3875
4218
  const app = new Hono2();
3876
4219
  app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
3877
- const projectRoot = dirname6(resolve7(opts.statePath));
4220
+ const projectRoot = dirname3(resolve9(opts.statePath));
3878
4221
  app.get("/:dir/*", async (c, next) => {
3879
4222
  const dirSeg = c.req.param("dir");
3880
4223
  if (!dirSeg.endsWith("-screenshots")) return next();
3881
- const shotsRoot = resolve7(projectRoot, dirSeg);
4224
+ const shotsRoot = resolve9(projectRoot, dirSeg);
3882
4225
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
3883
4226
  const rest = pathname.slice(`/${dirSeg}`.length);
3884
- const target = resolve7(shotsRoot, "." + rest);
4227
+ const target = resolve9(shotsRoot, "." + rest);
3885
4228
  const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
3886
4229
  if (inside) {
3887
4230
  const file = await readFileResponse(target);
@@ -3890,24 +4233,35 @@ function buildApp(opts) {
3890
4233
  return c.notFound();
3891
4234
  });
3892
4235
  if (!opts.dev) {
3893
- const root = resolve7(opts.uiDir ?? DEFAULT_UI_DIR);
4236
+ const root = resolve9(opts.uiDir ?? DEFAULT_UI_DIR);
3894
4237
  app.get("/*", async (c) => {
3895
4238
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
3896
- const target = resolve7(root, "." + pathname);
4239
+ const target = resolve9(root, "." + pathname);
3897
4240
  const inside = target === root || target.startsWith(root + sep2);
3898
4241
  if (inside && pathname !== "/") {
3899
4242
  const file = await readFileResponse(target);
3900
4243
  if (file) return file;
3901
4244
  }
3902
- const index = await readFileResponse(join7(root, "index.html"));
4245
+ const index = await readFileResponse(join9(root, "index.html"));
3903
4246
  if (index) return index;
3904
4247
  return c.notFound();
3905
4248
  });
4249
+ } else {
4250
+ app.get("/", (c) => c.html(DEV_LANDING_PAGE));
3906
4251
  }
3907
4252
  return app;
3908
4253
  }
3909
4254
  var DEFAULT_PORT = 3e3;
3910
4255
  var DEV_PORT = 8787;
4256
+ var DEV_UI_URL = "http://localhost:5173";
4257
+ var DEV_LANDING_PAGE = `<!doctype html>
4258
+ <html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
4259
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:16vh auto;padding:0 1.5rem;color:#1f2937}h1{font-size:1.4rem}a{color:#2563eb}code{background:#f3f4f6;padding:.1em .35em;border-radius:.3em}</style>
4260
+ </head><body>
4261
+ <h1>Glotfile \u2014 dev API server</h1>
4262
+ <p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
4263
+ <p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
4264
+ </body></html>`;
3911
4265
  function findAvailablePort(start) {
3912
4266
  return new Promise((resolveP, reject) => {
3913
4267
  const probe = createServer();
@@ -3936,7 +4290,7 @@ async function startServer(opts) {
3936
4290
  });
3937
4291
  }
3938
4292
  function backgroundScan(statePath) {
3939
- const projectRoot = dirname6(resolve7(statePath));
4293
+ const projectRoot = dirname3(resolve9(statePath));
3940
4294
  Promise.resolve().then(() => {
3941
4295
  const state = loadState(statePath);
3942
4296
  const existing = loadUsageCache(projectRoot);