@walkinissue/angy 0.2.17 → 0.2.19

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.js CHANGED
@@ -3,10 +3,11 @@ import { json as json4 } from "@sveltejs/kit";
3
3
 
4
4
  // src/lib/server/commit.ts
5
5
  import { json } from "@sveltejs/kit";
6
+ import { basename as basename3 } from "node:path";
6
7
 
7
8
  // src/lib/server/catalog.ts
8
9
  import { constants as fsConstants } from "node:fs";
9
- import { access, copyFile, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
10
+ import { access, copyFile, readFile as readFile2, rename, rm, writeFile as writeFile2 } from "node:fs/promises";
10
11
  import { dirname as dirname2, extname as extname2, join, basename as basename2 } from "node:path";
11
12
  import gettextParser from "gettext-parser";
12
13
  import Fuse from "fuse.js";
@@ -24,6 +25,30 @@ var CONFIG_FILENAMES = [
24
25
  "angy.config.mjs",
25
26
  "angy.config.cjs"
26
27
  ];
28
+ var NON_REASONING_SUGGESTION_MODELS = [
29
+ "gpt-4.1",
30
+ "gpt-4.1-mini",
31
+ "gpt-4.1-nano"
32
+ ];
33
+ var GPT54_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh"];
34
+ var GPT51_REASONING_EFFORTS = ["none", "low", "medium", "high"];
35
+ var GPT5_REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
36
+ var GPT5_PRO_REASONING_EFFORTS = ["high"];
37
+ var GPT5X_PRO_REASONING_EFFORTS = ["medium", "high", "xhigh"];
38
+ var REASONING_EFFORTS_BY_MODEL = {
39
+ "gpt-5.4": GPT54_REASONING_EFFORTS,
40
+ "gpt-5.4-mini": GPT54_REASONING_EFFORTS,
41
+ "gpt-5.4-nano": GPT54_REASONING_EFFORTS,
42
+ "gpt-5.2": GPT54_REASONING_EFFORTS,
43
+ "gpt-5.1": GPT51_REASONING_EFFORTS,
44
+ "gpt-5": GPT5_REASONING_EFFORTS,
45
+ "gpt-5-pro": GPT5_PRO_REASONING_EFFORTS,
46
+ "gpt-5.2-pro": GPT5X_PRO_REASONING_EFFORTS,
47
+ "gpt-5.4-pro": GPT5X_PRO_REASONING_EFFORTS
48
+ };
49
+ var DEFAULT_SUGGESTION_MODEL = {
50
+ model: "gpt-4.1-mini"
51
+ };
27
52
  function buildDefaultSystemMessage(sourceLocale, targetLocale) {
28
53
  return `You are an expert product localization assistant translating ${sourceLocale} UI copy into polished ${targetLocale}. Keep already-${targetLocale} text unchanged. Preserve placeholders like {0}, {1}, ICU fragments, HTML-like markers such as <0/> and <strong>, line breaks, punctuation, capitalization, and button-like brevity. Prefer terminology and syntax that stay close to the translated examples so the product voice remains cohesive. Do not invent extra context, do not expand abbreviations unless the examples already do, and avoid semantic drift. Return only high-confidence translation suggestions for the provided untranslated strings.`;
29
54
  }
@@ -50,6 +75,51 @@ function validateSuggestionProvider(suggestionProvider) {
50
75
  throw new Error(`[angy] suggestionProvider must be a function.`);
51
76
  }
52
77
  }
78
+ function isNonReasoningSuggestionModel(model) {
79
+ return NON_REASONING_SUGGESTION_MODELS.includes(model);
80
+ }
81
+ function isSupportedSuggestionModel(model) {
82
+ return isNonReasoningSuggestionModel(model) || Object.prototype.hasOwnProperty.call(REASONING_EFFORTS_BY_MODEL, model);
83
+ }
84
+ function normalizeSuggestionModelConfig(input) {
85
+ const suggestionModel = input ?? DEFAULT_SUGGESTION_MODEL;
86
+ if (typeof suggestionModel !== "object" || suggestionModel == null || Array.isArray(suggestionModel)) {
87
+ throw new Error(
88
+ `[angy] suggestionModel must be an object like { model: "gpt-4.1-mini" }.`
89
+ );
90
+ }
91
+ if (typeof suggestionModel.model !== "string" || !suggestionModel.model.trim()) {
92
+ throw new Error(`[angy] suggestionModel.model is required and must be a non-empty string.`);
93
+ }
94
+ if (!isSupportedSuggestionModel(suggestionModel.model)) {
95
+ throw new Error(`[angy] Unsupported suggestionModel.model "${suggestionModel.model}".`);
96
+ }
97
+ if (isNonReasoningSuggestionModel(suggestionModel.model)) {
98
+ if (!("reasoning" in suggestionModel) || suggestionModel.reasoning === void 0 || suggestionModel.reasoning === null) {
99
+ return { model: suggestionModel.model, reasoning: null };
100
+ }
101
+ throw new Error(
102
+ `[angy] suggestionModel.reasoning is not supported for ${suggestionModel.model}.`
103
+ );
104
+ }
105
+ const allowedReasoning = REASONING_EFFORTS_BY_MODEL[suggestionModel.model];
106
+ const reasoning = suggestionModel.reasoning;
107
+ if (reasoning == null) {
108
+ if (suggestionModel.model === "gpt-5-pro") {
109
+ return { model: suggestionModel.model, reasoning: "high" };
110
+ }
111
+ return { model: suggestionModel.model };
112
+ }
113
+ if (!allowedReasoning.includes(reasoning)) {
114
+ throw new Error(
115
+ `[angy] suggestionModel.reasoning "${reasoning}" is not supported for ${suggestionModel.model}.`
116
+ );
117
+ }
118
+ return {
119
+ model: suggestionModel.model,
120
+ reasoning
121
+ };
122
+ }
53
123
  var config = {
54
124
  basePoPath: resolve(__dirname, "../../locales/en.po"),
55
125
  workingPoPath: resolve(__dirname, "../../locales/en-working.po"),
@@ -58,7 +128,7 @@ var config = {
58
128
  routePath: "/api/translations",
59
129
  apiKey: "",
60
130
  systemMessage: buildDefaultSystemMessage("sv", "en"),
61
- suggestionModel: "gpt-4.1-mini",
131
+ suggestionModel: DEFAULT_SUGGESTION_MODEL,
62
132
  watchIgnore: ["**/en-working.po"]
63
133
  };
64
134
  var loadedConfigRoot = null;
@@ -102,6 +172,21 @@ function validateLocaleAliasUsage(sourceLocale, targetLocale) {
102
172
  throw new Error('[angy] targetLocale cannot be "base". Use an explicit target locale or "working".');
103
173
  }
104
174
  }
