glotfile 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/server/cli.js +644 -224
- package/dist/server/server.js +536 -182
- 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-DK-AGskd.js +0 -1805
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"];
|
|
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
|
}
|
|
@@ -2517,6 +2547,17 @@ var AnthropicProvider = class {
|
|
|
2517
2547
|
|
|
2518
2548
|
// src/server/ai/openai.ts
|
|
2519
2549
|
import { createRequire } from "module";
|
|
2550
|
+
function loadOpenAIClient(opts) {
|
|
2551
|
+
const require2 = createRequire(import.meta.url);
|
|
2552
|
+
let OpenAICtor;
|
|
2553
|
+
try {
|
|
2554
|
+
const mod = require2("openai");
|
|
2555
|
+
OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
|
|
2556
|
+
} catch {
|
|
2557
|
+
throw new Error("The OpenAI SDK is required for this provider. Install it: npm i openai");
|
|
2558
|
+
}
|
|
2559
|
+
return new OpenAICtor(opts);
|
|
2560
|
+
}
|
|
2520
2561
|
var OpenAIProvider = class {
|
|
2521
2562
|
constructor(config, client) {
|
|
2522
2563
|
this.config = config;
|
|
@@ -2527,15 +2568,7 @@ var OpenAIProvider = class {
|
|
|
2527
2568
|
if (!process.env.OPENAI_API_KEY) {
|
|
2528
2569
|
throw new Error("OPENAI_API_KEY is not set. AI translation requires it; every other feature works offline.");
|
|
2529
2570
|
}
|
|
2530
|
-
|
|
2531
|
-
let OpenAICtor;
|
|
2532
|
-
try {
|
|
2533
|
-
const mod = require2("openai");
|
|
2534
|
-
OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
|
|
2535
|
-
} catch {
|
|
2536
|
-
throw new Error('Provider "openai" requires the OpenAI SDK. Install it: npm i openai');
|
|
2537
|
-
}
|
|
2538
|
-
this.client = new OpenAICtor({ baseURL: config.endpoint ?? void 0 });
|
|
2571
|
+
this.client = loadOpenAIClient({ baseURL: config.endpoint ?? void 0 });
|
|
2539
2572
|
}
|
|
2540
2573
|
config;
|
|
2541
2574
|
client;
|
|
@@ -2620,7 +2653,7 @@ var BedrockProvider = class {
|
|
|
2620
2653
|
}
|
|
2621
2654
|
const region = config.region ?? process.env.AWS_REGION;
|
|
2622
2655
|
if (!region) {
|
|
2623
|
-
throw new Error("AWS region is not set. Set
|
|
2656
|
+
throw new Error("AWS region is not set. Set the Region in your local AI settings (.glotfile/settings.json) or the AWS_REGION environment variable for the bedrock provider.");
|
|
2624
2657
|
}
|
|
2625
2658
|
const require2 = createRequire2(import.meta.url);
|
|
2626
2659
|
let sdk;
|
|
@@ -2718,9 +2751,137 @@ var BedrockProvider = class {
|
|
|
2718
2751
|
}
|
|
2719
2752
|
};
|
|
2720
2753
|
|
|
2754
|
+
// src/server/ai/openrouter.ts
|
|
2755
|
+
var OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
2756
|
+
var OPENROUTER_REFERER = "https://www.npmjs.com/package/glotfile";
|
|
2757
|
+
var OPENROUTER_TITLE = "glotfile";
|
|
2758
|
+
function openRouterClientOptions(config) {
|
|
2759
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
2760
|
+
if (!apiKey) {
|
|
2761
|
+
throw new Error("OPENROUTER_API_KEY is not set. AI translation requires it; every other feature works offline.");
|
|
2762
|
+
}
|
|
2763
|
+
return {
|
|
2764
|
+
apiKey,
|
|
2765
|
+
baseURL: config.endpoint ?? OPENROUTER_BASE_URL,
|
|
2766
|
+
defaultHeaders: { "HTTP-Referer": OPENROUTER_REFERER, "X-Title": OPENROUTER_TITLE }
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
var OpenRouterProvider = class extends OpenAIProvider {
|
|
2770
|
+
constructor(config, client) {
|
|
2771
|
+
super(config, client ?? loadOpenAIClient(openRouterClientOptions(config)));
|
|
2772
|
+
}
|
|
2773
|
+
};
|
|
2774
|
+
|
|
2775
|
+
// src/server/ai/ollama.ts
|
|
2776
|
+
var OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
2777
|
+
function ollamaClientOptions(config) {
|
|
2778
|
+
return {
|
|
2779
|
+
apiKey: process.env.OLLAMA_API_KEY ?? "ollama",
|
|
2780
|
+
baseURL: config.endpoint ?? OLLAMA_BASE_URL
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
var OllamaProvider = class extends OpenAIProvider {
|
|
2784
|
+
constructor(config, client) {
|
|
2785
|
+
super(config, client ?? loadOpenAIClient(ollamaClientOptions(config)));
|
|
2786
|
+
}
|
|
2787
|
+
supportsVision() {
|
|
2788
|
+
return false;
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
|
|
2792
|
+
// src/server/ai/claudecode.ts
|
|
2793
|
+
import { spawn } from "child_process";
|
|
2794
|
+
function stripFences(s) {
|
|
2795
|
+
return s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
|
|
2796
|
+
}
|
|
2797
|
+
function defaultSpawn(prompt, systemPrompt, model) {
|
|
2798
|
+
return new Promise((resolve10, reject) => {
|
|
2799
|
+
const args = [
|
|
2800
|
+
"--print",
|
|
2801
|
+
"--output-format",
|
|
2802
|
+
"json",
|
|
2803
|
+
"--system-prompt",
|
|
2804
|
+
systemPrompt,
|
|
2805
|
+
// Only pass --model when explicitly configured; otherwise use the session default.
|
|
2806
|
+
...model ? ["--model", model] : []
|
|
2807
|
+
];
|
|
2808
|
+
const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2809
|
+
let stdout = "";
|
|
2810
|
+
let stderr = "";
|
|
2811
|
+
child.stdout.on("data", (chunk2) => {
|
|
2812
|
+
stdout += chunk2.toString();
|
|
2813
|
+
});
|
|
2814
|
+
child.stderr.on("data", (chunk2) => {
|
|
2815
|
+
stderr += chunk2.toString();
|
|
2816
|
+
});
|
|
2817
|
+
child.stdin.write(prompt);
|
|
2818
|
+
child.stdin.end();
|
|
2819
|
+
child.on("close", (code) => {
|
|
2820
|
+
if (code !== 0) {
|
|
2821
|
+
reject(new Error(`claude exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
try {
|
|
2825
|
+
const envelope = JSON.parse(stdout.trim());
|
|
2826
|
+
if (envelope.is_error) {
|
|
2827
|
+
reject(new Error(`claude error: ${envelope.result ?? "unknown error"}`));
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
resolve10(envelope.result ?? "");
|
|
2831
|
+
} catch {
|
|
2832
|
+
reject(new Error(`Failed to parse claude JSON output: ${stdout.slice(0, 200)}`));
|
|
2833
|
+
}
|
|
2834
|
+
});
|
|
2835
|
+
child.on("error", (err) => {
|
|
2836
|
+
reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed?`));
|
|
2837
|
+
});
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
var ClaudeCodeProvider = class {
|
|
2841
|
+
constructor(config, spawnFn) {
|
|
2842
|
+
this.config = config;
|
|
2843
|
+
this.spawnFn = spawnFn ?? defaultSpawn;
|
|
2844
|
+
}
|
|
2845
|
+
config;
|
|
2846
|
+
spawnFn;
|
|
2847
|
+
supportsVision() {
|
|
2848
|
+
return false;
|
|
2849
|
+
}
|
|
2850
|
+
translate(reqs, onBatchComplete, signal) {
|
|
2851
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2852
|
+
}
|
|
2853
|
+
async complete(req) {
|
|
2854
|
+
const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
|
|
2855
|
+
const textBlocks = req.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
2856
|
+
const result = await this.spawnFn(textBlocks, systemParts.join("\n\n"), this.config.model);
|
|
2857
|
+
try {
|
|
2858
|
+
return JSON.parse(stripFences(result));
|
|
2859
|
+
} catch {
|
|
2860
|
+
return {};
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
async callBatch(batch, signal) {
|
|
2864
|
+
if (signal?.aborted) return [];
|
|
2865
|
+
const prompt = buildBatchPrompt(batch);
|
|
2866
|
+
let result;
|
|
2867
|
+
try {
|
|
2868
|
+
result = await this.spawnFn(prompt, buildSystemPrompt(), this.config.model);
|
|
2869
|
+
} catch (err) {
|
|
2870
|
+
if (signal?.aborted) return [];
|
|
2871
|
+
throw err;
|
|
2872
|
+
}
|
|
2873
|
+
if (signal?.aborted) return [];
|
|
2874
|
+
try {
|
|
2875
|
+
const parsed = JSON.parse(stripFences(result));
|
|
2876
|
+
return parsed.items ?? [];
|
|
2877
|
+
} catch {
|
|
2878
|
+
return [];
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2721
2883
|
// src/server/ai/index.ts
|
|
2722
|
-
function makeProvider(
|
|
2723
|
-
const ai = config.ai;
|
|
2884
|
+
function makeProvider(ai) {
|
|
2724
2885
|
switch (ai.provider) {
|
|
2725
2886
|
case "anthropic":
|
|
2726
2887
|
return new AnthropicProvider(ai);
|
|
@@ -2728,36 +2889,38 @@ function makeProvider(config) {
|
|
|
2728
2889
|
return new OpenAIProvider(ai);
|
|
2729
2890
|
case "bedrock":
|
|
2730
2891
|
return new BedrockProvider(ai);
|
|
2892
|
+
case "openrouter":
|
|
2893
|
+
return new OpenRouterProvider(ai);
|
|
2894
|
+
case "ollama":
|
|
2895
|
+
return new OllamaProvider(ai);
|
|
2896
|
+
case "claude-code":
|
|
2897
|
+
return new ClaudeCodeProvider(ai);
|
|
2731
2898
|
default:
|
|
2732
|
-
throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock.`);
|
|
2899
|
+
throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock, openrouter, ollama, claude-code.`);
|
|
2733
2900
|
}
|
|
2734
2901
|
}
|
|
2735
2902
|
|
|
2736
|
-
// src/server/
|
|
2737
|
-
import { appendFileSync, readFileSync as readFileSync7, existsSync as
|
|
2738
|
-
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";
|
|
2739
2906
|
function logPath(projectRoot) {
|
|
2740
|
-
return
|
|
2907
|
+
return resolve5(projectRoot, ".glotfile", "log.jsonl");
|
|
2741
2908
|
}
|
|
2742
|
-
function
|
|
2743
|
-
|
|
2909
|
+
function appendLog(projectRoot, entry) {
|
|
2910
|
+
ensureGlotfileDir(projectRoot);
|
|
2744
2911
|
appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
|
|
2745
2912
|
}
|
|
2746
|
-
function
|
|
2913
|
+
function readLog(projectRoot, limit = 100) {
|
|
2747
2914
|
const path = logPath(projectRoot);
|
|
2748
|
-
if (!
|
|
2915
|
+
if (!existsSync7(path)) return [];
|
|
2749
2916
|
const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
|
|
2750
|
-
const entries = lines.map((l) =>
|
|
2751
|
-
const e = JSON.parse(l);
|
|
2752
|
-
e.kind ??= "translate";
|
|
2753
|
-
return e;
|
|
2754
|
-
});
|
|
2917
|
+
const entries = lines.map((l) => JSON.parse(l));
|
|
2755
2918
|
return entries.reverse().slice(0, limit);
|
|
2756
2919
|
}
|
|
2757
2920
|
|
|
2758
2921
|
// src/server/import/detect.ts
|
|
2759
|
-
import { existsSync as
|
|
2760
|
-
import { join as
|
|
2922
|
+
import { existsSync as existsSync8, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
|
|
2923
|
+
import { join as join4 } from "path";
|
|
2761
2924
|
var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
2762
2925
|
var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
|
|
2763
2926
|
function safeIsDir(p) {
|
|
@@ -2768,7 +2931,7 @@ function safeIsDir(p) {
|
|
|
2768
2931
|
}
|
|
2769
2932
|
}
|
|
2770
2933
|
function listDirs(dir) {
|
|
2771
|
-
return readdirSync3(dir).filter((e) => safeIsDir(
|
|
2934
|
+
return readdirSync3(dir).filter((e) => safeIsDir(join4(dir, e)));
|
|
2772
2935
|
}
|
|
2773
2936
|
function fileCount(dir) {
|
|
2774
2937
|
try {
|
|
@@ -2782,22 +2945,22 @@ function pickSource(locales, sizeOf) {
|
|
|
2782
2945
|
return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
|
|
2783
2946
|
}
|
|
2784
2947
|
function detectLaravel(root) {
|
|
2785
|
-
const localeRoot = [
|
|
2948
|
+
const localeRoot = [join4(root, "resources", "lang"), join4(root, "lang")].find(safeIsDir);
|
|
2786
2949
|
if (!localeRoot) return null;
|
|
2787
2950
|
const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
|
|
2788
2951
|
if (locales.length === 0) return null;
|
|
2789
|
-
const sourceLocale = pickSource(locales, (loc) => fileCount(
|
|
2952
|
+
const sourceLocale = pickSource(locales, (loc) => fileCount(join4(localeRoot, loc)));
|
|
2790
2953
|
return { format: "laravel-php", localeRoot, locales, sourceLocale };
|
|
2791
2954
|
}
|
|
2792
2955
|
function detectVue(root) {
|
|
2793
2956
|
for (const rel of VUE_DIR_CANDIDATES) {
|
|
2794
|
-
const localeRoot =
|
|
2957
|
+
const localeRoot = join4(root, rel);
|
|
2795
2958
|
if (!safeIsDir(localeRoot)) continue;
|
|
2796
2959
|
const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
|
|
2797
2960
|
if (locales.length >= 2) {
|
|
2798
2961
|
const sourceLocale = pickSource(locales, (loc) => {
|
|
2799
2962
|
try {
|
|
2800
|
-
return statSync2(
|
|
2963
|
+
return statSync2(join4(localeRoot, `${loc}.json`)).size;
|
|
2801
2964
|
} catch {
|
|
2802
2965
|
return 0;
|
|
2803
2966
|
}
|
|
@@ -2809,7 +2972,7 @@ function detectVue(root) {
|
|
|
2809
2972
|
}
|
|
2810
2973
|
function detectArb(root) {
|
|
2811
2974
|
for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
|
|
2812
|
-
const localeRoot =
|
|
2975
|
+
const localeRoot = join4(root, rel);
|
|
2813
2976
|
if (!safeIsDir(localeRoot)) continue;
|
|
2814
2977
|
const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
|
|
2815
2978
|
if (locales.length >= 1) {
|
|
@@ -2825,7 +2988,7 @@ var BY_FORMAT = {
|
|
|
2825
2988
|
"flutter-arb": detectArb
|
|
2826
2989
|
};
|
|
2827
2990
|
function detect(root, formatOverride) {
|
|
2828
|
-
if (!
|
|
2991
|
+
if (!existsSync8(root)) return null;
|
|
2829
2992
|
if (formatOverride) {
|
|
2830
2993
|
const fn = BY_FORMAT[formatOverride];
|
|
2831
2994
|
if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
|
|
@@ -2840,7 +3003,7 @@ function detect(root, formatOverride) {
|
|
|
2840
3003
|
|
|
2841
3004
|
// src/server/import/parsers/vue-i18n-json.ts
|
|
2842
3005
|
import { readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
|
|
2843
|
-
import { join as
|
|
3006
|
+
import { join as join5 } from "path";
|
|
2844
3007
|
|
|
2845
3008
|
// src/server/import/flatten.ts
|
|
2846
3009
|
function flattenObject(value, prefix, warnings) {
|
|
@@ -2879,7 +3042,7 @@ var vueI18nJson2 = {
|
|
|
2879
3042
|
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
2880
3043
|
let data;
|
|
2881
3044
|
try {
|
|
2882
|
-
data = JSON.parse(readFileSync8(
|
|
3045
|
+
data = JSON.parse(readFileSync8(join5(localeRoot, file), "utf8"));
|
|
2883
3046
|
} catch (e) {
|
|
2884
3047
|
warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
|
|
2885
3048
|
continue;
|
|
@@ -2895,7 +3058,7 @@ var vueI18nJson2 = {
|
|
|
2895
3058
|
|
|
2896
3059
|
// src/server/import/parsers/laravel-php.ts
|
|
2897
3060
|
import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
|
|
2898
|
-
import { join as
|
|
3061
|
+
import { join as join6, relative as relative2 } from "path";
|
|
2899
3062
|
import { execFileSync } from "child_process";
|
|
2900
3063
|
|
|
2901
3064
|
// src/server/import/placeholders.ts
|
|
@@ -2905,13 +3068,13 @@ function laravelToCanonical(value) {
|
|
|
2905
3068
|
|
|
2906
3069
|
// src/server/import/parsers/laravel-php.ts
|
|
2907
3070
|
function listDirs2(dir) {
|
|
2908
|
-
return readdirSync5(dir).filter((e) => statSync3(
|
|
3071
|
+
return readdirSync5(dir).filter((e) => statSync3(join6(dir, e)).isDirectory());
|
|
2909
3072
|
}
|
|
2910
3073
|
function listPhpFiles(dir) {
|
|
2911
3074
|
const out = [];
|
|
2912
3075
|
const walk = (d) => {
|
|
2913
3076
|
for (const e of readdirSync5(d)) {
|
|
2914
|
-
const full =
|
|
3077
|
+
const full = join6(d, e);
|
|
2915
3078
|
if (statSync3(full).isDirectory()) walk(full);
|
|
2916
3079
|
else if (e.endsWith(".php")) out.push(full);
|
|
2917
3080
|
}
|
|
@@ -2948,7 +3111,7 @@ var laravelPhp2 = {
|
|
|
2948
3111
|
for (const locale of listDirs2(localeRoot).sort()) {
|
|
2949
3112
|
if (locale === "vendor") continue;
|
|
2950
3113
|
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
2951
|
-
const localeDir =
|
|
3114
|
+
const localeDir = join6(localeRoot, locale);
|
|
2952
3115
|
locales.push(locale);
|
|
2953
3116
|
for (const file of listPhpFiles(localeDir)) {
|
|
2954
3117
|
const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
|
|
@@ -2972,7 +3135,7 @@ var laravelPhp2 = {
|
|
|
2972
3135
|
|
|
2973
3136
|
// src/server/import/parsers/flutter-arb.ts
|
|
2974
3137
|
import { readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
|
|
2975
|
-
import { join as
|
|
3138
|
+
import { join as join7 } from "path";
|
|
2976
3139
|
var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
2977
3140
|
function localeFromArbName(file) {
|
|
2978
3141
|
const m = file.match(/^(.+)\.arb$/);
|
|
@@ -3008,7 +3171,7 @@ var flutterArb2 = {
|
|
|
3008
3171
|
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
3009
3172
|
let data;
|
|
3010
3173
|
try {
|
|
3011
|
-
data = JSON.parse(readFileSync9(
|
|
3174
|
+
data = JSON.parse(readFileSync9(join7(localeRoot, file), "utf8"));
|
|
3012
3175
|
} catch (e) {
|
|
3013
3176
|
warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
|
|
3014
3177
|
continue;
|
|
@@ -3106,7 +3269,6 @@ function assemble2(parsed, opts) {
|
|
|
3106
3269
|
sourceLocale,
|
|
3107
3270
|
locales,
|
|
3108
3271
|
outputs: [output],
|
|
3109
|
-
ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
|
|
3110
3272
|
format: { indent: 2, sortKeys: true, finalNewline: true },
|
|
3111
3273
|
spelling: { customWords: [] }
|
|
3112
3274
|
},
|
|
@@ -3163,8 +3325,8 @@ function runImport(opts) {
|
|
|
3163
3325
|
}
|
|
3164
3326
|
|
|
3165
3327
|
// src/server/export-run.ts
|
|
3166
|
-
import {
|
|
3167
|
-
import {
|
|
3328
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
3329
|
+
import { resolve as resolve6 } from "path";
|
|
3168
3330
|
function effectiveLocales(config) {
|
|
3169
3331
|
const limit = config.exportLocales;
|
|
3170
3332
|
if (!limit || limit.length === 0) return config.locales;
|
|
@@ -3186,7 +3348,7 @@ function exportToDisk(state, projectRoot, opts) {
|
|
|
3186
3348
|
warnings.push(...result.warnings);
|
|
3187
3349
|
const writtenPaths = /* @__PURE__ */ new Set();
|
|
3188
3350
|
for (const f of result.files) {
|
|
3189
|
-
const abs =
|
|
3351
|
+
const abs = resolve6(projectRoot, f.path);
|
|
3190
3352
|
if (writtenPaths.has(abs)) {
|
|
3191
3353
|
skipped++;
|
|
3192
3354
|
continue;
|
|
@@ -3201,22 +3363,103 @@ function exportToDisk(state, projectRoot, opts) {
|
|
|
3201
3363
|
skipped++;
|
|
3202
3364
|
continue;
|
|
3203
3365
|
}
|
|
3204
|
-
|
|
3205
|
-
writeFileSync4(abs, f.contents, "utf8");
|
|
3366
|
+
writeFileAtomic(abs, f.contents);
|
|
3206
3367
|
written++;
|
|
3207
3368
|
}
|
|
3208
3369
|
}
|
|
3209
3370
|
return { written, skipped, warnings };
|
|
3210
3371
|
}
|
|
3211
3372
|
|
|
3373
|
+
// src/server/ui-prefs.ts
|
|
3374
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
3375
|
+
import { homedir } from "os";
|
|
3376
|
+
import { join as join8 } from "path";
|
|
3377
|
+
var THEMES = ["system", "light", "dark"];
|
|
3378
|
+
var isThemeMode = (v) => THEMES.includes(v);
|
|
3379
|
+
var defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
|
|
3380
|
+
var DEFAULTS = { theme: "system" };
|
|
3381
|
+
function readJson(path) {
|
|
3382
|
+
try {
|
|
3383
|
+
const parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
3384
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
3385
|
+
} catch {
|
|
3386
|
+
return {};
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
function loadUiPrefs(path) {
|
|
3390
|
+
const raw = readJson(path);
|
|
3391
|
+
return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
|
|
3392
|
+
}
|
|
3393
|
+
function saveUiPrefs(path, prefs) {
|
|
3394
|
+
const merged = { ...readJson(path), ...prefs };
|
|
3395
|
+
writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
// src/server/local-settings.ts
|
|
3399
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
3400
|
+
import { resolve as resolve7 } from "path";
|
|
3401
|
+
var EDITOR_IDS = ["vscode", "zed", "phpstorm"];
|
|
3402
|
+
var isEditorId = (v) => EDITOR_IDS.includes(v);
|
|
3403
|
+
var DEFAULT_AI = {
|
|
3404
|
+
provider: "anthropic",
|
|
3405
|
+
model: "claude-haiku-4-5-20251001",
|
|
3406
|
+
endpoint: null,
|
|
3407
|
+
region: null,
|
|
3408
|
+
batchSize: 25
|
|
3409
|
+
};
|
|
3410
|
+
var DEFAULT_EDITOR = "vscode";
|
|
3411
|
+
var settingsPath = (projectRoot) => resolve7(projectRoot, ".glotfile", "settings.json");
|
|
3412
|
+
function readJson2(path) {
|
|
3413
|
+
try {
|
|
3414
|
+
const parsed = JSON.parse(readFileSync12(path, "utf8"));
|
|
3415
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3416
|
+
} catch {
|
|
3417
|
+
return {};
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
function coerceAi(raw) {
|
|
3421
|
+
const a = raw && typeof raw === "object" ? raw : {};
|
|
3422
|
+
return {
|
|
3423
|
+
provider: PROVIDERS.includes(a.provider) ? a.provider : DEFAULT_AI.provider,
|
|
3424
|
+
model: typeof a.model === "string" && a.model ? a.model : DEFAULT_AI.model,
|
|
3425
|
+
endpoint: typeof a.endpoint === "string" ? a.endpoint : null,
|
|
3426
|
+
region: typeof a.region === "string" ? a.region : null,
|
|
3427
|
+
batchSize: typeof a.batchSize === "number" && a.batchSize > 0 ? a.batchSize : DEFAULT_AI.batchSize
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
3430
|
+
function loadLocalSettings(projectRoot) {
|
|
3431
|
+
const raw = readJson2(settingsPath(projectRoot));
|
|
3432
|
+
return { ai: coerceAi(raw.ai), editor: isEditorId(raw.editor) ? raw.editor : DEFAULT_EDITOR };
|
|
3433
|
+
}
|
|
3434
|
+
function saveLocalSettings(projectRoot, patch) {
|
|
3435
|
+
const path = settingsPath(projectRoot);
|
|
3436
|
+
const merged = { ...readJson2(path) };
|
|
3437
|
+
if (patch.ai !== void 0) merged.ai = patch.ai;
|
|
3438
|
+
if (patch.editor !== void 0) merged.editor = patch.editor;
|
|
3439
|
+
ensureGlotfileDir(projectRoot);
|
|
3440
|
+
writeFileAtomic(path, JSON.stringify(merged, null, 2) + "\n");
|
|
3441
|
+
}
|
|
3442
|
+
function aiConfigError(ai) {
|
|
3443
|
+
if (!ai || typeof ai !== "object") return "ai must be an object";
|
|
3444
|
+
const a = ai;
|
|
3445
|
+
if (typeof a.provider !== "string" || !PROVIDERS.includes(a.provider)) {
|
|
3446
|
+
return `ai.provider must be one of: ${PROVIDERS.join(", ")}`;
|
|
3447
|
+
}
|
|
3448
|
+
if (typeof a.model !== "string") return "ai.model must be a string";
|
|
3449
|
+
if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
|
|
3450
|
+
if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
|
|
3451
|
+
if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
|
|
3452
|
+
return null;
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3212
3455
|
// src/server/api.ts
|
|
3213
3456
|
var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
|
|
3214
3457
|
var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
|
|
3215
3458
|
function projectName(root) {
|
|
3216
|
-
const nameFile =
|
|
3217
|
-
if (
|
|
3459
|
+
const nameFile = resolve8(root, ".idea", ".name");
|
|
3460
|
+
if (existsSync9(nameFile)) {
|
|
3218
3461
|
try {
|
|
3219
|
-
const name =
|
|
3462
|
+
const name = readFileSync13(nameFile, "utf8").trim();
|
|
3220
3463
|
if (name) return name;
|
|
3221
3464
|
} catch {
|
|
3222
3465
|
}
|
|
@@ -3226,7 +3469,7 @@ function projectName(root) {
|
|
|
3226
3469
|
function createApi(deps) {
|
|
3227
3470
|
const app = new Hono();
|
|
3228
3471
|
const load = () => loadState(deps.statePath);
|
|
3229
|
-
const projectRoot =
|
|
3472
|
+
const projectRoot = dirname2(resolve8(deps.statePath));
|
|
3230
3473
|
let translateQueue = Promise.resolve();
|
|
3231
3474
|
const withTranslateLock = (fn) => {
|
|
3232
3475
|
const next = translateQueue.then(fn, fn);
|
|
@@ -3250,7 +3493,36 @@ function createApi(deps) {
|
|
|
3250
3493
|
saveState(deps.statePath, s);
|
|
3251
3494
|
scheduleAutoExport(s);
|
|
3252
3495
|
};
|
|
3496
|
+
const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3497
|
+
const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
|
|
3498
|
+
const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
|
|
3253
3499
|
app.get("/state", (c) => c.json(load()));
|
|
3500
|
+
app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
|
|
3501
|
+
app.put("/ui-prefs", async (c) => {
|
|
3502
|
+
const { theme } = await c.req.json();
|
|
3503
|
+
if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
|
|
3504
|
+
saveUiPrefs(uiPrefsPath, { theme });
|
|
3505
|
+
return c.json({ ok: true });
|
|
3506
|
+
});
|
|
3507
|
+
app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
|
|
3508
|
+
app.put("/local-settings", async (c) => {
|
|
3509
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3510
|
+
const patch = {};
|
|
3511
|
+
if (body.ai !== void 0) {
|
|
3512
|
+
const err = aiConfigError(body.ai);
|
|
3513
|
+
if (err) return c.json({ error: err }, 400);
|
|
3514
|
+
patch.ai = body.ai;
|
|
3515
|
+
}
|
|
3516
|
+
if (body.editor !== void 0) {
|
|
3517
|
+
if (!isEditorId(body.editor)) return c.json({ error: "editor must be one of: vscode, zed, phpstorm" }, 400);
|
|
3518
|
+
patch.editor = body.editor;
|
|
3519
|
+
}
|
|
3520
|
+
if (patch.ai === void 0 && patch.editor === void 0) {
|
|
3521
|
+
return c.json({ error: "provide ai and/or editor" }, 400);
|
|
3522
|
+
}
|
|
3523
|
+
saveLocalSettings(projectRoot, patch);
|
|
3524
|
+
return c.json({ ok: true });
|
|
3525
|
+
});
|
|
3254
3526
|
app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
|
|
3255
3527
|
app.get("/files", (c) => {
|
|
3256
3528
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -3262,10 +3534,10 @@ function createApi(deps) {
|
|
|
3262
3534
|
}
|
|
3263
3535
|
for (const name of entries) {
|
|
3264
3536
|
let abs;
|
|
3265
|
-
if ((name === "glotfile" || name.endsWith(".glotfile")) &&
|
|
3266
|
-
abs =
|
|
3537
|
+
if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync9(resolve8(projectRoot, name, "config.json"))) {
|
|
3538
|
+
abs = resolve8(projectRoot, `${name}.json`);
|
|
3267
3539
|
} else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
|
|
3268
|
-
abs =
|
|
3540
|
+
abs = resolve8(projectRoot, name);
|
|
3269
3541
|
} else {
|
|
3270
3542
|
continue;
|
|
3271
3543
|
}
|
|
@@ -3282,10 +3554,10 @@ function createApi(deps) {
|
|
|
3282
3554
|
app.post("/file", async (c) => {
|
|
3283
3555
|
const { path } = await c.req.json();
|
|
3284
3556
|
if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
|
|
3285
|
-
const resolved =
|
|
3557
|
+
const resolved = resolve8(projectRoot, path);
|
|
3286
3558
|
const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
|
|
3287
3559
|
if (!inside) return c.json({ error: "file is outside the project" }, 400);
|
|
3288
|
-
if (!
|
|
3560
|
+
if (!existsSync9(resolved)) return c.json({ error: "file not found" }, 400);
|
|
3289
3561
|
loadState(resolved);
|
|
3290
3562
|
deps.statePath = resolved;
|
|
3291
3563
|
return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
|
|
@@ -3300,6 +3572,7 @@ function createApi(deps) {
|
|
|
3300
3572
|
const s = load();
|
|
3301
3573
|
createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
|
|
3302
3574
|
persist(s);
|
|
3575
|
+
logChange({ kind: "key", summary: `Created key ${key}`, key, after: value });
|
|
3303
3576
|
console.log(`[key] created ${key}`);
|
|
3304
3577
|
return c.json({ ok: true });
|
|
3305
3578
|
});
|
|
@@ -3309,37 +3582,45 @@ function createApi(deps) {
|
|
|
3309
3582
|
const s = load();
|
|
3310
3583
|
addCustomWord(s, word);
|
|
3311
3584
|
persist(s);
|
|
3585
|
+
logChange({ kind: "dictionary", summary: `Added "${word}" to dictionary`, after: word });
|
|
3312
3586
|
return c.json({ ok: true });
|
|
3313
3587
|
});
|
|
3314
3588
|
app.delete("/dictionary/:word", (c) => {
|
|
3315
3589
|
const s = load();
|
|
3316
|
-
|
|
3590
|
+
const word = c.req.param("word");
|
|
3591
|
+
removeCustomWord(s, word);
|
|
3317
3592
|
persist(s);
|
|
3593
|
+
logChange({ kind: "dictionary", summary: `Removed "${word}" from dictionary`, before: word });
|
|
3318
3594
|
return c.json({ ok: true });
|
|
3319
3595
|
});
|
|
3320
3596
|
app.patch("/keys/:key", async (c) => {
|
|
3321
3597
|
const key = c.req.param("key");
|
|
3322
3598
|
const body = await c.req.json();
|
|
3323
3599
|
const s = load();
|
|
3600
|
+
const beforeSource = typeof body.source === "string" ? valueText(s, key, s.config.sourceLocale) : void 0;
|
|
3324
3601
|
if (typeof body.rename === "string") renameKey(s, key, body.rename);
|
|
3325
3602
|
const target = typeof body.rename === "string" ? body.rename : key;
|
|
3326
3603
|
if (body.metadata) setMetadata(s, target, body.metadata);
|
|
3327
3604
|
if (typeof body.source === "string") setSourceValue(s, target, body.source);
|
|
3328
3605
|
if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
|
|
3329
3606
|
persist(s);
|
|
3607
|
+
if (typeof body.rename === "string") logChange({ kind: "key", summary: `Renamed ${key} \u2192 ${body.rename}`, key: target, before: key, after: body.rename });
|
|
3608
|
+
if (body.metadata) logChange({ kind: "metadata", summary: `Updated metadata of ${target}`, key: target, after: body.metadata });
|
|
3609
|
+
if (typeof body.source === "string") logChange({ kind: "translation", summary: `Set source value of ${target}`, key: target, locale: s.config.sourceLocale, before: beforeSource, after: body.source });
|
|
3610
|
+
if (typeof body.pluralArg === "string" && body.pluralArg.trim()) logChange({ kind: "key", summary: `Changed plural arg of ${target}`, key: target, after: body.pluralArg.trim() });
|
|
3330
3611
|
if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
|
|
3331
3612
|
return c.json({ ok: true });
|
|
3332
3613
|
});
|
|
3333
3614
|
function removeOrphanScreenshot(s, screenshot) {
|
|
3334
3615
|
if (!screenshot) return;
|
|
3335
3616
|
for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
|
|
3336
|
-
const root =
|
|
3337
|
-
const abs =
|
|
3617
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3618
|
+
const abs = resolve8(root, screenshot);
|
|
3338
3619
|
const rel = relative3(root, abs);
|
|
3339
3620
|
const seg0 = rel.split(sep)[0] ?? "";
|
|
3340
|
-
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") &&
|
|
3621
|
+
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync9(abs)) {
|
|
3341
3622
|
try {
|
|
3342
|
-
|
|
3623
|
+
rmSync4(abs);
|
|
3343
3624
|
} catch {
|
|
3344
3625
|
}
|
|
3345
3626
|
}
|
|
@@ -3348,9 +3629,11 @@ function createApi(deps) {
|
|
|
3348
3629
|
const s = load();
|
|
3349
3630
|
const key = c.req.param("key");
|
|
3350
3631
|
const shot = s.keys[key]?.screenshot;
|
|
3632
|
+
const before = valueText(s, key, s.config.sourceLocale);
|
|
3351
3633
|
deleteKey(s, key);
|
|
3352
3634
|
removeOrphanScreenshot(s, shot);
|
|
3353
3635
|
persist(s);
|
|
3636
|
+
logChange({ kind: "key", summary: `Deleted key ${key}`, key, before });
|
|
3354
3637
|
console.log(`[key] deleted ${key}`);
|
|
3355
3638
|
return c.json({ ok: true });
|
|
3356
3639
|
});
|
|
@@ -3374,6 +3657,7 @@ function createApi(deps) {
|
|
|
3374
3657
|
}
|
|
3375
3658
|
}
|
|
3376
3659
|
persist(s);
|
|
3660
|
+
if (cleared) logChange({ kind: "translation", summary: `Cleared ${cleared} value(s) across ${keys.length} key(s)`, after: { locales } });
|
|
3377
3661
|
console.log(`[bulk] cleared ${cleared} value(s)`);
|
|
3378
3662
|
return c.json({ cleared });
|
|
3379
3663
|
});
|
|
@@ -3391,6 +3675,7 @@ function createApi(deps) {
|
|
|
3391
3675
|
}
|
|
3392
3676
|
for (const shot of shots) removeOrphanScreenshot(s, shot);
|
|
3393
3677
|
persist(s);
|
|
3678
|
+
if (removed.length) logChange({ kind: "key", summary: `Deleted ${removed.length} key(s)`, before: removed });
|
|
3394
3679
|
console.log(`[bulk] deleted ${removed.length} key(s)`);
|
|
3395
3680
|
return c.json({ removed });
|
|
3396
3681
|
});
|
|
@@ -3416,6 +3701,7 @@ function createApi(deps) {
|
|
|
3416
3701
|
updated++;
|
|
3417
3702
|
}
|
|
3418
3703
|
persist(s);
|
|
3704
|
+
if (updated) logChange({ kind: "metadata", summary: `Updated metadata on ${updated} key(s)` });
|
|
3419
3705
|
console.log(`[bulk] updated metadata on ${updated} key(s)`);
|
|
3420
3706
|
return c.json({ updated });
|
|
3421
3707
|
});
|
|
@@ -3439,6 +3725,7 @@ function createApi(deps) {
|
|
|
3439
3725
|
}
|
|
3440
3726
|
}
|
|
3441
3727
|
persist(s);
|
|
3728
|
+
if (updated) logChange({ kind: "translation", summary: `Marked ${updated} value(s) as ${next}`, after: next });
|
|
3442
3729
|
console.log(`[bulk] set state ${next} on ${updated} value(s)`);
|
|
3443
3730
|
return c.json({ updated });
|
|
3444
3731
|
});
|
|
@@ -3448,15 +3735,21 @@ function createApi(deps) {
|
|
|
3448
3735
|
const s = load();
|
|
3449
3736
|
const key = c.req.param("key");
|
|
3450
3737
|
const locale = c.req.param("locale");
|
|
3738
|
+
const before = valueText(s, key, locale);
|
|
3451
3739
|
if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
|
|
3452
3740
|
else setTargetValue(s, key, locale, value);
|
|
3453
3741
|
persist(s);
|
|
3742
|
+
logChange({ kind: "translation", summary: `Set ${locale} value of ${key}`, key, locale, before, after: value });
|
|
3454
3743
|
return c.json({ ok: true });
|
|
3455
3744
|
});
|
|
3456
3745
|
app.delete("/keys/:key/values/:locale", (c) => {
|
|
3457
3746
|
const s = load();
|
|
3458
|
-
|
|
3747
|
+
const key = c.req.param("key");
|
|
3748
|
+
const locale = c.req.param("locale");
|
|
3749
|
+
const before = valueText(s, key, locale);
|
|
3750
|
+
clearValue(s, key, locale);
|
|
3459
3751
|
persist(s);
|
|
3752
|
+
logChange({ kind: "translation", summary: `Cleared ${locale} value of ${key}`, key, locale, before });
|
|
3460
3753
|
return c.json({ ok: true });
|
|
3461
3754
|
});
|
|
3462
3755
|
app.put("/keys/:key/plural/:locale", async (c) => {
|
|
@@ -3465,52 +3758,68 @@ function createApi(deps) {
|
|
|
3465
3758
|
const s = load();
|
|
3466
3759
|
const key = c.req.param("key");
|
|
3467
3760
|
const locale = c.req.param("locale");
|
|
3761
|
+
const before = s.keys[key]?.values[locale]?.forms;
|
|
3468
3762
|
if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
|
|
3469
3763
|
else setPluralForms(s, key, locale, forms);
|
|
3470
3764
|
persist(s);
|
|
3765
|
+
logChange({ kind: "translation", summary: `Set ${locale} plural forms of ${key}`, key, locale, before, after: forms });
|
|
3471
3766
|
return c.json({ ok: true });
|
|
3472
3767
|
});
|
|
3473
3768
|
app.post("/keys/:key/plural", async (c) => {
|
|
3474
3769
|
const { arg } = await c.req.json();
|
|
3475
3770
|
if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
|
|
3476
3771
|
const s = load();
|
|
3477
|
-
|
|
3772
|
+
const key = c.req.param("key");
|
|
3773
|
+
convertToPlural(s, key, arg);
|
|
3478
3774
|
persist(s);
|
|
3775
|
+
logChange({ kind: "key", summary: `Converted ${key} to plural`, key, after: arg });
|
|
3479
3776
|
return c.json({ ok: true });
|
|
3480
3777
|
});
|
|
3481
3778
|
app.delete("/keys/:key/plural", (c) => {
|
|
3482
3779
|
const s = load();
|
|
3483
|
-
|
|
3780
|
+
const key = c.req.param("key");
|
|
3781
|
+
convertToScalar(s, key);
|
|
3484
3782
|
persist(s);
|
|
3783
|
+
logChange({ kind: "key", summary: `Converted ${key} to scalar`, key });
|
|
3485
3784
|
return c.json({ ok: true });
|
|
3486
3785
|
});
|
|
3487
3786
|
app.put("/keys/:key/values/:locale/state", async (c) => {
|
|
3488
3787
|
const { state } = await c.req.json();
|
|
3489
3788
|
const s = load();
|
|
3490
|
-
|
|
3789
|
+
const key = c.req.param("key");
|
|
3790
|
+
const locale = c.req.param("locale");
|
|
3791
|
+
const before = s.keys[key]?.values[locale]?.state;
|
|
3792
|
+
setKeyState(s, key, locale, state);
|
|
3491
3793
|
persist(s);
|
|
3794
|
+
logChange({ kind: "translation", summary: `Marked ${key} ${locale} as ${state}`, key, locale, before, after: state });
|
|
3492
3795
|
return c.json({ ok: true });
|
|
3493
3796
|
});
|
|
3494
3797
|
app.post("/keys/:key/notes", async (c) => {
|
|
3495
3798
|
const { text } = await c.req.json();
|
|
3496
3799
|
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3497
3800
|
const s = load();
|
|
3498
|
-
const
|
|
3801
|
+
const key = c.req.param("key");
|
|
3802
|
+
const note = addNote(s, key, text);
|
|
3499
3803
|
persist(s);
|
|
3804
|
+
logChange({ kind: "note", summary: `Added note to ${key}`, key, after: text });
|
|
3500
3805
|
return c.json(note);
|
|
3501
3806
|
});
|
|
3502
3807
|
app.put("/keys/:key/notes/:id", async (c) => {
|
|
3503
3808
|
const { text } = await c.req.json();
|
|
3504
3809
|
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3505
3810
|
const s = load();
|
|
3506
|
-
|
|
3811
|
+
const key = c.req.param("key");
|
|
3812
|
+
editNote(s, key, c.req.param("id"), text);
|
|
3507
3813
|
persist(s);
|
|
3814
|
+
logChange({ kind: "note", summary: `Edited note on ${key}`, key, after: text });
|
|
3508
3815
|
return c.json({ ok: true });
|
|
3509
3816
|
});
|
|
3510
3817
|
app.delete("/keys/:key/notes/:id", (c) => {
|
|
3511
3818
|
const s = load();
|
|
3512
|
-
|
|
3819
|
+
const key = c.req.param("key");
|
|
3820
|
+
deleteNote(s, key, c.req.param("id"));
|
|
3513
3821
|
persist(s);
|
|
3822
|
+
logChange({ kind: "note", summary: `Deleted note on ${key}`, key });
|
|
3514
3823
|
return c.json({ ok: true });
|
|
3515
3824
|
});
|
|
3516
3825
|
app.put("/config", async (c) => {
|
|
@@ -3519,6 +3828,7 @@ function createApi(deps) {
|
|
|
3519
3828
|
return c.json({ error: "config.locales must be an array" }, 400);
|
|
3520
3829
|
}
|
|
3521
3830
|
const s = load();
|
|
3831
|
+
const beforeCfg = { locales: s.config.locales };
|
|
3522
3832
|
const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
|
|
3523
3833
|
for (const l of removed) {
|
|
3524
3834
|
for (const e of Object.values(s.keys)) delete e.values[l];
|
|
@@ -3526,7 +3836,8 @@ function createApi(deps) {
|
|
|
3526
3836
|
s.config = newConfig;
|
|
3527
3837
|
validate(s);
|
|
3528
3838
|
persist(s);
|
|
3529
|
-
|
|
3839
|
+
logChange({ kind: "config", summary: `Saved config (${newConfig.locales.length} locale(s))`, before: beforeCfg, after: { locales: newConfig.locales } });
|
|
3840
|
+
console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s)`);
|
|
3530
3841
|
return c.json({ ok: true });
|
|
3531
3842
|
});
|
|
3532
3843
|
app.get("/glossary", (c) => c.json(load().glossary));
|
|
@@ -3534,14 +3845,19 @@ function createApi(deps) {
|
|
|
3534
3845
|
const entry = await c.req.json();
|
|
3535
3846
|
if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
|
|
3536
3847
|
const s = load();
|
|
3848
|
+
const before = s.glossary.find((g) => g.term === entry.term);
|
|
3537
3849
|
upsertGlossaryEntry(s, entry);
|
|
3538
3850
|
persist(s);
|
|
3851
|
+
logChange({ kind: "glossary", summary: `${before ? "Updated" : "Added"} glossary term "${entry.term}"`, before, after: entry });
|
|
3539
3852
|
return c.json({ ok: true });
|
|
3540
3853
|
});
|
|
3541
3854
|
app.delete("/glossary/:term", (c) => {
|
|
3542
3855
|
const s = load();
|
|
3543
|
-
|
|
3856
|
+
const term = decodeURIComponent(c.req.param("term"));
|
|
3857
|
+
const before = s.glossary.find((g) => g.term === term);
|
|
3858
|
+
deleteGlossaryEntry(s, term);
|
|
3544
3859
|
persist(s);
|
|
3860
|
+
logChange({ kind: "glossary", summary: `Deleted glossary term "${term}"`, before });
|
|
3545
3861
|
return c.json({ ok: true });
|
|
3546
3862
|
});
|
|
3547
3863
|
app.post("/keys/:key/screenshot", async (c) => {
|
|
@@ -3549,18 +3865,18 @@ function createApi(deps) {
|
|
|
3549
3865
|
const body = await c.req.parseBody();
|
|
3550
3866
|
const file = body["file"];
|
|
3551
3867
|
if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
|
|
3552
|
-
const root =
|
|
3868
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3553
3869
|
const dirName = screenshotDirName(deps.statePath);
|
|
3554
|
-
const dir =
|
|
3555
|
-
mkdirSync6(dir, { recursive: true });
|
|
3870
|
+
const dir = resolve8(root, dirName);
|
|
3556
3871
|
const filename = `${sanitize(key)}__${sanitize(file.name)}`;
|
|
3557
|
-
|
|
3872
|
+
writeFileAtomic(resolve8(dir, filename), Buffer.from(await file.arrayBuffer()));
|
|
3558
3873
|
const path = `${dirName}/${filename}`;
|
|
3559
3874
|
const s = load();
|
|
3560
3875
|
const prev = s.keys[key]?.screenshot;
|
|
3561
3876
|
setMetadata(s, key, { screenshot: path });
|
|
3562
3877
|
if (prev && prev !== path) removeOrphanScreenshot(s, prev);
|
|
3563
3878
|
persist(s);
|
|
3879
|
+
logChange({ kind: "metadata", summary: `${prev ? "Replaced" : "Added"} screenshot on ${key}`, key, before: prev, after: path });
|
|
3564
3880
|
return c.json({ path });
|
|
3565
3881
|
});
|
|
3566
3882
|
app.delete("/keys/:key/screenshot", (c) => {
|
|
@@ -3570,6 +3886,7 @@ function createApi(deps) {
|
|
|
3570
3886
|
setMetadata(s, key, { screenshot: void 0 });
|
|
3571
3887
|
removeOrphanScreenshot(s, shot);
|
|
3572
3888
|
persist(s);
|
|
3889
|
+
logChange({ kind: "metadata", summary: `Removed screenshot from ${key}`, key, before: shot });
|
|
3573
3890
|
return c.json({ ok: true });
|
|
3574
3891
|
});
|
|
3575
3892
|
app.get("/export/preview", (c) => {
|
|
@@ -3614,12 +3931,13 @@ function createApi(deps) {
|
|
|
3614
3931
|
return c.json({ error: e.message }, 400);
|
|
3615
3932
|
}
|
|
3616
3933
|
persist(result.state);
|
|
3934
|
+
logChange({ kind: "import", summary: `Imported ${result.keyCount} key(s) across ${result.localeCount} locale(s)` });
|
|
3617
3935
|
console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
|
|
3618
3936
|
return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
|
|
3619
3937
|
});
|
|
3620
3938
|
app.post("/export", (c) => {
|
|
3621
3939
|
const s = narrowForExport(load());
|
|
3622
|
-
const root =
|
|
3940
|
+
const root = dirname2(resolve8(deps.statePath));
|
|
3623
3941
|
const warnings = [];
|
|
3624
3942
|
let count = 0;
|
|
3625
3943
|
for (const output of s.config.outputs) {
|
|
@@ -3627,9 +3945,8 @@ function createApi(deps) {
|
|
|
3627
3945
|
const result = adapter.export(s, output);
|
|
3628
3946
|
warnings.push(...result.warnings);
|
|
3629
3947
|
for (const f of result.files) {
|
|
3630
|
-
const abs =
|
|
3631
|
-
|
|
3632
|
-
writeFileSync5(abs, f.contents, "utf8");
|
|
3948
|
+
const abs = resolve8(root, f.path);
|
|
3949
|
+
writeFileAtomic(abs, f.contents);
|
|
3633
3950
|
count++;
|
|
3634
3951
|
}
|
|
3635
3952
|
}
|
|
@@ -3648,41 +3965,62 @@ function createApi(deps) {
|
|
|
3648
3965
|
await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
|
|
3649
3966
|
return;
|
|
3650
3967
|
}
|
|
3968
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3651
3969
|
let provider;
|
|
3652
3970
|
try {
|
|
3653
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
3971
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3654
3972
|
} catch (e) {
|
|
3655
3973
|
await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
|
|
3656
3974
|
return;
|
|
3657
3975
|
}
|
|
3658
3976
|
const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
|
|
3659
|
-
if (skipped) console.warn(`Model "${
|
|
3660
|
-
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}`);
|
|
3661
3979
|
let totalWritten = 0;
|
|
3662
3980
|
const allErrors = [];
|
|
3663
3981
|
const system = buildSystemPrompt();
|
|
3664
3982
|
const reqById = new Map(reqs.map((r) => [r.id, r]));
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3983
|
+
const localeTotals = /* @__PURE__ */ new Map();
|
|
3984
|
+
for (const r of reqs) localeTotals.set(r.targetLocale, (localeTotals.get(r.targetLocale) ?? 0) + 1);
|
|
3985
|
+
const localeDone = /* @__PURE__ */ new Map();
|
|
3986
|
+
await stream.writeSSE({
|
|
3987
|
+
event: "start",
|
|
3988
|
+
data: JSON.stringify({ total: reqs.length, locales: [...localeTotals].map(([locale, total]) => ({ locale, total })) })
|
|
3989
|
+
});
|
|
3990
|
+
await runLocaleParallel(reqs, provider, {
|
|
3991
|
+
// Announce a language the moment a worker picks it up — this is the
|
|
3992
|
+
// signal that "something is happening" during the long first LLM call.
|
|
3993
|
+
onLocaleStart: (locale) => {
|
|
3994
|
+
void stream.writeSSE({ event: "locale-start", data: JSON.stringify({ locale }) });
|
|
3995
|
+
},
|
|
3996
|
+
onBatchComplete: (done, total, batchResults, locale) => {
|
|
3997
|
+
const { written, errors } = applyResults(s, reqs, batchResults);
|
|
3998
|
+
persist(s);
|
|
3999
|
+
totalWritten += written;
|
|
4000
|
+
allErrors.push(...errors);
|
|
4001
|
+
appendLog(projectRoot, {
|
|
4002
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4003
|
+
kind: "translate",
|
|
4004
|
+
summary: `Translated ${batchResults.length} item(s)`,
|
|
4005
|
+
model: aiCfg.model,
|
|
4006
|
+
system,
|
|
4007
|
+
items: batchResults.map((r) => {
|
|
4008
|
+
const req = reqById.get(r.id);
|
|
4009
|
+
return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
|
|
4010
|
+
}),
|
|
4011
|
+
results: batchResults
|
|
4012
|
+
});
|
|
4013
|
+
const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
|
|
4014
|
+
localeDone.set(locale, ld);
|
|
4015
|
+
console.log(`[translate] ${done}/${total}`);
|
|
4016
|
+
void stream.writeSSE({
|
|
4017
|
+
event: "progress",
|
|
4018
|
+
data: JSON.stringify({ done, total, written: totalWritten, errors, locale, localeDone: ld, localeTotal: localeTotals.get(locale) ?? 0 })
|
|
4019
|
+
});
|
|
4020
|
+
},
|
|
4021
|
+
onLocaleDone: (locale) => {
|
|
4022
|
+
void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
|
|
4023
|
+
}
|
|
3686
4024
|
}, void 0, signal);
|
|
3687
4025
|
if (!signal?.aborted) {
|
|
3688
4026
|
console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
|
|
@@ -3705,19 +4043,22 @@ function createApi(deps) {
|
|
|
3705
4043
|
let written = 0;
|
|
3706
4044
|
let errors = [];
|
|
3707
4045
|
if (toTranslate.length) {
|
|
4046
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3708
4047
|
let provider;
|
|
3709
4048
|
try {
|
|
3710
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
4049
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3711
4050
|
} catch (e) {
|
|
3712
4051
|
return c.json({ error: e.message }, 400);
|
|
3713
4052
|
}
|
|
3714
4053
|
const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
|
|
3715
|
-
if (skipped) console.warn(`Model "${
|
|
4054
|
+
if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
3716
4055
|
const results = await runLocaleParallel(toTranslate, provider);
|
|
3717
4056
|
({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
|
|
3718
4057
|
const entry = {
|
|
3719
4058
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3720
|
-
|
|
4059
|
+
kind: "translate",
|
|
4060
|
+
summary: `Translated ${toTranslate.length} item(s)`,
|
|
4061
|
+
model: aiCfg.model,
|
|
3721
4062
|
system: buildSystemPrompt(),
|
|
3722
4063
|
// Log the screenshot PATH only — never the image bytes.
|
|
3723
4064
|
items: toTranslate.map((r) => ({
|
|
@@ -3731,12 +4072,12 @@ function createApi(deps) {
|
|
|
3731
4072
|
})),
|
|
3732
4073
|
results
|
|
3733
4074
|
};
|
|
3734
|
-
|
|
4075
|
+
appendLog(projectRoot, entry);
|
|
3735
4076
|
}
|
|
3736
4077
|
persist(s);
|
|
3737
4078
|
return c.json({ requested: reqs.length, written, errors });
|
|
3738
4079
|
}));
|
|
3739
|
-
app.get("/
|
|
4080
|
+
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
3740
4081
|
app.post("/scan", async (c) => {
|
|
3741
4082
|
const s = load();
|
|
3742
4083
|
const existing = loadUsageCache(projectRoot);
|
|
@@ -3760,7 +4101,7 @@ function createApi(deps) {
|
|
|
3760
4101
|
const refs = [];
|
|
3761
4102
|
const prefixRefs = [];
|
|
3762
4103
|
for (const [file, entry] of Object.entries(cache2.files)) {
|
|
3763
|
-
const abs =
|
|
4104
|
+
const abs = resolve8(projectRoot, file);
|
|
3764
4105
|
for (const r of entry.refs) {
|
|
3765
4106
|
if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
|
|
3766
4107
|
}
|
|
@@ -3801,9 +4142,10 @@ function createApi(deps) {
|
|
|
3801
4142
|
keys: body.keys
|
|
3802
4143
|
}, cache2, body.lastRunAt);
|
|
3803
4144
|
if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
|
|
4145
|
+
const aiCfg = loadLocalSettings(projectRoot).ai;
|
|
3804
4146
|
let provider;
|
|
3805
4147
|
try {
|
|
3806
|
-
provider = deps.makeProvider ? deps.makeProvider(
|
|
4148
|
+
provider = deps.makeProvider ? deps.makeProvider() : makeProvider(aiCfg);
|
|
3807
4149
|
} catch (e) {
|
|
3808
4150
|
return c.json({ error: e.message }, 400);
|
|
3809
4151
|
}
|
|
@@ -3825,10 +4167,11 @@ function createApi(deps) {
|
|
|
3825
4167
|
const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
|
|
3826
4168
|
const batch = raw;
|
|
3827
4169
|
const { written, errors } = applyContext(s, targets, batch.items ?? []);
|
|
3828
|
-
|
|
4170
|
+
appendLog(projectRoot, {
|
|
3829
4171
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3830
4172
|
kind: "context",
|
|
3831
|
-
|
|
4173
|
+
summary: `Generated context for ${targets.length} key(s)`,
|
|
4174
|
+
model: aiCfg.model,
|
|
3832
4175
|
system,
|
|
3833
4176
|
items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
|
|
3834
4177
|
results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
|
|
@@ -3844,8 +4187,8 @@ function createApi(deps) {
|
|
|
3844
4187
|
}
|
|
3845
4188
|
|
|
3846
4189
|
// src/server/server.ts
|
|
3847
|
-
var here =
|
|
3848
|
-
var DEFAULT_UI_DIR =
|
|
4190
|
+
var here = dirname3(fileURLToPath(import.meta.url));
|
|
4191
|
+
var DEFAULT_UI_DIR = join9(here, "..", "ui");
|
|
3849
4192
|
var MIME = {
|
|
3850
4193
|
".html": "text/html; charset=utf-8",
|
|
3851
4194
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3874,14 +4217,14 @@ async function readFileResponse(absPath) {
|
|
|
3874
4217
|
function buildApp(opts) {
|
|
3875
4218
|
const app = new Hono2();
|
|
3876
4219
|
app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
|
|
3877
|
-
const projectRoot =
|
|
4220
|
+
const projectRoot = dirname3(resolve9(opts.statePath));
|
|
3878
4221
|
app.get("/:dir/*", async (c, next) => {
|
|
3879
4222
|
const dirSeg = c.req.param("dir");
|
|
3880
4223
|
if (!dirSeg.endsWith("-screenshots")) return next();
|
|
3881
|
-
const shotsRoot =
|
|
4224
|
+
const shotsRoot = resolve9(projectRoot, dirSeg);
|
|
3882
4225
|
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3883
4226
|
const rest = pathname.slice(`/${dirSeg}`.length);
|
|
3884
|
-
const target =
|
|
4227
|
+
const target = resolve9(shotsRoot, "." + rest);
|
|
3885
4228
|
const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
|
|
3886
4229
|
if (inside) {
|
|
3887
4230
|
const file = await readFileResponse(target);
|
|
@@ -3890,24 +4233,35 @@ function buildApp(opts) {
|
|
|
3890
4233
|
return c.notFound();
|
|
3891
4234
|
});
|
|
3892
4235
|
if (!opts.dev) {
|
|
3893
|
-
const root =
|
|
4236
|
+
const root = resolve9(opts.uiDir ?? DEFAULT_UI_DIR);
|
|
3894
4237
|
app.get("/*", async (c) => {
|
|
3895
4238
|
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3896
|
-
const target =
|
|
4239
|
+
const target = resolve9(root, "." + pathname);
|
|
3897
4240
|
const inside = target === root || target.startsWith(root + sep2);
|
|
3898
4241
|
if (inside && pathname !== "/") {
|
|
3899
4242
|
const file = await readFileResponse(target);
|
|
3900
4243
|
if (file) return file;
|
|
3901
4244
|
}
|
|
3902
|
-
const index = await readFileResponse(
|
|
4245
|
+
const index = await readFileResponse(join9(root, "index.html"));
|
|
3903
4246
|
if (index) return index;
|
|
3904
4247
|
return c.notFound();
|
|
3905
4248
|
});
|
|
4249
|
+
} else {
|
|
4250
|
+
app.get("/", (c) => c.html(DEV_LANDING_PAGE));
|
|
3906
4251
|
}
|
|
3907
4252
|
return app;
|
|
3908
4253
|
}
|
|
3909
4254
|
var DEFAULT_PORT = 3e3;
|
|
3910
4255
|
var DEV_PORT = 8787;
|
|
4256
|
+
var DEV_UI_URL = "http://localhost:5173";
|
|
4257
|
+
var DEV_LANDING_PAGE = `<!doctype html>
|
|
4258
|
+
<html lang="en"><head><meta charset="utf-8"><title>Glotfile \u2014 dev API</title>
|
|
4259
|
+
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:34rem;margin:16vh auto;padding:0 1.5rem;color:#1f2937}h1{font-size:1.4rem}a{color:#2563eb}code{background:#f3f4f6;padding:.1em .35em;border-radius:.3em}</style>
|
|
4260
|
+
</head><body>
|
|
4261
|
+
<h1>Glotfile \u2014 dev API server</h1>
|
|
4262
|
+
<p>This port serves the <strong>API only</strong>. In dev, the app is served by Vite.</p>
|
|
4263
|
+
<p>Open the app \u2192 <a href="${DEV_UI_URL}">${DEV_UI_URL}</a> (the <code>[ui] Local:</code> URL in your terminal).</p>
|
|
4264
|
+
</body></html>`;
|
|
3911
4265
|
function findAvailablePort(start) {
|
|
3912
4266
|
return new Promise((resolveP, reject) => {
|
|
3913
4267
|
const probe = createServer();
|
|
@@ -3936,7 +4290,7 @@ async function startServer(opts) {
|
|
|
3936
4290
|
});
|
|
3937
4291
|
}
|
|
3938
4292
|
function backgroundScan(statePath) {
|
|
3939
|
-
const projectRoot =
|
|
4293
|
+
const projectRoot = dirname3(resolve9(statePath));
|
|
3940
4294
|
Promise.resolve().then(() => {
|
|
3941
4295
|
const state = loadState(statePath);
|
|
3942
4296
|
const existing = loadUsageCache(projectRoot);
|