glotfile 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/server/cli.js +640 -231
- package/dist/server/server.js +540 -189
- package/dist/ui/assets/en-BZRN_IpI.svg +25 -0
- package/dist/ui/assets/index-DVTJ7ZX_.css +1 -0
- package/dist/ui/assets/index-LjEnW4jC.js +1847 -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, statSync as statSync4, 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 this.config.vision === true;
|
|
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,104 @@ 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
|
+
vision: typeof a.vision === "boolean" ? a.vision : void 0
|
|
3429
|
+
};
|
|
3430
|
+
}
|
|
3431
|
+
function loadLocalSettings(projectRoot) {
|
|
3432
|
+
const raw = readJson2(settingsPath(projectRoot));
|
|
3433
|
+
return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
|
|
3434
|
+
}
|
|
3435
|
+
function saveLocalSettings(projectRoot, patch) {
|
|
3436
|
+
const path = settingsPath(projectRoot);
|
|
3437
|
+
const merged = { ...readJson2(path) };
|
|
3438
|
+
if (patch.ai !== void 0) merged.ai = patch.ai;
|
|
3439
|
+
if (patch.editor !== void 0) merged.editor = patch.editor;
|
|
3440
|
+
ensureGlotfileDir(projectRoot);
|
|
3441
|
+
writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
|
|
3442
|
+
}
|
|
3443
|
+
function aiConfigError(ai) {
|
|
3444
|
+
if (!ai || typeof ai !== "object") return "ai must be an object";
|
|
3445
|
+
const a = ai;
|
|
3446
|
+
if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
|
|
3447
|
+
return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
|
|
3448
|
+
}
|
|
3449
|
+
if (typeof a.model !== "string") return "ai.model must be a string";
|
|
3450
|
+
if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
|
|
3451
|
+
if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
|
|
3452
|
+
if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
|
|
3453
|
+
return null;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3238
3456
|
// src/server/api.ts
|
|
3239
3457
|
var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
|
|
3240
3458
|
var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
|
|
3241
3459
|
function projectName(root) {
|
|
3242
|
-
const nameFile =
|
|
3243
|
-
if (
|
|
3460
|
+
const nameFile = resolve8(root, ".idea", ".name");
|
|
3461
|
+
if (existsSync9(nameFile)) {
|
|
3244
3462
|
try {
|
|
3245
|
-
const name =
|
|
3463
|
+
const name = readFileSync13(nameFile, "utf8").trim();
|
|
3246
3464
|
if (name) return name;
|
|
3247
3465
|
} catch {
|
|
3248
3466
|
}
|
|
@@ -3252,7 +3470,7 @@ function projectName(root) {
|
|
|
3252
3470
|
function createApi(deps) {
|
|
3253
3471
|
const app = new Hono();
|
|
3254
3472
|
const load = () => loadState(deps.statePath);
|
|
3255
|
-
const projectRoot =
|
|
3473
|
+
const projectRoot = dirname2(resolve8(deps.statePath));
|
|
3256
3474
|
let translateQueue = Promise.resolve();
|
|
3257
3475
|
const withTranslateLock = (fn) => {
|
|
3258
3476
|
const next = translateQueue.then(fn, fn);
|
|
@@ -3276,42 +3494,92 @@ function createApi(deps) {
|
|
|
3276
3494
|
saveState(deps.statePath, s);
|
|
3277
3495
|
scheduleAutoExport(s);
|
|
3278
3496
|
};
|
|
3497
|
+
const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3498
|
+
const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
|
|
3499
|
+
const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
|
|
3279
3500
|
app.get("/state", (c) => c.json(load()));
|
|
3501
|
+
app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
|
|
3502
|
+
app.put("/ui-prefs", async (c) => {
|
|
3503
|
+
const { theme } = await c.req.json();
|
|
3504
|
+
if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
|
|
3505
|
+
saveUiPrefs(uiPrefsPath, { theme });
|
|
3506
|
+
return c.json({ ok: true });
|
|
3507
|
+
});
|
|
3508
|
+
app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
|
|
3509
|
+
app.put("/local-settings", async (c) => {
|
|
3510
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3511
|
+
const patch = {};
|
|
3512
|
+
if (body.ai !== void 0) {
|
|
3513
|
+
const err = aiConfigError(body.ai);
|
|
3514
|
+
if (err) return c.json({ error: err }, 400);
|
|
3515
|
+
patch.ai = body.ai;
|
|
3516
|
+
}
|
|
3517
|
+
if (body.editor !== void 0) {
|
|
3518
|
+
if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
|
|
3519
|
+
patch.editor = body.editor;
|
|
3520
|
+
}
|
|
3521
|
+
if (patch.ai === void 0 && patch.editor === void 0) {
|
|
3522
|
+
return c.json({ error: "provide ai and/or editor" }, 400);
|
|
3523
|
+
}
|
|
3524
|
+
saveLocalSettings(projectRoot, patch);
|
|
3525
|
+
return c.json({ ok: true });
|
|
3526
|
+
});
|
|
3280
3527
|
app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
|
|
3281
3528
|
app.get("/files", (c) => {
|
|
3282
3529
|
const found = /* @__PURE__ */ new Map();
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
}
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
abs = resolve6(projectRoot, `${name}.json`);
|
|
3293
|
-
} else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
|
|
3294
|
-
abs = resolve6(projectRoot, name);
|
|
3295
|
-
} else {
|
|
3296
|
-
continue;
|
|
3297
|
-
}
|
|
3298
|
-
if (found.has(abs)) continue;
|
|
3530
|
+
const activeRel = relative3(projectRoot, deps.statePath);
|
|
3531
|
+
found.set(deps.statePath, {
|
|
3532
|
+
name: basename(deps.statePath),
|
|
3533
|
+
path: deps.statePath,
|
|
3534
|
+
relDir: activeRel !== basename(activeRel) ? dirname2(activeRel) : void 0
|
|
3535
|
+
});
|
|
3536
|
+
function walk(dir, depth) {
|
|
3537
|
+
if (depth > 4) return;
|
|
3538
|
+
let entries = [];
|
|
3299
3539
|
try {
|
|
3300
|
-
|
|
3301
|
-
found.set(abs, { name: basename(abs), path: abs });
|
|
3540
|
+
entries = readdirSync7(dir);
|
|
3302
3541
|
} catch {
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
for (const name of entries) {
|
|
3545
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
3546
|
+
const abs = resolve8(dir, name);
|
|
3547
|
+
let filePath = null;
|
|
3548
|
+
if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(abs, "config.json"))) {
|
|
3549
|
+
filePath = resolve8(dir, `${name}.json`);
|
|
3550
|
+
} else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
|
|
3551
|
+
filePath = abs;
|
|
3552
|
+
} else {
|
|
3553
|
+
try {
|
|
3554
|
+
if (statSync4(abs).isDirectory()) walk(abs, depth + 1);
|
|
3555
|
+
} catch {
|
|
3556
|
+
}
|
|
3557
|
+
continue;
|
|
3558
|
+
}
|
|
3559
|
+
if (found.has(filePath)) continue;
|
|
3560
|
+
try {
|
|
3561
|
+
loadState(filePath);
|
|
3562
|
+
const rel = relative3(projectRoot, filePath);
|
|
3563
|
+
found.set(filePath, { name: basename(filePath), path: filePath, relDir: rel !== basename(filePath) ? dirname2(rel) : void 0 });
|
|
3564
|
+
} catch {
|
|
3565
|
+
}
|
|
3303
3566
|
}
|
|
3304
3567
|
}
|
|
3305
|
-
|
|
3568
|
+
walk(projectRoot, 0);
|
|
3569
|
+
const files = [...found.values()].sort((a, b) => {
|
|
3570
|
+
const ka = a.relDir ? `${a.relDir}/${a.name}` : a.name;
|
|
3571
|
+
const kb = b.relDir ? `${b.relDir}/${b.name}` : b.name;
|
|
3572
|
+
return ka.localeCompare(kb);
|
|
3573
|
+
});
|
|
3306
3574
|
return c.json(files);
|
|
3307
3575
|
});
|
|
3308
3576
|
app.post("/file", async (c) => {
|
|
3309
3577
|
const { path } = await c.req.json();
|
|
3310
3578
|
if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
|
|
3311
|
-
const resolved =
|
|
3579
|
+
const resolved = resolve8(projectRoot, path);
|
|
3312
3580
|
const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
|
|
3313
3581
|
if (!inside) return c.json({ error: "file is outside the project" }, 400);
|
|
3314
|
-
if (!
|
|
3582
|
+
if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
|
|
3315
3583
|
loadState(resolved);
|
|
3316
3584
|
deps.statePath = resolved;
|
|
3317
3585
|
return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
|
|
@@ -3326,6 +3594,7 @@ function createApi(deps) {
|
|
|
3326
3594
|
const s = load();
|
|
3327
3595
|
createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
|
|
3328
3596
|
persist(s);
|
|
3597
|
+
logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
|
|
3329
3598
|
console.log(`[key] created ${key}`);
|
|
3330
3599
|
return c.json({ ok: true });
|
|
3331
3600
|
});
|
|
@@ -3335,37 +3604,45 @@ function createApi(deps) {
|
|
|
3335
3604
|
const s = load();
|
|
3336
3605
|
addCustomWord(s, word);
|
|
3337
3606
|
persist(s);
|
|
3607
|
+
logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
|
|
3338
3608
|
return c.json({ ok: true });
|
|
3339
3609
|
});
|
|
3340
3610
|
app.delete("/dictionary/:word", (c) => {
|
|
3341
3611
|
const s = load();
|
|
3342
|
-
|
|
3612
|
+
const word = c.req.param("word");
|
|
3613
|
+
removeCustomWord(s, word);
|
|
3343
3614
|
persist(s);
|
|
3615
|
+
logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
|
|
3344
3616
|
return c.json({ ok: true });
|
|
3345
3617
|
});
|
|
3346
3618
|
app.patch("/keys/:key", async (c) => {
|
|
3347
3619
|
const key = c.req.param("key");
|
|
3348
3620
|
const body = await c.req.json();
|
|
3349
3621
|
const s = load();
|
|
3622
|
+
const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
|
|
3350
3623
|
if (typeof body.rename === "string") renameKey(s, key, body.rename);
|
|
3351
3624
|
const target = typeof body.rename === "string" ? body.rename : key;
|
|
3352
3625
|
if (body.metadata) setMetadata(s, target, body.metadata);
|
|
3353
3626
|
if (typeof body.source === "string") setSourceValue(s, target, body.source);
|
|
3354
3627
|
if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
|
|
3355
3628
|
persist(s);
|
|
3629
|
+
if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
|
|
3630
|
+
if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
|
|
3631
|
+
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 });
|
|
3632
|
+
if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
|
|
3356
3633
|
if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
|
|
3357
3634
|
return c.json({ ok: true });
|
|
3358
3635
|
});
|
|
3359
3636
|
function removeOrphanScreenshot(s, screenshot) {
|
|
3360
3637
|
if (!screenshot) return;
|
|
3361
3638
|
for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
|
|
3362
|
-
const root =
|
|
3363
|
-
const abs =
|
|
3639
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3640
|
+
const abs = resolve8(root, screenshot);
|
|
3364
3641
|
const rel = relative3(root, abs);
|
|
3365
3642
|
const seg0 = rel.split(sep)[0] ?? "";
|
|
3366
|
-
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") &&
|
|
3643
|
+
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
|
|
3367
3644
|
try {
|
|
3368
|
-
|
|
3645
|
+
rmSync4(abs);
|
|
3369
3646
|
} catch {
|
|
3370
3647
|
}
|
|
3371
3648
|
}
|
|
@@ -3374,9 +3651,11 @@ function createApi(deps) {
|
|
|
3374
3651
|
const s = load();
|
|
3375
3652
|
const key = c.req.param("key");
|
|
3376
3653
|
const shot = s.keys[key]?.screenshot;
|
|
3654
|
+
const before = valueText(s, key, s.config.sourceLocale);
|
|
3377
3655
|
deleteKey(s, key);
|
|
3378
3656
|
removeOrphanScreenshot(s, shot);
|
|
3379
3657
|
persist(s);
|
|
3658
|
+
logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
|
|
3380
3659
|
console.log(`[key] deleted ${key}`);
|
|
3381
3660
|
return c.json({ ok: true });
|
|
3382
3661
|
});
|
|
@@ -3400,6 +3679,7 @@ function createApi(deps) {
|
|
|
3400
3679
|
}
|
|
3401
3680
|
}
|
|
3402
3681
|
persist(s);
|
|
3682
|
+
if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
|
|
3403
3683
|
console.log(`[bulk] cleared ${cleared} value(s)`);
|
|
3404
3684
|
return c.json({ cleared });
|
|
3405
3685
|
});
|
|
@@ -3417,6 +3697,7 @@ function createApi(deps) {
|
|
|
3417
3697
|
}
|
|
3418
3698
|
for (const shot of shots) removeOrphanScreenshot(s, shot);
|
|
3419
3699
|
persist(s);
|
|
3700
|
+
if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
|
|
3420
3701
|
console.log(`[bulk] deleted ${removed.length} key(s)`);
|
|
3421
3702
|
return c.json({ removed });
|
|
3422
3703
|
});
|
|
@@ -3442,6 +3723,7 @@ function createApi(deps) {
|
|
|
3442
3723
|
updated++;
|
|
3443
3724
|
}
|
|
3444
3725
|
persist(s);
|
|
3726
|
+
if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
|
|
3445
3727
|
console.log(`[bulk] updated metadata on ${updated} key(s)`);
|
|
3446
3728
|
return c.json({ updated });
|
|
3447
3729
|
});
|
|
@@ -3465,6 +3747,7 @@ function createApi(deps) {
|
|
|
3465
3747
|
}
|
|
3466
3748
|
}
|
|
3467
3749
|
persist(s);
|
|
3750
|
+
if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
|
|
3468
3751
|
console.log(`[bulk] set state ${next} on ${updated} value(s)`);
|
|
3469
3752
|
return c.json({ updated });
|
|
3470
3753
|
});
|
|
@@ -3474,15 +3757,21 @@ function createApi(deps) {
|
|
|
3474
3757
|
const s = load();
|
|
3475
3758
|
const key = c.req.param("key");
|
|
3476
3759
|
const locale = c.req.param("locale");
|
|
3760
|
+
const before = valueText(s, key, locale);
|
|
3477
3761
|
if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
|
|
3478
3762
|
else setTargetValue(s, key, locale, value);
|
|
3479
3763
|
persist(s);
|
|
3764
|
+
logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
|
|
3480
3765
|
return c.json({ ok: true });
|
|
3481
3766
|
});
|
|
3482
3767
|
app.delete("/keys/:key/values/:locale", (c) => {
|
|
3483
3768
|
const s = load();
|
|
3484
|
-
|
|
3769
|
+
const key = c.req.param("key");
|
|
3770
|
+
const locale = c.req.param("locale");
|
|
3771
|
+
const before = valueText(s, key, locale);
|
|
3772
|
+
clearValue(s, key, locale);
|
|
3485
3773
|
persist(s);
|
|
3774
|
+
logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
|
|
3486
3775
|
return c.json({ ok: true });
|
|
3487
3776
|
});
|
|
3488
3777
|
app.put("/keys/:key/plural/:locale", async (c) => {
|
|
@@ -3491,52 +3780,68 @@ function createApi(deps) {
|
|
|
3491
3780
|
const s = load();
|
|
3492
3781
|
const key = c.req.param("key");
|
|
3493
3782
|
const locale = c.req.param("locale");
|
|
3783
|
+
const before = s.keys[key]?.values[locale]?.forms;
|
|
3494
3784
|
if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
|
|
3495
3785
|
else setPluralForms(s, key, locale, forms);
|
|
3496
3786
|
persist(s);
|
|
3787
|
+
logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
|
|
3497
3788
|
return c.json({ ok: true });
|
|
3498
3789
|
});
|
|
3499
3790
|
app.post("/keys/:key/plural", async (c) => {
|
|
3500
3791
|
const { arg } = await c.req.json();
|
|
3501
3792
|
if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
|
|
3502
3793
|
const s = load();
|
|
3503
|
-
|
|
3794
|
+
const key = c.req.param("key");
|
|
3795
|
+
convertToPlural(s, key, arg);
|
|
3504
3796
|
persist(s);
|
|
3797
|
+
logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
|
|
3505
3798
|
return c.json({ ok: true });
|
|
3506
3799
|
});
|
|
3507
3800
|
app.delete("/keys/:key/plural", (c) => {
|
|
3508
3801
|
const s = load();
|
|
3509
|
-
|
|
3802
|
+
const key = c.req.param("key");
|
|
3803
|
+
convertToScalar(s, key);
|
|
3510
3804
|
persist(s);
|
|
3805
|
+
logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
|
|
3511
3806
|
return c.json({ ok: true });
|
|
3512
3807
|
});
|
|
3513
3808
|
app.put("/keys/:key/values/:locale/state", async (c) => {
|
|
3514
3809
|
const { state } = await c.req.json();
|
|
3515
3810
|
const s = load();
|
|
3516
|
-
|
|
3811
|
+
const key = c.req.param("key");
|
|
3812
|
+
const locale = c.req.param("locale");
|
|
3813
|
+
const before = s.keys[key]?.values[locale]?.state;
|
|
3814
|
+
setKeyState(s, key, locale, state);
|
|
3517
3815
|
persist(s);
|
|
3816
|
+
logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
|
|
3518
3817
|
return c.json({ ok: true });
|
|
3519
3818
|
});
|
|
3520
3819
|
app.post("/keys/:key/notes", async (c) => {
|
|
3521
3820
|
const { text } = await c.req.json();
|
|
3522
3821
|
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3523
3822
|
const s = load();
|
|
3524
|
-
const
|
|
3823
|
+
const key = c.req.param("key");
|
|
3824
|
+
const note = addNote(s, key, text);
|
|
3525
3825
|
persist(s);
|
|
3826
|
+
logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
|
|
3526
3827
|
return c.json(note);
|
|
3527
3828
|
});
|
|
3528
3829
|
app.put("/keys/:key/notes/:id", async (c) => {
|
|
3529
3830
|
const { text } = await c.req.json();
|
|
3530
3831
|
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3531
3832
|
const s = load();
|
|
3532
|
-
|
|
3833
|
+
const key = c.req.param("key");
|
|
3834
|
+
editNote(s, key, c.req.param("id"), text);
|
|
3533
3835
|
persist(s);
|
|
3836
|
+
logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
|
|
3534
3837
|
return c.json({ ok: true });
|
|
3535
3838
|
});
|
|
3536
3839
|
app.delete("/keys/:key/notes/:id", (c) => {
|
|
3537
3840
|
const s = load();
|
|
3538
|
-
|
|
3841
|
+
const key = c.req.param("key");
|
|
3842
|
+
deleteNote(s, key, c.req.param("id"));
|
|
3539
3843
|
persist(s);
|
|
3844
|
+
logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
|
|
3540
3845
|
return c.json({ ok: true });
|
|
3541
3846
|
});
|
|
3542
3847
|
app.put("/config", async (c) => {
|
|
@@ -3545,6 +3850,7 @@ function createApi(deps) {
|
|
|
3545
3850
|
return c.json({ error: "config.locales must be an array" }, 400);
|
|
3546
3851
|
}
|
|
3547
3852
|
const s = load();
|
|
3853
|
+
const beforeCfg = { locales: s.config.locales };
|
|
3548
3854
|
const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
|
|
3549
3855
|
for (const l of removed) {
|
|
3550
3856
|
for (const e of Object.values(s.keys)) delete e.values[l];
|
|
@@ -3552,7 +3858,8 @@ function createApi(deps) {
|
|
|
3552
3858
|
s.config = newConfig;
|
|
3553
3859
|
validate(s);
|
|
3554
3860
|
persist(s);
|
|
3555
|
-
|
|
3861
|
+
logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
|
|
3862
|
+
console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
|
|
3556
3863
|
return c.json({ ok: true });
|
|
3557
3864
|
});
|
|
3558
3865
|
app.get("/glossary", (c) => c.json(load().glossary));
|
|
@@ -3560,14 +3867,19 @@ function createApi(deps) {
|
|
|
3560
3867
|
const entry = await c.req.json();
|
|
3561
3868
|
if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
|
|
3562
3869
|
const s = load();
|
|
3870
|
+
const before = s.glossary.find((g) => g.term === entry.term);
|
|
3563
3871
|
upsertGlossaryEntry(s, entry);
|
|
3564
3872
|
persist(s);
|
|
3873
|
+
logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
|
|
3565
3874
|
return c.json({ ok: true });
|
|
3566
3875
|
});
|
|
3567
3876
|
app.delete("/glossary/:term", (c) => {
|
|
3568
3877
|
const s = load();
|
|
3569
|
-
|
|
3878
|
+
const term = decodeURIComponent(c.req.param("term"));
|
|
3879
|
+
const before = s.glossary.find((g) => g.term === term);
|
|
3880
|
+
deleteGlossaryEntry(s, term);
|
|
3570
3881
|
persist(s);
|
|
3882
|
+
logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
|
|
3571
3883
|
return c.json({ ok: true });
|
|
3572
3884
|
});
|
|
3573
3885
|
app.post("/keys/:key/screenshot", async (c) => {
|
|
@@ -3575,18 +3887,18 @@ function createApi(deps) {
|
|
|
3575
3887
|
const body = await c.req.parseBody();
|
|
3576
3888
|
const file = body["file"];
|
|
3577
3889
|
if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
|
|
3578
|
-
const root =
|
|
3890
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3579
3891
|
const dirName = screenshotDirName(deps.statePath);
|
|
3580
|
-
const dir =
|
|
3581
|
-
mkdirSync6(dir, { recursive: true });
|
|
3892
|
+
const dir = resolve8(root, dirName);
|
|
3582
3893
|
const filename = `${sanitize(key)}__${sanitize(file.name)}`;
|
|
3583
|
-
|
|
3894
|
+
writeFileAtomic(resolve8(dir, filename), Buffer.from(await file.arrayBuffer()));
|
|
3584
3895
|
const path = `${dirName}/${filename}`;
|
|
3585
3896
|
const s = load();
|
|
3586
3897
|
const prev = s.keys[key]?.screenshot;
|
|
3587
3898
|
setMetadata(s, key, { screenshot: path });
|
|
3588
3899
|
if (prev && prev !== path) removeOrphanScreenshot(s, prev);
|
|
3589
3900
|
persist(s);
|
|
3901
|
+
logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
|
|
3590
3902
|
return c.json({ path });
|
|
3591
3903
|
});
|
|
3592
3904
|
app.delete("/keys/:key/screenshot", (c) => {
|
|
@@ -3596,6 +3908,7 @@ function createApi(deps) {
|
|
|
3596
3908
|
setMetadata(s, key, { screenshot: void 0 });
|
|
3597
3909
|
removeOrphanScreenshot(s, shot);
|
|
3598
3910
|
persist(s);
|
|
3911
|
+
logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
|
|
3599
3912
|
return c.json({ ok: true });
|
|
3600
3913
|
});
|
|
3601
3914
|
app.get("/export/preview", (c) => {
|
|
@@ -3640,12 +3953,13 @@ function createApi(deps) {
|
|
|
3640
3953
|
return c.json({ error: e.message }, 400);
|
|
3641
3954
|
}
|
|
3642
3955
|
persist(result.state);
|
|
3956
|
+
logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
|
|
3643
3957
|
console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
|
|
3644
3958
|
return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
|
|
3645
3959
|
});
|
|
3646
3960
|
app.post("/export", (c) => {
|
|
3647
3961
|
const s = narrowForExport(load());
|
|
3648
|
-
const root =
|
|
3962
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3649
3963
|
const warnings = [];
|
|
3650
3964
|
let count = 0;
|
|
3651
3965
|
for (const output of s.config.outputs) {
|
|
@@ -3653,9 +3967,8 @@ function createApi(deps) {
|
|
|
3653
3967
|
const result = adapter.export(s, output);
|
|
3654
3968
|
warnings.push(...result.warnings);
|
|
3655
3969
|
for (const f of result.files) {
|
|
3656
|
-
const abs =
|
|
3657
|
-
|
|
3658
|
-
writeFileSync5(abs, f.contents, "utf8");
|
|
3970
|
+
const abs = resolve8(root, f.path);
|
|
3971
|
+
writeFileAtomic(abs, f.contents);
|
|
3659
3972
|
count++;
|
|
3660
3973
|
}
|
|
3661
3974
|
}
|
|
@@ -3674,41 +3987,63 @@ function createApi(deps) {
|
|
|
3674
3987
|
await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
|
|
3675
3988
|
return;
|
|
3676
3989
|
}
|
|
3990
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3677
3991
|
let provider;
|
|
3678
3992
|
try {
|
|
3679
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
3993
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3680
3994
|
} catch (e) {
|
|
3681
3995
|
await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
|
|
3682
3996
|
return;
|
|
3683
3997
|
}
|
|
3684
3998
|
const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
|
|
3685
|
-
if (skipped) console.warn(`Model "${
|
|
3686
|
-
console.log(`[translate] ${reqs.length} string(s) \u2192 ${
|
|
3999
|
+
if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
4000
|
+
console.log(`[translate] ${reqs.length} string(s) \u2192 ${aiCfg.model}`);
|
|
3687
4001
|
let totalWritten = 0;
|
|
3688
4002
|
const allErrors = [];
|
|
3689
4003
|
const system = buildSystemPrompt();
|
|
3690
4004
|
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
|
-
|
|
4005
|
+
const localeTotals = /* @__PURE__ */ new Map();
|
|
4006
|
+
for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
|
|
4007
|
+
const localeDone = /* @__PURE__ */ new Map();
|
|
4008
|
+
await stream.writeSSE({
|
|
4009
|
+
event: "start",
|
|
4010
|
+
data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
|
|
4011
|
+
});
|
|
4012
|
+
await runLocaleParallel(reqs, provider, {
|
|
4013
|
+
// Announce a language the moment a worker picks it up — this is the
|
|
4014
|
+
// signal that "something is happening" during the long first LLM call.
|
|
4015
|
+
onLocaleStart: (locale) => {
|
|
4016
|
+
void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
|
|
4017
|
+
},
|
|
4018
|
+
onBatchComplete: (done, total, batchResults, locale) => {
|
|
4019
|
+
const fresh = load();
|
|
4020
|
+
const { written, errors } = applyResults(fresh, reqs, batchResults);
|
|
4021
|
+
persist(fresh);
|
|
4022
|
+
totalWritten += written;
|
|
4023
|
+
allErrors.push(...errors);
|
|
4024
|
+
appendLog(projectRoot, {
|
|
4025
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4026
|
+
kind: "translate",
|
|
4027
|
+
summary: `Translated ${batchResults.length} item(s)`,
|
|
4028
|
+
model: aiCfg.model,
|
|
4029
|
+
system,
|
|
4030
|
+
items: batchResults.map((r) => {
|
|
4031
|
+
const req = reqById.get(r.id);
|
|
4032
|
+
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 };
|
|
4033
|
+
}),
|
|
4034
|
+
results: batchResults
|
|
4035
|
+
});
|
|
4036
|
+
const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
|
|
4037
|
+
localeDone.set(locale, ld);
|
|
4038
|
+
console.log(`[translate] ${done}/${total}`);
|
|
4039
|
+
void stream.writeSSE({
|
|
4040
|
+
event: "progress",
|
|
4041
|
+
data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
|
|
4042
|
+
});
|
|
4043
|
+
},
|
|
4044
|
+
onLocaleDone: (locale) => {
|
|
4045
|
+
void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
|
|
4046
|
+
}
|
|
3712
4047
|
}, void 0, signal);
|
|
3713
4048
|
if (!signal?.aborted) {
|
|
3714
4049
|
console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
|
|
@@ -3731,19 +4066,22 @@ function createApi(deps) {
|
|
|
3731
4066
|
let written = 0;
|
|
3732
4067
|
let errors = [];
|
|
3733
4068
|
if (toTranslate.length) {
|
|
4069
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3734
4070
|
let provider;
|
|
3735
4071
|
try {
|
|
3736
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
4072
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3737
4073
|
} catch (e) {
|
|
3738
4074
|
return c.json({ error: e.message }, 400);
|
|
3739
4075
|
}
|
|
3740
4076
|
const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
|
|
3741
|
-
if (skipped) console.warn(`Model "${
|
|
4077
|
+
if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
3742
4078
|
const results = await runLocaleParallel(toTranslate, provider);
|
|
3743
4079
|
({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
|
|
3744
4080
|
const entry = {
|
|
3745
4081
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3746
|
-
|
|
4082
|
+
kind: "translate",
|
|
4083
|
+
summary: `Translated ${toTranslate.length} item(s)`,
|
|
4084
|
+
model: aiCfg.model,
|
|
3747
4085
|
system: buildSystemPrompt(),
|
|
3748
4086
|
// Log the screenshot PATH only — never the image bytes.
|
|
3749
4087
|
items: toTranslate.map((r) => ({
|
|
@@ -3757,12 +4095,12 @@ function createApi(deps) {
|
|
|
3757
4095
|
})),
|
|
3758
4096
|
results
|
|
3759
4097
|
};
|
|
3760
|
-
|
|
4098
|
+
appendLog(projectRoot, entry);
|
|
3761
4099
|
}
|
|
3762
4100
|
persist(s);
|
|
3763
4101
|
return c.json({ requested: reqs.length, written, errors });
|
|
3764
4102
|
}));
|
|
3765
|
-
app.get("/
|
|
4103
|
+
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
3766
4104
|
app.post("/scan", async (c) => {
|
|
3767
4105
|
const s = load();
|
|
3768
4106
|
const existing = loadUsageCache(projectRoot);
|
|
@@ -3786,7 +4124,7 @@ function createApi(deps) {
|
|
|
3786
4124
|
const refs = [];
|
|
3787
4125
|
const prefixRefs = [];
|
|
3788
4126
|
for (const [file, entry] of Object.entries(cache2.files)) {
|
|
3789
|
-
const abs =
|
|
4127
|
+
const abs = resolve8(projectRoot, file);
|
|
3790
4128
|
for (const r of entry.refs) {
|
|
3791
4129
|
if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
|
|
3792
4130
|
}
|
|
@@ -3827,9 +4165,10 @@ function createApi(deps) {
|
|
|
3827
4165
|
keys: body.keys
|
|
3828
4166
|
}, cache2, body.lastRunAt);
|
|
3829
4167
|
if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
|
|
4168
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3830
4169
|
let provider;
|
|
3831
4170
|
try {
|
|
3832
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
4171
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3833
4172
|
} catch (e) {
|
|
3834
4173
|
return c.json({ error: e.message }, 400);
|
|
3835
4174
|
}
|
|
@@ -3851,10 +4190,11 @@ function createApi(deps) {
|
|
|
3851
4190
|
const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
|
|
3852
4191
|
const batch = raw;
|
|
3853
4192
|
const { written, errors } = applyContext(s, targets, batch.items ?? []);
|
|
3854
|
-
|
|
4193
|
+
appendLog(projectRoot, {
|
|
3855
4194
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3856
4195
|
kind: "context",
|
|
3857
|
-
|
|
4196
|
+
summary: `Generated context for ${targets.length} key(s)`,
|
|
4197
|
+
model: aiCfg.model,
|
|
3858
4198
|
system,
|
|
3859
4199
|
items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
|
|
3860
4200
|
results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
|
|
@@ -3870,8 +4210,8 @@ function createApi(deps) {
|
|
|
3870
4210
|
}
|
|
3871
4211
|
|
|
3872
4212
|
// src/server/server.ts
|
|
3873
|
-
var here =
|
|
3874
|
-
var DEFAULT_UI_DIR =
|
|
4213
|
+
var here = dirname3(fileURLToPath(import.meta.url));
|
|
4214
|
+
var DEFAULT_UI_DIR = join9(here, "..", "ui");
|
|
3875
4215
|
var MIME = {
|
|
3876
4216
|
".html": "text/html; charset=utf-8",
|
|
3877
4217
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3900,14 +4240,14 @@ async function readFileResponse(absPath) {
|
|
|
3900
4240
|
function buildApp(opts) {
|
|
3901
4241
|
const app = new Hono2();
|
|
3902
4242
|
app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
|
|
3903
|
-
const projectRoot =
|
|
4243
|
+
const projectRoot = dirname3(resolve9(opts.statePath));
|
|
3904
4244
|
app.get("/:dir/*", async (c, next) => {
|
|
3905
4245
|
const dirSeg = c.req.param("dir");
|
|
3906
4246
|
if (!dirSeg.endsWith("-screenshots")) return next();
|
|
3907
|
-
const shotsRoot =
|
|
4247
|
+
const shotsRoot = resolve9(projectRoot, dirSeg);
|
|
3908
4248
|
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3909
4249
|
const rest = pathname.slice(`/${dirSeg}`.length);
|
|
3910
|
-
const target =
|
|
4250
|
+
const target = resolve9(shotsRoot, "." + rest);
|
|
3911
4251
|
const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
|
|
3912
4252
|
if (inside) {
|
|
3913
4253
|
const file = await readFileResponse(target);
|
|
@@ -3916,24 +4256,35 @@ function buildApp(opts) {
|
|
|
3916
4256
|
return c.notFound();
|
|
3917
4257
|
});
|
|
3918
4258
|
if (!opts.dev) {
|
|
3919
|
-
const root =
|
|
4259
|
+
const root = resolve9(opts.uiDir ?? DEFAULT_UI_DIR);
|
|
3920
4260
|
app.get("/*", async (c) => {
|
|
3921
4261
|
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3922
|
-
const target =
|
|
4262
|
+
const target = resolve9(root, "." + pathname);
|
|
3923
4263
|
const inside = target === root || target.startsWith(root + sep2);
|
|
3924
4264
|
if (inside && pathname !== "/") {
|
|
3925
4265
|
const file = await readFileResponse(target);
|
|
3926
4266
|
if (file) return file;
|
|
3927
4267
|
}
|
|
3928
|
-
const index = await readFileResponse(
|
|
4268
|
+
const index = await readFileResponse(join9(root, "index.html"));
|
|
3929
4269
|
if (index) return index;
|
|
3930
4270
|
return c.notFound();
|
|
3931
4271
|
});
|
|
4272
|
+
} else {
|
|
4273
|
+
app.get("/", (c) => c.html(DEV_LANDING_PAGE));
|
|
3932
4274
|
}
|
|
3933
4275
|
return app;
|
|
3934
4276
|
}
|
|
3935
4277
|
var DEFAULT_PORT = 3e3;
|
|
3936
4278
|
var DEV_PORT = 8787;
|
|
4279
|
+
var DEV_UI_URL = "http://localhost:5173";
|
|
4280
|
+
var DEV_LANDING_PAGE = `<!doctype html>
|
|
4281
|
+
<html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
|
|
4282
|
+
<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>
|
|
4283
|
+
</head><body>
|
|
4284
|
+
<h1>Glotfile \u2014 dev API server</h1>
|
|
4285
|
+
<p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
|
|
4286
|
+
<p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
|
|
4287
|
+
</body></html>`;
|
|
3937
4288
|
function findAvailablePort(start) {
|
|
3938
4289
|
return new Promise((resolveP, reject) => {
|
|
3939
4290
|
const probe = createServer();
|
|
@@ -3962,7 +4313,7 @@ async function startServer(opts) {
|
|
|
3962
4313
|
});
|
|
3963
4314
|
}
|
|
3964
4315
|
function backgroundScan(statePath) {
|
|
3965
|
-
const projectRoot =
|
|
4316
|
+
const projectRoot = dirname3(resolve9(statePath));
|
|
3966
4317
|
Promise.resolve().then(() => {
|
|
3967
4318
|
const state = loadState(statePath);
|
|
3968
4319
|
const existing = loadUsageCache(projectRoot);
|