glotfile 0.4.3 → 0.4.4
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/dist/server/cli.js
CHANGED
|
@@ -901,6 +901,10 @@ function toI18next(value) {
|
|
|
901
901
|
if (isIcuPluralOrSelect(value)) return value;
|
|
902
902
|
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
903
903
|
}
|
|
904
|
+
function toRuby(value) {
|
|
905
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
906
|
+
return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
|
|
907
|
+
}
|
|
904
908
|
function placeholdersMatch(source, translation) {
|
|
905
909
|
const a = extractPlaceholders(source).sort();
|
|
906
910
|
const b = extractPlaceholders(translation).sort();
|
|
@@ -1565,6 +1569,95 @@ var init_angular_xliff = __esm({
|
|
|
1565
1569
|
}
|
|
1566
1570
|
});
|
|
1567
1571
|
|
|
1572
|
+
// src/server/adapters/rails-yaml.ts
|
|
1573
|
+
function yamlString(s) {
|
|
1574
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
|
|
1575
|
+
}
|
|
1576
|
+
function yamlKey(k) {
|
|
1577
|
+
if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
|
|
1578
|
+
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
|
|
1579
|
+
}
|
|
1580
|
+
function yamlMap(node, indent, level) {
|
|
1581
|
+
const pad = " ".repeat(indent * level);
|
|
1582
|
+
const lines = [];
|
|
1583
|
+
for (const key of Object.keys(node).sort()) {
|
|
1584
|
+
const v = node[key];
|
|
1585
|
+
if (v && typeof v === "object") {
|
|
1586
|
+
lines.push(`${pad}${yamlKey(key)}:`);
|
|
1587
|
+
lines.push(...yamlMap(v, indent, level + 1));
|
|
1588
|
+
} else {
|
|
1589
|
+
lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return lines;
|
|
1593
|
+
}
|
|
1594
|
+
var RESERVED_KEYS, DEFAULT_LOCALE_CASE8, railsYaml;
|
|
1595
|
+
var init_rails_yaml = __esm({
|
|
1596
|
+
"src/server/adapters/rails-yaml.ts"() {
|
|
1597
|
+
"use strict";
|
|
1598
|
+
init_adapters();
|
|
1599
|
+
init_shared();
|
|
1600
|
+
init_options();
|
|
1601
|
+
init_placeholders();
|
|
1602
|
+
init_schema();
|
|
1603
|
+
RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
|
|
1604
|
+
DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
|
|
1605
|
+
railsYaml = {
|
|
1606
|
+
name: "rails-yaml",
|
|
1607
|
+
capabilities: {
|
|
1608
|
+
plural: "native",
|
|
1609
|
+
select: "lossy",
|
|
1610
|
+
nesting: "nested",
|
|
1611
|
+
metadata: false,
|
|
1612
|
+
placeholderStyle: "named",
|
|
1613
|
+
fileGrouping: "per-locale"
|
|
1614
|
+
},
|
|
1615
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE8,
|
|
1616
|
+
export(state, output) {
|
|
1617
|
+
const warnings = [];
|
|
1618
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
|
|
1619
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
1620
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
1621
|
+
const files = [];
|
|
1622
|
+
for (const locale of state.config.locales) {
|
|
1623
|
+
const flat = {};
|
|
1624
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
1625
|
+
if (entry.plural) {
|
|
1626
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1627
|
+
if (!forms) continue;
|
|
1628
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
1629
|
+
const body2 = forms[cat];
|
|
1630
|
+
if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
|
|
1631
|
+
}
|
|
1632
|
+
} else {
|
|
1633
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1634
|
+
if (raw === null) continue;
|
|
1635
|
+
if (raw && isIcuPluralOrSelect(raw)) {
|
|
1636
|
+
warnings.push({
|
|
1637
|
+
code: "lossy-plural",
|
|
1638
|
+
key,
|
|
1639
|
+
locale,
|
|
1640
|
+
message: "rails-yaml cannot represent ICU plural/select; written unconverted"
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
flat[key] = toRuby(raw);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
const { tree: nested, collisions } = nestKeys(flat);
|
|
1647
|
+
for (const c of collisions) {
|
|
1648
|
+
warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
|
|
1649
|
+
}
|
|
1650
|
+
const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
|
|
1651
|
+
const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
|
|
1652
|
+
files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
|
|
1653
|
+
}
|
|
1654
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1655
|
+
return { files, warnings };
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1568
1661
|
// src/server/adapters/index.ts
|
|
1569
1662
|
function resolvePath(template, locale, namespace = "") {
|
|
1570
1663
|
return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
|
|
@@ -1597,7 +1690,8 @@ function getRegistry() {
|
|
|
1597
1690
|
[gettextPo.name]: gettextPo,
|
|
1598
1691
|
[appleStringsdict.name]: appleStringsdict,
|
|
1599
1692
|
[vueI18nJson.name]: vueI18nJson,
|
|
1600
|
-
[angularXliff.name]: angularXliff
|
|
1693
|
+
[angularXliff.name]: angularXliff,
|
|
1694
|
+
[railsYaml.name]: railsYaml
|
|
1601
1695
|
};
|
|
1602
1696
|
}
|
|
1603
1697
|
function getAdapter(name) {
|
|
@@ -1617,6 +1711,7 @@ var init_adapters = __esm({
|
|
|
1617
1711
|
init_apple_stringsdict();
|
|
1618
1712
|
init_vue_i18n_json();
|
|
1619
1713
|
init_angular_xliff();
|
|
1714
|
+
init_rails_yaml();
|
|
1620
1715
|
}
|
|
1621
1716
|
});
|
|
1622
1717
|
|
|
@@ -2457,7 +2552,9 @@ function coerceAi(raw) {
|
|
|
2457
2552
|
contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
|
|
2458
2553
|
contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
|
|
2459
2554
|
vision: typeof a.vision === "boolean" ? a.vision : void 0,
|
|
2460
|
-
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
|
|
2555
|
+
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
|
|
2556
|
+
inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
|
|
2557
|
+
outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
|
|
2461
2558
|
};
|
|
2462
2559
|
}
|
|
2463
2560
|
function coerceProfiles(raw) {
|
|
@@ -2496,6 +2593,10 @@ function aiConfigError(ai) {
|
|
|
2496
2593
|
if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
|
|
2497
2594
|
if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
|
|
2498
2595
|
if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
|
|
2596
|
+
for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
|
|
2597
|
+
const v = a[f];
|
|
2598
|
+
if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
|
|
2599
|
+
}
|
|
2499
2600
|
return null;
|
|
2500
2601
|
}
|
|
2501
2602
|
var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
|
|
@@ -2711,6 +2812,118 @@ var init_run = __esm({
|
|
|
2711
2812
|
}
|
|
2712
2813
|
});
|
|
2713
2814
|
|
|
2815
|
+
// src/server/ai/pricing.ts
|
|
2816
|
+
function bareModelId(model) {
|
|
2817
|
+
let id = model.trim().toLowerCase();
|
|
2818
|
+
const slash = id.lastIndexOf("/");
|
|
2819
|
+
if (slash !== -1) id = id.slice(slash + 1);
|
|
2820
|
+
const anth = id.lastIndexOf("anthropic.");
|
|
2821
|
+
if (anth !== -1) id = id.slice(anth + "anthropic.".length);
|
|
2822
|
+
return id;
|
|
2823
|
+
}
|
|
2824
|
+
function resolvePricing(ai) {
|
|
2825
|
+
if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
|
|
2826
|
+
return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
|
|
2827
|
+
}
|
|
2828
|
+
if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
|
|
2829
|
+
const id = bareModelId(ai.model);
|
|
2830
|
+
let best;
|
|
2831
|
+
for (const row of PRICE_TABLE) {
|
|
2832
|
+
if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
|
|
2833
|
+
}
|
|
2834
|
+
return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
|
|
2835
|
+
}
|
|
2836
|
+
var PRICE_TABLE, FREE_PROVIDERS;
|
|
2837
|
+
var init_pricing = __esm({
|
|
2838
|
+
"src/server/ai/pricing.ts"() {
|
|
2839
|
+
"use strict";
|
|
2840
|
+
PRICE_TABLE = [
|
|
2841
|
+
["claude-fable-5", 10, 50],
|
|
2842
|
+
["claude-mythos-5", 10, 50],
|
|
2843
|
+
// Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
|
|
2844
|
+
// the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
|
|
2845
|
+
["claude-opus-4-1", 15, 75],
|
|
2846
|
+
["claude-opus-4-0", 15, 75],
|
|
2847
|
+
["claude-opus-4-2025", 15, 75],
|
|
2848
|
+
["claude-opus-4", 5, 25],
|
|
2849
|
+
["claude-sonnet-4", 3, 15],
|
|
2850
|
+
["claude-haiku-4", 1, 5],
|
|
2851
|
+
["claude-3-5-haiku", 0.8, 4],
|
|
2852
|
+
["gpt-5.5-pro", 30, 180],
|
|
2853
|
+
["gpt-5.5", 5, 30],
|
|
2854
|
+
["gpt-5.4-pro", 30, 180],
|
|
2855
|
+
["gpt-5.4-mini", 0.75, 4.5],
|
|
2856
|
+
["gpt-5.4-nano", 0.2, 1.25],
|
|
2857
|
+
["gpt-5.4", 2.5, 15],
|
|
2858
|
+
["gpt-5.3-codex", 1.75, 14]
|
|
2859
|
+
];
|
|
2860
|
+
FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2864
|
+
// src/server/ai/estimate.ts
|
|
2865
|
+
function estimateTokens(text) {
|
|
2866
|
+
const cjk = text.match(CJK_RE)?.length ?? 0;
|
|
2867
|
+
return Math.ceil((text.length - cjk) / 4 + cjk / 2);
|
|
2868
|
+
}
|
|
2869
|
+
function estimateOutputTokens(req) {
|
|
2870
|
+
const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
|
|
2871
|
+
if (req.plural) {
|
|
2872
|
+
return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
|
|
2873
|
+
}
|
|
2874
|
+
return ITEM_REPLY_OVERHEAD + translated;
|
|
2875
|
+
}
|
|
2876
|
+
function estimateTranslation(state, ai, opts) {
|
|
2877
|
+
const reqs = selectRequests(state, opts);
|
|
2878
|
+
const byLocale = /* @__PURE__ */ new Map();
|
|
2879
|
+
for (const r of reqs) {
|
|
2880
|
+
let group = byLocale.get(r.targetLocale);
|
|
2881
|
+
if (!group) {
|
|
2882
|
+
group = [];
|
|
2883
|
+
byLocale.set(r.targetLocale, group);
|
|
2884
|
+
}
|
|
2885
|
+
group.push(r);
|
|
2886
|
+
}
|
|
2887
|
+
const perLocale = [];
|
|
2888
|
+
for (const [locale, group] of byLocale) {
|
|
2889
|
+
let inputTokens2 = 0;
|
|
2890
|
+
let outputTokens2 = 0;
|
|
2891
|
+
const batches = chunk(group, Math.max(1, ai.batchSize));
|
|
2892
|
+
for (const batch of batches) {
|
|
2893
|
+
const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
|
|
2894
|
+
inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
|
|
2895
|
+
for (const r of batch) outputTokens2 += estimateOutputTokens(r);
|
|
2896
|
+
}
|
|
2897
|
+
perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
|
|
2898
|
+
}
|
|
2899
|
+
const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
|
|
2900
|
+
const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
|
|
2901
|
+
const pricing = resolvePricing(ai);
|
|
2902
|
+
return {
|
|
2903
|
+
requests: reqs.length,
|
|
2904
|
+
batches: perLocale.reduce((n, l) => n + l.batches, 0),
|
|
2905
|
+
perLocale,
|
|
2906
|
+
inputTokens,
|
|
2907
|
+
outputTokens,
|
|
2908
|
+
pricing,
|
|
2909
|
+
estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
|
|
2913
|
+
var init_estimate = __esm({
|
|
2914
|
+
"src/server/ai/estimate.ts"() {
|
|
2915
|
+
"use strict";
|
|
2916
|
+
init_run();
|
|
2917
|
+
init_provider();
|
|
2918
|
+
init_batch();
|
|
2919
|
+
init_pricing();
|
|
2920
|
+
CJK_RE = /[ -鿿가-豈-]/g;
|
|
2921
|
+
EXPANSION = 1.2;
|
|
2922
|
+
ITEM_REPLY_OVERHEAD = 16;
|
|
2923
|
+
FORM_REPLY_OVERHEAD = 8;
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2714
2927
|
// src/server/log.ts
|
|
2715
2928
|
import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
|
|
2716
2929
|
import { resolve as resolve5 } from "path";
|
|
@@ -5111,6 +5324,13 @@ function createApi(deps) {
|
|
|
5111
5324
|
}
|
|
5112
5325
|
return c.json({ requested: reqs.length, written, errors });
|
|
5113
5326
|
}));
|
|
5327
|
+
app.post("/translate/estimate", async (c) => {
|
|
5328
|
+
const body = await c.req.json().catch(() => ({}));
|
|
5329
|
+
const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
|
|
5330
|
+
const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
|
|
5331
|
+
const ai = loadLocalSettings(projectRoot).ai;
|
|
5332
|
+
return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
|
|
5333
|
+
});
|
|
5114
5334
|
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
5115
5335
|
app.post("/scan", async (c) => {
|
|
5116
5336
|
const s = load();
|
|
@@ -5277,6 +5497,7 @@ var init_api = __esm({
|
|
|
5277
5497
|
init_ai();
|
|
5278
5498
|
init_run();
|
|
5279
5499
|
init_provider();
|
|
5500
|
+
init_estimate();
|
|
5280
5501
|
init_log();
|
|
5281
5502
|
init_schema();
|
|
5282
5503
|
init_run3();
|
|
@@ -5436,6 +5657,7 @@ init_ai();
|
|
|
5436
5657
|
init_local_settings();
|
|
5437
5658
|
init_run();
|
|
5438
5659
|
init_provider();
|
|
5660
|
+
init_estimate();
|
|
5439
5661
|
init_log();
|
|
5440
5662
|
init_scan();
|
|
5441
5663
|
init_scanner();
|
|
@@ -5573,6 +5795,7 @@ function parseArgs(argv) {
|
|
|
5573
5795
|
} else if (flag === "--empty-source") args.emptySource = true;
|
|
5574
5796
|
else if (flag === "--unused") args.unused = true;
|
|
5575
5797
|
else if (flag === "--write") args.write = true;
|
|
5798
|
+
else if (flag === "--estimate") args.estimate = true;
|
|
5576
5799
|
}
|
|
5577
5800
|
return args;
|
|
5578
5801
|
}
|
|
@@ -5626,6 +5849,31 @@ async function runExport(args) {
|
|
|
5626
5849
|
async function runTranslate(args) {
|
|
5627
5850
|
const state = loadState(args.statePath);
|
|
5628
5851
|
const projectRoot = dirname5(resolve11(args.statePath));
|
|
5852
|
+
if (args.estimate) {
|
|
5853
|
+
const ai = loadLocalSettings(projectRoot).ai;
|
|
5854
|
+
const est = estimateTranslation(state, ai, {
|
|
5855
|
+
onlyMissing: args.all ? false : args.onlyMissing ?? true,
|
|
5856
|
+
locales: args.locales,
|
|
5857
|
+
keyGlob: args.keyGlob
|
|
5858
|
+
});
|
|
5859
|
+
if (!est.requests) {
|
|
5860
|
+
console.log("Nothing to translate.");
|
|
5861
|
+
return;
|
|
5862
|
+
}
|
|
5863
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
5864
|
+
console.log(`Estimate for ${fmt(est.requests)} request(s) in ${fmt(est.batches)} batch(es) \u2014 ${ai.provider} \xB7 ${ai.model}`);
|
|
5865
|
+
for (const l of est.perLocale) {
|
|
5866
|
+
console.log(` ${l.locale.padEnd(8)} ${fmt(l.requests).padStart(7)} req ${fmt(l.batches).padStart(5)} batch(es) ~${fmt(l.inputTokens)} in / ~${fmt(l.outputTokens)} out tokens`);
|
|
5867
|
+
}
|
|
5868
|
+
console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
|
|
5869
|
+
if (est.pricing) {
|
|
5870
|
+
const cost = est.estimatedCost;
|
|
5871
|
+
console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
|
|
5872
|
+
} else {
|
|
5873
|
+
console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
|
|
5874
|
+
}
|
|
5875
|
+
return;
|
|
5876
|
+
}
|
|
5629
5877
|
const reqs = selectRequests(state, {
|
|
5630
5878
|
// Default to translating only empty values; --all forces a full re-translate
|
|
5631
5879
|
// (overwriting existing translations). --only missing stays as a no-op alias.
|
|
@@ -5896,9 +6144,10 @@ var COMMAND_HELP = {
|
|
|
5896
6144
|
},
|
|
5897
6145
|
translate: {
|
|
5898
6146
|
summary: "AI-translate missing strings into your target locales (writes back to the state file).",
|
|
5899
|
-
usage: "glotfile translate [--all] [--locale <list>] [--key <glob>]",
|
|
6147
|
+
usage: "glotfile translate [--all] [--estimate] [--locale <list>] [--key <glob>]",
|
|
5900
6148
|
options: [
|
|
5901
6149
|
["--all", "Re-translate every string, not just empty values"],
|
|
6150
|
+
["--estimate", "Print batches, tokens and estimated cost without translating"],
|
|
5902
6151
|
["--locale <list>", "Comma-separated target locales (alias: --locales)"],
|
|
5903
6152
|
["--key <glob>", "Only keys matching this glob"]
|
|
5904
6153
|
]
|
package/dist/server/server.js
CHANGED
|
@@ -1397,6 +1397,10 @@ function toI18next(value) {
|
|
|
1397
1397
|
if (isIcuPluralOrSelect(value)) return value;
|
|
1398
1398
|
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
1399
1399
|
}
|
|
1400
|
+
function toRuby(value) {
|
|
1401
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1402
|
+
return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
|
|
1403
|
+
}
|
|
1400
1404
|
function placeholdersMatch(source, translation) {
|
|
1401
1405
|
const a = extractPlaceholders(source).sort();
|
|
1402
1406
|
const b = extractPlaceholders(translation).sort();
|
|
@@ -2706,6 +2710,84 @@ var angularXliff = {
|
|
|
2706
2710
|
}
|
|
2707
2711
|
};
|
|
2708
2712
|
|
|
2713
|
+
// src/server/adapters/rails-yaml.ts
|
|
2714
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
|
|
2715
|
+
function yamlString(s) {
|
|
2716
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
|
|
2717
|
+
}
|
|
2718
|
+
function yamlKey(k) {
|
|
2719
|
+
if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
|
|
2720
|
+
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
|
|
2721
|
+
}
|
|
2722
|
+
function yamlMap(node, indent, level) {
|
|
2723
|
+
const pad = " ".repeat(indent * level);
|
|
2724
|
+
const lines = [];
|
|
2725
|
+
for (const key of Object.keys(node).sort()) {
|
|
2726
|
+
const v = node[key];
|
|
2727
|
+
if (v && typeof v === "object") {
|
|
2728
|
+
lines.push(`${pad}${yamlKey(key)}:`);
|
|
2729
|
+
lines.push(...yamlMap(v, indent, level + 1));
|
|
2730
|
+
} else {
|
|
2731
|
+
lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return lines;
|
|
2735
|
+
}
|
|
2736
|
+
var DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
|
|
2737
|
+
var railsYaml = {
|
|
2738
|
+
name: "rails-yaml",
|
|
2739
|
+
capabilities: {
|
|
2740
|
+
plural: "native",
|
|
2741
|
+
select: "lossy",
|
|
2742
|
+
nesting: "nested",
|
|
2743
|
+
metadata: false,
|
|
2744
|
+
placeholderStyle: "named",
|
|
2745
|
+
fileGrouping: "per-locale"
|
|
2746
|
+
},
|
|
2747
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE8,
|
|
2748
|
+
export(state, output) {
|
|
2749
|
+
const warnings = [];
|
|
2750
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
|
|
2751
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
2752
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
2753
|
+
const files = [];
|
|
2754
|
+
for (const locale of state.config.locales) {
|
|
2755
|
+
const flat = {};
|
|
2756
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
2757
|
+
if (entry.plural) {
|
|
2758
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2759
|
+
if (!forms) continue;
|
|
2760
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
2761
|
+
const body2 = forms[cat];
|
|
2762
|
+
if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
|
|
2763
|
+
}
|
|
2764
|
+
} else {
|
|
2765
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2766
|
+
if (raw === null) continue;
|
|
2767
|
+
if (raw && isIcuPluralOrSelect(raw)) {
|
|
2768
|
+
warnings.push({
|
|
2769
|
+
code: "lossy-plural",
|
|
2770
|
+
key,
|
|
2771
|
+
locale,
|
|
2772
|
+
message: "rails-yaml cannot represent ICU plural/select; written unconverted"
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
flat[key] = toRuby(raw);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
const { tree: nested, collisions } = nestKeys(flat);
|
|
2779
|
+
for (const c of collisions) {
|
|
2780
|
+
warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
|
|
2781
|
+
}
|
|
2782
|
+
const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
|
|
2783
|
+
const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
|
|
2784
|
+
files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
|
|
2785
|
+
}
|
|
2786
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
2787
|
+
return { files, warnings };
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
|
|
2709
2791
|
// src/server/adapters/index.ts
|
|
2710
2792
|
function resolvePath(template, locale, namespace = "") {
|
|
2711
2793
|
return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
|
|
@@ -2739,7 +2821,8 @@ function getRegistry() {
|
|
|
2739
2821
|
[gettextPo.name]: gettextPo,
|
|
2740
2822
|
[appleStringsdict.name]: appleStringsdict,
|
|
2741
2823
|
[vueI18nJson.name]: vueI18nJson,
|
|
2742
|
-
[angularXliff.name]: angularXliff
|
|
2824
|
+
[angularXliff.name]: angularXliff,
|
|
2825
|
+
[railsYaml.name]: railsYaml
|
|
2743
2826
|
};
|
|
2744
2827
|
}
|
|
2745
2828
|
function getAdapter(name) {
|
|
@@ -3370,6 +3453,102 @@ function makeProvider(ai) {
|
|
|
3370
3453
|
}
|
|
3371
3454
|
}
|
|
3372
3455
|
|
|
3456
|
+
// src/server/ai/pricing.ts
|
|
3457
|
+
var PRICE_TABLE = [
|
|
3458
|
+
["claude-fable-5", 10, 50],
|
|
3459
|
+
["claude-mythos-5", 10, 50],
|
|
3460
|
+
// Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
|
|
3461
|
+
// the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
|
|
3462
|
+
["claude-opus-4-1", 15, 75],
|
|
3463
|
+
["claude-opus-4-0", 15, 75],
|
|
3464
|
+
["claude-opus-4-2025", 15, 75],
|
|
3465
|
+
["claude-opus-4", 5, 25],
|
|
3466
|
+
["claude-sonnet-4", 3, 15],
|
|
3467
|
+
["claude-haiku-4", 1, 5],
|
|
3468
|
+
["claude-3-5-haiku", 0.8, 4],
|
|
3469
|
+
["gpt-5.5-pro", 30, 180],
|
|
3470
|
+
["gpt-5.5", 5, 30],
|
|
3471
|
+
["gpt-5.4-pro", 30, 180],
|
|
3472
|
+
["gpt-5.4-mini", 0.75, 4.5],
|
|
3473
|
+
["gpt-5.4-nano", 0.2, 1.25],
|
|
3474
|
+
["gpt-5.4", 2.5, 15],
|
|
3475
|
+
["gpt-5.3-codex", 1.75, 14]
|
|
3476
|
+
];
|
|
3477
|
+
var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
|
|
3478
|
+
function bareModelId(model) {
|
|
3479
|
+
let id = model.trim().toLowerCase();
|
|
3480
|
+
const slash = id.lastIndexOf("/");
|
|
3481
|
+
if (slash !== -1) id = id.slice(slash + 1);
|
|
3482
|
+
const anth = id.lastIndexOf("anthropic.");
|
|
3483
|
+
if (anth !== -1) id = id.slice(anth + "anthropic.".length);
|
|
3484
|
+
return id;
|
|
3485
|
+
}
|
|
3486
|
+
function resolvePricing(ai) {
|
|
3487
|
+
if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
|
|
3488
|
+
return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
|
|
3489
|
+
}
|
|
3490
|
+
if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
|
|
3491
|
+
const id = bareModelId(ai.model);
|
|
3492
|
+
let best;
|
|
3493
|
+
for (const row of PRICE_TABLE) {
|
|
3494
|
+
if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
|
|
3495
|
+
}
|
|
3496
|
+
return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
// src/server/ai/estimate.ts
|
|
3500
|
+
var CJK_RE = /[ -鿿가-豈-]/g;
|
|
3501
|
+
function estimateTokens(text) {
|
|
3502
|
+
const cjk = text.match(CJK_RE)?.length ?? 0;
|
|
3503
|
+
return Math.ceil((text.length - cjk) / 4 + cjk / 2);
|
|
3504
|
+
}
|
|
3505
|
+
var EXPANSION = 1.2;
|
|
3506
|
+
var ITEM_REPLY_OVERHEAD = 16;
|
|
3507
|
+
var FORM_REPLY_OVERHEAD = 8;
|
|
3508
|
+
function estimateOutputTokens(req) {
|
|
3509
|
+
const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
|
|
3510
|
+
if (req.plural) {
|
|
3511
|
+
return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
|
|
3512
|
+
}
|
|
3513
|
+
return ITEM_REPLY_OVERHEAD + translated;
|
|
3514
|
+
}
|
|
3515
|
+
function estimateTranslation(state, ai, opts) {
|
|
3516
|
+
const reqs = selectRequests(state, opts);
|
|
3517
|
+
const byLocale = /* @__PURE__ */ new Map();
|
|
3518
|
+
for (const r of reqs) {
|
|
3519
|
+
let group = byLocale.get(r.targetLocale);
|
|
3520
|
+
if (!group) {
|
|
3521
|
+
group = [];
|
|
3522
|
+
byLocale.set(r.targetLocale, group);
|
|
3523
|
+
}
|
|
3524
|
+
group.push(r);
|
|
3525
|
+
}
|
|
3526
|
+
const perLocale = [];
|
|
3527
|
+
for (const [locale, group] of byLocale) {
|
|
3528
|
+
let inputTokens2 = 0;
|
|
3529
|
+
let outputTokens2 = 0;
|
|
3530
|
+
const batches = chunk(group, Math.max(1, ai.batchSize));
|
|
3531
|
+
for (const batch of batches) {
|
|
3532
|
+
const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
|
|
3533
|
+
inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
|
|
3534
|
+
for (const r of batch) outputTokens2 += estimateOutputTokens(r);
|
|
3535
|
+
}
|
|
3536
|
+
perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
|
|
3537
|
+
}
|
|
3538
|
+
const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
|
|
3539
|
+
const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
|
|
3540
|
+
const pricing = resolvePricing(ai);
|
|
3541
|
+
return {
|
|
3542
|
+
requests: reqs.length,
|
|
3543
|
+
batches: perLocale.reduce((n, l) => n + l.batches, 0),
|
|
3544
|
+
perLocale,
|
|
3545
|
+
inputTokens,
|
|
3546
|
+
outputTokens,
|
|
3547
|
+
pricing,
|
|
3548
|
+
estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
|
|
3549
|
+
};
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3373
3552
|
// src/server/log.ts
|
|
3374
3553
|
import { appendFileSync, readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
|
|
3375
3554
|
import { resolve as resolve6 } from "path";
|
|
@@ -3970,7 +4149,9 @@ function coerceAi(raw) {
|
|
|
3970
4149
|
contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
|
|
3971
4150
|
contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
|
|
3972
4151
|
vision: typeof a.vision === "boolean" ? a.vision : void 0,
|
|
3973
|
-
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
|
|
4152
|
+
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
|
|
4153
|
+
inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
|
|
4154
|
+
outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
|
|
3974
4155
|
};
|
|
3975
4156
|
}
|
|
3976
4157
|
function coerceProfiles(raw) {
|
|
@@ -4009,6 +4190,10 @@ function aiConfigError(ai) {
|
|
|
4009
4190
|
if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
|
|
4010
4191
|
if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
|
|
4011
4192
|
if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
|
|
4193
|
+
for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
|
|
4194
|
+
const v = a[f];
|
|
4195
|
+
if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
|
|
4196
|
+
}
|
|
4012
4197
|
return null;
|
|
4013
4198
|
}
|
|
4014
4199
|
|
|
@@ -4709,6 +4894,13 @@ function createApi(deps) {
|
|
|
4709
4894
|
}
|
|
4710
4895
|
return c.json({ requested: reqs.length, written, errors });
|
|
4711
4896
|
}));
|
|
4897
|
+
app.post("/translate/estimate", async (c) => {
|
|
4898
|
+
const body = await c.req.json().catch(() => ({}));
|
|
4899
|
+
const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
|
|
4900
|
+
const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
|
|
4901
|
+
const ai = loadLocalSettings(projectRoot).ai;
|
|
4902
|
+
return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
|
|
4903
|
+
});
|
|
4712
4904
|
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
4713
4905
|
app.post("/scan", async (c) => {
|
|
4714
4906
|
const s = load();
|