glotfile 0.2.0 → 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.
@@ -31,6 +31,32 @@ var init_format = __esm({
31
31
  }
32
32
  });
33
33
 
34
+ // src/server/atomic-write.ts
35
+ import { writeFileSync, renameSync, mkdirSync, rmSync } from "fs";
36
+ import { dirname, join } from "path";
37
+ function writeFileAtomic(path, data) {
38
+ const dir = dirname(path);
39
+ mkdirSync(dir, { recursive: true });
40
+ const tmp = join(dir, `.${process.pid}.${counter++}.tmp`);
41
+ try {
42
+ writeFileSync(tmp, data);
43
+ renameSync(tmp, path);
44
+ } catch (e) {
45
+ try {
46
+ rmSync(tmp, { force: true });
47
+ } catch {
48
+ }
49
+ throw e;
50
+ }
51
+ }
52
+ var counter;
53
+ var init_atomic_write = __esm({
54
+ "src/server/atomic-write.ts"() {
55
+ "use strict";
56
+ counter = 0;
57
+ }
58
+ });
59
+
34
60
  // src/server/lint/registry.ts
35
61
  var RULE_IDS, DEFAULT_SEVERITY;
36
62
  var init_registry = __esm({
@@ -117,17 +143,6 @@ function validate(raw) {
117
143
  }
118
144
  }
119
145
  }
120
- if (!isObject(config.ai)) fail("config.ai must be an object");
121
- const ai = config.ai;
122
- if (typeof ai.provider !== "string" || !PROVIDERS.includes(ai.provider)) {
123
- fail(`config.ai.provider must be one of: ${PROVIDERS.join(", ")}`);
124
- }
125
- if (typeof ai.model !== "string") fail("config.ai.model must be a string");
126
- if (!(ai.endpoint === null || typeof ai.endpoint === "string")) fail("config.ai.endpoint must be a string or null");
127
- if (!(ai.region === void 0 || ai.region === null || typeof ai.region === "string")) {
128
- fail("config.ai.region must be a string or null");
129
- }
130
- if (typeof ai.batchSize !== "number") fail("config.ai.batchSize must be a number");
131
146
  if (!isObject(config.format)) fail("config.format must be an object");
132
147
  const fmt = config.format;
133
148
  if (typeof fmt.indent !== "number") fail("config.format.indent must be a number");
