glotfile 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -202,9 +202,6 @@ function validate(raw) {
202
202
  if (lint.ignore !== void 0 && (!Array.isArray(lint.ignore) || !lint.ignore.every((g) => typeof g === "string"))) {
203
203
  fail("config.lint.ignore must be an array of strings");
204
204
  }
205
- if (lint.dictionary !== void 0 && (!Array.isArray(lint.dictionary) || !lint.dictionary.every((w) => typeof w === "string"))) {
206
- fail("config.lint.dictionary must be an array of strings");
207
- }
208
205
  if (lint.spelling !== void 0) {
209
206
  if (!isObject(lint.spelling)) fail("config.lint.spelling must be an object");
210
207
  if (lint.spelling.locales !== void 0 && (!isObject(lint.spelling.locales) || !Object.values(lint.spelling.locales).every((v) => typeof v === "string"))) {
@@ -269,6 +266,20 @@ function validate(raw) {
269
266
  lv.value = lv.value.trim();
270
267
  }
271
268
  }
269
+ if (entry.suppressions !== void 0) {
270
+ if (!Array.isArray(entry.suppressions)) fail(`key "${key}" suppressions must be an array`);
271
+ for (const s of entry.suppressions) {
272
+ if (!isObject(s) || typeof s.locale !== "string" || typeof s.source !== "string") {
273
+ fail(`key "${key}" has an invalid suppression (needs string rule, locale, source)`);
274
+ }
275
+ if (!RULE_IDS.includes(s.rule)) {
276
+ fail(`key "${key}" suppression has unknown rule id "${String(s.rule)}"`);
277
+ }
278
+ if (s.at !== void 0 && typeof s.at !== "string") {
279
+ fail(`key "${key}" suppression "at" must be a string`);
280
+ }
281
+ }
282
+ }
272
283
  if (entry.notes !== void 0) {
273
284
  if (!Array.isArray(entry.notes)) fail(`key "${key}" notes must be an array`);
274
285
  for (const n of entry.notes) {
@@ -512,6 +523,30 @@ function normalizeSource(value) {
512
523
  return value.trim().replace(/\s+/g, " ").toLowerCase();
513
524
  }
514
525
 
526
+ // src/server/lint/suppress.ts
527
+ import { createHash } from "crypto";
528
+ function sourceSignature(entry, sourceLocale) {
529
+ const lv = entry.values[sourceLocale];
530
+ if (entry.plural) {
531
+ return Object.entries(lv?.forms ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([cat, body]) => `${cat}:${normalizeSource(body ?? "")}`).join("|");
532
+ }
533
+ return normalizeSource(lv?.value ?? "");
534
+ }
535
+ function sourceHash(entry, sourceLocale) {
536
+ return createHash("sha256").update(sourceSignature(entry, sourceLocale)).digest("hex").slice(0, 12);
537
+ }
538
+ function findSuppression(entry, sourceLocale, ruleId, locale) {
539
+ if (!entry.suppressions?.length) return void 0;
540
+ const current = sourceHash(entry, sourceLocale);
541
+ return entry.suppressions.find((s) => s.rule === ruleId && s.locale === locale && s.source === current);
542
+ }
543
+ function pruneStaleSuppressions(entry, sourceLocale) {
544
+ if (!entry.suppressions?.length) return;
545
+ const current = sourceHash(entry, sourceLocale);
546
+ entry.suppressions = entry.suppressions.filter((s) => s.source === current);
547
+ if (!entry.suppressions.length) delete entry.suppressions;
548
+ }
549
+
515
550
  // src/server/state.ts
516
551
  var systemClock = () => (/* @__PURE__ */ new Date()).toISOString();
517
552
  function canonLocale(locale) {
@@ -532,6 +567,7 @@ function normalizeState(state) {
532
567
  const remapped = {};
533
568
  for (const [loc, lv] of Object.entries(entry.values)) remapped[canonLocale(loc)] = lv;
534
569
  entry.values = remapped;
570
+ for (const s of entry.suppressions ?? []) s.locale = canonLocale(s.locale);
535
571
  }
536
572
  for (const output of state.config.outputs) {
537
573
  if (!output.localeMap) continue;
@@ -623,6 +659,7 @@ function setSourceValue(state, key, value) {
623
659
  lv.state = "needs-review";
624
660
  }
625
661
  }
662
+ pruneStaleSuppressions(entry, state.config.sourceLocale);
626
663
  }
627
664
  }
628
665
  function setTargetValue(state, key, locale, value, clock = systemClock) {
@@ -646,6 +683,7 @@ function setSourcePluralForms(state, key, forms) {
646
683
  lv.state = "needs-review";
647
684
  }
648
685
  }
686
+ pruneStaleSuppressions(entry, state.config.sourceLocale);
649
687
  }
650
688
  }
651
689
  function setPluralForms(state, key, locale, forms, clock = systemClock) {
@@ -719,6 +757,19 @@ function deleteNote(state, key, id) {
719
757
  if (!entry.notes) return;
720
758
  entry.notes = entry.notes.filter((n) => n.id !== id);
721
759
  }
760
+ function addSuppression(state, key, rule, locale, clock = systemClock) {
761
+ const entry = requireKey(state, key);
762
+ if (!RULE_IDS.includes(rule)) throw new GlotfileError(`Unknown lint rule: ${rule}`);
763
+ const list = (entry.suppressions ?? []).filter((s) => !(s.rule === rule && s.locale === locale));
764
+ list.push({ rule, locale, source: sourceHash(entry, state.config.sourceLocale), at: clock() });
765
+ entry.suppressions = list;
766
+ }
767
+ function removeSuppression(state, key, rule, locale) {
768
+ const entry = requireKey(state, key);
769
+ if (!entry.suppressions) return;
770
+ entry.suppressions = entry.suppressions.filter((s) => !(s.rule === rule && s.locale === locale));
771
+ if (!entry.suppressions.length) delete entry.suppressions;
772
+ }
722
773
  function upsertGlossaryEntry(state, entry) {
723
774
  const i = state.glossary.findIndex((e) => e.term === entry.term);
724
775
  if (i === -1) state.glossary.push(entry);
@@ -755,6 +806,23 @@ function applyMachineTranslationForms(state, key, locale, forms, clock = systemC
755
806
  return true;
756
807
  }
757
808
 
809
+ // src/server/lint/accept.ts
810
+ function acceptFindings(state, findings, opts = {}, clock = systemClock) {
811
+ const byRule = {};
812
+ let accepted = 0;
813
+ for (const f of findings) {
814
+ if (f.locale === "" || f.suppressed) continue;
815
+ if (f.severity === "error" && !opts.includeErrors) continue;
816
+ if (opts.rules && !opts.rules.includes(f.ruleId)) continue;
817
+ if (opts.locales && !opts.locales.includes(f.locale)) continue;
818
+ if (!state.keys[f.key]) continue;
819
+ addSuppression(state, f.key, f.ruleId, f.locale, clock);
820
+ byRule[f.ruleId] = (byRule[f.ruleId] ?? 0) + 1;
821
+ accepted++;
822
+ }
823
+ return { accepted, byRule };
824
+ }
825
+
758
826
  // src/server/scan.ts
759
827
  import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
760
828
  import { resolve as resolve2 } from "path";
@@ -1425,6 +1493,88 @@ function globToRegExp2(glob) {
1425
1493
  return new RegExp(`^${escaped}$`);
1426
1494
  }
1427
1495
 
1496
+ // src/server/ai/batch.ts
1497
+ var MalformedReplyError = class extends Error {
1498
+ constructor(raw) {
1499
+ super("Model reply was not valid translation JSON.");
1500
+ this.raw = raw;
1501
+ this.name = "MalformedReplyError";
1502
+ }
1503
+ raw;
1504
+ };
1505
+ function parseReplyItems(text) {
1506
+ let parsed;
1507
+ try {
1508
+ parsed = JSON.parse(text);
1509
+ } catch {
1510
+ throw new MalformedReplyError(text);
1511
+ }
1512
+ if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
1513
+ return parsed.items;
1514
+ }
1515
+ function chunk(items, size) {
1516
+ const out = [];
1517
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
1518
+ return out;
1519
+ }
1520
+ function validateTranslation(req, translation) {
1521
+ if (translation === void 0) return { id: req.id, error: "No translation returned." };
1522
+ if (!placeholdersMatch(req.source, translation)) {
1523
+ return { id: req.id, error: "Placeholder mismatch between source and translation." };
1524
+ }
1525
+ if (req.maxLength !== void 0 && translation.length > req.maxLength) {
1526
+ return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
1527
+ }
1528
+ return { id: req.id, translation };
1529
+ }
1530
+ function validatePlural(req, forms) {
1531
+ if (!forms) return { id: req.id, error: "No translation returned." };
1532
+ const plural = req.plural;
1533
+ if (!plural) return { id: req.id, error: "validatePlural called on a non-plural request." };
1534
+ const cats = plural.categories;
1535
+ const missing = cats.filter((c) => typeof forms[c] !== "string");
1536
+ if (missing.length) return { id: req.id, error: `Missing plural categories: ${missing.join(", ")}.` };
1537
+ const badPh = cats.find((c) => !pluralFormPlaceholdersMatch(c, req.source, forms[c]));
1538
+ if (badPh) return { id: req.id, error: `Placeholder mismatch in plural form "${badPh}".` };
1539
+ if (req.maxLength !== void 0) {
1540
+ const over = cats.find((c) => forms[c].length > req.maxLength);
1541
+ if (over) return { id: req.id, error: `Plural form "${over}" exceeds maxLength (${forms[over].length} > ${req.maxLength}).` };
1542
+ }
1543
+ const out = {};
1544
+ for (const c of cats) out[c] = forms[c];
1545
+ return { id: req.id, forms: out };
1546
+ }
1547
+ function validateReply(req, item) {
1548
+ return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
1549
+ }
1550
+ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
1551
+ const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
1552
+ async function resolveBatch(batch, isRetry = false) {
1553
+ let reply;
1554
+ try {
1555
+ reply = await callBatch(batch, signal);
1556
+ } catch (err) {
1557
+ if (!(err instanceof MalformedReplyError)) throw err;
1558
+ onMalformedReply?.(err.raw, batch.length);
1559
+ if (signal?.aborted) return failBatch(batch);
1560
+ if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
1561
+ const mid = Math.ceil(batch.length / 2);
1562
+ return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
1563
+ }
1564
+ const byId = new Map(reply.map((r) => [r.id, r]));
1565
+ return batch.map((req) => validateReply(req, byId.get(req.id)));
1566
+ }
1567
+ const results = [];
1568
+ const total = reqs.length;
1569
+ for (const batch of chunk(reqs, Math.max(1, batchSize))) {
1570
+ if (signal?.aborted) break;
1571
+ const batchResults = await resolveBatch(batch);
1572
+ results.push(...batchResults);
1573
+ onBatchComplete?.(results.length, total, batchResults);
1574
+ }
1575
+ return results;
1576
+ }
1577
+
1428
1578
  // src/server/ai/run.ts
1429
1579
  function selectRequests(state, opts) {
1430
1580
  const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
@@ -1535,7 +1685,7 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
1535
1685
  return { skipped: keys.size };
1536
1686
  }
1537
1687
  var DEFAULT_LOCALE_CONCURRENCY = 3;
1538
- async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
1688
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity) {
1539
1689
  if (!reqs.length) return [];
1540
1690
  const byLocale = /* @__PURE__ */ new Map();
1541
1691
  for (const req of reqs) {
@@ -1546,26 +1696,42 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
1546
1696
  }
1547
1697
  group.push(req);
1548
1698
  }
1699
+ const localeBatches = [...byLocale.entries()].map(([locale, group]) => ({
1700
+ locale,
1701
+ batches: chunk(group, Math.max(1, batchSize))
1702
+ }));
1703
+ const jobs = [];
1704
+ const maxBatches = Math.max(...localeBatches.map((g) => g.batches.length));
1705
+ for (let i = 0; i < maxBatches; i++) {
1706
+ for (const g of localeBatches) {
1707
+ if (i < g.batches.length) jobs.push({ locale: g.locale, batch: g.batches[i] });
1708
+ }
1709
+ }
1710
+ const remaining = new Map(localeBatches.map((g) => [g.locale, g.batches.length]));
1711
+ const started = /* @__PURE__ */ new Set();
1549
1712
  const total = reqs.length;
1550
1713
  let done = 0;
1551
1714
  const allResults = [];
1552
- const groups = [...byLocale.values()];
1553
1715
  let next = 0;
1554
1716
  async function worker() {
1555
- while (next < groups.length) {
1717
+ while (next < jobs.length) {
1556
1718
  if (signal?.aborted) break;
1557
- const group = groups[next++];
1558
- const locale = group[0].targetLocale;
1559
- hooks.onLocaleStart?.(locale);
1560
- const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
1561
- done += batchResults.length;
1562
- hooks.onBatchComplete?.(done, total, batchResults, locale);
1563
- }, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
1564
- allResults.push(...localeResults);
1565
- if (!signal?.aborted) hooks.onLocaleDone?.(locale);
1566
- }
1567
- }
1568
- const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
1719
+ const { locale, batch } = jobs[next++];
1720
+ if (!started.has(locale)) {
1721
+ started.add(locale);
1722
+ hooks.onLocaleStart?.(locale);
1723
+ }
1724
+ const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
1725
+ done += results.length;
1726
+ hooks.onBatchComplete?.(done, total, results, locale);
1727
+ }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
1728
+ allResults.push(...batchResults);
1729
+ const left = remaining.get(locale) - 1;
1730
+ remaining.set(locale, left);
1731
+ if (left === 0 && !signal?.aborted) hooks.onLocaleDone?.(locale);
1732
+ }
1733
+ }
1734
+ const workers = Array.from({ length: Math.min(concurrency, jobs.length) }, worker);
1569
1735
  await Promise.all(workers);