175
+ function validateCatalogPathSemantics(next) {
176
+ const baseLocale = inferLocaleFromCatalogPath(next.basePoPath);
177
+ const workingLocale = inferLocaleFromCatalogPath(next.workingPoPath);
178
+ const expectedWorkingLocale = `${next.targetLocale}-working`;
179
+ if (baseLocale !== next.targetLocale) {
180
+ throw new Error(
181
+ `[angy] basePoPath must point to the ${next.targetLocale}.po catalog. Received "${baseLocale ?? "unknown"}".`
182
+ );
183
+ }
184
+ if (workingLocale !== expectedWorkingLocale) {
185
+ throw new Error(
186
+ `[angy] workingPoPath must point to the ${expectedWorkingLocale}.po catalog. Received "${workingLocale ?? "unknown"}".`
187
+ );
188
+ }
189
+ }
105
190
  function suspendWorkingCatalogWatch(path, delayMs = 800) {
106
191
  const normalizedPath = normalizeFsPath(path);
107
192
  for (const controller of workingCatalogWatchControllers) {
@@ -124,12 +209,14 @@ function resolveConfiguredLocaleAliases(next) {
124
209
  const resolvedSourceLocale = resolveLocaleAlias(rawSourceLocale, next);
125
210
  const resolvedTargetLocale = resolveLocaleAlias(rawTargetLocale, next);
126
211
  const usesDefaultSystemMessage = next.systemMessage === buildDefaultSystemMessage(rawSourceLocale, rawTargetLocale);
127
- return {
212
+ const resolved = {
128
213
  ...next,
129
214
  sourceLocale: resolvedSourceLocale,
130
215
  targetLocale: resolvedTargetLocale,
131
216
  systemMessage: usesDefaultSystemMessage ? buildDefaultSystemMessage(resolvedSourceLocale, resolvedTargetLocale) : next.systemMessage
132
217
  };
218
+ validateCatalogPathSemantics(resolved);
219
+ return resolved;
133
220
  }
134
221
  function completeAngyConfig(input) {
135
222
  assertNonEmptyString(input.basePoPath, "basePoPath");
@@ -137,8 +224,8 @@ function completeAngyConfig(input) {
137
224
  assertNonEmptyString(input.sourceLocale, "sourceLocale");
138
225
  assertNonEmptyString(input.targetLocale, "targetLocale");
139
226
  validateLocaleAliasUsage(input.sourceLocale, input.targetLocale);
140
- if (typeof input.apiKey !== "string") {
141
- throw new Error(`[angy] apiKey is required and must be a string. Use an empty string to disable suggestions.`);
227
+ if (typeof input.apiKey !== "string" && typeof input.apiKey !== "undefined") {
228
+ throw new Error(`[angy] apiKey must be a string when provided. Use an empty string to disable suggestions.`);
142
229
  }
143
230
  validateRoutePath(input.routePath);
144
231
  validateWatchIgnore(input.watchIgnore);
@@ -149,15 +236,15 @@ function completeAngyConfig(input) {
149
236
  sourceLocale: input.sourceLocale,
150
237
  targetLocale: input.targetLocale,
151
238
  routePath: input.routePath ?? "/api/translations",
152
- apiKey: input.apiKey,
239
+ apiKey: input.apiKey ?? "",
153
240
  systemMessage: input.systemMessage ?? buildDefaultSystemMessage(input.sourceLocale, input.targetLocale),
154
- suggestionModel: input.suggestionModel ?? "gpt-4.1-mini",
241
+ suggestionModel: normalizeSuggestionModelConfig(input.suggestionModel),
155
242
  watchIgnore: input.watchIgnore ?? ["**/en-working.po"],
156
243
  suggestionProvider: input.suggestionProvider
157
244
  };
158
245
  }
159
246
  function defineAngyConfig(config2) {
160
- return completeAngyConfig(config2);
247
+ return resolveConfiguredLocaleAliases(completeAngyConfig(config2));
161
248
  }
162
249
  async function fileExists(path) {
163
250
  try {
@@ -177,13 +264,19 @@ async function loadTsConfigModule(path) {
177
264
  const tempPath = `${path}.angy.tmp.mjs`;
178
265
  await writeFile(tempPath, transformed.code, "utf8");
179
266
  try {
180
- return await import(`${pathToFileURL(tempPath).href}?t=${Date.now()}`);
267
+ return await import(
268
+ /* @vite-ignore */
269
+ `${pathToFileURL(tempPath).href}?t=${Date.now()}`
270
+ );
181
271
  } finally {
182
272
  await unlink(tempPath).catch(() => void 0);
183
273
  }
184
274
  }
185
275
  async function loadJsConfigModule(path) {
186
- return import(pathToFileURL(path).href);
276
+ return import(
277
+ /* @vite-ignore */
278
+ pathToFileURL(path).href
279
+ );
187
280
  }
188
281
  async function loadAngyConfigFromRoot(root) {
189
282
  if (loadedConfigRoot === root) {
@@ -207,6 +300,14 @@ async function loadAngyConfigFromRoot(root) {
207
300
 
208
301
  // src/lib/server/catalog.ts
209
302
  var runtimeTranslations = /* @__PURE__ */ new Map();
303
+ var CatalogIntegrityError = class extends Error {
304
+ issues;
305
+ constructor(message, issues) {
306
+ super(message);
307
+ this.name = "CatalogIntegrityError";
308
+ this.issues = issues;
309
+ }
310
+ };
210
311
  function catalogEntryKey(msgid, msgctxt) {
211
312
  return `${msgctxt ?? ""}::${msgid}`;
212
313
  }
@@ -280,20 +381,9 @@ function createFuse(entries) {
280
381
  function buildEntryMap(entries) {
281
382
  return new Map(entries.map((entry) => [catalogEntryKey(entry.msgid, entry.msgctxt), entry]));
282
383
  }
283
- function applyWorkingState(baseEntry, workingEntry) {
284
- if (!workingEntry) {
285
- return baseEntry;
286
- }
287
- return {
288
- ...baseEntry,
289
- msgstr: workingEntry.msgstr,
290
- flags: workingEntry.flags,
291
- previous: workingEntry.previous,
292
- obsolete: workingEntry.obsolete,
293
- hasTranslation: workingEntry.hasTranslation,
294
- isFuzzy: workingEntry.isFuzzy,
295
- translationOrigin: workingEntry.translationOrigin
296
- };
384
+ function getEntryValue(entry) {
385
+ if (!entry?.msgstr?.length) return "";
386
+ return entry.msgstr.find((item) => item.trim().length > 0)?.trim() ?? "";
297
387
  }
298
388
  async function fileExists2(path) {
299
389
  try {
@@ -303,27 +393,9 @@ async function fileExists2(path) {
303
393
  return false;
304
394
  }
305
395
  }
306
- async function readCatIndex(catalog) {
307
- const { basePoPath, workingPoPath } = getTranslationHelperConfig();
308
- let path = "";
309
- if (catalog === "base") {
310
- path = basePoPath;
311
- } else {
312
- const exists = await fileExists2(workingPoPath);
313
- if (!exists) return null;
314
- path = workingPoPath;
315
- }
316
- const raw = await readFile2(path);
317
- const parsed = gettextParser.po.parse(raw);
318
- const entries = flattenPoEntries(parsed, catalog);
319
- return {
320
- entries,
321
- fuse: createFuse(entries)
322
- };
323
- }
324
396
  async function readParsedCatalog(catalog) {
325
- const { basePoPath, workingPoPath } = getTranslationHelperConfig();
326
- const path = catalog === "base" ? basePoPath : workingPoPath;
397
+ const { basePoPath } = getTranslationHelperConfig();
398
+ const path = catalog === "base" ? basePoPath : await getEffectiveWorkingPoPath();
327
399
  if (catalog === "working") {
328
400
  const exists = await fileExists2(path);
329
401
  if (!exists) return null;
@@ -338,15 +410,183 @@ async function ensureWorkingCatalog() {
338
410
  await copyFile(basePoPath, workingPoPath);
339
411
  }
340
412
  }
413
+ function getWorkingDraftPoPath() {
414
+ const { workingPoPath } = getTranslationHelperConfig();
415
+ const extension = extname2(workingPoPath);
416
+ const stem = workingPoPath.slice(0, workingPoPath.length - extension.length);
417
+ return `${stem}.angy-draft${extension}`;
418
+ }
419
+ async function getEffectiveWorkingPoPath() {
420
+ const draftPoPath = getWorkingDraftPoPath();
421
+ if (await fileExists2(draftPoPath)) {
422
+ return draftPoPath;
423
+ }
424
+ return getTranslationHelperConfig().workingPoPath;
425
+ }
426
+ async function ensureWorkingDraftCatalog() {
427
+ await ensureWorkingCatalog();
428
+ const { workingPoPath } = getTranslationHelperConfig();
429
+ const draftPoPath = getWorkingDraftPoPath();
430
+ const exists = await fileExists2(draftPoPath);
431
+ if (!exists) {
432
+ await copyFile(workingPoPath, draftPoPath);
433
+ }
434
+ }
341
435
  function removeFuzzyFlag(flagString) {
342
436
  if (!flagString) return "";
343
437
  return flagString.split(",").map((item) => item.trim()).filter(Boolean).filter((item) => item !== "fuzzy").join(", ");
344
438
  }
345
439
  async function writeWorkingCatalog(parsed) {
440
+ await ensureWorkingDraftCatalog();
441
+ const draftPoPath = getWorkingDraftPoPath();
442
+ const compiled = gettextParser.po.compile(parsed);
443
+ await writeFile2(draftPoPath, compiled);
444
+ }
445
+ async function promoteWorkingDraftToRuntime() {
446
+ await ensureWorkingDraftCatalog();
346
447
  const { workingPoPath } = getTranslationHelperConfig();
448
+ const draftPoPath = getWorkingDraftPoPath();
449
+ const publishPath = `${workingPoPath}.angy-publish`;
450
+ const compiled = await readFile2(draftPoPath);
347
451
  suspendWorkingCatalogWatch(workingPoPath);
348
- const compiled = gettextParser.po.compile(parsed);
349
- await writeFile2(workingPoPath, compiled);
452
+ await writeFile2(publishPath, compiled);
453
+ await rm(workingPoPath, { force: true });
454
+ await rename(publishPath, workingPoPath);
455
+ }
456
+ function collectCatalogIntegrityIssues(baseEntries, workingEntries) {
457
+ const issues = [];
458
+ const baseMap = buildEntryMap(baseEntries);
459
+ const workingMap = buildEntryMap(workingEntries);
460
+ for (const baseEntry of baseEntries) {
461
+ const key = catalogEntryKey(baseEntry.msgid, baseEntry.msgctxt);
462
+ const workingEntry = workingMap.get(key);
463
+ if (!workingEntry) {
464
+ issues.push({
465
+ type: "key_mismatch",
466
+ msgid: baseEntry.msgid,
467
+ msgctxt: baseEntry.msgctxt,
468
+ reason: "missing_in_working"
469
+ });
470
+ continue;
471
+ }
472
+ const baseValue = getEntryValue(baseEntry);
473
+ const workingValue = getEntryValue(workingEntry);
474
+ if (baseValue && !workingValue) {
475
+ issues.push({
476
+ type: "missing_working_translation",
477
+ msgid: baseEntry.msgid,
478
+ msgctxt: baseEntry.msgctxt,
479
+ baseValue
480
+ });
481
+ }
482
+ }
483
+ for (const workingEntry of workingEntries) {
484
+ const key = catalogEntryKey(workingEntry.msgid, workingEntry.msgctxt);
485
+ if (!baseMap.has(key)) {
486
+ issues.push({
487
+ type: "key_mismatch",
488
+ msgid: workingEntry.msgid,
489
+ msgctxt: workingEntry.msgctxt,
490
+ reason: "missing_in_base"
491
+ });
492
+ }
493
+ }
494
+ return issues;
495
+ }
496
+ function assertCatalogIntegrity(baseEntries, workingEntries) {
497
+ const issues = collectCatalogIntegrityIssues(baseEntries, workingEntries);
498
+ if (issues.length) {
499
+ throw new CatalogIntegrityError(
500
+ "Working catalog is out of sync with the base catalog. Regenerate or replace the working catalog before using Angy.",
501
+ issues
502
+ );
503
+ }
504
+ }
505
+ function collectRotationImpact(baseEntries, workingMap) {
506
+ const impacted = [];
507
+ for (const baseEntry of baseEntries) {
508
+ const workingEntry = workingMap.get(catalogEntryKey(baseEntry.msgid, baseEntry.msgctxt));
509
+ if (!workingEntry) continue;
510
+ const baseValue = getEntryValue(baseEntry);
511
+ const workingValue = getEntryValue(workingEntry);
512
+ if (!workingValue) continue;
513
+ if (baseValue === workingValue) continue;
514
+ impacted.push({
515
+ msgid: baseEntry.msgid,
516
+ msgctxt: baseEntry.msgctxt,
517
+ baseValue,
518
+ workingValue
519
+ });
520
+ }
521
+ return impacted;
522
+ }
523
+ function resolveTranslationState(baseEntry, workingEntry) {
524
+ const baseValue = getEntryValue(baseEntry);
525
+ const workingValue = getEntryValue(workingEntry);
526
+ const activeEntry = workingEntry && workingValue ? workingEntry : baseEntry;
527
+ const fuzzy = Boolean(activeEntry.isFuzzy);
528
+ let translationOrigin = "base";
529
+ let translationStatus = "none";
530
+ let effectiveEntry = baseEntry;
531
+ if (workingEntry && workingValue) {
532
+ effectiveEntry = workingEntry;
533
+ if (!baseValue || workingValue !== baseValue) {
534
+ translationOrigin = "working";
535
+ translationStatus = "working";
536
+ } else {
537
+ translationOrigin = "base";
538
+ translationStatus = "base";
539
+ }
540
+ } else if (baseValue) {
541
+ effectiveEntry = baseEntry;
542
+ translationOrigin = "base";
543
+ translationStatus = "base";
544
+ }
545
+ if (fuzzy) {
546
+ translationStatus = "fuzzy";
547
+ }
548
+ return {
549
+ effectiveEntry,
550
+ baseValue,
551
+ workingValue,
552
+ translationOrigin,
553
+ translationStatus,
554
+ hasTranslation: Boolean(getEntryValue(effectiveEntry)),
555
+ isFuzzy: fuzzy
556
+ };
557
+ }
558
+ async function readCatalogPair(options) {
559
+ if (options?.ensureWorking) {
560
+ await ensureWorkingCatalog();
561
+ }
562
+ const [baseParsed, workingParsed] = await Promise.all([
563
+ readParsedCatalog("base"),
564
+ readParsedCatalog("working")
565
+ ]);
566
+ if (!baseParsed || !workingParsed) {
567
+ throw new Error("Unable to load base and working catalogs.");
568
+ }
569
+ const baseEntries = flattenPoEntries(baseParsed, "base");
570
+ const workingEntries = flattenPoEntries(workingParsed, "working");
571
+ assertCatalogIntegrity(baseEntries, workingEntries);
572
+ const baseMap = buildEntryMap(baseEntries);
573
+ const workingMap = buildEntryMap(workingEntries);
574
+ const rotationImpact = collectRotationImpact(baseEntries, workingMap);
575
+ return {
576
+ baseEntries,
577
+ workingEntries,
578
+ baseMap,
579
+ workingMap,
580
+ rotationImpact
581
+ };
582
+ }
583
+ async function getRotationPreflight() {
584
+ const { rotationImpact } = await readCatalogPair({ ensureWorking: true });
585
+ return {
586
+ safe: true,
587
+ status: "ok",
588
+ affected: rotationImpact
589
+ };
350
590
  }
351
591
  function timestampForBackup() {
352
592
  return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -357,18 +597,21 @@ function buildBackupPath(path, label) {
357
597
  const stem = basename2(path, extension);
358
598
  return join(directory, `${stem}.${label}.${timestampForBackup()}${extension}`);
359
599
  }
360
- async function rotateCatalogs() {
600
+ async function rotateCatalogs(options) {
361
601
  const { basePoPath, workingPoPath } = getTranslationHelperConfig();
362
- const workingExists = await fileExists2(workingPoPath);
602
+ const draftPoPath = getWorkingDraftPoPath();
603
+ const effectiveWorkingPoPath = await getEffectiveWorkingPoPath();
604
+ const workingExists = await fileExists2(effectiveWorkingPoPath);
363
605
  if (!workingExists) {
364
606
  return { ok: false, error: "Working catalog does not exist" };
365
607
  }
366
608
  const baseBackupPath = buildBackupPath(basePoPath, "base-backup");
367
609
  const workingBackupPath = buildBackupPath(workingPoPath, "working-backup");
368
610
  await copyFile(basePoPath, baseBackupPath);
369
- await copyFile(workingPoPath, workingBackupPath);
370
- await copyFile(workingPoPath, basePoPath);
611
+ await copyFile(effectiveWorkingPoPath, workingBackupPath);
612
+ await copyFile(effectiveWorkingPoPath, basePoPath);
371
613
  await copyFile(basePoPath, workingPoPath);
614
+ await copyFile(workingPoPath, draftPoPath);
372
615
  return {
373
616
  ok: true,
374
617
  basePoPath,
@@ -384,10 +627,8 @@ function runtimeKey(msgid, msgctxt) {
384
627
  }
385
628
  async function writeTranslationToWorkingCatalog(resolvedMsgid, resolvedMsgctxt, translationValue) {
386
629
  await ensureWorkingCatalog();
387
- const [baseParsed, workingParsed] = await Promise.all([
388
- readParsedCatalog("base"),
389
- readParsedCatalog("working")
390
- ]);
630
+ await readCatalogPair({ ensureWorking: true });
631
+ const [baseParsed, workingParsed] = await Promise.all([readParsedCatalog("base"), readParsedCatalog("working")]);
391
632
  if (!baseParsed || !workingParsed) {
392
633
  return { ok: false, error: "Unable to load catalogs" };
393
634
  }
@@ -409,11 +650,12 @@ async function writeTranslationToWorkingCatalog(resolvedMsgid, resolvedMsgctxt,
409
650
  entry.comments.flag = removeFuzzyFlag(entry.comments.flag);
410
651
  await writeWorkingCatalog(workingParsed);
411
652
  runtimeTranslations.set(runtimeKey(resolvedMsgid, resolvedMsgctxt), translationValue);
653
+ const workingCatalogName = inferLocaleFromCatalogPath(getTranslationHelperConfig().workingPoPath) ?? "working";
412
654
  return {
413
655
  ok: true,
414
656
  msgid: resolvedMsgid,
415
657
  msgctxt: resolvedMsgctxt,
416
- workingCatalog: "en-working.po"
658
+ workingCatalog: workingCatalogName
417
659
  };
418
660
  }
419
661
  function cloneEntryForWorking(baseEntry, msgid, msgctxt) {
@@ -443,6 +685,22 @@ async function handleCommitBatch(request) {
443
685
  { status: 400 }
444
686
  );
445
687
  }
688
+ try {
689
+ await readCatalogPair({ ensureWorking: true });
690
+ } catch (error) {
691
+ if (error instanceof CatalogIntegrityError) {
692
+ return json(
693
+ {
694
+ success: false,
695
+ error: error.message,
696
+ code: "catalog_integrity_error",
697
+ issues: error.issues
698
+ },
699
+ { status: 409 }
700
+ );
701
+ }
702
+ throw error;
703
+ }
446
704
  const results = [];
447
705
  for (const item of items) {
448
706
  const resolvedMsgid = item.resolvedMsgid?.trim();
@@ -490,7 +748,7 @@ async function handleCommitBatch(request) {
490
748
  }
491
749
  return json({
492
750
  success: true,
493
- message: `Committed ${results.length} translations to en-working.po`,
751
+ message: `Committed ${results.length} translations to Angy draft working catalog`,
494
752
  results
495
753
  });
496
754
  }
@@ -525,21 +783,85 @@ async function handleCommit(request) {
525
783
  }
526
784
  return json({
527
785
  success: true,
528
- message: "Translation written to en-working.po",
786
+ message: "Translation written to Angy draft working catalog",
529
787
  msgid: result.msgid,
530
788
  msgctxt: result.msgctxt,
531
789
  workingCatalog: result.workingCatalog
532
790
  });
533
791
  }
534
- async function handleRotateCatalogs() {
535
- const result = await rotateCatalogs();
792
+ async function handlePromoteWorkingPreview() {
793
+ try {
794
+ await readCatalogPair({ ensureWorking: true });
795
+ await promoteWorkingDraftToRuntime();
796
+ return json({
797
+ success: true,
798
+ message: `Promoted Angy draft into ${basename3(getTranslationHelperConfig().workingPoPath)}`
799
+ });
800
+ } catch (error) {
801
+ if (error instanceof CatalogIntegrityError) {
802
+ return json(
803
+ {
804
+ success: false,
805
+ error: error.message,
806
+ code: "catalog_integrity_error",
807
+ issues: error.issues
808
+ },
809
+ { status: 409 }
810
+ );
811
+ }
812
+ throw error;
813
+ }
814
+ }
815
+ async function handleRotatePreflight() {
816
+ try {
817
+ const preflight = await getRotationPreflight();
818
+ return json({
819
+ success: true,
820
+ safe: preflight.safe,
821
+ status: preflight.status,
822
+ affected: preflight.affected
823
+ });
824
+ } catch (error) {
825
+ if (error instanceof CatalogIntegrityError) {
826
+ return json(
827
+ {
828
+ success: false,
829
+ error: error.message,
830
+ code: "catalog_integrity_error",
831
+ issues: error.issues
832
+ },
833
+ { status: 409 }
834
+ );
835
+ }
836
+ throw error;
837
+ }
838
+ }
839
+ async function handleRotateCatalogs(request) {
840
+ const data = await request.json().catch(() => null);
841
+ const confirmDestructive = data?.confirmDestructive === true;
842
+ const preflight = await getRotationPreflight();
843
+ if (!preflight.safe && !confirmDestructive) {
844
+ return json(
845
+ {
846
+ success: false,
847
+ error: "Catalog rotation would overwrite working-only translations. Confirm rotation explicitly to continue.",
848
+ code: "rotation_confirmation_required",
849
+ status: preflight.status,
850
+ affected: preflight.affected
851
+ },
852
+ { status: 409 }
853
+ );
854
+ }
855
+ const result = await rotateCatalogs({ allowOutOfSync: confirmDestructive });
536
856
  if (!result.ok) {
537
857
  return json(
538
858
  {
539
859
  success: false,
540
- error: result.error
860
+ error: result.error,
861
+ status: "status" in result ? result.status : void 0,
862
+ affected: "affected" in result ? result.affected : void 0
541
863
  },
542
- { status: 400 }
864
+ { status: "status" in result && result.status === "out_of_sync" ? 409 : 400 }
543
865
  );
544
866
  }
545
867
  return json({
@@ -725,13 +1047,16 @@ function buildAlternativePool(topDirectAlternatives, sharedReferenceAlternatives
725
1047
  return alternatives.slice(0, MAX_ALTERNATIVES);
726
1048
  }
727
1049
  async function findTranslationContext(key, currentPath) {
728
- const baseIndex = await readCatIndex("base");
729
- if (!baseIndex) return null;
730
- const workingIndex = await readCatIndex("working");
731
- const workingMap = workingIndex ? buildEntryMap(workingIndex.entries) : /* @__PURE__ */ new Map();
732
- const effectiveEntries = baseIndex.entries.map(
733
- (entry) => applyWorkingState(entry, workingMap.get(entryKey(entry.msgid, entry.msgctxt)))
734
- );
1050
+ const catalogPair = await readCatalogPair({ ensureWorking: true });
1051
+ const effectiveEntries = catalogPair.baseEntries.map((entry) => {
1052
+ const state = resolveTranslationState(entry, catalogPair.workingMap.get(entryKey(entry.msgid, entry.msgctxt)));
1053
+ return {
1054
+ ...state.effectiveEntry,
1055
+ hasTranslation: state.hasTranslation,
1056
+ isFuzzy: state.isFuzzy,
1057
+ translationOrigin: state.translationOrigin
1058
+ };
1059
+ });
735
1060
  const effectiveIndex = {
736
1061
  entries: effectiveEntries,
737
1062
  fuse: createFuse(effectiveEntries)
@@ -789,47 +1114,48 @@ async function findTranslationContext(key, currentPath) {
789
1114
  untranslatedSimilarityAlternatives
790
1115
  ),
791
1116
  routeReference,
792
- workingMap
1117
+ baseMap: catalogPair.baseMap,
1118
+ workingMap: catalogPair.workingMap,
1119
+ catalogState: {
1120
+ status: "ok",
1121
+ affectedCount: catalogPair.rotationImpact.length
1122
+ }
793
1123
  };
794
1124
  }
795
1125
  function toPublicEntry(entry, workingEntry) {
796
- const effective = workingEntry ?? entry;
797
- const baseValue = entry.msgstr[0] ?? "";
798
- const workingValue = workingEntry?.msgstr[0] ?? "";
799
- const workingDiffersFromBase = Boolean(workingEntry?.hasTranslation) && workingValue !== baseValue;
1126
+ const state = resolveTranslationState(entry, workingEntry);
800
1127
  return {
801
1128
  msgid: entry.msgid,
802
1129
  msgctxt: entry.msgctxt,
803
1130
  msgidPlural: entry.msgidPlural,
804
- msgstr: effective.msgstr,
1131
+ msgstr: state.effectiveEntry.msgstr,
805
1132
  references: entry.references,
806
1133
  extractedComments: entry.extractedComments,
807
- flags: effective.flags,
808
- previous: effective.previous,
809
- obsolete: effective.obsolete,
810
- hasTranslation: effective.hasTranslation,
811
- isFuzzy: effective.isFuzzy,
812
- isCommittedToWorking: workingDiffersFromBase,
813
- matchesTargetTranslation: Boolean(effective.hasTranslation) && (!workingEntry?.hasTranslation || workingValue === baseValue),
814
- translationOrigin: workingDiffersFromBase ? "working" : "base"
1134
+ flags: state.effectiveEntry.flags,
1135
+ previous: state.effectiveEntry.previous,
1136
+ obsolete: state.effectiveEntry.obsolete,
1137
+ hasTranslation: state.hasTranslation,
1138
+ isFuzzy: state.isFuzzy,
1139
+ isCommittedToWorking: state.translationOrigin === "working",
1140
+ matchesTargetTranslation: state.translationOrigin === "base" && state.hasTranslation,
1141
+ translationOrigin: state.translationOrigin,
1142
+ translationStatus: state.translationStatus
815
1143
  };
816
1144
  }
817
1145
  function toPublicAlternative(entry, score, workingEntry) {
818
- const effective = workingEntry ?? entry;
819
- const baseValue = entry.msgstr[0] ?? "";
820
- const workingValue = workingEntry?.msgstr[0] ?? "";
821
- const workingDiffersFromBase = Boolean(workingEntry?.hasTranslation) && workingValue !== baseValue;
1146
+ const state = resolveTranslationState(entry, workingEntry);
822
1147
  return {
823
1148
  msgid: entry.msgid,
824
1149
  msgctxt: entry.msgctxt,
825
1150
  score,
826
1151
  references: entry.references,
827
- msgstr: effective.msgstr,
828
- hasTranslation: effective.hasTranslation,
829
- isFuzzy: effective.isFuzzy,
830
- isCommittedToWorking: workingDiffersFromBase,
831
- matchesTargetTranslation: Boolean(effective.hasTranslation) && (!workingEntry?.hasTranslation || workingValue === baseValue),
832
- translationOrigin: workingDiffersFromBase ? "working" : "base"
1152
+ msgstr: state.effectiveEntry.msgstr,
1153
+ hasTranslation: state.hasTranslation,
1154
+ isFuzzy: state.isFuzzy,
1155
+ isCommittedToWorking: state.translationOrigin === "working",
1156
+ matchesTargetTranslation: state.translationOrigin === "base" && state.hasTranslation,
1157
+ translationOrigin: state.translationOrigin,
1158
+ translationStatus: state.translationStatus
833
1159
  };
834
1160
  }
835
1161
  async function handleContext(request) {
@@ -845,7 +1171,23 @@ async function handleContext(request) {
845
1171
  { status: 400 }
846
1172
  );
847
1173
  }
848
- const baseFound = await findTranslationContext(key, currentPath);
1174
+ let baseFound;
1175
+ try {
1176
+ baseFound = await findTranslationContext(key, currentPath);
1177
+ } catch (error) {
1178
+ if (error instanceof CatalogIntegrityError) {
1179
+ return json2(
1180
+ {
1181
+ success: false,
1182
+ error: error.message,
1183
+ code: "catalog_integrity_error",
1184
+ issues: error.issues
1185
+ },
1186
+ { status: 409 }
1187
+ );
1188
+ }
1189
+ throw error;
1190
+ }
849
1191
  if (!baseFound) {
850
1192
  return json2(
851
1193
  {
@@ -855,7 +1197,7 @@ async function handleContext(request) {
855
1197
  { status: 404 }
856
1198
  );
857
1199
  }
858
- const bestBase = baseFound.best.entry;
1200
+ const bestBase = baseFound.baseMap.get(entryKey(baseFound.best.entry.msgid, baseFound.best.entry.msgctxt)) ?? baseFound.best.entry;
859
1201
  const bestWorking = baseFound.workingMap.get(entryKey(bestBase.msgid, bestBase.msgctxt));
860
1202
  return json2({
861
1203
  success: true,
@@ -863,10 +1205,12 @@ async function handleContext(request) {
863
1205
  score: baseFound.best.score,
864
1206
  via: baseFound.best.variant
865
1207
  },
1208
+ catalogState: baseFound.catalogState,
866
1209
  entry: toPublicEntry(bestBase, bestWorking),
867
1210
  alternatives: baseFound.alternatives.map((item) => {
868
- const workingAlt = baseFound.workingMap.get(entryKey(item.entry.msgid, item.entry.msgctxt));
869
- return toPublicAlternative(item.entry, item.score, workingAlt);
1211
+ const baseAlt = baseFound.baseMap.get(entryKey(item.entry.msgid, item.entry.msgctxt)) ?? item.entry;
1212
+ const workingAlt = baseFound.workingMap.get(entryKey(baseAlt.msgid, baseAlt.msgctxt));
1213
+ return toPublicAlternative(baseAlt, item.score, workingAlt);
870
1214
  })
871
1215
  });
872
1216
  }
@@ -914,6 +1258,58 @@ function parseSuggestions(responseText) {
914
1258
  return [];
915
1259
  }
916
1260
  }
1261
+ function buildSuggestionRequestBody({
1262
+ context,
1263
+ items,
1264
+ modelConfig,
1265
+ systemMessage
1266
+ }) {
1267
+ const body = {
1268
+ model: modelConfig.model,
1269
+ input: [
1270
+ {
1271
+ role: "system",
1272
+ content: [{ type: "input_text", text: systemMessage }]
1273
+ },
1274
+ {
1275
+ role: "user",
1276
+ content: [{ type: "input_text", text: buildUserMessage(context, items) }]
1277
+ }
1278
+ ],
1279
+ text: {
1280
+ format: {
1281
+ type: "json_schema",
1282
+ name: "translation_suggestions",
1283
+ schema: {
1284
+ type: "object",
1285
+ additionalProperties: false,
1286
+ properties: {
1287
+ items: {
1288
+ type: "array",
1289
+ items: {
1290
+ type: "object",
1291
+ additionalProperties: false,
1292
+ properties: {
1293
+ msgid: { type: "string" },
1294
+ msgctxt: { type: ["string", "null"] },
1295
+ suggestion: { type: "string" }
1296
+ },
1297
+ required: ["msgid", "msgctxt", "suggestion"]
1298
+ }
1299
+ }
1300
+ },
1301
+ required: ["items"]
1302
+ }
1303
+ }
1304
+ }
1305
+ };
1306
+ if (modelConfig.reasoning && modelConfig.reasoning !== "none") {
1307
+ body.reasoning = {
1308
+ effort: modelConfig.reasoning
1309
+ };
1310
+ }
1311
+ return body;
1312
+ }
917
1313
  async function handleSuggestions(request) {
918
1314
  const {
919
1315
  apiKey,
@@ -942,7 +1338,7 @@ async function handleSuggestions(request) {
942
1338
  sourceLocale,
943
1339
  targetLocale,
944
1340
  systemMessage,
945
- model: suggestionModel,
1341
+ suggestionModel,
946
1342
  apiKey
947
1343
  });
948
1344
  return json3({
@@ -960,7 +1356,8 @@ async function handleSuggestions(request) {
960
1356
  }
961
1357
  try {
962
1358
  console.info("[angy] Suggestion request starting.", {
963
- model: suggestionModel,
1359
+ model: suggestionModel.model,
1360
+ reasoning: suggestionModel.reasoning ?? null,
964
1361
  sourceLocale,
965
1362
  targetLocale,
966
1363
  itemCount: items.length,
@@ -972,48 +1369,14 @@ async function handleSuggestions(request) {
972
1369
  Authorization: `Bearer ${apiKey}`,
973
1370
  "content-type": "application/json"
974
1371
  },
975
- body: JSON.stringify({
976
- model: suggestionModel,
977
- reasoning: {
978
- effort: "medium"
979
- },
980
- input: [
981
- {
982
- role: "system",
983
- content: [{ type: "input_text", text: systemMessage }]
984
- },
985
- {
986
- role: "user",
987
- content: [{ type: "input_text", text: buildUserMessage(context, items) }]
988
- }
989
- ],
990
- text: {
991
- format: {
992
- type: "json_schema",
993
- name: "translation_suggestions",
994
- schema: {
995
- type: "object",
996
- additionalProperties: false,
997
- properties: {
998
- items: {
999
- type: "array",
1000
- items: {
1001
- type: "object",
1002
- additionalProperties: false,
1003
- properties: {
1004
- msgid: { type: "string" },
1005
- msgctxt: { type: ["string", "null"] },
1006
- suggestion: { type: "string" }
1007
- },
1008
- required: ["msgid", "msgctxt", "suggestion"]
1009
- }
1010
- }
1011
- },
1012
- required: ["items"]
1013
- }
1014
- }
1015
- }
1016
- })
1372
+ body: JSON.stringify(
1373
+ buildSuggestionRequestBody({
1374
+ context,
1375
+ items,
1376
+ modelConfig: suggestionModel,
1377
+ systemMessage
1378
+ })
1379
+ )
1017
1380
  });
1018
1381
  if (!response.ok) {
1019
1382
  const errorBody = await response.text().catch(() => "");
@@ -1078,7 +1441,13 @@ async function handleTranslationRequest(request, url, options) {
1078
1441
  return handleSuggestions(request);
1079
1442
  }
1080
1443
  if (intent === "rotate-catalogs") {
1081
- return handleRotateCatalogs();
1444
+ return handleRotateCatalogs(request);
1445
+ }
1446
+ if (intent === "rotate-preflight") {
1447
+ return handleRotatePreflight();
1448
+ }
1449
+ if (intent === "promote-working-preview") {
1450
+ return handlePromoteWorkingPreview();
1082
1451
  }
1083
1452
  return handleCommit(request);
1084
1453
  }
@@ -1087,8 +1456,16 @@ function createAngyHandler(options) {
1087
1456
  }
1088
1457
  var handler = createAngyHandler();
1089
1458
  export {
1459
+ CatalogIntegrityError,
1460
+ buildSuggestionRequestBody,
1461
+ collectCatalogIntegrityIssues,
1462
+ collectRotationImpact,
1090
1463
  defineAngyConfig,
1464
+ getRotationPreflight,
1091
1465
  handleTranslationRequest,
1092
- handler
1466
+ handler,
1467
+ normalizeSuggestionModelConfig,
1468
+ readCatalogPair,
1469
+ resolveTranslationState
1093
1470
  };
1094
1471
  //# sourceMappingURL=server.js.map