@@ -258,7 +273,6 @@ function defaultState() {
258
273
  { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
259
274
  { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" }
260
275
  ],
261
- ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", endpoint: null, region: null, batchSize: 25 },
262
276
  format: { indent: 2, sortKeys: true, finalNewline: true },
263
277
  spelling: { customWords: [] },
264
278
  autoExport: true
@@ -277,7 +291,7 @@ var init_schema = __esm({
277
291
  PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
278
292
  EXACT_SELECTOR_RE = /^=\d+$/;
279
293
  LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
280
- PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter"];
294
+ PROVIDERS = ["anthropic", "openai", "bedrock", "openrouter", "ollama", "claude-code"];
281
295
  GlotfileError = class extends Error {
282
296
  };
283
297
  }
@@ -402,13 +416,13 @@ var init_plurals = __esm({
402
416
  });
403
417
 
404
418
  // src/server/storage.ts
405
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "fs";
406
- import { join, dirname } from "path";
419
+ import { existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, rmSync as rmSync2 } from "fs";
420
+ import { join as join2 } from "path";
407
421
  function splitDirFor(statePath) {
408
422
  return statePath.replace(/\.json$/i, "");
409
423
  }
410
424
  function detectFormat(statePath) {
411
- if (existsSync(join(splitDirFor(statePath), "config.json"))) return "split";
425
+ if (existsSync(join2(splitDirFor(statePath), "config.json"))) return "split";
412
426
  if (existsSync(statePath)) return "single";
413
427
  return "none";
414
428
  }
@@ -438,15 +452,15 @@ function assemble(parts) {
438
452
  return { ...parts.manifest, keys };
439
453
  }
440
454
  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");
455
+ const readJson3 = (p) => JSON.parse(readFileSync(p, "utf8"));
456
+ const manifest = readJson3(join2(splitDir, "config.json"));
457
+ const keysPath = join2(splitDir, "keys.json");
458
+ const keys = existsSync(keysPath) ? readJson3(keysPath) : {};
459
+ const localesDir = join2(splitDir, "locales");
446
460
  const locales = {};
447
461
  if (existsSync(localesDir)) {
448
462
  for (const name of readdirSync(localesDir)) {
449
- if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson(join(localesDir, name));
463
+ if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson3(join2(localesDir, name));
450
464
  }
451
465
  }
452
466
  return assemble({ manifest, keys, locales });
@@ -458,15 +472,14 @@ function writeIfChanged(path, contents) {
458
472
  } catch {
459
473
  }
460
474
  if (current === contents) return false;
461
- mkdirSync(dirname(path), { recursive: true });
462
- writeFileSync(path, contents, "utf8");
475
+ writeFileAtomic(path, contents);
463
476
  return true;
464
477
  }
465
478
  function saveSplit(splitDir, state) {
466
479
  const fmt = state.config.format;
467
480
  const parts = disassemble(state);
468
- const localesDir = join(splitDir, "locales");
469
- mkdirSync(localesDir, { recursive: true });
481
+ const localesDir = join2(splitDir, "locales");
482
+ mkdirSync2(localesDir, { recursive: true });
470
483
  let written = 0;
471
484
  let skipped = 0;
472
485
  let deleted = 0;
@@ -474,15 +487,15 @@ function saveSplit(splitDir, state) {
474
487
  if (changed) written++;
475
488
  else skipped++;
476
489
  };
477
- track(writeIfChanged(join(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
478
- track(writeIfChanged(join(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
490
+ track(writeIfChanged(join2(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
491
+ track(writeIfChanged(join2(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
479
492
  for (const [locale, entries] of Object.entries(parts.locales)) {
480
- track(writeIfChanged(join(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
493
+ track(writeIfChanged(join2(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
481
494
  }
482
495
  const live = new Set(Object.keys(parts.locales).map((l) => `${l}.json`));
483
496
  for (const name of readdirSync(localesDir)) {
484
497
  if (name.endsWith(".json") && !live.has(name)) {
485
- rmSync(join(localesDir, name));
498
+ rmSync2(join2(localesDir, name));
486
499
  deleted++;
487
500
  }
488
501
  }
@@ -492,6 +505,7 @@ var init_storage = __esm({
492
505
  "src/server/storage.ts"() {
493
506
  "use strict";
494
507
  init_format();
508
+ init_atomic_write();
495
509
  }
496
510
  });
497
511
 
@@ -506,8 +520,7 @@ var init_normalize = __esm({
506
520
  });
507
521
 
508
522
  // src/server/state.ts
509
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
510
- import { dirname as dirname2 } from "path";
523
+ import { readFileSync as readFileSync2, existsSync as existsSync2, rmSync as rmSync3 } from "fs";
511
524
  import { randomUUID } from "crypto";
512
525
  function canonLocale(locale) {
513
526
  return locale.trim().toLowerCase().replace(/_/g, "-");
@@ -552,10 +565,9 @@ function saveState(path, state) {
552
565
  normalizeState(state);
553
566
  if (state.config.storage === "split") {
554
567
  saveSplit(splitDirFor(path), state);
555
- if (existsSync2(path)) rmSync2(path);
568
+ if (existsSync2(path)) rmSync3(path);
556
569
  } else {
557
- mkdirSync2(dirname2(path), { recursive: true });
558
- writeFileSync2(path, serializeJson(state, state.config.format), "utf8");
570
+ writeFileAtomic(path, serializeJson(state, state.config.format));
559
571
  }
560
572
  }
561
573
  function requireKey(state, key) {
@@ -703,6 +715,9 @@ function setMetadata(state, key, partial) {
703
715
  delete entry.contextAt;
704
716
  }
705
717
  Object.assign(entry, safe);
718
+ if ("context" in safe && !entry.context) delete entry.context;
719
+ if ("tags" in safe && !entry.tags?.length) delete entry.tags;
720
+ if ("maxLength" in safe && !entry.maxLength) delete entry.maxLength;
706
721
  }
707
722
  function addNote(state, key, text, clock = systemClock) {
708
723
  const entry = requireKey(state, key);
@@ -761,6 +776,7 @@ var init_state = __esm({
761
776
  "src/server/state.ts"() {
762
777
  "use strict";
763
778
  init_format();
779
+ init_atomic_write();
764
780
  init_schema();
765
781
  init_plurals();
766
782
  init_storage();
@@ -1494,8 +1510,8 @@ var init_adapters = __esm({
1494
1510
  });
1495
1511
 
1496
1512
  // src/server/export-run.ts
1497
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3 } from "fs";
1498
- import { dirname as dirname3, resolve } from "path";
1513
+ import { readFileSync as readFileSync3 } from "fs";
1514
+ import { resolve } from "path";
1499
1515
  function effectiveLocales(config) {
1500
1516
  const limit = config.exportLocales;
1501
1517
  if (!limit || limit.length === 0) return config.locales;
@@ -1532,8 +1548,7 @@ function exportToDisk(state, projectRoot, opts) {
1532
1548
  skipped++;
1533
1549
  continue;
1534
1550
  }
1535
- mkdirSync3(dirname3(abs), { recursive: true });
1536
- writeFileSync3(abs, f.contents, "utf8");
1551
+ writeFileAtomic(abs, f.contents);
1537
1552
  written++;
1538
1553
  }
1539
1554
  }
@@ -1543,6 +1558,7 @@ var init_export_run = __esm({
1543
1558
  "src/server/export-run.ts"() {
1544
1559
  "use strict";
1545
1560
  init_adapters();
1561
+ init_atomic_write();
1546
1562
  }
1547
1563
  });
1548
1564
 
@@ -1636,7 +1652,7 @@ function validateTranslation(req, translation) {
1636
1652
  return { id: req.id, error: "Placeholder mismatch between source and translation." };
1637
1653
  }
1638
1654
  if (req.maxLength !== void 0 && translation.length > req.maxLength) {
1639
- return { id: req.id, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1655
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1640
1656
  }
1641
1657
  return { id: req.id, translation };
1642
1658
  }
@@ -1888,7 +1904,7 @@ var init_bedrock = __esm({
1888
1904
  }
1889
1905
  const region = config.region ?? process.env.AWS_REGION;
1890
1906
  if (!region) {
1891
- throw new Error("AWS region is not set. Set config.ai.region or the AWS_REGION environment variable for the bedrock provider.");
1907
+ 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.");
1892
1908
  }
1893
1909
  const require2 = createRequire2(import.meta.url);
1894
1910
  let sdk;
@@ -2016,9 +2032,131 @@ var init_openrouter = __esm({
2016
2032
  }
2017
2033
  });
2018
2034
 
2035
+ // src/server/ai/ollama.ts
2036
+ function ollamaClientOptions(config) {
2037
+ return {
2038
+ apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
2039
+ baseURL: config.endpoint ?? OLLAMA_BASE_URL
2040
+ };
2041
+ }
2042
+ var OLLAMA_BASE_URL, OllamaProvider;
2043
+ var init_ollama = __esm({
2044
+ "src/server/ai/ollama.ts"() {
2045
+ "use strict";
2046
+ init_openai();
2047
+ OLLAMA_BASE_URL = "http://localhost:11434/v1";
2048
+ OllamaProvider = class extends OpenAIProvider {
2049
+ constructor(config, client) {
2050
+ super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
2051
+ }
2052
+ supportsVision() {
2053
+ return false;
2054
+ }
2055
+ };
2056
+ }
2057
+ });
2058
+
2059
+ // src/server/ai/claudecode.ts
2060
+ import { spawn } from "child_process";
2061
+ function stripFences(s) {
2062
+ return s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
2063
+ }
2064
+ function defaultSpawn(prompt, systemPrompt, model) {
2065
+ return new Promise((resolve12, reject) => {
2066
+ const args = [
2067
+ "--print",
2068
+ "--output-format",
2069
+ "json",
2070
+ "--system-prompt",
2071
+ systemPrompt,
2072
+ // Only pass --model when explicitly configured; otherwise use the session default.
2073
+ ...model ? ["--model", model] : []
2074
+ ];
2075
+ const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
2076
+ let stdout = "";
2077
+ let stderr = "";
2078
+ child.stdout.on("data", (chunk2) => {
2079
+ stdout += chunk2.toString();
2080
+ });
2081
+ child.stderr.on("data", (chunk2) => {
2082
+ stderr += chunk2.toString();
2083
+ });
2084
+ child.stdin.write(prompt);
2085
+ child.stdin.end();
2086
+ child.on("close", (code) => {
2087
+ if (code !== 0) {
2088
+ reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
2089
+ return;
2090
+ }
2091
+ try {
2092
+ const envelope = JSON.parse(stdout.trim());
2093
+ if (envelope.is_error) {
2094
+ reject(new Error(`claude error: ${envelope.result ?? "unknown error"}`));
2095
+ return;
2096
+ }
2097
+ resolve12(envelope.result ?? "");
2098
+ } catch {
2099
+ reject(new Error(`Failed to parse claude JSON output: ${stdout.slice(0, 200)}`));
2100
+ }
2101
+ });
2102
+ child.on("error", (err) => {
2103
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed?`));
2104
+ });
2105
+ });
2106
+ }
2107
+ var ClaudeCodeProvider;
2108
+ var init_claudecode = __esm({
2109
+ "src/server/ai/claudecode.ts"() {
2110
+ "use strict";
2111
+ init_provider();
2112
+ init_batch();
2113
+ ClaudeCodeProvider = class {
2114
+ constructor(config, spawnFn) {
2115
+ this.config = config;
2116
+ this.spawnFn = spawnFn ?? defaultSpawn;
2117
+ }
2118
+ config;
2119
+ spawnFn;
2120
+ supportsVision() {
2121
+ return false;
2122
+ }
2123
+ translate(reqs, onBatchComplete, signal) {
2124
+ return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
2125
+ }
2126
+ async complete(req) {
2127
+ const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
2128
+ const textBlocks = req.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
2129
+ const result = await this.spawnFn(textBlocks, systemParts.join("\n\n"), this.config.model);
2130
+ try {
2131
+ return JSON.parse(stripFences(result));
2132
+ } catch {
2133
+ return {};
2134
+ }
2135
+ }
2136
+ async callBatch(batch, signal) {
2137
+ if (signal?.aborted) return [];
2138
+ const prompt = buildBatchPrompt(batch);
2139
+ let result;
2140
+ try {
2141
+ result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
2142
+ } catch (err) {
2143
+ if (signal?.aborted) return [];
2144
+ throw err;
2145
+ }
2146
+ if (signal?.aborted) return [];
2147
+ try {
2148
+ const parsed = JSON.parse(stripFences(result));
2149
+ return parsed.items ?? [];
2150
+ } catch {
2151
+ return [];
2152
+ }
2153
+ }
2154
+ };
2155
+ }
2156
+ });
2157
+
2019
2158
  // src/server/ai/index.ts
2020
- function makeProvider(config) {
2021
- const ai = config.ai;
2159
+ function makeProvider(ai) {
2022
2160
  switch (ai.provider) {
2023
2161
  case "anthropic":
2024
2162
  return new AnthropicProvider(ai);
@@ -2028,8 +2166,12 @@ function makeProvider(config) {
2028
2166
  return new BedrockProvider(ai);
2029
2167
  case "openrouter":
2030
2168
  return new OpenRouterProvider(ai);
2169
+ case "ollama":
2170
+ return new OllamaProvider(ai);
2171
+ case "claude-code":
2172
+ return new ClaudeCodeProvider(ai);
2031
2173
  default:
2032
- throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter.`);
2174
+ throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
2033
2175
  }
2034
2176
  }
2035
2177
  var init_ai = __esm({
@@ -2039,6 +2181,95 @@ var init_ai = __esm({
2039
2181
  init_openai();
2040
2182
  init_bedrock();
2041
2183
  init_openrouter();
2184
+ init_ollama();
2185
+ init_claudecode();
2186
+ }
2187
+ });
2188
+
2189
+ // src/server/glotfile-dir.ts
2190
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2191
+ import { resolve as resolve2 } from "path";
2192
+ function ensureGlotfileDir(projectRoot) {
2193
+ const dir = resolve2(projectRoot, ".glotfile");
2194
+ mkdirSync3(dir, { recursive: true });
2195
+ const ignore = resolve2(dir, ".gitignore");
2196
+ if (!existsSync3(ignore)) {
2197
+ try {
2198
+ writeFileSync2(ignore, "*\n");
2199
+ } catch {
2200
+ }
2201
+ }
2202
+ return dir;
2203
+ }
2204
+ var init_glotfile_dir = __esm({
2205
+ "src/server/glotfile-dir.ts"() {
2206
+ "use strict";
2207
+ }
2208
+ });
2209
+
2210
+ // src/server/local-settings.ts
2211
+ import { readFileSync as readFileSync4 } from "fs";
2212
+ import { resolve as resolve3 } from "path";
2213
+ function readJson(path) {
2214
+ try {
2215
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
2216
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2217
+ } catch {
2218
+ return {};
2219
+ }
2220
+ }
2221
+ function coerceAi(raw) {
2222
+ const a = raw && typeof raw === "object" ? raw : {};
2223
+ return {
2224
+ provider: PROVIDERS.includes(a.provider) ? a.provider : DEFAULT_AI.provider,
2225
+ model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
2226
+ endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
2227
+ region: typeof a.region === "string" ? a.region : null,
2228
+ batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
2229
+ };
2230
+ }
2231
+ function loadLocalSettings(projectRoot) {
2232
+ const raw = readJson(settingsPath(projectRoot));
2233
+ return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
2234
+ }
2235
+ function saveLocalSettings(projectRoot, patch) {
2236
+ const path = settingsPath(projectRoot);
2237
+ const merged = { ...readJson(path) };
2238
+ if (patch.ai !== void 0) merged.ai = patch.ai;
2239
+ if (patch.editor !== void 0) merged.editor = patch.editor;
2240
+ ensureGlotfileDir(projectRoot);
2241
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
2242
+ }
2243
+ function aiConfigError(ai) {
2244
+ if (!ai || typeof ai !== "object") return "ai must be an object";
2245
+ const a = ai;
2246
+ if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
2247
+ return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
2248
+ }
2249
+ if (typeof a.model !== "string") return "ai.model must be a string";
2250
+ if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
2251
+ if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
2252
+ if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
2253
+ return null;
2254
+ }
2255
+ var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
2256
+ var init_local_settings = __esm({
2257
+ "src/server/local-settings.ts"() {
2258
+ "use strict";
2259
+ init_atomic_write();
2260
+ init_glotfile_dir();
2261
+ init_schema();
2262
+ EDITOR_IDS = ["vscode", "zed", "phpstorm"];
2263
+ isEditorId = (v) => EDITOR_IDS.includes(v);
2264
+ DEFAULT_AI = {
2265
+ provider: "anthropic",
2266
+ model: "claude-haiku-4-5-20251001",
2267
+ endpoint: null,
2268
+ region: null,
2269
+ batchSize: 25
2270
+ };
2271
+ DEFAULT_EDITOR = "vscode";
2272
+ settingsPath = (projectRoot) => resolve3(projectRoot, ".glotfile", "settings.json");
2042
2273
  }
2043
2274
  });
2044
2275
 
@@ -2054,8 +2285,8 @@ var init_glob = __esm({
2054
2285
  });
2055
2286
 
2056
2287
  // src/server/ai/run.ts
2057
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
2058
- import { resolve as resolve2, extname } from "path";
2288
+ import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
2289
+ import { resolve as resolve4, extname } from "path";
2059
2290
  function selectRequests(state, opts) {
2060
2291
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
2061
2292
  const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
@@ -2134,11 +2365,11 @@ function attachScreenshots(reqs, state, projectRoot) {
2134
2365
  const mediaType = MEDIA_TYPES[extname(screenshot).toLowerCase()];
2135
2366
  if (!mediaType) continue;
2136
2367
  if (!cache2.has(screenshot)) {
2137
- const abs = resolve2(projectRoot, screenshot);
2138
- if (!existsSync3(abs)) {
2368
+ const abs = resolve4(projectRoot, screenshot);
2369
+ if (!existsSync4(abs)) {
2139
2370
  cache2.set(screenshot, null);
2140
2371
  } else {
2141
- const buf = readFileSync4(abs);
2372
+ const buf = readFileSync5(abs);
2142
2373
  cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
2143
2374
  }
2144
2375
  }
@@ -2154,7 +2385,7 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
2154
2385
  const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
2155
2386
  return { skipped: keys.size };
2156
2387
  }
2157
- async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
2388
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
2158
2389
  if (!reqs.length) return [];
2159
2390
  const byLocale = /* @__PURE__ */ new Map();
2160
2391
  for (const req of reqs) {
@@ -2174,11 +2405,14 @@ async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency =
2174
2405
  while (next < groups.length) {
2175
2406
  if (signal?.aborted) break;
2176
2407
  const group = groups[next++];
2408
+ const locale = group[0].targetLocale;
2409
+ hooks.onLocaleStart?.(locale);
2177
2410
  const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
2178
2411
  done += batchResults.length;
2179
- onBatchComplete?.(done, total, batchResults);
2412
+ hooks.onBatchComplete?.(done, total, batchResults, locale);
2180
2413
  }, signal);
2181
2414
  allResults.push(...localeResults);
2415
+ if (!signal?.aborted) hooks.onLocaleDone?.(locale);
2182
2416
  }
2183
2417
  }
2184
2418
  const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
@@ -2200,10 +2434,11 @@ function applyResults(state, reqs, results, clock = systemClock, force = false)
2200
2434
  if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
2201
2435
  continue;
2202
2436
  }
2203
- if (res.error || res.translation === void 0) {
2437
+ if (res.translation === void 0) {
2204
2438
  errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
2205
2439
  continue;
2206
2440
  }
2441
+ if (res.error) errors.push({ key: req.key, locale: req.targetLocale, error: res.error });
2207
2442
  if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
2208
2443
  }
2209
2444
  return { written, errors };
@@ -2228,49 +2463,46 @@ var init_run = __esm({
2228
2463
  }
2229
2464
  });
2230
2465
 
2231
- // src/server/ai/log.ts
2232
- import { appendFileSync, readFileSync as readFileSync5, existsSync as existsSync4, mkdirSync as mkdirSync4 } from "fs";
2233
- import { resolve as resolve3 } from "path";
2466
+ // src/server/log.ts
2467
+ import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
2468
+ import { resolve as resolve5 } from "path";
2234
2469
  function logPath(projectRoot) {
2235
- return resolve3(projectRoot, ".glotfile", "ai-log.jsonl");
2470
+ return resolve5(projectRoot, ".glotfile", "log.jsonl");
2236
2471
  }
2237
- function appendAiLog(projectRoot, entry) {
2238
- mkdirSync4(resolve3(projectRoot, ".glotfile"), { recursive: true });
2472
+ function appendLog(projectRoot, entry) {
2473
+ ensureGlotfileDir(projectRoot);
2239
2474
  appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
2240
2475
  }
2241
- function readAiLog(projectRoot, limit = 100) {
2476
+ function readLog(projectRoot, limit = 100) {
2242
2477
  const path = logPath(projectRoot);
2243
- if (!existsSync4(path)) return [];
2244
- const lines = readFileSync5(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2245
- const entries = lines.map((l) => {
2246
- const e = JSON.parse(l);
2247
- e.kind ??= "translate";
2248
- return e;
2249
- });
2478
+ if (!existsSync5(path)) return [];
2479
+ const lines = readFileSync6(path, "utf8").split("\n").filter((l) => l.trim() !== "");
2480
+ const entries = lines.map((l) => JSON.parse(l));
2250
2481
  return entries.reverse().slice(0, limit);
2251
2482
  }
2252
2483
  var init_log = __esm({
2253
- "src/server/ai/log.ts"() {
2484
+ "src/server/log.ts"() {
2254
2485
  "use strict";
2486
+ init_glotfile_dir();
2255
2487
  }
2256
2488
  });
2257
2489
 
2258
2490
  // src/server/scan.ts
2259
- import { existsSync as existsSync5, readFileSync as readFileSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2260
- import { resolve as resolve4, dirname as dirname4 } from "path";
2491
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2492
+ import { resolve as resolve6 } from "path";
2261
2493
  function loadUsageCache(projectRoot) {
2262
- const path = resolve4(projectRoot, ".glotfile", "usage.json");
2263
- if (!existsSync5(path)) return null;
2494
+ const path = resolve6(projectRoot, ".glotfile", "usage.json");
2495
+ if (!existsSync6(path)) return null;
2264
2496
  try {
2265
- return JSON.parse(readFileSync6(path, "utf8"));
2497
+ return JSON.parse(readFileSync7(path, "utf8"));
2266
2498
  } catch {
2267
2499
  return null;
2268
2500
  }
2269
2501
  }
2270
2502
  function saveUsageCache(projectRoot, cache2) {
2271
- const path = resolve4(projectRoot, ".glotfile", "usage.json");
2272
- mkdirSync5(dirname4(path), { recursive: true });
2273
- writeFileSync4(path, JSON.stringify(cache2, null, 2) + "\n", "utf8");
2503
+ ensureGlotfileDir(projectRoot);
2504
+ const path = resolve6(projectRoot, ".glotfile", "usage.json");
2505
+ writeFileAtomic(path, JSON.stringify(cache2, null, 2) + "\n");
2274
2506
  }
2275
2507
  function findMissing(state) {
2276
2508
  const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
@@ -2297,12 +2529,14 @@ function computeUsedKeys(state, cache2) {
2297
2529
  var init_scan = __esm({
2298
2530
  "src/server/scan.ts"() {
2299
2531
  "use strict";
2532
+ init_atomic_write();
2533
+ init_glotfile_dir();
2300
2534
  }
2301
2535
  });
2302
2536
 
2303
2537
  // src/server/scanner.ts
2304
- import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync7 } from "fs";
2305
- import { join as join2, extname as extname2, relative } from "path";
2538
+ import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync8 } from "fs";
2539
+ import { join as join3, extname as extname2, relative } from "path";
2306
2540
  function scannerForExt(ext) {
2307
2541
  return EXT_SCANNER[ext] ?? null;
2308
2542
  }
@@ -2428,7 +2662,7 @@ function* walkFiles(dir, root, exclude) {
2428
2662
  }
2429
2663
  for (const name of entries) {
2430
2664
  if (ALWAYS_EXCLUDE.has(name)) continue;
2431
- const abs = join2(dir, name);
2665
+ const abs = join3(dir, name);
2432
2666
  const rel = relative(root, abs);
2433
2667
  let st;
2434
2668
  try {
@@ -2458,7 +2692,7 @@ function runScan(projectRoot, opts, existing) {
2458
2692
  const ext = extname2(relPath);
2459
2693
  const scanner = scannerForExt(ext);
2460
2694
  if (!scanner) continue;
2461
- const abs = join2(projectRoot, relPath);
2695
+ const abs = join3(projectRoot, relPath);
2462
2696
  let st;
2463
2697
  try {
2464
2698
  st = statSync(abs);
@@ -2474,7 +2708,7 @@ function runScan(projectRoot, opts, existing) {
2474
2708
  }
2475
2709
  let content;
2476
2710
  try {
2477
- content = readFileSync7(abs, "utf8");
2711
+ content = readFileSync8(abs, "utf8");
2478
2712
  } catch {
2479
2713
  continue;
2480
2714
  }
@@ -2579,8 +2813,8 @@ var init_scanner = __esm({
2579
2813
  });
2580
2814
 
2581
2815
  // src/server/ai/context.ts
2582
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
2583
- import { resolve as resolve5 } from "path";
2816
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
2817
+ import { resolve as resolve7 } from "path";
2584
2818
  function globToRegExp2(glob) {
2585
2819
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
2586
2820
  return new RegExp(`^${escaped}$`);
@@ -2592,10 +2826,10 @@ function extractSnippets(refs, projectRoot, fileCache) {
2592
2826
  const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
2593
2827
  const snippets = [];
2594
2828
  for (const ref of selected) {
2595
- const absPath = resolve5(projectRoot, ref.file);
2829
+ const absPath = resolve7(projectRoot, ref.file);
2596
2830
  if (!fileCache.has(ref.file)) {
2597
- if (!existsSync6(absPath)) continue;
2598
- const content = readFileSync8(absPath, "utf8");
2831
+ if (!existsSync7(absPath)) continue;
2832
+ const content = readFileSync9(absPath, "utf8");
2599
2833
  fileCache.set(ref.file, content.split("\n"));
2600
2834
  }
2601
2835
  const lines = fileCache.get(ref.file);
@@ -2740,8 +2974,8 @@ var init_context = __esm({
2740
2974
  });
2741
2975
 
2742
2976
  // src/server/import/detect.ts
2743
- import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2744
- import { join as join3 } from "path";
2977
+ import { existsSync as existsSync9, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2978
+ import { join as join4 } from "path";
2745
2979
  function safeIsDir(p) {
2746
2980
  try {
2747
2981
  return statSync2(p).isDirectory();
@@ -2750,7 +2984,7 @@ function safeIsDir(p) {
2750
2984
  }
2751
2985
  }
2752
2986
  function listDirs(dir) {
2753
- return readdirSync3(dir).filter((e) => safeIsDir(join3(dir, e)));
2987
+ return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
2754
2988
  }
2755
2989
  function fileCount(dir) {
2756
2990
  try {
@@ -2764,22 +2998,22 @@ function pickSource(locales, sizeOf) {
2764
2998
  return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
2765
2999
  }
2766
3000
  function detectLaravel(root) {
2767
- const localeRoot = [join3(root, "resources", "lang"), join3(root, "lang")].find(safeIsDir);
3001
+ const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
2768
3002
  if (!localeRoot) return null;
2769
3003
  const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
2770
3004
  if (locales.length === 0) return null;
2771
- const sourceLocale = pickSource(locales, (loc) => fileCount(join3(localeRoot, loc)));
3005
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
2772
3006
  return { format: "laravel-php", localeRoot, locales, sourceLocale };
2773
3007
  }
2774
3008
  function detectVue(root) {
2775
3009
  for (const rel of VUE_DIR_CANDIDATES) {
2776
- const localeRoot = join3(root, rel);
3010
+ const localeRoot = join4(root, rel);
2777
3011
  if (!safeIsDir(localeRoot)) continue;
2778
3012
  const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
2779
3013
  if (locales.length >= 2) {
2780
3014
  const sourceLocale = pickSource(locales, (loc) => {
2781
3015
  try {
2782
- return statSync2(join3(localeRoot, `${loc}.json`)).size;
3016
+ return statSync2(join4(localeRoot, `${loc}.json`)).size;
2783
3017
  } catch {
2784
3018
  return 0;
2785
3019
  }
@@ -2791,7 +3025,7 @@ function detectVue(root) {
2791
3025
  }
2792
3026
  function detectArb(root) {
2793
3027
  for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
2794
- const localeRoot = join3(root, rel);
3028
+ const localeRoot = join4(root, rel);
2795
3029
  if (!safeIsDir(localeRoot)) continue;
2796
3030
  const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
2797
3031
  if (locales.length >= 1) {
@@ -2801,7 +3035,7 @@ function detectArb(root) {
2801
3035
  return null;
2802
3036
  }
2803
3037
  function detect(root, formatOverride) {
2804
- if (!existsSync8(root)) return null;
3038
+ if (!existsSync9(root)) return null;
2805
3039
  if (formatOverride) {
2806
3040
  const fn = BY_FORMAT[formatOverride];
2807
3041
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -2856,8 +3090,8 @@ var init_flatten = __esm({
2856
3090
  });
2857
3091
 
2858
3092
  // src/server/import/parsers/vue-i18n-json.ts
2859
- import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "fs";
2860
- import { join as join4 } from "path";
3093
+ import { readdirSync as readdirSync4, readFileSync as readFileSync11 } from "fs";
3094
+ import { join as join5 } from "path";
2861
3095
  var LOCALE_RE2, vueI18nJson2;
2862
3096
  var init_vue_i18n_json2 = __esm({
2863
3097
  "src/server/import/parsers/vue-i18n-json.ts"() {
@@ -2877,7 +3111,7 @@ var init_vue_i18n_json2 = __esm({
2877
3111
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2878
3112
  let data;
2879
3113
  try {
2880
- data = JSON.parse(readFileSync10(join4(localeRoot, file), "utf8"));
3114
+ data = JSON.parse(readFileSync11(join5(localeRoot, file), "utf8"));
2881
3115
  } catch (e) {
2882
3116
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
2883
3117
  continue;
@@ -2905,16 +3139,16 @@ var init_placeholders2 = __esm({
2905
3139
 
2906
3140
  // src/server/import/parsers/laravel-php.ts
2907
3141
  import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2908
- import { join as join5, relative as relative2 } from "path";
3142
+ import { join as join6, relative as relative2 } from "path";
2909
3143
  import { execFileSync } from "child_process";
2910
3144
  function listDirs2(dir) {
2911
- return readdirSync5(dir).filter((e) => statSync3(join5(dir, e)).isDirectory());
3145
+ return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
2912
3146
  }
2913
3147
  function listPhpFiles(dir) {
2914
3148
  const out = [];
2915
3149
  const walk = (d) => {
2916
3150
  for (const e of readdirSync5(d)) {
2917
- const full = join5(d, e);
3151
+ const full = join6(d, e);
2918
3152
  if (statSync3(full).isDirectory()) walk(full);
2919
3153
  else if (e.endsWith(".php")) out.push(full);
2920
3154
  }
@@ -2957,7 +3191,7 @@ var init_laravel_php2 = __esm({
2957
3191
  for (const locale of listDirs2(localeRoot).sort()) {
2958
3192
  if (locale === "vendor") continue;
2959
3193
  if (opts?.locales && !opts.locales.includes(locale)) continue;
2960
- const localeDir = join5(localeRoot, locale);
3194
+ const localeDir = join6(localeRoot, locale);
2961
3195
  locales.push(locale);
2962
3196
  for (const file of listPhpFiles(localeDir)) {
2963
3197
  const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
@@ -2982,8 +3216,8 @@ var init_laravel_php2 = __esm({
2982
3216
  });
2983
3217
 
2984
3218
  // src/server/import/parsers/flutter-arb.ts
2985
- import { readdirSync as readdirSync6, readFileSync as readFileSync11 } from "fs";
2986
- import { join as join6 } from "path";
3219
+ import { readdirSync as readdirSync6, readFileSync as readFileSync12 } from "fs";
3220
+ import { join as join7 } from "path";
2987
3221
  function localeFromArbName(file) {
2988
3222
  const m = file.match(/^(.+)\.arb$/);
2989
3223
  if (!m) return null;
@@ -3023,7 +3257,7 @@ var init_flutter_arb2 = __esm({
3023
3257
  if (opts?.locales && !opts.locales.includes(locale)) continue;
3024
3258
  let data;
3025
3259
  try {
3026
- data = JSON.parse(readFileSync11(join6(localeRoot, file), "utf8"));
3260
+ data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
3027
3261
  } catch (e) {
3028
3262
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
3029
3263
  continue;
@@ -3127,7 +3361,6 @@ function assemble2(parsed, opts) {
3127
3361
  sourceLocale,
3128
3362
  locales,
3129
3363
  outputs: [output],
3130
- ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
3131
3364
  format: { indent: 2, sortKeys: true, finalNewline: true },
3132
3365
  spelling: { customWords: [] }
3133
3366
  },
@@ -3554,16 +3787,48 @@ var init_checks = __esm({
3554
3787
  }
3555
3788
  });
3556
3789
 
3790
+ // src/server/ui-prefs.ts
3791
+ import { readFileSync as readFileSync13 } from "fs";
3792
+ import { homedir } from "os";
3793
+ import { join as join8 } from "path";
3794
+ function readJson2(path) {
3795
+ try {
3796
+ const parsed = JSON.parse(readFileSync13(path, "utf8"));
3797
+ return parsed && typeof parsed === "object" ? parsed : {};
3798
+ } catch {
3799
+ return {};
3800
+ }
3801
+ }
3802
+ function loadUiPrefs(path) {
3803
+ const raw = readJson2(path);
3804
+ return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
3805
+ }
3806
+ function saveUiPrefs(path, prefs) {
3807
+ const merged = { ...readJson2(path), ...prefs };
3808
+ writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
3809
+ }
3810
+ var THEMES, isThemeMode, defaultUiPrefsPath, DEFAULTS;
3811
+ var init_ui_prefs = __esm({
3812
+ "src/server/ui-prefs.ts"() {
3813
+ "use strict";
3814
+ init_atomic_write();
3815
+ THEMES = ["system", "light", "dark"];
3816
+ isThemeMode = (v) => THEMES.includes(v);
3817
+ defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
3818
+ DEFAULTS = { theme: "system" };
3819
+ }
3820
+ });
3821
+
3557
3822
  // src/server/api.ts
3558
3823
  import { Hono } from "hono";
3559
3824
  import { streamSSE } from "hono/streaming";
3560
- import { writeFileSync as writeFileSync5, readFileSync as readFileSync12, mkdirSync as mkdirSync6, existsSync as existsSync9, readdirSync as readdirSync7, rmSync as rmSync3 } from "fs";
3561
- import { dirname as dirname5, resolve as resolve7, basename, relative as relative3, sep } from "path";
3825
+ import { readFileSync as readFileSync14, existsSync as existsSync10, readdirSync as readdirSync7, rmSync as rmSync4 } from "fs";
3826
+ import { dirname as dirname2, resolve as resolve9, basename, relative as relative3, sep } from "path";
3562
3827
  function projectName(root) {
3563
- const nameFile = resolve7(root, ".idea", ".name");
3564
- if (existsSync9(nameFile)) {
3828
+ const nameFile = resolve9(root, ".idea", ".name");
3829
+ if (existsSync10(nameFile)) {
3565
3830
  try {
3566
- const name = readFileSync12(nameFile, "utf8").trim();
3831
+ const name = readFileSync14(nameFile, "utf8").trim();
3567
3832
  if (name) return name;
3568
3833
  } catch {
3569
3834
  }
@@ -3573,7 +3838,7 @@ function projectName(root) {
3573
3838
  function createApi(deps) {
3574
3839
  const app = new Hono();
3575
3840
  const load = () => loadState(deps.statePath);
3576
- const projectRoot = dirname5(resolve7(deps.statePath));
3841
+ const projectRoot = dirname2(resolve9(deps.statePath));
3577
3842
  let translateQueue = Promise.resolve();
3578
3843
  const withTranslateLock = (fn) => {
3579
3844
  const next = translateQueue.then(fn, fn);
@@ -3597,7 +3862,36 @@ function createApi(deps) {
3597
3862
  saveState(deps.statePath, s);
3598
3863
  scheduleAutoExport(s);
3599
3864
  };
3865
+ const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
3866
+ const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
3867
+ const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
3600
3868
  app.get("/state", (c) => c.json(load()));
3869
+ app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
3870
+ app.put("/ui-prefs", async (c) => {
3871
+ const { theme } = await c.req.json();
3872
+ if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
3873
+ saveUiPrefs(uiPrefsPath, { theme });
3874
+ return c.json({ ok: true });
3875
+ });
3876
+ app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
3877
+ app.put("/local-settings", async (c) => {
3878
+ const body = await c.req.json().catch(() => ({}));
3879
+ const patch = {};
3880
+ if (body.ai !== void 0) {
3881
+ const err = aiConfigError(body.ai);
3882
+ if (err) return c.json({ error: err }, 400);
3883
+ patch.ai = body.ai;
3884
+ }
3885
+ if (body.editor !== void 0) {
3886
+ if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
3887
+ patch.editor = body.editor;
3888
+ }
3889
+ if (patch.ai === void 0 && patch.editor === void 0) {
3890
+ return c.json({ error: "provide ai and/or editor" }, 400);
3891
+ }
3892
+ saveLocalSettings(projectRoot, patch);
3893
+ return c.json({ ok: true });
3894
+ });
3601
3895
  app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
3602
3896
  app.get("/files", (c) => {
3603
3897
  const found = /* @__PURE__ */ new Map();
@@ -3609,10 +3903,10 @@ function createApi(deps) {
3609
3903
  }
3610
3904
  for (const name of entries) {
3611
3905
  let abs;
3612
- if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve7(projectRoot, name, "config.json"))) {
3613
- abs = resolve7(projectRoot, `${name}.json`);
3906
+ if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync10(resolve9(projectRoot, name, "config.json"))) {
3907
+ abs = resolve9(projectRoot, `${name}.json`);
3614
3908
  } else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
3615
- abs = resolve7(projectRoot, name);
3909
+ abs = resolve9(projectRoot, name);
3616
3910
  } else {
3617
3911
  continue;
3618
3912
  }
@@ -3629,10 +3923,10 @@ function createApi(deps) {
3629
3923
  app.post("/file", async (c) => {
3630
3924
  const { path } = await c.req.json();
3631
3925
  if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
3632
- const resolved = resolve7(projectRoot, path);
3926
+ const resolved = resolve9(projectRoot, path);
3633
3927
  const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
3634
3928
  if (!inside) return c.json({ error: "file is outside the project" }, 400);
3635
- if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
3929
+ if (!existsSync10(resolved)) return c.json({ error: "file not found" }, 400);
3636
3930
  loadState(resolved);
3637
3931
  deps.statePath = resolved;
3638
3932
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
@@ -3647,6 +3941,7 @@ function createApi(deps) {
3647
3941
  const s = load();
3648
3942
  createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
3649
3943
  persist(s);
3944
+ logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
3650
3945
  console.log(`[key] created ${key}`);
3651
3946
  return c.json({ ok: true });
3652
3947
  });
@@ -3656,37 +3951,45 @@ function createApi(deps) {
3656
3951
  const s = load();
3657
3952
  addCustomWord(s, word);
3658
3953
  persist(s);
3954
+ logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
3659
3955
  return c.json({ ok: true });
3660
3956
  });
3661
3957
  app.delete("/dictionary/:word", (c) => {
3662
3958
  const s = load();
3663
- removeCustomWord(s, c.req.param("word"));
3959
+ const word = c.req.param("word");
3960
+ removeCustomWord(s, word);
3664
3961
  persist(s);
3962
+ logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
3665
3963
  return c.json({ ok: true });
3666
3964
  });
3667
3965
  app.patch("/keys/:key", async (c) => {
3668
3966
  const key = c.req.param("key");
3669
3967
  const body = await c.req.json();
3670
3968
  const s = load();
3969
+ const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
3671
3970
  if (typeof body.rename === "string") renameKey(s, key, body.rename);
3672
3971
  const target = typeof body.rename === "string" ? body.rename : key;
3673
3972
  if (body.metadata) setMetadata(s, target, body.metadata);
3674
3973
  if (typeof body.source === "string") setSourceValue(s, target, body.source);
3675
3974
  if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
3676
3975
  persist(s);
3976
+ if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
3977
+ if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
3978
+ 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 });
3979
+ if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
3677
3980
  if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
3678
3981
  return c.json({ ok: true });
3679
3982
  });
3680
3983
  function removeOrphanScreenshot(s, screenshot) {
3681
3984
  if (!screenshot) return;
3682
3985
  for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
3683
- const root = dirname5(resolve7(deps.statePath));
3684
- const abs = resolve7(root, screenshot);
3986
+ const root = dirname2(resolve9(deps.statePath));
3987
+ const abs = resolve9(root, screenshot);
3685
3988
  const rel = relative3(root, abs);
3686
3989
  const seg0 = rel.split(sep)[0] ?? "";
3687
- if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
3990
+ if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync10(abs)) {
3688
3991
  try {
3689
- rmSync3(abs);
3992
+ rmSync4(abs);
3690
3993
  } catch {
3691
3994
  }
3692
3995
  }
@@ -3695,9 +3998,11 @@ function createApi(deps) {
3695
3998
  const s = load();
3696
3999
  const key = c.req.param("key");
3697
4000
  const shot = s.keys[key]?.screenshot;
4001
+ const before = valueText(s, key, s.config.sourceLocale);
3698
4002
  deleteKey(s, key);
3699
4003
  removeOrphanScreenshot(s, shot);
3700
4004
  persist(s);
4005
+ logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
3701
4006
  console.log(`[key] deleted ${key}`);
3702
4007
  return c.json({ ok: true });
3703
4008
  });
@@ -3721,6 +4026,7 @@ function createApi(deps) {
3721
4026
  }
3722
4027
  }
3723
4028
  persist(s);
4029
+ if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
3724
4030
  console.log(`[bulk] cleared ${cleared} value(s)`);
3725
4031
  return c.json({ cleared });
3726
4032
  });
@@ -3738,6 +4044,7 @@ function createApi(deps) {
3738
4044
  }
3739
4045
  for (const shot of shots) removeOrphanScreenshot(s, shot);
3740
4046
  persist(s);
4047
+ if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
3741
4048
  console.log(`[bulk] deleted ${removed.length} key(s)`);
3742
4049
  return c.json({ removed });
3743
4050
  });
@@ -3763,6 +4070,7 @@ function createApi(deps) {
3763
4070
  updated++;
3764
4071
  }
3765
4072
  persist(s);
4073
+ if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
3766
4074
  console.log(`[bulk] updated metadata on ${updated} key(s)`);
3767
4075
  return c.json({ updated });
3768
4076
  });
@@ -3786,6 +4094,7 @@ function createApi(deps) {
3786
4094
  }
3787
4095
  }
3788
4096
  persist(s);
4097
+ if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
3789
4098
  console.log(`[bulk] set state ${next} on ${updated} value(s)`);
3790
4099
  return c.json({ updated });
3791
4100
  });
@@ -3795,15 +4104,21 @@ function createApi(deps) {
3795
4104
  const s = load();
3796
4105
  const key = c.req.param("key");
3797
4106
  const locale = c.req.param("locale");
4107
+ const before = valueText(s, key, locale);
3798
4108
  if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
3799
4109
  else setTargetValue(s, key, locale, value);
3800
4110
  persist(s);
4111
+ logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
3801
4112
  return c.json({ ok: true });
3802
4113
  });
3803
4114
  app.delete("/keys/:key/values/:locale", (c) => {
3804
4115
  const s = load();
3805
- clearValue(s, c.req.param("key"), c.req.param("locale"));
4116
+ const key = c.req.param("key");
4117
+ const locale = c.req.param("locale");
4118
+ const before = valueText(s, key, locale);
4119
+ clearValue(s, key, locale);
3806
4120
  persist(s);
4121
+ logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
3807
4122
  return c.json({ ok: true });
3808
4123
  });
3809
4124
  app.put("/keys/:key/plural/:locale", async (c) => {
@@ -3812,52 +4127,68 @@ function createApi(deps) {
3812
4127
  const s = load();
3813
4128
  const key = c.req.param("key");
3814
4129
  const locale = c.req.param("locale");
4130
+ const before = s.keys[key]?.values[locale]?.forms;
3815
4131
  if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
3816
4132
  else setPluralForms(s, key, locale, forms);
3817
4133
  persist(s);
4134
+ logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
3818
4135
  return c.json({ ok: true });
3819
4136
  });
3820
4137
  app.post("/keys/:key/plural", async (c) => {
3821
4138
  const { arg } = await c.req.json();
3822
4139
  if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
3823
4140
  const s = load();
3824
- convertToPlural(s, c.req.param("key"), arg);
4141
+ const key = c.req.param("key");
4142
+ convertToPlural(s, key, arg);
3825
4143
  persist(s);
4144
+ logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
3826
4145
  return c.json({ ok: true });
3827
4146
  });
3828
4147
  app.delete("/keys/:key/plural", (c) => {
3829
4148
  const s = load();
3830
- convertToScalar(s, c.req.param("key"));
4149
+ const key = c.req.param("key");
4150
+ convertToScalar(s, key);
3831
4151
  persist(s);
4152
+ logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
3832
4153
  return c.json({ ok: true });
3833
4154
  });
3834
4155
  app.put("/keys/:key/values/:locale/state", async (c) => {
3835
4156
  const { state } = await c.req.json();
3836
4157
  const s = load();
3837
- setKeyState(s, c.req.param("key"), c.req.param("locale"), state);
4158
+ const key = c.req.param("key");
4159
+ const locale = c.req.param("locale");
4160
+ const before = s.keys[key]?.values[locale]?.state;
4161
+ setKeyState(s, key, locale, state);
3838
4162
  persist(s);
4163
+ logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
3839
4164
  return c.json({ ok: true });
3840
4165
  });
3841
4166
  app.post("/keys/:key/notes", async (c) => {
3842
4167
  const { text } = await c.req.json();
3843
4168
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3844
4169
  const s = load();
3845
- const note = addNote(s, c.req.param("key"), text);
4170
+ const key = c.req.param("key");
4171
+ const note = addNote(s, key, text);
3846
4172
  persist(s);
4173
+ logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
3847
4174
  return c.json(note);
3848
4175
  });
3849
4176
  app.put("/keys/:key/notes/:id", async (c) => {
3850
4177
  const { text } = await c.req.json();
3851
4178
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
3852
4179
  const s = load();
3853
- editNote(s, c.req.param("key"), c.req.param("id"), text);
4180
+ const key = c.req.param("key");
4181
+ editNote(s, key, c.req.param("id"), text);
3854
4182
  persist(s);
4183
+ logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
3855
4184
  return c.json({ ok: true });
3856
4185
  });
3857
4186
  app.delete("/keys/:key/notes/:id", (c) => {
3858
4187
  const s = load();
3859
- deleteNote(s, c.req.param("key"), c.req.param("id"));
4188
+ const key = c.req.param("key");
4189
+ deleteNote(s, key, c.req.param("id"));
3860
4190
  persist(s);
4191
+ logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
3861
4192
  return c.json({ ok: true });
3862
4193
  });
3863
4194
  app.put("/config", async (c) => {
@@ -3866,6 +4197,7 @@ function createApi(deps) {
3866
4197
  return c.json({ error: "config.locales must be an array" }, 400);
3867
4198
  }
3868
4199
  const s = load();
4200
+ const beforeCfg = { locales: s.config.locales };
3869
4201
  const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
3870
4202
  for (const l of removed) {
3871
4203
  for (const e of Object.values(s.keys)) delete e.values[l];
@@ -3873,7 +4205,8 @@ function createApi(deps) {
3873
4205
  s.config = newConfig;
3874
4206
  validate(s);
3875
4207
  persist(s);
3876
- console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s), model ${newConfig.ai.model}`);
4208
+ logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
4209
+ console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
3877
4210
  return c.json({ ok: true });
3878
4211
  });
3879
4212
  app.get("/glossary", (c) => c.json(load().glossary));
@@ -3881,14 +4214,19 @@ function createApi(deps) {
3881
4214
  const entry = await c.req.json();
3882
4215
  if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
3883
4216
  const s = load();
4217
+ const before = s.glossary.find((g) => g.term === entry.term);
3884
4218
  upsertGlossaryEntry(s, entry);
3885
4219
  persist(s);
4220
+ logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
3886
4221
  return c.json({ ok: true });
3887
4222
  });
3888
4223
  app.delete("/glossary/:term", (c) => {
3889
4224
  const s = load();
3890
- deleteGlossaryEntry(s, decodeURIComponent(c.req.param("term")));
4225
+ const term = decodeURIComponent(c.req.param("term"));
4226
+ const before = s.glossary.find((g) => g.term === term);
4227
+ deleteGlossaryEntry(s, term);
3891
4228
  persist(s);
4229
+ logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
3892
4230
  return c.json({ ok: true });
3893
4231
  });
3894
4232
  app.post("/keys/:key/screenshot", async (c) => {
@@ -3896,18 +4234,18 @@ function createApi(deps) {
3896
4234
  const body = await c.req.parseBody();
3897
4235
  const file = body["file"];
3898
4236
  if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
3899
- const root = dirname5(resolve7(deps.statePath));
4237
+ const root = dirname2(resolve9(deps.statePath));
3900
4238
  const dirName = screenshotDirName(deps.statePath);
3901
- const dir = resolve7(root, dirName);
3902
- mkdirSync6(dir, { recursive: true });
4239
+ const dir = resolve9(root, dirName);
3903
4240
  const filename = `${sanitize(key)}__${sanitize(file.name)}`;
3904
- writeFileSync5(resolve7(dir, filename), Buffer.from(await file.arrayBuffer()));
4241
+ writeFileAtomic(resolve9(dir, filename), Buffer.from(await file.arrayBuffer()));
3905
4242
  const path = `${dirName}/${filename}`;
3906
4243
  const s = load();
3907
4244
  const prev = s.keys[key]?.screenshot;
3908
4245
  setMetadata(s, key, { screenshot: path });
3909
4246
  if (prev && prev !== path) removeOrphanScreenshot(s, prev);
3910
4247
  persist(s);
4248
+ logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
3911
4249
  return c.json({ path });
3912
4250
  });
3913
4251
  app.delete("/keys/:key/screenshot", (c) => {
@@ -3917,6 +4255,7 @@ function createApi(deps) {
3917
4255
  setMetadata(s, key, { screenshot: void 0 });
3918
4256
  removeOrphanScreenshot(s, shot);
3919
4257
  persist(s);
4258
+ logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
3920
4259
  return c.json({ ok: true });
3921
4260
  });
3922
4261
  app.get("/export/preview", (c) => {
@@ -3961,12 +4300,13 @@ function createApi(deps) {
3961
4300
  return c.json({ error: e.message }, 400);
3962
4301
  }
3963
4302
  persist(result.state);
4303
+ logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
3964
4304
  console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
3965
4305
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
3966
4306
  });
3967
4307
  app.post("/export", (c) => {
3968
4308
  const s = narrowForExport(load());
3969
- const root = dirname5(resolve7(deps.statePath));
4309
+ const root = dirname2(resolve9(deps.statePath));
3970
4310
  const warnings = [];
3971
4311
  let count = 0;
3972
4312
  for (const output of s.config.outputs) {
@@ -3974,9 +4314,8 @@ function createApi(deps) {
3974
4314
  const result = adapter.export(s, output);
3975
4315
  warnings.push(...result.warnings);
3976
4316
  for (const f of result.files) {
3977
- const abs = resolve7(root, f.path);
3978
- mkdirSync6(dirname5(abs), { recursive: true });
3979
- writeFileSync5(abs, f.contents, "utf8");
4317
+ const abs = resolve9(root, f.path);
4318
+ writeFileAtomic(abs, f.contents);
3980
4319
  count++;
3981
4320
  }
3982
4321
  }
@@ -3995,41 +4334,62 @@ function createApi(deps) {
3995
4334
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
3996
4335
  return;
3997
4336
  }
4337
+ const aiCfg = loadLocalSettings(projectRoot).ai;
3998
4338
  let provider;
3999
4339
  try {
4000
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4340
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4001
4341
  } catch (e) {
4002
4342
  await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
4003
4343
  return;
4004
4344
  }
4005
4345
  const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
4006
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4007
- console.log(`[translate] ${reqs.length} string(s) \u2192 ${s.config.ai.model}`);
4346
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4347
+ console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
4008
4348
  let totalWritten = 0;
4009
4349
  const allErrors = [];
4010
4350
  const system = buildSystemPrompt();
4011
4351
  const reqById = new Map(reqs.map((r) => [r.id, r]));
4012
- await runLocaleParallel(reqs, provider, (done, total, batchResults) => {
4013
- const { written, errors } = applyResults(s, reqs, batchResults);
4014
- persist(s);
4015
- totalWritten += written;
4016
- allErrors.push(...errors);
4017
- appendAiLog(projectRoot, {
4018
- at: (/* @__PURE__ */ new Date()).toISOString(),
4019
- kind: "translate",
4020
- model: s.config.ai.model,
4021
- system,
4022
- items: batchResults.map((r) => {
4023
- const req = reqById.get(r.id);
4024
- 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 };
4025
- }),
4026
- results: batchResults
4027
- });
4028
- console.log(`[translate] ${done}/${total}`);
4029
- void stream.writeSSE({
4030
- event: "progress",
4031
- data: JSON.stringify({ done, total, written: totalWritten, errors })
4032
- });
4352
+ const localeTotals = /* @__PURE__ */ new Map();
4353
+ for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
4354
+ const localeDone = /* @__PURE__ */ new Map();
4355
+ await stream.writeSSE({
4356
+ event: "start",
4357
+ data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
4358
+ });
4359
+ await runLocaleParallel(reqs, provider, {
4360
+ // Announce a language the moment a worker picks it up — this is the
4361
+ // signal that "something is happening" during the long first LLM call.
4362
+ onLocaleStart: (locale) => {
4363
+ void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
4364
+ },
4365
+ onBatchComplete: (done, total, batchResults, locale) => {
4366
+ const { written, errors } = applyResults(s, reqs, batchResults);
4367
+ persist(s);
4368
+ totalWritten += written;
4369
+ allErrors.push(...errors);
4370
+ appendLog(projectRoot, {
4371
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4372
+ kind: "translate",
4373
+ summary: `Translated ${batchResults.length} item(s)`,
4374
+ model: aiCfg.model,
4375
+ system,
4376
+ items: batchResults.map((r) => {
4377
+ const req = reqById.get(r.id);
4378
+ 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 };
4379
+ }),
4380
+ results: batchResults
4381
+ });
4382
+ const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
4383
+ localeDone.set(locale, ld);
4384
+ console.log(`[translate] ${done}/${total}`);
4385
+ void stream.writeSSE({
4386
+ event: "progress",
4387
+ data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
4388
+ });
4389
+ },
4390
+ onLocaleDone: (locale) => {
4391
+ void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
4392
+ }
4033
4393
  }, void 0, signal);
4034
4394
  if (!signal?.aborted) {
4035
4395
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
@@ -4052,19 +4412,22 @@ function createApi(deps) {
4052
4412
  let written = 0;
4053
4413
  let errors = [];
4054
4414
  if (toTranslate.length) {
4415
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4055
4416
  let provider;
4056
4417
  try {
4057
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4418
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4058
4419
  } catch (e) {
4059
4420
  return c.json({ error: e.message }, 400);
4060
4421
  }
4061
4422
  const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
4062
- if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4423
+ if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4063
4424
  const results = await runLocaleParallel(toTranslate, provider);
4064
4425
  ({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
4065
4426
  const entry = {
4066
4427
  at: (/* @__PURE__ */ new Date()).toISOString(),
4067
- model: s.config.ai.model,
4428
+ kind: "translate",
4429
+ summary: `Translated ${toTranslate.length} item(s)`,
4430
+ model: aiCfg.model,
4068
4431
  system: buildSystemPrompt(),
4069
4432
  // Log the screenshot PATH only — never the image bytes.
4070
4433
  items: toTranslate.map((r) => ({
@@ -4078,12 +4441,12 @@ function createApi(deps) {
4078
4441
  })),
4079
4442
  results
4080
4443
  };
4081
- appendAiLog(projectRoot, entry);
4444
+ appendLog(projectRoot, entry);
4082
4445
  }
4083
4446
  persist(s);
4084
4447
  return c.json({ requested: reqs.length, written, errors });
4085
4448
  }));
4086
- app.get("/ai-log", (c) => c.json(readAiLog(projectRoot, 100)));
4449
+ app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
4087
4450
  app.post("/scan", async (c) => {
4088
4451
  const s = load();
4089
4452
  const existing = loadUsageCache(projectRoot);
@@ -4107,7 +4470,7 @@ function createApi(deps) {
4107
4470
  const refs = [];
4108
4471
  const prefixRefs = [];
4109
4472
  for (const [file, entry] of Object.entries(cache2.files)) {
4110
- const abs = resolve7(projectRoot, file);
4473
+ const abs = resolve9(projectRoot, file);
4111
4474
  for (const r of entry.refs) {
4112
4475
  if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
4113
4476
  }
@@ -4148,9 +4511,10 @@ function createApi(deps) {
4148
4511
  keys: body.keys
4149
4512
  }, cache2, body.lastRunAt);
4150
4513
  if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
4514
+ const aiCfg = loadLocalSettings(projectRoot).ai;
4151
4515
  let provider;
4152
4516
  try {
4153
- provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
4517
+ provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
4154
4518
  } catch (e) {
4155
4519
  return c.json({ error: e.message }, 400);
4156
4520
  }
@@ -4172,10 +4536,11 @@ function createApi(deps) {
4172
4536
  const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
4173
4537
  const batch = raw;
4174
4538
  const { written, errors } = applyContext(s, targets, batch.items ?? []);
4175
- appendAiLog(projectRoot, {
4539
+ appendLog(projectRoot, {
4176
4540
  at: (/* @__PURE__ */ new Date()).toISOString(),
4177
4541
  kind: "context",
4178
- model: s.config.ai.model,
4542
+ summary: `Generated context for ${targets.length} key(s)`,
4543
+ model: aiCfg.model,
4179
4544
  system,
4180
4545
  items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
4181
4546
  results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
@@ -4207,6 +4572,9 @@ var init_api = __esm({
4207
4572
  init_schema();
4208
4573
  init_run2();
4209
4574
  init_export_run();
4575
+ init_ui_prefs();
4576
+ init_local_settings();
4577
+ init_atomic_write();
4210
4578
  sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
4211
4579
  screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
4212
4580
  }
@@ -4221,7 +4589,7 @@ __export(server_exports, {
4221
4589
  import { Hono as Hono2 } from "hono";
4222
4590
  import { serve } from "@hono/node-server";
4223
4591
  import { fileURLToPath } from "url";
4224
- import { dirname as dirname6, join as join7, resolve as resolve8, extname as extname3, sep as sep2 } from "path";
4592
+ import { dirname as dirname3, join as join9, resolve as resolve10, extname as extname3, sep as sep2 } from "path";
4225
4593
  import { readFile, stat } from "fs/promises";
4226
4594
  import { createServer } from "net";
4227
4595
  import open from "open";
@@ -4239,14 +4607,14 @@ async function readFileResponse(absPath) {
4239
4607
  function buildApp(opts) {
4240
4608
  const app = new Hono2();
4241
4609
  app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
4242
- const projectRoot = dirname6(resolve8(opts.statePath));
4610
+ const projectRoot = dirname3(resolve10(opts.statePath));
4243
4611
  app.get("/:dir/*", async (c, next) => {
4244
4612
  const dirSeg = c.req.param("dir");
4245
4613
  if (!dirSeg.endsWith("-screenshots")) return next();
4246
- const shotsRoot = resolve8(projectRoot, dirSeg);
4614
+ const shotsRoot = resolve10(projectRoot, dirSeg);
4247
4615
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4248
4616
  const rest = pathname.slice(`/${dirSeg}`.length);
4249
- const target = resolve8(shotsRoot, "." + rest);
4617
+ const target = resolve10(shotsRoot, "." + rest);
4250
4618
  const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
4251
4619
  if (inside) {
4252
4620
  const file = await readFileResponse(target);
@@ -4255,19 +4623,21 @@ function buildApp(opts) {
4255
4623
  return c.notFound();
4256
4624
  });
4257
4625
  if (!opts.dev) {
4258
- const root = resolve8(opts.uiDir ?? DEFAULT_UI_DIR);
4626
+ const root = resolve10(opts.uiDir ?? DEFAULT_UI_DIR);
4259
4627
  app.get("/*", async (c) => {
4260
4628
  const pathname = decodeURIComponent(new URL(c.req.url).pathname);
4261
- const target = resolve8(root, "." + pathname);
4629
+ const target = resolve10(root, "." + pathname);
4262
4630
  const inside = target === root || target.startsWith(root + sep2);
4263
4631
  if (inside && pathname !== "/") {
4264
4632
  const file = await readFileResponse(target);
4265
4633
  if (file) return file;
4266
4634
  }
4267
- const index = await readFileResponse(join7(root, "index.html"));
4635
+ const index = await readFileResponse(join9(root, "index.html"));
4268
4636
  if (index) return index;
4269
4637
  return c.notFound();
4270
4638
  });
4639
+ } else {
4640
+ app.get("/", (c) => c.html(DEV_LANDING_PAGE));
4271
4641
  }
4272
4642
  return app;
4273
4643
  }
@@ -4299,7 +4669,7 @@ async function startServer(opts) {
4299
4669
  });
4300
4670
  }
4301
4671
  function backgroundScan(statePath) {
4302
- const projectRoot = dirname6(resolve8(statePath));
4672
+ const projectRoot = dirname3(resolve10(statePath));
4303
4673
  Promise.resolve().then(() => {
4304
4674
  const state = loadState(statePath);
4305
4675
  const existing = loadUsageCache(projectRoot);
@@ -4311,7 +4681,7 @@ function backgroundScan(statePath) {
4311
4681
  console.warn("[scan] failed:", err instanceof Error ? err.message : String(err));
4312
4682
  });
4313
4683
  }
4314
- var here, DEFAULT_UI_DIR, MIME, DEFAULT_PORT, DEV_PORT;
4684
+ var here, DEFAULT_UI_DIR, MIME, DEFAULT_PORT, DEV_PORT, DEV_UI_URL, DEV_LANDING_PAGE;
4315
4685
  var init_server = __esm({
4316
4686
  "src/server/server.ts"() {
4317
4687
  "use strict";
@@ -4319,8 +4689,8 @@ var init_server = __esm({
4319
4689
  init_state();
4320
4690
  init_scan();
4321
4691
  init_scanner();
4322
- here = dirname6(fileURLToPath(import.meta.url));
4323
- DEFAULT_UI_DIR = join7(here, "..", "ui");
4692
+ here = dirname3(fileURLToPath(import.meta.url));
4693
+ DEFAULT_UI_DIR = join9(here, "..", "ui");
4324
4694
  MIME = {
4325
4695
  ".html": "text/html; charset=utf-8",
4326
4696
  ".js": "text/javascript; charset=utf-8",
@@ -4337,6 +4707,15 @@ var init_server = __esm({
4337
4707
  };
4338
4708
  DEFAULT_PORT = 3e3;
4339
4709
  DEV_PORT = 8787;
4710
+ DEV_UI_URL = "http://localhost:5173";
4711
+ DEV_LANDING_PAGE = `<!doctype html>
4712
+ <html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
4713
+ <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>
4714
+ </head><body>
4715
+ <h1>Glotfile \u2014 dev API server</h1>
4716
+ <p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
4717
+ <p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
4718
+ </body></html>`;
4340
4719
  }
4341
4720
  });
4342
4721
 
@@ -4345,14 +4724,15 @@ init_state();
4345
4724
  init_export_run();
4346
4725
  init_storage();
4347
4726
  init_ai();
4727
+ init_local_settings();
4348
4728
  init_run();
4349
4729
  init_provider();
4350
4730
  init_log();
4351
4731
  init_scan();
4352
4732
  init_scanner();
4353
4733
  init_context();
4354
- import { resolve as resolve9, dirname as dirname7 } from "path";
4355
- import { readFileSync as readFileSync13, existsSync as existsSync10 } from "fs";
4734
+ import { resolve as resolve11, dirname as dirname4 } from "path";
4735
+ import { readFileSync as readFileSync15, existsSync as existsSync11 } from "fs";
4356
4736
 
4357
4737
  // src/server/lint/run.ts
4358
4738
  init_glob();
@@ -4654,15 +5034,15 @@ async function runLint(state, options = {}) {
4654
5034
 
4655
5035
  // src/server/lint/outputs.ts
4656
5036
  init_adapters();
4657
- import { readFileSync as readFileSync9, existsSync as existsSync7 } from "fs";
4658
- import { resolve as resolve6 } from "path";
5037
+ import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
5038
+ import { resolve as resolve8 } from "path";
4659
5039
  function checkOutputs(state, root) {
4660
5040
  const out = [];
4661
5041
  for (const output of state.config.outputs) {
4662
5042
  const result = getAdapter(output.adapter).export(state, output);
4663
5043
  for (const file of result.files) {
4664
- const abs = resolve6(root, file.path);
4665
- const current = existsSync7(abs) ? readFileSync9(abs, "utf8") : null;
5044
+ const abs = resolve8(root, file.path);
5045
+ const current = existsSync8(abs) ? readFileSync10(abs, "utf8") : null;
4666
5046
  if (current === null) {
4667
5047
  out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4668
5048
  } else if (current !== file.contents) {
@@ -4740,7 +5120,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
4740
5120
  var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "build-context", "scan", "prune", "split"];
4741
5121
  var isCommand = (s) => s != null && COMMANDS.includes(s);
4742
5122
  function parseArgs(argv) {
4743
- const statePath = resolve9(process.cwd(), "glotfile.json");
5123
+ const statePath = resolve11(process.cwd(), "glotfile.json");
4744
5124
  const first = argv[0];
4745
5125
  if (first === "help" || first === "--help" || first === "-h") {
4746
5126
  return isCommand(argv[1]) ? { command: argv[1], statePath, help: true } : { command: "help", statePath };
@@ -4758,7 +5138,7 @@ function parseArgs(argv) {
4758
5138
  if (flag === "--help" || flag === "-h") args.help = true;
4759
5139
  else if (flag === "--dev") args.dev = true;
4760
5140
  else if ((flag === "--file" || flag === "-f") && next) {
4761
- args.statePath = resolve9(process.cwd(), next);
5141
+ args.statePath = resolve11(process.cwd(), next);
4762
5142
  i++;
4763
5143
  } else if (flag === "--adapter" && next) {
4764
5144
  args.adapter = next;
@@ -4814,7 +5194,7 @@ function watchTargetFor(statePath) {
4814
5194
  return detectFormat(statePath) === "split" ? { path: splitDirFor(statePath), recursive: true } : { path: statePath, recursive: false };
4815
5195
  }
4816
5196
  async function runExport(args) {
4817
- const root = dirname7(resolve9(args.statePath));
5197
+ const root = dirname4(resolve11(args.statePath));
4818
5198
  const runOnce = () => {
4819
5199
  const state = loadState(args.statePath);
4820
5200
  const result = exportToDisk(state, root, args.adapter ? { adapter: args.adapter } : void 0);
@@ -4850,7 +5230,7 @@ async function runExport(args) {
4850
5230
  }
4851
5231
  async function runTranslate(args) {
4852
5232
  const state = loadState(args.statePath);
4853
- const projectRoot = dirname7(resolve9(args.statePath));
5233
+ const projectRoot = dirname4(resolve11(args.statePath));
4854
5234
  const reqs = selectRequests(state, {
4855
5235
  // Default to translating only empty values; --all forces a full re-translate
4856
5236
  // (overwriting existing translations). --only missing stays as a no-op alias.
@@ -4862,33 +5242,38 @@ async function runTranslate(args) {
4862
5242
  let written = 0;
4863
5243
  let errors = [];
4864
5244
  if (toTranslate.length) {
5245
+ const ai = loadLocalSettings(projectRoot).ai;
4865
5246
  let provider;
4866
5247
  try {
4867
- provider = makeProvider(state.config);
5248
+ provider = makeProvider(ai);
4868
5249
  } catch (e) {
4869
5250
  console.error(e.message);
4870
5251
  process.exitCode = 1;
4871
5252
  return;
4872
5253
  }
4873
5254
  const { skipped } = attachScreenshotsForProvider(toTranslate, state, projectRoot, provider.supportsVision());
4874
- if (skipped) console.warn(`Model "${state.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
5255
+ if (skipped) console.warn(`Model "${ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
4875
5256
  console.log(`Translating ${toTranslate.length} string(s)\u2026`);
4876
5257
  let batchCallbackFired = false;
4877
- const results = await runLocaleParallel(toTranslate, provider, (done, total, batchResults) => {
4878
- batchCallbackFired = true;
4879
- const batchApplied = applyResults(state, toTranslate, batchResults);
4880
- written += batchApplied.written;
4881
- errors.push(...batchApplied.errors);
4882
- saveState(args.statePath, state);
4883
- process.stdout.write(`\r ${done}/${total} translated`);
5258
+ const results = await runLocaleParallel(toTranslate, provider, {
5259
+ onBatchComplete: (done, total, batchResults) => {
5260
+ batchCallbackFired = true;
5261
+ const batchApplied = applyResults(state, toTranslate, batchResults);
5262
+ written += batchApplied.written;
5263
+ errors.push(...batchApplied.errors);
5264
+ saveState(args.statePath, state);
5265
+ process.stdout.write(`\r ${done}/${total} translated`);
5266
+ }
4884
5267
  });
4885
5268
  process.stdout.write("\n");
4886
5269
  if (!batchCallbackFired) {
4887
5270
  ({ written, errors } = applyResults(state, toTranslate, results));
4888
5271
  }
4889
- appendAiLog(projectRoot, {
5272
+ appendLog(projectRoot, {
4890
5273
  at: (/* @__PURE__ */ new Date()).toISOString(),
4891
- model: state.config.ai.model,
5274
+ kind: "translate",
5275
+ summary: `Translated ${toTranslate.length} item(s)`,
5276
+ model: ai.model,
4892
5277
  system: buildSystemPrompt(),
4893
5278
  items: toTranslate.map((r) => ({
4894
5279
  id: r.id,
@@ -4915,7 +5300,7 @@ function printReport(report, format, rawText) {
4915
5300
  }
4916
5301
  async function runLintCmd(args) {
4917
5302
  const state = loadState(args.statePath);
4918
- const rawText = existsSync10(args.statePath) ? readFileSync13(args.statePath, "utf8") : "";
5303
+ const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
4919
5304
  const report = await runLint(state, { locales: args.locales, ruleIds: args.ruleIds });
4920
5305
  printReport(report, args.format, rawText);
4921
5306
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
@@ -4935,8 +5320,8 @@ async function runCheck(args) {
4935
5320
  process.exitCode = 1;
4936
5321
  return;
4937
5322
  }
4938
- const rawText = existsSync10(args.statePath) ? readFileSync13(args.statePath, "utf8") : "";
4939
- const root = dirname7(resolve9(args.statePath));
5323
+ const rawText = existsSync11(args.statePath) ? readFileSync15(args.statePath, "utf8") : "";
5324
+ const root = dirname4(resolve11(args.statePath));
4940
5325
  const lint = await runLint(state, {});
4941
5326
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
4942
5327
  const counts = countSeverities(findings);
@@ -4946,9 +5331,9 @@ async function runCheck(args) {
4946
5331
  }
4947
5332
  async function runImportCmd(args) {
4948
5333
  const { runImport: runImport2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
4949
- const projectRoot = args.importSource ? resolve9(args.importSource) : dirname7(resolve9(args.statePath));
4950
- const out = resolve9(projectRoot, "glotfile.json");
4951
- if (existsSync10(out) && !args.importForce) {
5334
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname4(resolve11(args.statePath));
5335
+ const out = resolve11(projectRoot, "glotfile.json");
5336
+ if (existsSync11(out) && !args.importForce) {
4952
5337
  console.error(`${out} already exists; pass --force to overwrite`);
4953
5338
  process.exitCode = 1;
4954
5339
  return;
@@ -4973,7 +5358,7 @@ async function runImportCmd(args) {
4973
5358
  }
4974
5359
  async function runBuildContext(args) {
4975
5360
  const state = loadState(args.statePath);
4976
- const projectRoot = dirname7(resolve9(args.statePath));
5361
+ const projectRoot = dirname4(resolve11(args.statePath));
4977
5362
  const cache2 = loadUsageCache(projectRoot);
4978
5363
  if (!cache2) {
4979
5364
  console.error("No usage index found. Run 'glotfile scan' first.");
@@ -4992,7 +5377,7 @@ async function runBuildContext(args) {
4992
5377
  }
4993
5378
  let provider;
4994
5379
  try {
4995
- provider = makeProvider(state.config);
5380
+ provider = makeProvider(loadLocalSettings(projectRoot).ai);
4996
5381
  } catch (e) {
4997
5382
  console.error(e.message);
4998
5383
  process.exitCode = 1;
@@ -5022,7 +5407,7 @@ async function runBuildContext(args) {
5022
5407
  }
5023
5408
  async function runScanCmd(args) {
5024
5409
  const state = loadState(args.statePath);
5025
- const projectRoot = dirname7(resolve9(args.statePath));
5410
+ const projectRoot = dirname4(resolve11(args.statePath));
5026
5411
  const existing = loadUsageCache(projectRoot);
5027
5412
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
5028
5413
  const fileCount2 = Object.keys(result.files).length;
@@ -5041,7 +5426,7 @@ async function runPrune(args) {
5041
5426
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
5042
5427
  }
5043
5428
  if (args.unused) {
5044
- const projectRoot = dirname7(resolve9(args.statePath));
5429
+ const projectRoot = dirname4(resolve11(args.statePath));
5045
5430
  const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
5046
5431
  const used = new Set(computeUsedKeys(state, cache2));
5047
5432
  for (const k of Object.keys(state.keys)) {
@@ -5208,9 +5593,10 @@ async function main(argv) {
5208
5593
  if (args.command === "split") return runSplit(args);
5209
5594
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
5210
5595
  const { url } = await startServer2({ statePath: args.statePath, dev: args.dev });
5211
- console.log(`Glotfile running at ${url}`);
5596
+ if (args.dev) console.log(`Glotfile dev API on ${url} \u2014 open the UI at the Vite "Local:" URL above`);
5597
+ else console.log(`Glotfile running at ${url}`);
5212
5598
  }
5213
- if (resolve9(process.argv[1] ?? "") === resolve9(fileURLToPath2(import.meta.url))) {
5599
+ if (resolve11(process.argv[1] ?? "") === resolve11(fileURLToPath2(import.meta.url))) {
5214
5600
  main(process.argv.slice(2)).catch((err) => {
5215
5601
  console.error(err instanceof Error ? err.message : String(err));
5216
5602
  process.exit(1);