1570
1736
  return allResults;
1571
1737
  }
@@ -1653,11 +1819,19 @@ function spellValue(locale, value, ignore) {
1653
1819
 
1654
1820
  // src/server/checks.ts
1655
1821
  var CHECK_IDS = ["untranslated", "placeholder", "spelling", "length", "glossary"];
1822
+ var CHECK_RULE = {
1823
+ untranslated: "empty-translation",
1824
+ placeholder: "placeholder-mismatch",
1825
+ spelling: "spelling",
1826
+ length: "max-length",
1827
+ glossary: "glossary-violation"
1828
+ };
1656
1829
  function contains(haystack, needle, caseSensitive) {
1657
1830
  return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
1658
1831
  }
1659
1832
  function runChecks(state, opts = {}) {
1660
- const on = (id) => !opts.only || opts.only.includes(id);
1833
+ const ruleOff = (id) => state.config.lint?.rules?.[CHECK_RULE[id]] === "off";
1834
+ const on = (id) => (!opts.only || opts.only.includes(id)) && !ruleOff(id);
1661
1835
  const issues = [];
1662
1836
  let spellPending = false;
1663
1837
  const { sourceLocale } = state.config;
@@ -1785,7 +1959,11 @@ function runChecks(state, opts = {}) {
1785
1959
  }
1786
1960
  }
1787
1961
  }
