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.
- package/README.md +4 -3
- package/dist/server/cli.js +601 -215
- package/dist/server/server.js +501 -173
- package/dist/ui/assets/en-BZRN_IpI.svg +25 -0
- package/dist/ui/assets/index-BHjDAL9d.js +1847 -0
- package/dist/ui/assets/index-iW_TzurC.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +2 -2
- package/dist/ui/assets/index-BqcYDTXL.css +0 -1
- package/dist/ui/assets/index-DB5e5FME.js +0 -1819
package/dist/server/server.js
CHANGED
|
@@ -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
|
|
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,
|
|
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", "openrouter"];
|
|
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,
|
|
406
|
-
import { join
|
|
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(
|
|
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
|
|
442
|
-
const manifest =
|
|
443
|
-
const keysPath =
|
|
444
|
-
const keys = existsSync(keysPath) ?
|
|
445
|
-
const localesDir =
|
|
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)] =
|
|
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
|
-
|
|
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 =
|
|
469
|
-
|
|
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(
|
|
478
|
-
track(writeIfChanged(
|
|
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(
|
|
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
|
-
|
|
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))
|
|
548
|
+
if (existsSync2(path)) rmSync3(path);
|
|
543
549
|
} else {
|
|
544
|
-
|
|
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
|
|
740
|
-
import { resolve
|
|
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 =
|
|
743
|
-
if (!
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
1051
|
-
import { resolve as
|
|
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 =
|
|
1093
|
+
const absPath = resolve3(projectRoot, ref.file);
|
|
1068
1094
|
if (!fileCache.has(ref.file)) {
|
|
1069
|
-
if (!
|
|
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
|
|
1376
|
-
import { resolve as
|
|
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 =
|
|
1472
|
-
if (!
|
|
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,
|
|
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.
|
|
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 {
|
|
2314
|
-
import { dirname as
|
|
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
|
}
|
|
@@ -2623,7 +2653,7 @@ var BedrockProvider = class {
|
|
|
2623
2653
|
}
|
|
2624
2654
|
const region = config.region ?? process.env.AWS_REGION;
|
|
2625
2655
|
if (!region) {
|
|
2626
|
-
throw new Error("AWS region is not set. Set
|
|
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.");
|
|
2627
2657
|
}
|
|
2628
2658
|
const require2 = createRequire2(import.meta.url);
|
|
2629
2659
|
let sdk;
|
|
@@ -2742,9 +2772,116 @@ var OpenRouterProvider = class extends OpenAIProvider {
|
|
|
2742
2772
|
}
|
|
2743
2773
|
};
|
|
2744
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
|
+
|
|
2745
2883
|
// src/server/ai/index.ts
|
|
2746
|
-
function makeProvider(
|
|
2747
|
-
const ai = config.ai;
|
|
2884
|
+
function makeProvider(ai) {
|
|
2748
2885
|
switch (ai.provider) {
|
|
2749
2886
|
case "anthropic":
|
|
2750
2887
|
return new AnthropicProvider(ai);
|
|
@@ -2754,36 +2891,36 @@ function makeProvider(config) {
|
|
|
2754
2891
|
return new BedrockProvider(ai);
|
|
2755
2892
|
case "openrouter":
|
|
2756
2893
|
return new OpenRouterProvider(ai);
|
|
2894
|
+
case "ollama":
|
|
2895
|
+
return new OllamaProvider(ai);
|
|
2896
|
+
case "claude-code":
|
|
2897
|
+
return new ClaudeCodeProvider(ai);
|
|
2757
2898
|
default:
|
|
2758
|
-
throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter.`);
|
|
2899
|
+
throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
|
|
2759
2900
|
}
|
|
2760
2901
|
}
|
|
2761
2902
|
|
|
2762
|
-
// src/server/
|
|
2763
|
-
import { appendFileSync, readFileSync as readFileSync7, existsSync as
|
|
2764
|
-
import { resolve as
|
|
2903
|
+
// src/server/log.ts
|
|
2904
|
+
import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
|
|
2905
|
+
import { resolve as resolve5 } from "path";
|
|
2765
2906
|
function logPath(projectRoot) {
|
|
2766
|
-
return
|
|
2907
|
+
return resolve5(projectRoot, ".glotfile", "log.jsonl");
|
|
2767
2908
|
}
|
|
2768
|
-
function
|
|
2769
|
-
|
|
2909
|
+
function appendLog(projectRoot, entry) {
|
|
2910
|
+
ensureGlotfileDir(projectRoot);
|
|
2770
2911
|
appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
|
|
2771
2912
|
}
|
|
2772
|
-
function
|
|
2913
|
+
function readLog(projectRoot, limit = 100) {
|
|
2773
2914
|
const path = logPath(projectRoot);
|
|
2774
|
-
if (!
|
|
2915
|
+
if (!existsSync7(path)) return [];
|
|
2775
2916
|
const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
|
|
2776
|
-
const entries = lines.map((l) =>
|
|
2777
|
-
const e = JSON.parse(l);
|
|
2778
|
-
e.kind ??= "translate";
|
|
2779
|
-
return e;
|
|
2780
|
-
});
|
|
2917
|
+
const entries = lines.map((l) => JSON.parse(l));
|
|
2781
2918
|
return entries.reverse().slice(0, limit);
|
|
2782
2919
|
}
|
|
2783
2920
|
|
|
2784
2921
|
// src/server/import/detect.ts
|
|
2785
|
-
import { existsSync as
|
|
2786
|
-
import { join as
|
|
2922
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
|
|
2923
|
+
import { join as join4 } from "path";
|
|
2787
2924
|
var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
2788
2925
|
var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
|
|
2789
2926
|
function safeIsDir(p) {
|
|
@@ -2794,7 +2931,7 @@ function safeIsDir(p) {
|
|
|
2794
2931
|
}
|
|
2795
2932
|
}
|
|
2796
2933
|
function listDirs(dir) {
|
|
2797
|
-
return readdirSync3(dir).filter((e) => safeIsDir(
|
|
2934
|
+
return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
|
|
2798
2935
|
}
|
|
2799
2936
|
function fileCount(dir) {
|
|
2800
2937
|
try {
|
|
@@ -2808,22 +2945,22 @@ function pickSource(locales, sizeOf) {
|
|
|
2808
2945
|
return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
|
|
2809
2946
|
}
|
|
2810
2947
|
function detectLaravel(root) {
|
|
2811
|
-
const localeRoot = [
|
|
2948
|
+
const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
|
|
2812
2949
|
if (!localeRoot) return null;
|
|
2813
2950
|
const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
|
|
2814
2951
|
if (locales.length === 0) return null;
|
|
2815
|
-
const sourceLocale = pickSource(locales, (loc) => fileCount(
|
|
2952
|
+
const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
|
|
2816
2953
|
return { format: "laravel-php", localeRoot, locales, sourceLocale };
|
|
2817
2954
|
}
|
|
2818
2955
|
function detectVue(root) {
|
|
2819
2956
|
for (const rel of VUE_DIR_CANDIDATES) {
|
|
2820
|
-
const localeRoot =
|
|
2957
|
+
const localeRoot = join4(root, rel);
|
|
2821
2958
|
if (!safeIsDir(localeRoot)) continue;
|
|
2822
2959
|
const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
|
|
2823
2960
|
if (locales.length >= 2) {
|
|
2824
2961
|
const sourceLocale = pickSource(locales, (loc) => {
|
|
2825
2962
|
try {
|
|
2826
|
-
return statSync2(
|
|
2963
|
+
return statSync2(join4(localeRoot, `${loc}.json`)).size;
|
|
2827
2964
|
} catch {
|
|
2828
2965
|
return 0;
|
|
2829
2966
|
}
|
|
@@ -2835,7 +2972,7 @@ function detectVue(root) {
|
|
|
2835
2972
|
}
|
|
2836
2973
|
function detectArb(root) {
|
|
2837
2974
|
for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
|
|
2838
|
-
const localeRoot =
|
|
2975
|
+
const localeRoot = join4(root, rel);
|
|
2839
2976
|
if (!safeIsDir(localeRoot)) continue;
|
|
2840
2977
|
const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
|
|
2841
2978
|
if (locales.length >= 1) {
|
|
@@ -2851,7 +2988,7 @@ var BY_FORMAT = {
|
|
|
2851
2988
|
"flutter-arb": detectArb
|
|
2852
2989
|
};
|
|
2853
2990
|
function detect(root, formatOverride) {
|
|
2854
|
-
if (!
|
|
2991
|
+
if (!existsSync8(root)) return null;
|
|
2855
2992
|
if (formatOverride) {
|
|
2856
2993
|
const fn = BY_FORMAT[formatOverride];
|
|
2857
2994
|
if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
|
|
@@ -2866,7 +3003,7 @@ function detect(root, formatOverride) {
|
|
|
2866
3003
|
|
|
2867
3004
|
// src/server/import/parsers/vue-i18n-json.ts
|
|
2868
3005
|
import { readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
|
|
2869
|
-
import { join as
|
|
3006
|
+
import { join as join5 } from "path";
|
|
2870
3007
|
|
|
2871
3008
|
// src/server/import/flatten.ts
|
|
2872
3009
|
function flattenObject(value, prefix, warnings) {
|
|
@@ -2905,7 +3042,7 @@ var vueI18nJson2 = {
|
|
|
2905
3042
|
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
2906
3043
|
let data;
|
|
2907
3044
|
try {
|
|
2908
|
-
data = JSON.parse(readFileSync8(
|
|
3045
|
+
data = JSON.parse(readFileSync8(join5(localeRoot, file), "utf8"));
|
|
2909
3046
|
} catch (e) {
|
|
2910
3047
|
warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
|
|
2911
3048
|
continue;
|
|
@@ -2921,7 +3058,7 @@ var vueI18nJson2 = {
|
|
|
2921
3058
|
|
|
2922
3059
|
// src/server/import/parsers/laravel-php.ts
|
|
2923
3060
|
import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
|
|
2924
|
-
import { join as
|
|
3061
|
+
import { join as join6, relative as relative2 } from "path";
|
|
2925
3062
|
import { execFileSync } from "child_process";
|
|
2926
3063
|
|
|
2927
3064
|
// src/server/import/placeholders.ts
|
|
@@ -2931,13 +3068,13 @@ function laravelToCanonical(value) {
|
|
|
2931
3068
|
|
|
2932
3069
|
// src/server/import/parsers/laravel-php.ts
|
|
2933
3070
|
function listDirs2(dir) {
|
|
2934
|
-
return readdirSync5(dir).filter((e) => statSync3(
|
|
3071
|
+
return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
|
|
2935
3072
|
}
|
|
2936
3073
|
function listPhpFiles(dir) {
|
|
2937
3074
|
const out = [];
|
|
2938
3075
|
const walk = (d) => {
|
|
2939
3076
|
for (const e of readdirSync5(d)) {
|
|
2940
|
-
const full =
|
|
3077
|
+
const full = join6(d, e);
|
|
2941
3078
|
if (statSync3(full).isDirectory()) walk(full);
|
|
2942
3079
|
else if (e.endsWith(".php")) out.push(full);
|
|
2943
3080
|
}
|
|
@@ -2974,7 +3111,7 @@ var laravelPhp2 = {
|
|
|
2974
3111
|
for (const locale of listDirs2(localeRoot).sort()) {
|
|
2975
3112
|
if (locale === "vendor") continue;
|
|
2976
3113
|
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
2977
|
-
const localeDir =
|
|
3114
|
+
const localeDir = join6(localeRoot, locale);
|
|
2978
3115
|
locales.push(locale);
|
|
2979
3116
|
for (const file of listPhpFiles(localeDir)) {
|
|
2980
3117
|
const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
|
|
@@ -2998,7 +3135,7 @@ var laravelPhp2 = {
|
|
|
2998
3135
|
|
|
2999
3136
|
// src/server/import/parsers/flutter-arb.ts
|
|
3000
3137
|
import { readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
|
|
3001
|
-
import { join as
|
|
3138
|
+
import { join as join7 } from "path";
|
|
3002
3139
|
var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
3003
3140
|
function localeFromArbName(file) {
|
|
3004
3141
|
const m = file.match(/^(.+)\.arb$/);
|
|
@@ -3034,7 +3171,7 @@ var flutterArb2 = {
|
|
|
3034
3171
|
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
3035
3172
|
let data;
|
|
3036
3173
|
try {
|
|
3037
|
-
data = JSON.parse(readFileSync9(
|
|
3174
|
+
data = JSON.parse(readFileSync9(join7(localeRoot, file), "utf8"));
|
|
3038
3175
|
} catch (e) {
|
|
3039
3176
|
warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
|
|
3040
3177
|
continue;
|
|
@@ -3132,7 +3269,6 @@ function assemble2(parsed, opts) {
|
|
|
3132
3269
|
sourceLocale,
|
|
3133
3270
|
locales,
|
|
3134
3271
|
outputs: [output],
|
|
3135
|
-
ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
|
|
3136
3272
|
format: { indent: 2, sortKeys: true, finalNewline: true },
|
|
3137
3273
|
spelling: { customWords: [] }
|
|
3138
3274
|
},
|
|
@@ -3189,8 +3325,8 @@ function runImport(opts) {
|
|
|
3189
3325
|
}
|
|
3190
3326
|
|
|
3191
3327
|
// src/server/export-run.ts
|
|
3192
|
-
import {
|
|
3193
|
-
import {
|
|
3328
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
3329
|
+
import { resolve as resolve6 } from "path";
|
|
3194
3330
|
function effectiveLocales(config) {
|
|
3195
3331
|
const limit = config.exportLocales;
|
|
3196
3332
|
if (!limit || limit.length === 0) return config.locales;
|
|
@@ -3212,7 +3348,7 @@ function exportToDisk(state, projectRoot, opts) {
|
|
|
3212
3348
|
warnings.push(...result.warnings);
|
|
3213
3349
|
const writtenPaths = /* @__PURE__ */ new Set();
|
|
3214
3350
|
for (const f of result.files) {
|
|
3215
|
-
const abs =
|
|
3351
|
+
const abs = resolve6(projectRoot, f.path);
|
|
3216
3352
|
if (writtenPaths.has(abs)) {
|
|
3217
3353
|
skipped++;
|
|
3218
3354
|
continue;
|
|
@@ -3227,22 +3363,103 @@ function exportToDisk(state, projectRoot, opts) {
|
|
|
3227
3363
|
skipped++;
|
|
3228
3364
|
continue;
|
|
3229
3365
|
}
|
|
3230
|
-
|
|
3231
|
-
writeFileSync4(abs, f.contents, "utf8");
|
|
3366
|
+
writeFileAtomic(abs, f.contents);
|
|
3232
3367
|
written++;
|
|
3233
3368
|
}
|
|
3234
3369
|
}
|
|
3235
3370
|
return { written, skipped, warnings };
|
|
3236
3371
|
}
|
|
3237
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
|
+
|
|
3238
3455
|
// src/server/api.ts
|
|
3239
3456
|
var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
|
|
3240
3457
|
var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
|
|
3241
3458
|
function projectName(root) {
|
|
3242
|
-
const nameFile =
|
|
3243
|
-
if (
|
|
3459
|
+
const nameFile = resolve8(root, ".idea", ".name");
|
|
3460
|
+
if (existsSync9(nameFile)) {
|
|
3244
3461
|
try {
|
|
3245
|
-
const name =
|
|
3462
|
+
const name = readFileSync13(nameFile, "utf8").trim();
|
|
3246
3463
|
if (name) return name;
|
|
3247
3464
|
} catch {
|
|
3248
3465
|
}
|
|
@@ -3252,7 +3469,7 @@ function projectName(root) {
|
|
|
3252
3469
|
function createApi(deps) {
|
|
3253
3470
|
const app = new Hono();
|
|
3254
3471
|
const load = () => loadState(deps.statePath);
|
|
3255
|
-
const projectRoot =
|
|
3472
|
+
const projectRoot = dirname2(resolve8(deps.statePath));
|
|
3256
3473
|
let translateQueue = Promise.resolve();
|
|
3257
3474
|
const withTranslateLock = (fn) => {
|
|
3258
3475
|
const next = translateQueue.then(fn, fn);
|
|
@@ -3276,7 +3493,36 @@ function createApi(deps) {
|
|
|
3276
3493
|
saveState(deps.statePath, s);
|
|
3277
3494
|
scheduleAutoExport(s);
|
|
3278
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();
|
|
3279
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
|
+
});
|
|
3280
3526
|
app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
|
|
3281
3527
|
app.get("/files", (c) => {
|
|
3282
3528
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -3288,10 +3534,10 @@ function createApi(deps) {
|
|
|
3288
3534
|
}
|
|
3289
3535
|
for (const name of entries) {
|
|
3290
3536
|
let abs;
|
|
3291
|
-
if ((name === "glotfile" || name.endsWith(".glotfile")) &&
|
|
3292
|
-
abs =
|
|
3537
|
+
if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(projectRoot, name, "config.json"))) {
|
|
3538
|
+
abs = resolve8(projectRoot, `${name}.json`);
|
|
3293
3539
|
} else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
|
|
3294
|
-
abs =
|
|
3540
|
+
abs = resolve8(projectRoot, name);
|
|
3295
3541
|
} else {
|
|
3296
3542
|
continue;
|
|
3297
3543
|
}
|
|
@@ -3308,10 +3554,10 @@ function createApi(deps) {
|
|
|
3308
3554
|
app.post("/file", async (c) => {
|
|
3309
3555
|
const { path } = await c.req.json();
|
|
3310
3556
|
if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
|
|
3311
|
-
const resolved =
|
|
3557
|
+
const resolved = resolve8(projectRoot, path);
|
|
3312
3558
|
const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
|
|
3313
3559
|
if (!inside) return c.json({ error: "file is outside the project" }, 400);
|
|
3314
|
-
if (!
|
|
3560
|
+
if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
|
|
3315
3561
|
loadState(resolved);
|
|
3316
3562
|
deps.statePath = resolved;
|
|
3317
3563
|
return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
|
|
@@ -3326,6 +3572,7 @@ function createApi(deps) {
|
|
|
3326
3572
|
const s = load();
|
|
3327
3573
|
createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
|
|
3328
3574
|
persist(s);
|
|
3575
|
+
logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
|
|
3329
3576
|
console.log(`[key] created ${key}`);
|
|
3330
3577
|
return c.json({ ok: true });
|
|
3331
3578
|
});
|
|
@@ -3335,37 +3582,45 @@ function createApi(deps) {
|
|
|
3335
3582
|
const s = load();
|
|
3336
3583
|
addCustomWord(s, word);
|
|
3337
3584
|
persist(s);
|
|
3585
|
+
logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
|
|
3338
3586
|
return c.json({ ok: true });
|
|
3339
3587
|
});
|
|
3340
3588
|
app.delete("/dictionary/:word", (c) => {
|
|
3341
3589
|
const s = load();
|
|
3342
|
-
|
|
3590
|
+
const word = c.req.param("word");
|
|
3591
|
+
removeCustomWord(s, word);
|
|
3343
3592
|
persist(s);
|
|
3593
|
+
logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
|
|
3344
3594
|
return c.json({ ok: true });
|
|
3345
3595
|
});
|
|
3346
3596
|
app.patch("/keys/:key", async (c) => {
|
|
3347
3597
|
const key = c.req.param("key");
|
|
3348
3598
|
const body = await c.req.json();
|
|
3349
3599
|
const s = load();
|
|
3600
|
+
const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
|
|
3350
3601
|
if (typeof body.rename === "string") renameKey(s, key, body.rename);
|
|
3351
3602
|
const target = typeof body.rename === "string" ? body.rename : key;
|
|
3352
3603
|
if (body.metadata) setMetadata(s, target, body.metadata);
|
|
3353
3604
|
if (typeof body.source === "string") setSourceValue(s, target, body.source);
|
|
3354
3605
|
if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
|
|
3355
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() });
|
|
3356
3611
|
if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
|
|
3357
3612
|
return c.json({ ok: true });
|
|
3358
3613
|
});
|
|
3359
3614
|
function removeOrphanScreenshot(s, screenshot) {
|
|
3360
3615
|
if (!screenshot) return;
|
|
3361
3616
|
for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
|
|
3362
|
-
const root =
|
|
3363
|
-
const abs =
|
|
3617
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3618
|
+
const abs = resolve8(root, screenshot);
|
|
3364
3619
|
const rel = relative3(root, abs);
|
|
3365
3620
|
const seg0 = rel.split(sep)[0] ?? "";
|
|
3366
|
-
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") &&
|
|
3621
|
+
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
|
|
3367
3622
|
try {
|
|
3368
|
-
|
|
3623
|
+
rmSync4(abs);
|
|
3369
3624
|
} catch {
|
|
3370
3625
|
}
|
|
3371
3626
|
}
|
|
@@ -3374,9 +3629,11 @@ function createApi(deps) {
|
|
|
3374
3629
|
const s = load();
|
|
3375
3630
|
const key = c.req.param("key");
|
|
3376
3631
|
const shot = s.keys[key]?.screenshot;
|
|
3632
|
+
const before = valueText(s, key, s.config.sourceLocale);
|
|
3377
3633
|
deleteKey(s, key);
|
|
3378
3634
|
removeOrphanScreenshot(s, shot);
|
|
3379
3635
|
persist(s);
|
|
3636
|
+
logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
|
|
3380
3637
|
console.log(`[key] deleted ${key}`);
|
|
3381
3638
|
return c.json({ ok: true });
|
|
3382
3639
|
});
|
|
@@ -3400,6 +3657,7 @@ function createApi(deps) {
|
|
|
3400
3657
|
}
|
|
3401
3658
|
}
|
|
3402
3659
|
persist(s);
|
|
3660
|
+
if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
|
|
3403
3661
|
console.log(`[bulk] cleared ${cleared} value(s)`);
|
|
3404
3662
|
return c.json({ cleared });
|
|
3405
3663
|
});
|
|
@@ -3417,6 +3675,7 @@ function createApi(deps) {
|
|
|
3417
3675
|
}
|
|
3418
3676
|
for (const shot of shots) removeOrphanScreenshot(s, shot);
|
|
3419
3677
|
persist(s);
|
|
3678
|
+
if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
|
|
3420
3679
|
console.log(`[bulk] deleted ${removed.length} key(s)`);
|
|
3421
3680
|
return c.json({ removed });
|
|
3422
3681
|
});
|
|
@@ -3442,6 +3701,7 @@ function createApi(deps) {
|
|
|
3442
3701
|
updated++;
|
|
3443
3702
|
}
|
|
3444
3703
|
persist(s);
|
|
3704
|
+
if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
|
|
3445
3705
|
console.log(`[bulk] updated metadata on ${updated} key(s)`);
|
|
3446
3706
|
return c.json({ updated });
|
|
3447
3707
|
});
|
|
@@ -3465,6 +3725,7 @@ function createApi(deps) {
|
|
|
3465
3725
|
}
|
|
3466
3726
|
}
|
|
3467
3727
|
persist(s);
|
|
3728
|
+
if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
|
|
3468
3729
|
console.log(`[bulk] set state ${next} on ${updated} value(s)`);
|
|
3469
3730
|
return c.json({ updated });
|
|
3470
3731
|
});
|
|
@@ -3474,15 +3735,21 @@ function createApi(deps) {
|
|
|
3474
3735
|
const s = load();
|
|
3475
3736
|
const key = c.req.param("key");
|
|
3476
3737
|
const locale = c.req.param("locale");
|
|
3738
|
+
const before = valueText(s, key, locale);
|
|
3477
3739
|
if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
|
|
3478
3740
|
else setTargetValue(s, key, locale, value);
|
|
3479
3741
|
persist(s);
|
|
3742
|
+
logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
|
|
3480
3743
|
return c.json({ ok: true });
|
|
3481
3744
|
});
|
|
3482
3745
|
app.delete("/keys/:key/values/:locale", (c) => {
|
|
3483
3746
|
const s = load();
|
|
3484
|
-
|
|
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);
|
|
3485
3751
|
persist(s);
|
|
3752
|
+
logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
|
|
3486
3753
|
return c.json({ ok: true });
|
|
3487
3754
|
});
|
|
3488
3755
|
app.put("/keys/:key/plural/:locale", async (c) => {
|
|
@@ -3491,52 +3758,68 @@ function createApi(deps) {
|
|
|
3491
3758
|
const s = load();
|
|
3492
3759
|
const key = c.req.param("key");
|
|
3493
3760
|
const locale = c.req.param("locale");
|
|
3761
|
+
const before = s.keys[key]?.values[locale]?.forms;
|
|
3494
3762
|
if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
|
|
3495
3763
|
else setPluralForms(s, key, locale, forms);
|
|
3496
3764
|
persist(s);
|
|
3765
|
+
logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
|
|
3497
3766
|
return c.json({ ok: true });
|
|
3498
3767
|
});
|
|
3499
3768
|
app.post("/keys/:key/plural", async (c) => {
|
|
3500
3769
|
const { arg } = await c.req.json();
|
|
3501
3770
|
if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
|
|
3502
3771
|
const s = load();
|
|
3503
|
-
|
|
3772
|
+
const key = c.req.param("key");
|
|
3773
|
+
convertToPlural(s, key, arg);
|
|
3504
3774
|
persist(s);
|
|
3775
|
+
logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
|
|
3505
3776
|
return c.json({ ok: true });
|
|
3506
3777
|
});
|
|
3507
3778
|
app.delete("/keys/:key/plural", (c) => {
|
|
3508
3779
|
const s = load();
|
|
3509
|
-
|
|
3780
|
+
const key = c.req.param("key");
|
|
3781
|
+
convertToScalar(s, key);
|
|
3510
3782
|
persist(s);
|
|
3783
|
+
logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
|
|
3511
3784
|
return c.json({ ok: true });
|
|
3512
3785
|
});
|
|
3513
3786
|
app.put("/keys/:key/values/:locale/state", async (c) => {
|
|
3514
3787
|
const { state } = await c.req.json();
|
|
3515
3788
|
const s = load();
|
|
3516
|
-
|
|
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);
|
|
3517
3793
|
persist(s);
|
|
3794
|
+
logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
|
|
3518
3795
|
return c.json({ ok: true });
|
|
3519
3796
|
});
|
|
3520
3797
|
app.post("/keys/:key/notes", async (c) => {
|
|
3521
3798
|
const { text } = await c.req.json();
|
|
3522
3799
|
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3523
3800
|
const s = load();
|
|
3524
|
-
const
|
|
3801
|
+
const key = c.req.param("key");
|
|
3802
|
+
const note = addNote(s, key, text);
|
|
3525
3803
|
persist(s);
|
|
3804
|
+
logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
|
|
3526
3805
|
return c.json(note);
|
|
3527
3806
|
});
|
|
3528
3807
|
app.put("/keys/:key/notes/:id", async (c) => {
|
|
3529
3808
|
const { text } = await c.req.json();
|
|
3530
3809
|
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3531
3810
|
const s = load();
|
|
3532
|
-
|
|
3811
|
+
const key = c.req.param("key");
|
|
3812
|
+
editNote(s, key, c.req.param("id"), text);
|
|
3533
3813
|
persist(s);
|
|
3814
|
+
logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
|
|
3534
3815
|
return c.json({ ok: true });
|
|
3535
3816
|
});
|
|
3536
3817
|
app.delete("/keys/:key/notes/:id", (c) => {
|
|
3537
3818
|
const s = load();
|
|
3538
|
-
|
|
3819
|
+
const key = c.req.param("key");
|
|
3820
|
+
deleteNote(s, key, c.req.param("id"));
|
|
3539
3821
|
persist(s);
|
|
3822
|
+
logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
|
|
3540
3823
|
return c.json({ ok: true });
|
|
3541
3824
|
});
|
|
3542
3825
|
app.put("/config", async (c) => {
|
|
@@ -3545,6 +3828,7 @@ function createApi(deps) {
|
|
|
3545
3828
|
return c.json({ error: "config.locales must be an array" }, 400);
|
|
3546
3829
|
}
|
|
3547
3830
|
const s = load();
|
|
3831
|
+
const beforeCfg = { locales: s.config.locales };
|
|
3548
3832
|
const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
|
|
3549
3833
|
for (const l of removed) {
|
|
3550
3834
|
for (const e of Object.values(s.keys)) delete e.values[l];
|
|
@@ -3552,7 +3836,8 @@ function createApi(deps) {
|
|
|
3552
3836
|
s.config = newConfig;
|
|
3553
3837
|
validate(s);
|
|
3554
3838
|
persist(s);
|
|
3555
|
-
|
|
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)`);
|
|
3556
3841
|
return c.json({ ok: true });
|
|
3557
3842
|
});
|
|
3558
3843
|
app.get("/glossary", (c) => c.json(load().glossary));
|
|
@@ -3560,14 +3845,19 @@ function createApi(deps) {
|
|
|
3560
3845
|
const entry = await c.req.json();
|
|
3561
3846
|
if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
|
|
3562
3847
|
const s = load();
|
|
3848
|
+
const before = s.glossary.find((g) => g.term === entry.term);
|
|
3563
3849
|
upsertGlossaryEntry(s, entry);
|
|
3564
3850
|
persist(s);
|
|
3851
|
+
logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
|
|
3565
3852
|
return c.json({ ok: true });
|
|
3566
3853
|
});
|
|
3567
3854
|
app.delete("/glossary/:term", (c) => {
|
|
3568
3855
|
const s = load();
|
|
3569
|
-
|
|
3856
|
+
const term = decodeURIComponent(c.req.param("term"));
|
|
3857
|
+
const before = s.glossary.find((g) => g.term === term);
|
|
3858
|
+
deleteGlossaryEntry(s, term);
|
|
3570
3859
|
persist(s);
|
|
3860
|
+
logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
|
|
3571
3861
|
return c.json({ ok: true });
|
|
3572
3862
|
});
|
|
3573
3863
|
app.post("/keys/:key/screenshot", async (c) => {
|
|
@@ -3575,18 +3865,18 @@ function createApi(deps) {
|
|
|
3575
3865
|
const body = await c.req.parseBody();
|
|
3576
3866
|
const file = body["file"];
|
|
3577
3867
|
if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
|
|
3578
|
-
const root =
|
|
3868
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3579
3869
|
const dirName = screenshotDirName(deps.statePath);
|
|
3580
|
-
const dir =
|
|
3581
|
-
mkdirSync6(dir, { recursive: true });
|
|
3870
|
+
const dir = resolve8(root, dirName);
|
|
3582
3871
|
const filename = `${sanitize(key)}__${sanitize(file.name)}`;
|
|
3583
|
-
|
|
3872
|
+
writeFileAtomic(resolve8(dir, filename), Buffer.from(await file.arrayBuffer()));
|
|
3584
3873
|
const path = `${dirName}/${filename}`;
|
|
3585
3874
|
const s = load();
|
|
3586
3875
|
const prev = s.keys[key]?.screenshot;
|
|
3587
3876
|
setMetadata(s, key, { screenshot: path });
|
|
3588
3877
|
if (prev && prev !== path) removeOrphanScreenshot(s, prev);
|
|
3589
3878
|
persist(s);
|
|
3879
|
+
logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
|
|
3590
3880
|
return c.json({ path });
|
|
3591
3881
|
});
|
|
3592
3882
|
app.delete("/keys/:key/screenshot", (c) => {
|
|
@@ -3596,6 +3886,7 @@ function createApi(deps) {
|
|
|
3596
3886
|
setMetadata(s, key, { screenshot: void 0 });
|
|
3597
3887
|
removeOrphanScreenshot(s, shot);
|
|
3598
3888
|
persist(s);
|
|
3889
|
+
logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
|
|
3599
3890
|
return c.json({ ok: true });
|
|
3600
3891
|
});
|
|
3601
3892
|
app.get("/export/preview", (c) => {
|
|
@@ -3640,12 +3931,13 @@ function createApi(deps) {
|
|
|
3640
3931
|
return c.json({ error: e.message }, 400);
|
|
3641
3932
|
}
|
|
3642
3933
|
persist(result.state);
|
|
3934
|
+
logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
|
|
3643
3935
|
console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
|
|
3644
3936
|
return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
|
|
3645
3937
|
});
|
|
3646
3938
|
app.post("/export", (c) => {
|
|
3647
3939
|
const s = narrowForExport(load());
|
|
3648
|
-
const root =
|
|
3940
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3649
3941
|
const warnings = [];
|
|
3650
3942
|
let count = 0;
|
|
3651
3943
|
for (const output of s.config.outputs) {
|
|
@@ -3653,9 +3945,8 @@ function createApi(deps) {
|
|
|
3653
3945
|
const result = adapter.export(s, output);
|
|
3654
3946
|
warnings.push(...result.warnings);
|
|
3655
3947
|
for (const f of result.files) {
|
|
3656
|
-
const abs =
|
|
3657
|
-
|
|
3658
|
-
writeFileSync5(abs, f.contents, "utf8");
|
|
3948
|
+
const abs = resolve8(root, f.path);
|
|
3949
|
+
writeFileAtomic(abs, f.contents);
|
|
3659
3950
|
count++;
|
|
3660
3951
|
}
|
|
3661
3952
|
}
|
|
@@ -3674,41 +3965,62 @@ function createApi(deps) {
|
|
|
3674
3965
|
await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
|
|
3675
3966
|
return;
|
|
3676
3967
|
}
|
|
3968
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3677
3969
|
let provider;
|
|
3678
3970
|
try {
|
|
3679
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
3971
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3680
3972
|
} catch (e) {
|
|
3681
3973
|
await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
|
|
3682
3974
|
return;
|
|
3683
3975
|
}
|
|
3684
3976
|
const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
|
|
3685
|
-
if (skipped) console.warn(`Model "${
|
|
3686
|
-
console.log(`[translate] ${reqs.length} string(s) \u2192 ${
|
|
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}`);
|
|
3687
3979
|
let totalWritten = 0;
|
|
3688
3980
|
const allErrors = [];
|
|
3689
3981
|
const system = buildSystemPrompt();
|
|
3690
3982
|
const reqById = new Map(reqs.map((r) => [r.id, r]));
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
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
|
+
}
|
|
3712
4024
|
}, void 0, signal);
|
|
3713
4025
|
if (!signal?.aborted) {
|
|
3714
4026
|
console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
|
|
@@ -3731,19 +4043,22 @@ function createApi(deps) {
|
|
|
3731
4043
|
let written = 0;
|
|
3732
4044
|
let errors = [];
|
|
3733
4045
|
if (toTranslate.length) {
|
|
4046
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3734
4047
|
let provider;
|
|
3735
4048
|
try {
|
|
3736
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
4049
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3737
4050
|
} catch (e) {
|
|
3738
4051
|
return c.json({ error: e.message }, 400);
|
|
3739
4052
|
}
|
|
3740
4053
|
const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
|
|
3741
|
-
if (skipped) console.warn(`Model "${
|
|
4054
|
+
if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
3742
4055
|
const results = await runLocaleParallel(toTranslate, provider);
|
|
3743
4056
|
({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
|
|
3744
4057
|
const entry = {
|
|
3745
4058
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3746
|
-
|
|
4059
|
+
kind: "translate",
|
|
4060
|
+
summary: `Translated ${toTranslate.length} item(s)`,
|
|
4061
|
+
model: aiCfg.model,
|
|
3747
4062
|
system: buildSystemPrompt(),
|
|
3748
4063
|
// Log the screenshot PATH only — never the image bytes.
|
|
3749
4064
|
items: toTranslate.map((r) => ({
|
|
@@ -3757,12 +4072,12 @@ function createApi(deps) {
|
|
|
3757
4072
|
})),
|
|
3758
4073
|
results
|
|
3759
4074
|
};
|
|
3760
|
-
|
|
4075
|
+
appendLog(projectRoot, entry);
|
|
3761
4076
|
}
|
|
3762
4077
|
persist(s);
|
|
3763
4078
|
return c.json({ requested: reqs.length, written, errors });
|
|
3764
4079
|
}));
|
|
3765
|
-
app.get("/
|
|
4080
|
+
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
3766
4081
|
app.post("/scan", async (c) => {
|
|
3767
4082
|
const s = load();
|
|
3768
4083
|
const existing = loadUsageCache(projectRoot);
|
|
@@ -3786,7 +4101,7 @@ function createApi(deps) {
|
|
|
3786
4101
|
const refs = [];
|
|
3787
4102
|
const prefixRefs = [];
|
|
3788
4103
|
for (const [file, entry] of Object.entries(cache2.files)) {
|
|
3789
|
-
const abs =
|
|
4104
|
+
const abs = resolve8(projectRoot, file);
|
|
3790
4105
|
for (const r of entry.refs) {
|
|
3791
4106
|
if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
|
|
3792
4107
|
}
|
|
@@ -3827,9 +4142,10 @@ function createApi(deps) {
|
|
|
3827
4142
|
keys: body.keys
|
|
3828
4143
|
}, cache2, body.lastRunAt);
|
|
3829
4144
|
if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
|
|
4145
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3830
4146
|
let provider;
|
|
3831
4147
|
try {
|
|
3832
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
4148
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3833
4149
|
} catch (e) {
|
|
3834
4150
|
return c.json({ error: e.message }, 400);
|
|
3835
4151
|
}
|
|
@@ -3851,10 +4167,11 @@ function createApi(deps) {
|
|
|
3851
4167
|
const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
|
|
3852
4168
|
const batch = raw;
|
|
3853
4169
|
const { written, errors } = applyContext(s, targets, batch.items ?? []);
|
|
3854
|
-
|
|
4170
|
+
appendLog(projectRoot, {
|
|
3855
4171
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3856
4172
|
kind: "context",
|
|
3857
|
-
|
|
4173
|
+
summary: `Generated context for ${targets.length} key(s)`,
|
|
4174
|
+
model: aiCfg.model,
|
|
3858
4175
|
system,
|
|
3859
4176
|
items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
|
|
3860
4177
|
results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
|
|
@@ -3870,8 +4187,8 @@ function createApi(deps) {
|
|
|
3870
4187
|
}
|
|
3871
4188
|
|
|
3872
4189
|
// src/server/server.ts
|
|
3873
|
-
var here =
|
|
3874
|
-
var DEFAULT_UI_DIR =
|
|
4190
|
+
var here = dirname3(fileURLToPath(import.meta.url));
|
|
4191
|
+
var DEFAULT_UI_DIR = join9(here, "..", "ui");
|
|
3875
4192
|
var MIME = {
|
|
3876
4193
|
".html": "text/html; charset=utf-8",
|
|
3877
4194
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3900,14 +4217,14 @@ async function readFileResponse(absPath) {
|
|
|
3900
4217
|
function buildApp(opts) {
|
|
3901
4218
|
const app = new Hono2();
|
|
3902
4219
|
app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
|
|
3903
|
-
const projectRoot =
|
|
4220
|
+
const projectRoot = dirname3(resolve9(opts.statePath));
|
|
3904
4221
|
app.get("/:dir/*", async (c, next) => {
|
|
3905
4222
|
const dirSeg = c.req.param("dir");
|
|
3906
4223
|
if (!dirSeg.endsWith("-screenshots")) return next();
|
|
3907
|
-
const shotsRoot =
|
|
4224
|
+
const shotsRoot = resolve9(projectRoot, dirSeg);
|
|
3908
4225
|
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3909
4226
|
const rest = pathname.slice(`/${dirSeg}`.length);
|
|
3910
|
-
const target =
|
|
4227
|
+
const target = resolve9(shotsRoot, "." + rest);
|
|
3911
4228
|
const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
|
|
3912
4229
|
if (inside) {
|
|
3913
4230
|
const file = await readFileResponse(target);
|
|
@@ -3916,24 +4233,35 @@ function buildApp(opts) {
|
|
|
3916
4233
|
return c.notFound();
|
|
3917
4234
|
});
|
|
3918
4235
|
if (!opts.dev) {
|
|
3919
|
-
const root =
|
|
4236
|
+
const root = resolve9(opts.uiDir ?? DEFAULT_UI_DIR);
|
|
3920
4237
|
app.get("/*", async (c) => {
|
|
3921
4238
|
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3922
|
-
const target =
|
|
4239
|
+
const target = resolve9(root, "." + pathname);
|
|
3923
4240
|
const inside = target === root || target.startsWith(root + sep2);
|
|
3924
4241
|
if (inside && pathname !== "/") {
|
|
3925
4242
|
const file = await readFileResponse(target);
|
|
3926
4243
|
if (file) return file;
|
|
3927
4244
|
}
|
|
3928
|
-
const index = await readFileResponse(
|
|
4245
|
+
const index = await readFileResponse(join9(root, "index.html"));
|
|
3929
4246
|
if (index) return index;
|
|
3930
4247
|
return c.notFound();
|
|
3931
4248
|
});
|
|
4249
|
+
} else {
|
|
4250
|
+
app.get("/", (c) => c.html(DEV_LANDING_PAGE));
|
|
3932
4251
|
}
|
|
3933
4252
|
return app;
|
|
3934
4253
|
}
|
|
3935
4254
|
var DEFAULT_PORT = 3e3;
|
|
3936
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>`;
|
|
3937
4265
|
function findAvailablePort(start) {
|
|
3938
4266
|
return new Promise((resolveP, reject) => {
|
|
3939
4267
|
const probe = createServer();
|
|
@@ -3962,7 +4290,7 @@ async function startServer(opts) {
|
|
|
3962
4290
|
});
|
|
3963
4291
|
}
|
|
3964
4292
|
function backgroundScan(statePath) {
|
|
3965
|
-
const projectRoot =
|
|
4293
|
+
const projectRoot = dirname3(resolve9(statePath));
|
|
3966
4294
|
Promise.resolve().then(() => {
|
|
3967
4295
|
const state = loadState(statePath);
|
|
3968
4296
|
const existing = loadUsageCache(projectRoot);
|