1788
- return { issues, spellPending };
1962
+ const visible = issues.filter((i) => {
1963
+ const entry = state.keys[i.key];
1964
+ return !entry || !findSuppression(entry, sourceLocale, CHECK_RULE[i.check], i.locale);
1965
+ });
1966
+ return { issues: visible, spellPending };
1789
1967
  }
1790
1968
 
1791
1969
  // src/server/lint/spelling.ts
@@ -2027,7 +2205,10 @@ function sortFindings(findings) {
2027
2205
  }
2028
2206
  function countSeverities(findings) {
2029
2207
  let error = 0, warn = 0;
2030
- for (const f of findings) f.severity === "error" ? error++ : warn++;
2208
+ for (const f of findings) {
2209
+ if (f.suppressed) continue;
2210
+ f.severity === "error" ? error++ : warn++;
2211
+ }
2031
2212
  return { error, warn };
2032
2213
  }
2033
2214
  async function loadSpellers(locales, config, load, warn) {
@@ -2053,7 +2234,7 @@ async function runLint(state, options = {}) {
2053
2234
  const active = rules.filter(isActive);
2054
2235
  const spellingOn = active.some((r) => r.id === "spelling");
2055
2236
  const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
2056
- const allowWords = spellingOn ? buildAllowWords(state.glossary, config.dictionary) : /* @__PURE__ */ new Set();
2237
+ const allowWords = spellingOn ? buildAllowWords(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
2057
2238
  const ctx = {
2058
2239
  config,
2059
2240
  sourceLocale: state.config.sourceLocale,
@@ -2065,16 +2246,23 @@ async function runLint(state, options = {}) {
2065
2246
  const ignoreRes = (config.ignore ?? []).map(globToRegExp2);
2066
2247
  const localeFilter = options.locales ? new Set(options.locales) : null;
2067
2248
  const findings = [];
2249
+ let suppressed = 0;
2068
2250
  for (const rule of active) {
2069
2251
  const severity = resolveSeverity(rule.id, config);
2070
2252
  for (const raw of rule.run(state, ctx)) {
2071
2253
  if (ignoreRes.some((re) => re.test(raw.key))) continue;
2072
2254
  if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
2255
+ const entry = state.keys[raw.key];
2256
+ if (raw.locale !== "" && entry && findSuppression(entry, state.config.sourceLocale, rule.id, raw.locale)) {
2257
+ suppressed++;
2258
+ if (options.includeSuppressed) findings.push({ ...raw, severity, suppressed: true });
2259
+ continue;
2260
+ }
2073
2261
  findings.push({ ...raw, severity });
2074
2262
  }
2075
2263
  }
2076
2264
  const sorted = sortFindings(findings);
2077
- const counts = countSeverities(sorted);
2265
+ const counts = { ...countSeverities(sorted), suppressed };
2078
2266
  return { findings: sorted, counts, ok: counts.error === 0 };
2079
2267
  }
2080
2268
 
@@ -2952,88 +3140,6 @@ var BATCH_SCHEMA = {
2952
3140
  additionalProperties: false
2953
3141
  };
2954
3142
 
2955
- // src/server/ai/batch.ts
2956
- var MalformedReplyError = class extends Error {
2957
- constructor(raw) {
2958
- super("Model reply was not valid translation JSON.");
2959
- this.raw = raw;
2960
- this.name = "MalformedReplyError";
2961
- }
2962
- raw;
2963
- };
2964
- function parseReplyItems(text) {
2965
- let parsed;
2966
- try {
2967
- parsed = JSON.parse(text);
2968
- } catch {
2969
- throw new MalformedReplyError(text);
2970
- }
2971
- if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
2972
- return parsed.items;
2973
- }
2974
- function chunk(items, size) {
2975
- const out = [];
2976
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2977
- return out;
2978
- }
2979
- function validateTranslation(req, translation) {
2980
- if (translation === void 0) return { id: req.id, error: "No translation returned." };
2981
- if (!placeholdersMatch(req.source, translation)) {
2982
- return { id: req.id, error: "Placeholder mismatch between source and translation." };
2983
- }
2984
- if (req.maxLength !== void 0 && translation.length > req.maxLength) {
2985
- return { id: req.id, translation, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
2986
- }
2987
- return { id: req.id, translation };
2988
- }
2989
- function validatePlural(req, forms) {
2990
- if (!forms) return { id: req.id, error: "No translation returned." };
2991
- const plural = req.plural;
2992
- if (!plural) return { id: req.id, error: "validatePlural called on a non-plural request." };
2993
- const cats = plural.categories;
2994
- const missing = cats.filter((c) => typeof forms[c] !== "string");
2995
- if (missing.length) return { id: req.id, error: `Missing plural categories: ${missing.join(", ")}.` };
2996
- const badPh = cats.find((c) => !pluralFormPlaceholdersMatch(c, req.source, forms[c]));
2997
- if (badPh) return { id: req.id, error: `Placeholder mismatch in plural form "${badPh}".` };
2998
- if (req.maxLength !== void 0) {
2999
- const over = cats.find((c) => forms[c].length > req.maxLength);
3000
- if (over) return { id: req.id, error: `Plural form "${over}" exceeds maxLength (${forms[over].length} > ${req.maxLength}).` };
3001
- }
3002
- const out = {};
3003
- for (const c of cats) out[c] = forms[c];
3004
- return { id: req.id, forms: out };
3005
- }
3006
- function validateReply(req, item) {
3007
- return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
3008
- }
3009
- async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
3010
- const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
3011
- async function resolveBatch(batch, isRetry = false) {
3012
- let reply;
3013
- try {
3014
- reply = await callBatch(batch, signal);
3015
- } catch (err) {
3016
- if (!(err instanceof MalformedReplyError)) throw err;
3017
- onMalformedReply?.(err.raw, batch.length);
3018
- if (signal?.aborted) return failBatch(batch);
3019
- if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
3020
- const mid = Math.ceil(batch.length / 2);
3021
- return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
3022
- }
3023
- const byId = new Map(reply.map((r) => [r.id, r]));
3024
- return batch.map((req) => validateReply(req, byId.get(req.id)));
3025
- }
3026
- const results = [];
3027
- const total = reqs.length;
3028
- for (const batch of chunk(reqs, Math.max(1, batchSize))) {
3029
- if (signal?.aborted) break;
3030
- const batchResults = await resolveBatch(batch);
3031
- results.push(...batchResults);
3032
- onBatchComplete?.(results.length, total, batchResults);
3033
- }
3034
- return results;
3035
- }
3036
-
3037
3143
  // src/server/ai/anthropic.ts
3038
3144
  var AnthropicProvider = class {
3039
3145
  constructor(config, client) {
@@ -4108,6 +4214,7 @@ import { homedir } from "os";
4108
4214
  import { join as join8 } from "path";
4109
4215
  var THEMES = ["system", "light", "dark"];
4110
4216
  var isThemeMode = (v) => THEMES.includes(v);
4217
+ var isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
4111
4218
  var defaultUiPrefsPath = () => join8(homedir(), ".glotfile", "ui.json");
4112
4219
  var DEFAULTS = { theme: "system" };
4113
4220
  function readJson(path) {
@@ -4120,7 +4227,10 @@ function readJson(path) {
4120
4227
  }
4121
4228
  function loadUiPrefs(path) {
4122
4229
  const raw = readJson(path);
4123
- return { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
4230
+ const prefs = { theme: isThemeMode(raw.theme) ? raw.theme : DEFAULTS.theme };
4231
+ if (isPanelWidth(raw.keyColumnWidth)) prefs.keyColumnWidth = Math.round(raw.keyColumnWidth);
4232
+ if (isPanelWidth(raw.detailPanelWidth)) prefs.detailPanelWidth = Math.round(raw.detailPanelWidth);
4233
+ return prefs;
4124
4234
  }
4125
4235
  function saveUiPrefs(path, prefs) {
4126
4236
  const merged = { ...readJson(path), ...prefs };
@@ -4256,9 +4366,20 @@ function createApi(deps) {
4256
4366
  app.get("/state", (c) => c.json(load()));
4257
4367
  app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
4258
4368
  app.put("/ui-prefs", async (c) => {
4259
- const { theme } = await c.req.json();
4260
- if (!isThemeMode(theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
4261
- saveUiPrefs(uiPrefsPath, { theme });
4369
+ const body = await c.req.json();
4370
+ const patch = {};
4371
+ if ("theme" in body) {
4372
+ if (!isThemeMode(body.theme)) return c.json({ error: "theme must be system, light, or dark" }, 400);
4373
+ patch.theme = body.theme;
4374
+ }
4375
+ for (const field of ["keyColumnWidth", "detailPanelWidth"]) {
4376
+ if (field in body) {
4377
+ if (!isPanelWidth(body[field])) return c.json({ error: `${field} must be a number between 120 and 1200` }, 400);
4378
+ patch[field] = Math.round(body[field]);
4379
+ }
4380
+ }
4381
+ if (Object.keys(patch).length === 0) return c.json({ error: "no recognized preferences in body" }, 400);
4382
+ saveUiPrefs(uiPrefsPath, patch);
4262
4383
  return c.json({ ok: true });
4263
4384
  });
4264
4385
  app.get("/local-settings", (c) => c.json(loadLocalSettings(projectRoot)));
@@ -4732,12 +4853,47 @@ function createApi(deps) {
4732
4853
  };
4733
4854
  app.get("/lint", async (c) => {
4734
4855
  const state = load();
4856
+ const includeSuppressed = c.req.query("includeSuppressed") === "1";
4735
4857
  const lint = await runLint(state, { loadSpeller: cachedLoader, warn: () => {
4736
- } });
4858
+ }, includeSuppressed });
4737
4859
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, projectRoot)]);
4738
- const counts = countSeverities(findings);
4860
+ const counts = { ...countSeverities(findings), suppressed: lint.counts.suppressed };
4739
4861
  return c.json({ findings, counts, ok: counts.error === 0 });
4740
4862
  });
4863
+ app.post("/keys/:key/suppressions", async (c) => {
4864
+ const key = c.req.param("key");
4865
+ const { rule, locale } = await c.req.json();
4866
+ if (typeof rule !== "string" || !rule) return c.json({ error: "rule is required" }, 400);
4867
+ if (typeof locale !== "string" || !locale) return c.json({ error: "locale is required" }, 400);
4868
+ const s = load();
4869
+ addSuppression(s, key, rule, locale);
4870
+ persist(s);
4871
+ logChange({ kind: "suppression", summary: `Suppressed ${rule} for ${key} [${locale}]`, key, locale, after: rule });
4872
+ return c.json({ ok: true });
4873
+ });
4874
+ app.delete("/keys/:key/suppressions", (c) => {
4875
+ const key = c.req.param("key");
4876
+ const rule = c.req.query("rule") ?? "";
4877
+ const locale = c.req.query("locale") ?? "";
4878
+ if (!rule || !locale) return c.json({ error: "rule and locale are required" }, 400);
4879
+ const s = load();
4880
+ removeSuppression(s, key, rule, locale);
4881
+ persist(s);
4882
+ logChange({ kind: "suppression", summary: `Unsuppressed ${rule} for ${key} [${locale}]`, key, locale, before: rule });
4883
+ return c.json({ ok: true });
4884
+ });
4885
+ app.post("/lint/accept", async (c) => {
4886
+ const body = await c.req.json().catch(() => ({}));
4887
+ const s = load();
4888
+ const lint = await runLint(s, { loadSpeller: cachedLoader, warn: () => {
4889
+ } });
4890
+ const result = acceptFindings(s, lint.findings, { rules: body.rules, locales: body.locales });
4891
+ if (result.accepted > 0) {
4892
+ persist(s);
4893
+ logChange({ kind: "suppression", summary: `Suppressed ${result.accepted} finding(s)`, after: result.byRule });
4894
+ }
4895
+ return c.json({ ok: true, ...result });
4896
+ });
4741
4897
  app.get("/checks", (c) => {
4742
4898
  const param = c.req.query("checks");
4743
4899
  const only = param ? param.split(",").map((s) => s.trim()).filter((s) => CHECK_IDS.includes(s)) : void 0;
@@ -4862,7 +5018,7 @@ function createApi(deps) {
4862
5018
  raw
4863
5019
  });
4864
5020
  }
4865
- }, aiCfg.concurrency, signal);
5021
+ }, aiCfg.concurrency, signal, aiCfg.batchSize);
4866
5022
  if (!signal?.aborted) {
4867
5023
  console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
4868
5024
  await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
@@ -4905,7 +5061,7 @@ function createApi(deps) {
4905
5061
  raw
4906
5062
  });
4907
5063
  }
4908
- }, aiCfg.concurrency);
5064
+ }, aiCfg.concurrency, void 0, aiCfg.batchSize);
4909
5065
  const latest = load();
4910
5066
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
4911
5067
  const entry = {