@braze/sanity-plugin-ai-translation 0.1.77 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,23 +1,27 @@
1
1
  import { useClient, defineType, defineField, pathToString, definePlugin } from "sanity";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, ArrowLeftIcon, EditIcon, InfoOutlineIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
4
- import { createContext, useContext, useEffect, useState, useReducer, useMemo } from "react";
5
- import { Card, Flex, Stack, Text, Spinner, Button, Box, Checkbox, Select, TextInput, TextArea, Heading, Inline, Tooltip, Badge, Grid, Label, Code } from "@sanity/ui";
3
+ import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, WarningOutlineIcon, InfoOutlineIcon, ArrowLeftIcon, EditIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
4
+ import { createContext, useContext, useEffect, useState, useReducer, useCallback, useMemo } from "react";
5
+ import { Card, Flex, Stack, Text, Spinner, Button, Box, Tooltip, Checkbox, Select, TextInput, TextArea, Heading, Inline, Badge, Grid, Radio, Label, Code } from "@sanity/ui";
6
6
  import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
7
7
  import defaults from "lodash.defaults";
8
- import { diffPatch } from "@sanity/diff-patch";
8
+ import { toPlainText } from "@portabletext/react";
9
+ import cloneDeep from "lodash.clonedeep";
10
+ import get from "lodash.get";
11
+ import set from "lodash.set";
9
12
  const TranslationContext = createContext({
10
13
  document: null,
11
14
  config: null,
12
15
  overallStatus: "idle",
13
16
  selectedTargetLanguages: [],
14
17
  translationJobs: /* @__PURE__ */ new Map(),
15
- shouldTranslateDiffs: !1,
16
18
  shouldOverwriteFreshTranslations: !1,
17
19
  shouldTranslateReferences: !1,
18
20
  maxReferenceDepth: 1,
19
21
  references: [],
20
22
  translationMetadata: null,
23
+ translationMode: "sanity-only",
24
+ openaiValidated: !1,
21
25
  dispatch: () => {
22
26
  }
23
27
  }), useTranslationContext = () => {
@@ -27,7 +31,7 @@ const TranslationContext = createContext({
27
31
  "useTranslationContext must be used within a TranslationContextProvider with document and config props"
28
32
  );
29
33
  return context;
30
- }, TRANSLATION_METADATA_TYPE = "translation.metadata", UPDATE_EXISTING_TRANSLATION_PROMPT = "***IMPORTANT:** This document has an existing, but out-of-date translation. Please review the existing translation before processing and change as little as possible. Existing translation: $extantTranslation", undraftId = (id) => id.replace(/^drafts\./, ""), draftId = (id) => `drafts.${undraftId(id)}`, TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
34
+ }, TRANSLATION_METADATA_TYPE = "translation.metadata", undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
31
35
  const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
32
36
  params: {
33
37
  documentId: undraftId(documentId),
@@ -80,6 +84,102 @@ const TranslationContext = createContext({
80
84
  isLoggingEnabled() && console.timeEnd(`[AI Translation] ${label}`);
81
85
  }
82
86
  };
87
+ async function validateSuggestion(client, suggestion) {
88
+ return suggestion.type === "glossaryTerm" ? validateGlossaryTerm(client, suggestion) : suggestion.type === "protectedPhrase" ? validateProtectedPhrase(client, suggestion) : suggestion.type === "globalPrompt" || suggestion.type === "localePrompt" ? validatePrompt(suggestion) : { valid: !0, status: "valid" };
89
+ }
90
+ async function validateGlossaryTerm(client, suggestion) {
91
+ const { sourceValue, targetValue, sourceLocale, targetLocale } = suggestion;
92
+ if (!sourceValue || !targetValue || !sourceLocale || !targetLocale)
93
+ return {
94
+ valid: !1,
95
+ status: "invalid",
96
+ reason: "Missing required fields for glossary term"
97
+ };
98
+ if (sourceValue.trim() === targetValue.trim())
99
+ return {
100
+ valid: !1,
101
+ status: "invalid",
102
+ reason: `Source and target are identical ("${sourceValue}") - this should be a protected phrase instead`
103
+ };
104
+ const protectedPhrases = await client.fetch('*[_id == "translation-settings"][0].protectedPhrases');
105
+ if (protectedPhrases?.some((phrase) => phrase.toLowerCase() === sourceValue.toLowerCase()))
106
+ return {
107
+ valid: !1,
108
+ status: "invalid",
109
+ reason: `Cannot add "${sourceValue}" to glossary because it is already a protected phrase. Protected phrases are never translated. If you want to create a translation mapping for this term, please remove it from protected phrases first by editing Translation Settings.`
110
+ };
111
+ if (targetValue && protectedPhrases?.some((phrase) => phrase.toLowerCase() === targetValue.toLowerCase()))
112
+ return {
113
+ valid: !1,
114
+ status: "invalid",
115
+ reason: `Cannot use "${targetValue}" as a translation target because it is a protected phrase. Protected phrases should not appear as translation targets.`
116
+ };
117
+ const existingTerm = await client.fetch(
118
+ `*[_type == "translationGlossaryTerm" && count(translations[_key == $sourceLocale && value == $sourceValue]) > 0][0] {
119
+ translations
120
+ }`,
121
+ { sourceLocale, sourceValue }
122
+ );
123
+ if (existingTerm) {
124
+ const existingTarget = existingTerm.translations.find((t) => t._key === targetLocale);
125
+ if (existingTarget?.value === targetValue)
126
+ return {
127
+ valid: !0,
128
+ status: "warning",
129
+ reason: `This translation already exists in the glossary: "${sourceValue}" \u2192 "${targetValue}"`
130
+ };
131
+ if (existingTarget && existingTarget.value !== targetValue)
132
+ return {
133
+ valid: !0,
134
+ status: "warning",
135
+ reason: `"${sourceValue}" already has a different translation in the glossary: "${existingTarget.value}". Applying this will update it to "${targetValue}".`
136
+ };
137
+ }
138
+ return { valid: !0, status: "valid" };
139
+ }
140
+ async function validateProtectedPhrase(client, suggestion) {
141
+ const phrase = suggestion.description.trim();
142
+ if (!phrase)
143
+ return {
144
+ valid: !1,
145
+ status: "invalid",
146
+ reason: "Protected phrase cannot be empty"
147
+ };
148
+ if ((await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.some((p) => p.toLowerCase() === phrase.toLowerCase()))
149
+ return {
150
+ valid: !0,
151
+ status: "warning",
152
+ reason: `"${phrase}" is already in protected phrases`
153
+ };
154
+ const glossaryTermWithPhrase = await client.fetch(
155
+ `*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0][0] {
156
+ _id,
157
+ translations
158
+ }`,
159
+ { phrase }
160
+ );
161
+ if (glossaryTermWithPhrase) {
162
+ const matchingTranslations = glossaryTermWithPhrase.translations.filter((t) => t.value.toLowerCase() === phrase.toLowerCase()).map((t) => t._key).join(", ");
163
+ return {
164
+ valid: !1,
165
+ status: "invalid",
166
+ reason: `Cannot add "${phrase}" as a protected phrase because it exists in the glossary as a translation (${matchingTranslations}). If you want to prevent this term from being translated, please remove it from the glossary first by editing Translation Settings.`
167
+ };
168
+ }
169
+ return { valid: !0, status: "valid" };
170
+ }
171
+ function validatePrompt(suggestion) {
172
+ const update = suggestion.description.trim();
173
+ return update ? update.toLowerCase().includes("has been") || update.toLowerCase().includes("was changed") || update.toLowerCase().includes("corrected to") ? {
174
+ valid: !0,
175
+ status: "warning",
176
+ reason: "This update may be describing past changes rather than adding new rules"
177
+ } : { valid: !0, status: "valid" } : {
178
+ valid: !1,
179
+ status: "invalid",
180
+ reason: "Prompt update cannot be empty"
181
+ };
182
+ }
83
183
  async function analyzeFeedback({
84
184
  client,
85
185
  sourceLanguage,
@@ -107,8 +207,25 @@ async function analyzeFeedback({
107
207
  currentDocument
108
208
  }), analysis = await analyzeWithAI(client, analysisPrompt);
109
209
  devLog.info("AI analysis complete", { analysis });
110
- const suggestions = convertAnalysisToSuggestions(analysis);
111
- return devLog.info("Generated suggestions", { count: suggestions.length }), suggestions;
210
+ const suggestions = convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage);
211
+ devLog.info("Generated suggestions", { count: suggestions.length }), onProgress?.({ stage: "analyzing", message: "Validating suggestions..." });
212
+ const validatedSuggestions = await Promise.all(
213
+ suggestions.map(async (suggestion) => {
214
+ const validation = await validateSuggestion(client, suggestion);
215
+ return {
216
+ ...suggestion,
217
+ warning: validation.reason,
218
+ validationStatus: validation.status,
219
+ // Only auto-approve valid suggestions; invalid ones cannot be approved
220
+ approved: validation.status === "valid"
221
+ };
222
+ })
223
+ );
224
+ return devLog.info("Suggestions validated", {
225
+ valid: validatedSuggestions.filter((s) => s.validationStatus === "valid").length,
226
+ warnings: validatedSuggestions.filter((s) => s.validationStatus === "warning").length,
227
+ invalid: validatedSuggestions.filter((s) => s.validationStatus === "invalid").length
228
+ }), validatedSuggestions;
112
229
  }
113
230
  async function applySuggestions({
114
231
  client,
@@ -216,11 +333,67 @@ function buildAnalysisPrompt({
216
333
  "",
217
334
  "Be conservative: only suggest changes when there is clear evidence.",
218
335
  "If in doubt, prefer locale-specific updates over global updates.",
219
- "Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts."
336
+ "Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts.",
337
+ "",
338
+ "CONCRETE EXAMPLES:",
339
+ "",
340
+ "Example 1 - Brand name protection:",
341
+ 'User feedback: "Braze should never be translated"',
342
+ "Correct output:",
343
+ "{",
344
+ ' "protectedPhrasesToAdd": ["Braze"],',
345
+ ' "reasoning": "User explicitly stated Braze is a brand name that should not be translated"',
346
+ "}",
347
+ "WRONG: Do not add this to glossary or prompts.",
348
+ "",
349
+ "Example 2 - Terminology correction:",
350
+ 'Edit analysis: User changed "discussion" to "chat" consistently in French translation',
351
+ "Correct output:",
352
+ "{",
353
+ ` "glossaryTermsToAdd": [{"sourceLocale": "${sourceLanguage}", "sourceValue": "discussion", "targetLocale": "${targetLanguage}", "targetValue": "chat"}],`,
354
+ ' "reasoning": "User consistently changed discussion\u2192chat, indicating preferred terminology"',
355
+ "}",
356
+ "WRONG: Do not add to protected phrases (values differ) or prompts (specific mapping).",
357
+ "",
358
+ "Example 3 - Style guidance:",
359
+ 'User feedback: "Translation is too formal, use casual tone"',
360
+ "Correct output:",
361
+ "{",
362
+ ` "localePromptUpdates": "Use a casual, conversational tone when translating to ${targetLanguage}. Avoid overly formal language.",`,
363
+ ' "reasoning": "This is style guidance specific to this language pair"',
364
+ "}",
365
+ "WRONG: Do not make global (applies only to this target language).",
366
+ "",
367
+ "Example 4 - Technical term in English:",
368
+ `User feedback: "Keep technical terms like 'API' and 'webhook' in English"`,
369
+ "Correct output:",
370
+ "{",
371
+ ` "localePromptUpdates": "When translating to ${targetLanguage}, keep technical terms like 'API', 'webhook', and similar technical jargon in English.",`,
372
+ ' "reasoning": "This is language-pair-specific guidance about handling technical terms"',
373
+ "}",
374
+ 'WRONG: Do not add "API" or "webhook" to protected phrases (already covered by prompt guidance).',
375
+ "",
376
+ "Example 5 - Multiple issues:",
377
+ 'User feedback: "MyApp is our product name. Also, use more concise language."',
378
+ "Correct output:",
379
+ "{",
380
+ ' "protectedPhrasesToAdd": ["MyApp"],',
381
+ ` "localePromptUpdates": "Use concise language when translating to ${targetLanguage}. Prefer shorter phrases over lengthy explanations.",`,
382
+ ' "reasoning": "MyApp is a product name (protected). Conciseness is a style preference for this language pair (locale prompt)."',
383
+ "}",
384
+ "",
385
+ "Example 6 - Identical source and target:",
386
+ 'Edit analysis: User kept "dashboard" as "dashboard" in Spanish',
387
+ "Correct output:",
388
+ "{",
389
+ ' "protectedPhrasesToAdd": ["dashboard"],',
390
+ ' "reasoning": "User kept the term unchanged, indicating it should not be translated"',
391
+ "}",
392
+ "WRONG: Do not add to glossary when source === target."
220
393
  ), parts.join(`
221
394
  `);
222
395
  }
223
- function convertAnalysisToSuggestions(analysis) {
396
+ function convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage) {
224
397
  const suggestions = [];
225
398
  if (analysis.globalPromptUpdates && suggestions.push({
226
399
  id: crypto.randomUUID(),
@@ -247,9 +420,10 @@ function convertAnalysisToSuggestions(analysis) {
247
420
  type: "glossaryTerm",
248
421
  description: `${term.sourceValue} \u2192 ${term.targetValue}`,
249
422
  approved: !0,
250
- sourceLocale: term.sourceLocale,
423
+ // Use the provided source/target languages instead of relying on AI to provide them
424
+ sourceLocale: sourceLanguage,
251
425
  sourceValue: term.sourceValue,
252
- targetLocale: term.targetLocale,
426
+ targetLocale: targetLanguage,
253
427
  targetValue: term.targetValue
254
428
  });
255
429
  return suggestions;
@@ -296,13 +470,35 @@ function parseAIResponse(responseText) {
296
470
  }
297
471
  }
298
472
  async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage, suggestions, onProgress) {
299
- devLog.info("Applying improvements from suggestions", { count: suggestions.length });
473
+ devLog.info("Applying improvements from suggestions", {
474
+ count: suggestions.length,
475
+ targetLanguage,
476
+ suggestions: suggestions.map((s) => ({
477
+ type: s.type,
478
+ approved: s.approved,
479
+ sourceLocale: s.sourceLocale,
480
+ targetLocale: s.targetLocale,
481
+ sourceValue: s.sourceValue,
482
+ targetValue: s.targetValue
483
+ }))
484
+ });
300
485
  const settingsId = "translation-settings";
301
486
  await client.fetch("*[_id == $id][0]", { id: settingsId }) || await client.createIfNotExists({
302
487
  _id: settingsId,
303
488
  _type: "translationSettings"
304
489
  });
305
- const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()), glossaryTerms = suggestions.filter(
490
+ const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()), glossaryTermsBeforeFilter = suggestions.filter((s) => s.type === "glossaryTerm");
491
+ devLog.info("Glossary terms before filtering", {
492
+ count: glossaryTermsBeforeFilter.length,
493
+ terms: glossaryTermsBeforeFilter.map((s) => ({
494
+ sourceLocale: s.sourceLocale,
495
+ sourceValue: s.sourceValue,
496
+ targetLocale: s.targetLocale,
497
+ targetValue: s.targetValue,
498
+ approved: s.approved
499
+ }))
500
+ });
501
+ const glossaryTerms = suggestions.filter(
306
502
  (s) => s.type === "glossaryTerm" && s.sourceLocale?.trim() && s.sourceValue?.trim() && s.targetLocale?.trim() && s.targetValue?.trim()
307
503
  );
308
504
  if (devLog.info("Filtered suggestions by type", {
@@ -313,7 +509,7 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
313
509
  }), (globalPrompts.length > 0 || localePrompts.length > 0) && (onProgress?.({
314
510
  stage: "applying",
315
511
  message: globalPrompts.length > 0 && localePrompts.length > 0 ? "Updating global and locale translation prompts..." : globalPrompts.length > 0 ? "Updating global translation prompt..." : `Updating ${targetLanguage} translation prompt...`
316
- }), updates.push(updatePromptsWithTransform(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts))), protectedPhrases.length > 0) {
512
+ }), updates.push(updatePromptsWithTemplate(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts))), protectedPhrases.length > 0) {
317
513
  onProgress?.({ stage: "applying", message: "Adding protected phrases..." });
318
514
  const phrases = protectedPhrases.map((s) => s.description);
319
515
  devLog.debug("Applying protected phrases", { phrases }), updates.push(addProtectedPhrases(client, settingsId, phrases));
@@ -330,14 +526,17 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
330
526
  }));
331
527
  devLog.debug("Applying glossary terms", { terms }), updates.push(addGlossaryTerms(client, terms));
332
528
  }
333
- updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates), devLog.info("All improvements applied successfully");
529
+ updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates), onProgress?.({ stage: "applying", message: "Recording changes..." }), await recordChangeHistory(
530
+ client,
531
+ settingsId,
532
+ targetLanguage,
533
+ suggestions.filter((s) => s.approved)
534
+ ), devLog.info("All improvements applied successfully");
334
535
  }
335
- async function updatePromptsWithTransform(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
336
- if (devLog.info("Updating prompts with transform action", {
536
+ async function updatePromptsWithTemplate(client, _schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
537
+ if (devLog.info("Updating prompts with deterministic template", {
337
538
  settingsId,
338
539
  targetLanguage,
339
- targetLanguageType: typeof targetLanguage,
340
- targetLanguageValue: targetLanguage,
341
540
  hasGlobalUpdates: globalPrompts.length > 0,
342
541
  hasLocaleUpdates: localePrompts.length > 0
343
542
  }), !targetLanguage)
@@ -346,91 +545,56 @@ async function updatePromptsWithTransform(client, schemaId, settingsId, targetLa
346
545
  globalPrompts: globalPrompts.length,
347
546
  localePrompts: localePrompts.length
348
547
  }), new Error("targetLanguage is required but was undefined");
349
- const targets = [];
548
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], currentSettings = await client.fetch("*[_id == $settingsId][0]{ globalPrompt, localePrompts }", { settingsId });
350
549
  if (globalPrompts.length > 0) {
351
- const combinedGlobalUpdate = globalPrompts.map((s) => s.description).join(`
352
-
550
+ const currentGlobalPrompt = currentSettings?.globalPrompt || "", updates = globalPrompts.map((s) => s.description.trim()).filter(Boolean), newGlobalPrompt = [
551
+ currentGlobalPrompt,
552
+ currentGlobalPrompt ? `
553
+ ` : "",
554
+ `## User Feedback (${timestamp})`,
555
+ ...updates.map((update) => `- ${update}`)
556
+ ].filter(Boolean).join(`
353
557
  `);
354
- targets.push({
355
- path: "globalPrompt",
356
- instruction: `You are updating a translation prompt document that contains rules and guidance for future AI translations.
357
-
358
- IMPORTANT: The text below is NEW GUIDANCE that should be INCORPORATED INTO the existing prompt as additional rules/instructions for future translations. DO NOT apply or execute these rules on the prompt text itself.
359
-
360
- Your task: Merge the new guidance below into the existing prompt text. Treat the new guidance as rules that will guide future translations, not as transformations to apply to the current text.
361
-
362
- Integration guidelines:
363
- - Add the new guidance as additional instructions/rules in the prompt
364
- - If it conflicts with existing guidance, update or replace the conflicting parts
365
- - If it's already covered, don't add redundant text
366
- - Maintain clear structure and consistent tone
367
- - Preserve all existing guidance that is still relevant
368
- - Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
369
-
370
- New guidance to incorporate:
371
-
372
- \`\`\`
373
- ${combinedGlobalUpdate}
374
- \`\`\``
375
- }), devLog.debug("Added global prompt target", { combinedUpdate: combinedGlobalUpdate });
558
+ await client.patch(settingsId).set({ globalPrompt: newGlobalPrompt }).commit(), devLog.info("Global prompt updated", {
559
+ previousLength: currentGlobalPrompt.length,
560
+ newLength: newGlobalPrompt.length,
561
+ addedUpdates: updates.length
562
+ });
376
563
  }
377
564
  if (localePrompts.length > 0) {
378
- const combinedLocaleUpdate = localePrompts.map((s) => s.description).join(`
379
-
380
- `), existingLocalePrompts = await client.fetch(
381
- "*[_id == $settingsId][0].localePrompts",
382
- { settingsId }
383
- ), hasExistingLocalePrompt = existingLocalePrompts?.some((lp) => lp._key === targetLanguage);
384
- devLog.debug("Checking existing locale prompt", {
385
- targetLanguage,
386
- hasExistingLocalePrompt,
387
- existingCount: existingLocalePrompts?.length || 0
388
- }), hasExistingLocalePrompt || (devLog.info("Creating new locale prompt entry", { targetLanguage }), await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
389
- {
390
- _type: "internationalizedArrayTextValue",
391
- _key: targetLanguage,
392
- value: ""
393
- // Start with empty value, transform will fill it
394
- }
395
- ]).commit());
396
- const localePromptPath = ["localePrompts", { _key: targetLanguage }, "value"];
397
- targets.push({
398
- // @ts-expect-error
399
- path: localePromptPath,
400
- instruction: `You are updating a translation prompt document that contains rules and guidance for future AI translations.
401
-
402
- IMPORTANT: The text below is NEW GUIDANCE that should be INCORPORATED INTO the existing prompt as additional rules/instructions for future translations. DO NOT apply or execute these rules on the prompt text itself.
403
-
404
- Your task: Merge the new guidance below into the existing prompt text. Treat the new guidance as rules that will guide future translations, not as transformations to apply to the current text.
405
-
406
- Integration guidelines:
407
- - Add the new guidance as additional instructions/rules in the prompt
408
- - If it conflicts with existing guidance, update or replace the conflicting parts
409
- - If it's already covered, don't add redundant text
410
- - Maintain clear structure and consistent tone
411
- - Preserve all existing guidance that is still relevant
412
- - Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
413
-
414
- New guidance to incorporate:
415
-
416
- \`\`\`
417
- ${combinedLocaleUpdate}
418
- \`\`\``
419
- }), devLog.debug("Added locale prompt target", {
420
- targetLanguage,
421
- localePromptPath,
422
- combinedUpdate: combinedLocaleUpdate
423
- });
565
+ const updates = localePrompts.map((s) => s.description.trim()).filter(Boolean), existingLocalePrompts = currentSettings?.localePrompts || [], existingLocalePrompt = existingLocalePrompts.find((lp) => lp._key === targetLanguage);
566
+ if (existingLocalePrompt) {
567
+ const currentValue = existingLocalePrompt.value || "", newValue = [
568
+ currentValue,
569
+ currentValue ? `
570
+ ` : "",
571
+ `## User Feedback (${timestamp})`,
572
+ ...updates.map((update) => `- ${update}`)
573
+ ].filter(Boolean).join(`
574
+ `), localeIndex = existingLocalePrompts.findIndex((lp) => lp._key === targetLanguage);
575
+ await client.patch(settingsId).set({ [`localePrompts[${localeIndex}].value`]: newValue }).commit(), devLog.info("Locale prompt updated", {
576
+ targetLanguage,
577
+ previousLength: currentValue.length,
578
+ newLength: newValue.length,
579
+ addedUpdates: updates.length
580
+ });
581
+ } else {
582
+ const newValue = [`## User Feedback (${timestamp})`, ...updates.map((update) => `- ${update}`)].filter(Boolean).join(`
583
+ `);
584
+ await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
585
+ {
586
+ _type: "internationalizedArrayTextValue",
587
+ _key: targetLanguage,
588
+ value: newValue
589
+ }
590
+ ]).commit(), devLog.info("Locale prompt created", {
591
+ targetLanguage,
592
+ length: newValue.length,
593
+ addedUpdates: updates.length
594
+ });
595
+ }
424
596
  }
425
- const result = await client.agent.action.transform({
426
- schemaId,
427
- documentId: settingsId,
428
- instruction: "Update translation prompts based on user feedback to improve future translations.",
429
- target: targets,
430
- forcePublishedWrite: !0
431
- });
432
- devLog.info("Prompts updated via transform action", {
433
- resultId: result._id,
597
+ devLog.info("Prompts updated successfully", {
434
598
  globalUpdated: globalPrompts.length > 0,
435
599
  localeUpdated: localePrompts.length > 0
436
600
  });
@@ -439,8 +603,14 @@ async function addProtectedPhrases(client, settingsId, phrases) {
439
603
  devLog.info("Adding protected phrases", { settingsId, phrasesCount: phrases.length, phrases });
440
604
  const currentPhrases = await client.fetch("*[_id == $settingsId][0].protectedPhrases", { settingsId });
441
605
  devLog.debug("Fetched current protected phrases", { currentCount: currentPhrases?.length });
442
- const newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
443
- if (newPhrases.length === 0) {
606
+ let newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
607
+ const conflictingPhrases = [];
608
+ for (const phrase of newPhrases)
609
+ await client.fetch(
610
+ 'count(*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0]) > 0',
611
+ { phrase }
612
+ ) && (conflictingPhrases.push(phrase), devLog.warn("Skipping protected phrase due to glossary conflict", { phrase }));
613
+ if (newPhrases = newPhrases.filter((phrase) => !conflictingPhrases.includes(phrase)), newPhrases.length === 0) {
444
614
  devLog.info("No new protected phrases to add (all already exist)");
445
615
  return;
446
616
  }
@@ -448,7 +618,13 @@ async function addProtectedPhrases(client, settingsId, phrases) {
448
618
  devLog.info("Protected phrases added", { count: newPhrases.length, phrases: newPhrases, resultId: result._id });
449
619
  }
450
620
  async function addGlossaryTerms(client, terms) {
451
- const validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
621
+ const protectedPhrasesLower = (await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.map((p) => p.toLowerCase()) || [], validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
622
+ sourceValue,
623
+ targetValue
624
+ }), !1) : protectedPhrasesLower.includes(sourceValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because source value is a protected phrase", {
625
+ sourceValue,
626
+ targetValue
627
+ }), !1) : protectedPhrasesLower.includes(targetValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because target value is a protected phrase", {
452
628
  sourceValue,
453
629
  targetValue
454
630
  }), !1) : !0);
@@ -465,32 +641,47 @@ async function addGlossaryTerms(client, terms) {
465
641
  }`,
466
642
  { sourceLocale, sourceValue }
467
643
  );
468
- existingTerm ? existingTerm.translations.some((t) => t._key === targetLocale) ? devLog.info("Glossary term already has this translation", {
469
- sourceLocale,
470
- sourceValue,
471
- targetLocale,
472
- targetValue
473
- }) : (await client.patch(existingTerm._id).append("translations", [
474
- {
475
- _type: "internationalizedArrayStringValue",
476
- _key: targetLocale,
477
- value: targetValue
478
- }
479
- ]).commit(), devLog.info("Updated existing glossary term", { sourceLocale, sourceValue, targetLocale, targetValue })) : (await client.create({
480
- _type: "translationGlossaryTerm",
481
- translations: [
482
- {
483
- _type: "internationalizedArrayStringValue",
484
- _key: sourceLocale,
485
- value: sourceValue
486
- },
644
+ if (existingTerm) {
645
+ const existingTargetIndex = existingTerm.translations.findIndex((t) => t._key === targetLocale), existingTargetValue = existingTargetIndex >= 0 ? existingTerm.translations[existingTargetIndex].value : null;
646
+ existingTargetIndex === -1 ? (await client.patch(existingTerm._id).append("translations", [
487
647
  {
488
648
  _type: "internationalizedArrayStringValue",
489
649
  _key: targetLocale,
490
650
  value: targetValue
491
651
  }
492
- ]
493
- }), devLog.info("Created new glossary term", { sourceLocale, sourceValue, targetLocale, targetValue }));
652
+ ]).commit(), devLog.info("Added target language to existing glossary term", {
653
+ sourceLocale,
654
+ sourceValue,
655
+ targetLocale,
656
+ targetValue
657
+ })) : existingTargetValue !== targetValue ? (await client.patch(existingTerm._id).set({ [`translations[${existingTargetIndex}].value`]: targetValue }).commit(), devLog.info("Updated existing glossary term translation", {
658
+ sourceLocale,
659
+ sourceValue,
660
+ targetLocale,
661
+ oldValue: existingTargetValue,
662
+ newValue: targetValue
663
+ })) : devLog.info("Glossary term already has this exact translation", {
664
+ sourceLocale,
665
+ sourceValue,
666
+ targetLocale,
667
+ targetValue
668
+ });
669
+ } else
670
+ await client.create({
671
+ _type: "translationGlossaryTerm",
672
+ translations: [
673
+ {
674
+ _type: "internationalizedArrayStringValue",
675
+ _key: sourceLocale,
676
+ value: sourceValue
677
+ },
678
+ {
679
+ _type: "internationalizedArrayStringValue",
680
+ _key: targetLocale,
681
+ value: targetValue
682
+ }
683
+ ]
684
+ }), devLog.info("Created new glossary term", { sourceLocale, sourceValue, targetLocale, targetValue });
494
685
  }
495
686
  devLog.info("Glossary terms processed", {
496
687
  totalRequested: terms.length,
@@ -498,6 +689,20 @@ async function addGlossaryTerms(client, terms) {
498
689
  skipped: terms.length - validTerms.length
499
690
  });
500
691
  }
692
+ async function recordChangeHistory(client, settingsId, targetLanguage, suggestions) {
693
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString(), historyEntries = suggestions.map((suggestion) => ({
694
+ _type: "object",
695
+ _key: crypto.randomUUID(),
696
+ timestamp,
697
+ changeType: suggestion.type,
698
+ description: suggestion.description,
699
+ locale: suggestion.type === "localePrompt" ? targetLanguage : void 0
700
+ }));
701
+ historyEntries.length !== 0 && (await client.patch(settingsId).setIfMissing({ changeHistory: [] }).append("changeHistory", historyEntries).commit(), devLog.info("Change history recorded", {
702
+ count: historyEntries.length,
703
+ timestamp
704
+ }));
705
+ }
501
706
  const ICON_SIZE = 64, progressIconMap = {
502
707
  in_progress: /* @__PURE__ */ jsx(Spinner, {}),
503
708
  completed: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: ICON_SIZE }),
@@ -520,7 +725,7 @@ const ICON_SIZE = 64, progressIconMap = {
520
725
  localePrompt: "Locale Prompt",
521
726
  protectedPhrase: "Protected Phrase",
522
727
  glossaryTerm: "Glossary Term"
523
- }, createNewSuggestion = (type = "protectedPhrase") => {
728
+ }, createNewSuggestion = (type = "protectedPhrase", sourceLanguage, targetLanguage) => {
524
729
  const baseSuggestion = {
525
730
  id: crypto.randomUUID(),
526
731
  type,
@@ -530,11 +735,18 @@ const ICON_SIZE = 64, progressIconMap = {
530
735
  return type === "glossaryTerm" ? {
531
736
  ...baseSuggestion,
532
737
  sourceValue: "",
533
- targetValue: ""
738
+ targetValue: "",
739
+ sourceLocale: sourceLanguage,
740
+ targetLocale: targetLanguage
534
741
  } : baseSuggestion;
535
- }, SuggestionReviewList = ({ suggestions, onSuggestionsChange }) => {
742
+ }, SuggestionReviewList = ({
743
+ suggestions,
744
+ onSuggestionsChange,
745
+ sourceLanguage,
746
+ targetLanguage
747
+ }) => {
536
748
  const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
537
- const updated = suggestions.map((s) => s.id === id ? { ...s, approved: !s.approved } : s);
749
+ const updated = suggestions.map((s) => s.id === id && s.validationStatus !== "invalid" ? { ...s, approved: !s.approved } : s);
538
750
  onSuggestionsChange(updated);
539
751
  }, handleTypeChange = (id, newType) => {
540
752
  const updated = suggestions.map((s) => {
@@ -546,7 +758,9 @@ const ICON_SIZE = 64, progressIconMap = {
546
758
  ...s,
547
759
  type: newType,
548
760
  sourceValue: match ? match[1].trim() : "",
549
- targetValue: match ? match[2].trim() : ""
761
+ targetValue: match ? match[2].trim() : "",
762
+ sourceLocale: sourceLanguage,
763
+ targetLocale: targetLanguage
550
764
  };
551
765
  }
552
766
  return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
@@ -567,6 +781,7 @@ const ICON_SIZE = 64, progressIconMap = {
567
781
  ...s,
568
782
  sourceValue: newSource,
569
783
  description: `${newSource} \u2192 ${s.targetValue || ""}`
784
+ // Preserve sourceLocale and targetLocale
570
785
  } : s
571
786
  );
572
787
  onSuggestionsChange(updated);
@@ -576,6 +791,7 @@ const ICON_SIZE = 64, progressIconMap = {
576
791
  ...s,
577
792
  targetValue: newTarget,
578
793
  description: `${s.sourceValue || ""} \u2192 ${newTarget}`
794
+ // Preserve sourceLocale and targetLocale
579
795
  } : s
580
796
  );
581
797
  onSuggestionsChange(updated);
@@ -583,7 +799,7 @@ const ICON_SIZE = 64, progressIconMap = {
583
799
  const updated = suggestions.filter((s) => s.id !== id);
584
800
  onSuggestionsChange(updated);
585
801
  }, handleAddSuggestion = (type) => {
586
- const newSuggestion = createNewSuggestion(type);
802
+ const newSuggestion = createNewSuggestion(type, sourceLanguage, targetLanguage);
587
803
  onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
588
804
  };
589
805
  return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
@@ -610,16 +826,24 @@ const ICON_SIZE = 64, progressIconMap = {
610
826
  ) })
611
827
  ] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
612
828
  suggestions.map((suggestion) => {
613
- const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt";
829
+ const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt", hasWarning = suggestion.warning && suggestion.validationStatus !== "valid", isInvalid = suggestion.validationStatus === "invalid";
614
830
  return /* @__PURE__ */ jsx(
615
831
  Card,
616
832
  {
617
833
  padding: 4,
618
834
  border: !0,
619
- tone: suggestion.approved ? "default" : "transparent",
835
+ tone: isInvalid ? "critical" : hasWarning ? "caution" : suggestion.approved ? "default" : "transparent",
620
836
  style: { opacity: suggestion.approved ? 1 : 0.5 },
621
837
  children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
622
- /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
838
+ /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: isInvalid ? /* @__PURE__ */ jsx(
839
+ Tooltip,
840
+ {
841
+ content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "This suggestion cannot be applied because it conflicts with existing settings. See the error message below for details." }) }),
842
+ placement: "top",
843
+ portal: !0,
844
+ children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Checkbox, { checked: !1, disabled: !0, readOnly: !0 }) })
845
+ }
846
+ ) : /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
623
847
  /* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
624
848
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
625
849
  /* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
@@ -628,6 +852,7 @@ const ICON_SIZE = 64, progressIconMap = {
628
852
  fontSize: 1,
629
853
  value: suggestion.type,
630
854
  onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
855
+ "aria-label": "Suggestion type",
631
856
  children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
632
857
  }
633
858
  ) }),
@@ -639,31 +864,42 @@ const ICON_SIZE = 64, progressIconMap = {
639
864
  icon: TrashIcon,
640
865
  fontSize: 1,
641
866
  padding: 2,
642
- onClick: () => handleRemove(suggestion.id)
867
+ onClick: () => handleRemove(suggestion.id),
868
+ "aria-label": "Remove suggestion",
869
+ title: "Remove suggestion"
643
870
  }
644
871
  )
645
872
  ] }),
646
- isGlossaryTerm ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
647
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
648
- TextInput,
649
- {
650
- fontSize: 1,
651
- placeholder: "Source term",
652
- value: suggestion.sourceValue || "",
653
- onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value)
654
- }
655
- ) }),
656
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "\u2192" }),
657
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
658
- TextInput,
659
- {
660
- fontSize: 1,
661
- placeholder: "Target term",
662
- value: suggestion.targetValue || "",
663
- onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value)
664
- }
665
- ) })
666
- ] }) }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
873
+ isGlossaryTerm ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
874
+ (suggestion.sourceLocale || suggestion.targetLocale) && /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
875
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.sourceLocale ? `Source (${suggestion.sourceLocale})` : "Source" }) }),
876
+ /* @__PURE__ */ jsx(Box, { style: { width: "20px" } }),
877
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.targetLocale ? `Target (${suggestion.targetLocale})` : "Target" }) })
878
+ ] }),
879
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
880
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
881
+ TextInput,
882
+ {
883
+ fontSize: 1,
884
+ placeholder: "Source term",
885
+ value: suggestion.sourceValue || "",
886
+ onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value),
887
+ "aria-label": `Source term in ${suggestion.sourceLocale || "source language"}`
888
+ }
889
+ ) }),
890
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, "aria-hidden": "true", children: "\u2192" }),
891
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
892
+ TextInput,
893
+ {
894
+ fontSize: 1,
895
+ placeholder: "Target term",
896
+ value: suggestion.targetValue || "",
897
+ onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value),
898
+ "aria-label": `Target term in ${suggestion.targetLocale || "target language"}`
899
+ }
900
+ ) })
901
+ ] })
902
+ ] }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
667
903
  TextArea,
668
904
  {
669
905
  fontSize: 1,
@@ -672,7 +908,8 @@ const ICON_SIZE = 64, progressIconMap = {
672
908
  onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
673
909
  onFocus: () => setEditingId(suggestion.id),
674
910
  onBlur: () => setEditingId(null),
675
- placeholder: "Enter prompt guidance..."
911
+ placeholder: "Enter prompt guidance...",
912
+ "aria-label": `${IMPROVEMENT_TYPE_LABELS[suggestion.type]} content`
676
913
  }
677
914
  ) }) : /* @__PURE__ */ jsx(
678
915
  TextInput,
@@ -680,9 +917,17 @@ const ICON_SIZE = 64, progressIconMap = {
680
917
  fontSize: 1,
681
918
  value: suggestion.description,
682
919
  onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
683
- placeholder: "Enter protected phrase..."
920
+ placeholder: "Enter protected phrase...",
921
+ "aria-label": "Protected phrase"
684
922
  }
685
- )
923
+ ),
924
+ hasWarning && /* @__PURE__ */ jsx(Card, { padding: 3, tone: isInvalid ? "critical" : "caution", border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 2, children: [
925
+ /* @__PURE__ */ jsx(Text, { size: 1, children: isInvalid ? /* @__PURE__ */ jsx(WarningOutlineIcon, {}) : /* @__PURE__ */ jsx(InfoOutlineIcon, {}) }),
926
+ /* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
927
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: isInvalid ? "Invalid Suggestion" : "Warning" }),
928
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: suggestion.warning })
929
+ ] })
930
+ ] }) })
686
931
  ] })
687
932
  ] }) })
688
933
  },
@@ -704,7 +949,7 @@ const ICON_SIZE = 64, progressIconMap = {
704
949
  }, GiveFeedbackDialog = ({ setIsDialogOpen }) => {
705
950
  const client = useClient({ apiVersion: "vX" }), { document, config, translationMetadata } = useTranslationContext(), { loading } = useTranslationMetadata({
706
951
  documentId: document._id
707
- }), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), handleShouldIncludeEditsChange = () => {
952
+ }), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), [sourceLanguage, setSourceLanguage] = useState(""), [targetLanguage, setTargetLanguage] = useState(""), handleShouldIncludeEditsChange = () => {
708
953
  setShouldIncludeEdits(!shouldIncludeEdits);
709
954
  }, handleCancel = () => {
710
955
  setIsDialogOpen(!1);
@@ -726,14 +971,16 @@ const ICON_SIZE = 64, progressIconMap = {
726
971
  throw new Error(
727
972
  "No translation history found for this document. Please ensure this document was translated using the AI Translation plugin."
728
973
  );
729
- const { sourceSnapshot, targetSnapshot, sourceLanguage } = translationHistory2;
974
+ const { sourceSnapshot, targetSnapshot, sourceLanguage: srcLang } = translationHistory2;
730
975
  if (!sourceSnapshot || !targetSnapshot)
731
976
  throw new Error("Translation snapshots are missing from history.");
977
+ const srcLanguage = srcLang || "en-us", tgtLanguage = document[config.languageFieldName];
978
+ setSourceLanguage(srcLanguage), setTargetLanguage(tgtLanguage);
732
979
  const generatedSuggestions = await analyzeFeedback({
733
980
  client,
734
981
  schemaId: config.schemaId,
735
- sourceLanguage: sourceLanguage || "en-us",
736
- targetLanguage: document[config.languageFieldName],
982
+ sourceLanguage: srcLanguage,
983
+ targetLanguage: tgtLanguage,
737
984
  userFeedback: feedback || void 0,
738
985
  shouldIncludeEdits,
739
986
  sourceSnapshot,
@@ -787,7 +1034,15 @@ const ICON_SIZE = 64, progressIconMap = {
787
1034
  /* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
788
1035
  /* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
789
1036
  ] }),
790
- /* @__PURE__ */ jsx(SuggestionReviewList, { suggestions, onSuggestionsChange: setSuggestions }),
1037
+ /* @__PURE__ */ jsx(
1038
+ SuggestionReviewList,
1039
+ {
1040
+ suggestions,
1041
+ onSuggestionsChange: setSuggestions,
1042
+ sourceLanguage,
1043
+ targetLanguage
1044
+ }
1045
+ ),
791
1046
  error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
792
1047
  /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
793
1048
  /* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
@@ -895,6 +1150,33 @@ const ICON_SIZE = 64, progressIconMap = {
895
1150
  )
896
1151
  ] })
897
1152
  ] });
1153
+ }, validateOpenAIApiKey = async (apiKey, model = "gpt-5") => {
1154
+ try {
1155
+ const response = await fetch("https://api.openai.com/v1/models", {
1156
+ method: "GET",
1157
+ headers: {
1158
+ Authorization: `Bearer ${apiKey}`
1159
+ }
1160
+ });
1161
+ if (!response.ok)
1162
+ return {
1163
+ valid: !1,
1164
+ error: (await response.json()).error?.message || "Invalid API key"
1165
+ };
1166
+ const availableModels = (await response.json()).data.map((m) => m.id);
1167
+ return availableModels.includes(model) ? {
1168
+ valid: !0,
1169
+ modelAvailable: availableModels
1170
+ } : {
1171
+ valid: !1,
1172
+ error: `Model ${model} not available. Try: ${availableModels.slice(0, 5).join(", ")}`
1173
+ };
1174
+ } catch (error) {
1175
+ return {
1176
+ valid: !1,
1177
+ error: error instanceof Error ? error.message : "Network error"
1178
+ };
1179
+ }
898
1180
  }, translationReducer = (context, action) => {
899
1181
  const logContext = {
900
1182
  documentId: context.document?._id,
@@ -914,10 +1196,15 @@ const ICON_SIZE = 64, progressIconMap = {
914
1196
  ...context,
915
1197
  selectedTargetLanguages: action.payload
916
1198
  };
917
- case "SET_SHOULD_TRANSLATE_DIFFS":
1199
+ case "SET_TRANSLATION_MODE":
1200
+ return {
1201
+ ...context,
1202
+ translationMode: action.payload
1203
+ };
1204
+ case "SET_OPENAI_VALIDATED":
918
1205
  return {
919
1206
  ...context,
920
- shouldTranslateDiffs: action.payload
1207
+ openaiValidated: action.payload
921
1208
  };
922
1209
  case "SET_SHOULD_TRANSLATE_REFERENCES":
923
1210
  return {
@@ -962,18 +1249,44 @@ const ICON_SIZE = 64, progressIconMap = {
962
1249
  overallStatus: "idle",
963
1250
  translationMetadata: null,
964
1251
  selectedTargetLanguages: [],
965
- shouldTranslateDiffs: !1,
966
1252
  shouldOverwriteFreshTranslations: !1,
967
1253
  shouldTranslateReferences: !1,
968
1254
  maxReferenceDepth: 1,
969
1255
  references: [],
970
- translationJobs: /* @__PURE__ */ new Map()
1256
+ translationJobs: /* @__PURE__ */ new Map(),
1257
+ translationMode: config?.openai?.defaultMode || "sanity-only",
1258
+ openaiValidated: !1
971
1259
  }, [context, dispatch] = useReducer(translationReducer, initialContext);
972
- return devLog.debug("Translation context state initialized", {
1260
+ return useEffect(() => {
1261
+ (async () => {
1262
+ if (config?.openai?.enabled && config?.openai?.apiKey)
1263
+ try {
1264
+ const validation = await validateOpenAIApiKey(config.openai.apiKey, config.openai.model || "gpt-5");
1265
+ dispatch({
1266
+ type: "SET_OPENAI_VALIDATED",
1267
+ payload: validation.valid
1268
+ }), validation.valid ? devLog.info("OpenAI API key validated successfully", {
1269
+ model: config.openai.model || "gpt-5",
1270
+ component: "TranslationContextProvider"
1271
+ }) : devLog.error("OpenAI API key validation failed", {
1272
+ error: validation.error,
1273
+ component: "TranslationContextProvider"
1274
+ });
1275
+ } catch (error) {
1276
+ devLog.error("OpenAI validation error", error instanceof Error ? error : new Error(String(error)), {
1277
+ component: "TranslationContextProvider"
1278
+ }), dispatch({
1279
+ type: "SET_OPENAI_VALIDATED",
1280
+ payload: !1
1281
+ });
1282
+ }
1283
+ })();
1284
+ }, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), devLog.debug("Translation context state initialized", {
973
1285
  documentId: document._id,
974
1286
  overallStatus: context.overallStatus,
975
1287
  selectedLanguages: context.selectedTargetLanguages,
976
- shouldTranslateDiffs: context.shouldTranslateDiffs,
1288
+ translationMode: context.translationMode,
1289
+ openaiEnabled: config?.openai?.enabled,
977
1290
  component: "TranslationContextProvider"
978
1291
  }), /* @__PURE__ */ jsx(TranslationContext.Provider, { value: { ...context, dispatch }, children });
979
1292
  }, getGiveTranslationFeedbackAction = (config) => ({ draft, published }) => {
@@ -1076,26 +1389,98 @@ const ICON_SIZE = 64, progressIconMap = {
1076
1389
  title: "Translation Settings",
1077
1390
  icon: TranslateIcon,
1078
1391
  type: "document",
1392
+ groups: [
1393
+ {
1394
+ name: "general",
1395
+ title: "General",
1396
+ default: !0
1397
+ },
1398
+ {
1399
+ name: "history",
1400
+ title: "History"
1401
+ }
1402
+ ],
1079
1403
  fields: [
1080
1404
  defineField({
1081
1405
  name: "globalPrompt",
1082
1406
  title: "Global Translation Prompt",
1083
1407
  description: "A prompt that will given to the Sanity AI Agent for all translation tasks.",
1084
1408
  type: "text",
1085
- rows: 5
1409
+ rows: 5,
1410
+ group: "general"
1086
1411
  }),
1087
1412
  defineField({
1088
1413
  name: "localePrompts",
1089
1414
  title: "Locale Prompts",
1090
1415
  description: "Prompts that will be given to the Sanity AI Agent for each locale.",
1091
- type: "internationalizedArrayText"
1416
+ type: "internationalizedArrayText",
1417
+ group: "general"
1092
1418
  }),
1093
1419
  defineField({
1094
1420
  name: "protectedPhrases",
1095
1421
  title: "Protected Phrases",
1096
1422
  description: "Phrases that will never be translated.",
1097
1423
  type: "array",
1098
- of: [{ type: "string" }]
1424
+ of: [{ type: "string" }],
1425
+ group: "general"
1426
+ }),
1427
+ defineField({
1428
+ name: "changeHistory",
1429
+ title: "Change History",
1430
+ description: "History of changes made via user feedback.",
1431
+ type: "array",
1432
+ readOnly: !0,
1433
+ group: "history",
1434
+ of: [
1435
+ {
1436
+ type: "object",
1437
+ fields: [
1438
+ defineField({
1439
+ name: "timestamp",
1440
+ title: "Timestamp",
1441
+ type: "datetime"
1442
+ }),
1443
+ defineField({
1444
+ name: "changeType",
1445
+ title: "Change Type",
1446
+ type: "string",
1447
+ options: {
1448
+ list: [
1449
+ { title: "Global Prompt", value: "globalPrompt" },
1450
+ { title: "Locale Prompt", value: "localePrompt" },
1451
+ { title: "Protected Phrase", value: "protectedPhrase" },
1452
+ { title: "Glossary Term", value: "glossaryTerm" }
1453
+ ]
1454
+ }
1455
+ }),
1456
+ defineField({
1457
+ name: "description",
1458
+ title: "Description",
1459
+ type: "text",
1460
+ rows: 3
1461
+ }),
1462
+ defineField({
1463
+ name: "locale",
1464
+ title: "Locale",
1465
+ type: "string",
1466
+ description: "Target locale for locale-specific changes"
1467
+ })
1468
+ ],
1469
+ preview: {
1470
+ select: {
1471
+ timestamp: "timestamp",
1472
+ changeType: "changeType",
1473
+ description: "description"
1474
+ },
1475
+ prepare({ timestamp, changeType, description }) {
1476
+ return {
1477
+ title: `${changeType} - ${new Date(timestamp).toLocaleDateString()}`,
1478
+ subtitle: description
1479
+ };
1480
+ }
1481
+ }
1482
+ }
1483
+ ]
1099
1484
  })
1100
1485
  ],
1101
1486
  preview: {
@@ -1103,13 +1488,180 @@ const ICON_SIZE = 64, progressIconMap = {
1103
1488
  title: "Translation Settings"
1104
1489
  })
1105
1490
  }
1106
- }), fetchTranslationPrompts = async ({
1491
+ });
1492
+ class Semaphore {
1493
+ permits;
1494
+ queue = [];
1495
+ constructor(permits) {
1496
+ this.permits = permits;
1497
+ }
1498
+ /**
1499
+ * Acquire a permit from the semaphore
1500
+ * If no permits are available, the caller will wait until one becomes available
1501
+ */
1502
+ async acquire() {
1503
+ return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
1504
+ this.queue.push(resolve);
1505
+ });
1506
+ }
1507
+ /**
1508
+ * Release a permit back to the semaphore
1509
+ * If there are waiting callers, one will be woken up
1510
+ */
1511
+ release() {
1512
+ const next = this.queue.shift();
1513
+ next ? next() : this.permits++;
1514
+ }
1515
+ /**
1516
+ * Execute a function with semaphore protection
1517
+ * Automatically acquires and releases the permit
1518
+ */
1519
+ async withPermit(fn) {
1520
+ await this.acquire();
1521
+ try {
1522
+ return await fn();
1523
+ } finally {
1524
+ this.release();
1525
+ }
1526
+ }
1527
+ /**
1528
+ * Get the number of available permits
1529
+ */
1530
+ availablePermits() {
1531
+ return this.permits;
1532
+ }
1533
+ /**
1534
+ * Get the number of waiting callers
1535
+ */
1536
+ queueLength() {
1537
+ return this.queue.length;
1538
+ }
1539
+ /**
1540
+ * Update the number of permits
1541
+ * If increasing permits, waiting callers will be notified
1542
+ * If decreasing permits below current available, available permits will be reduced
1543
+ */
1544
+ updatePermits(newPermits) {
1545
+ const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
1546
+ if (difference > 0) {
1547
+ for (let i = 0; i < difference && this.queue.length > 0; i++) {
1548
+ const next = this.queue.shift();
1549
+ next && next();
1550
+ }
1551
+ const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
1552
+ remainingIncrease > 0 && (this.permits += remainingIncrease);
1553
+ } else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
1554
+ }
1555
+ }
1556
+ function documentToPlainText(document) {
1557
+ if (!document) return "";
1558
+ const result = {};
1559
+ for (const [key, value] of Object.entries(document))
1560
+ Array.isArray(value) && value.some(
1561
+ (item) => typeof item == "object" && item !== null && "_type" in item && item._type === "block"
1562
+ ) ? result[key] = toPlainText(value) : result[key] = value;
1563
+ return JSON.stringify(result);
1564
+ }
1565
+ const DEFAULTS = {
1566
+ windowSlack: 2,
1567
+ minTokenMatchRatio: 1,
1568
+ stopwords: [
1569
+ // common English fillers — extend per your locales
1570
+ "a",
1571
+ "an",
1572
+ "the",
1573
+ "and",
1574
+ "or",
1575
+ "to",
1576
+ "of",
1577
+ "for",
1578
+ "in",
1579
+ "on",
1580
+ "at",
1581
+ "by",
1582
+ "with",
1583
+ "using",
1584
+ "from",
1585
+ "into",
1586
+ "over",
1587
+ "under",
1588
+ "as",
1589
+ "is",
1590
+ "are",
1591
+ "be"
1592
+ ],
1593
+ dropVeryShortTermTokens: !0,
1594
+ allowedWordCharsRegex: /[\p{L}\p{N}_]+/gu
1595
+ // letters/numbers/underscore (Unicode)
1596
+ };
1597
+ function normalizeForMatch(s) {
1598
+ return s.toLowerCase().normalize("NFKD").replace(new RegExp("\\p{M}+", "gu"), "").trim();
1599
+ }
1600
+ function tokenize(text, allowedWordChars = DEFAULTS.allowedWordCharsRegex) {
1601
+ return normalizeForMatch(text).match(allowedWordChars) || [];
1602
+ }
1603
+ function preprocessSource(sourceText, opts) {
1604
+ return { tokens: tokenize(sourceText, DEFAULTS.allowedWordCharsRegex) };
1605
+ }
1606
+ function prepareTermTokens(term, opts) {
1607
+ const filtered = tokenize(term, opts.allowedWordCharsRegex).filter((t) => !opts.stopwords.includes(t));
1608
+ return opts.dropVeryShortTermTokens ? filtered.filter((t) => t.length > 2) : filtered;
1609
+ }
1610
+ function freqMap(tokens) {
1611
+ const m = /* @__PURE__ */ new Map();
1612
+ for (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);
1613
+ return m;
1614
+ }
1615
+ function matchTermInDocument(term, source, options) {
1616
+ const opts = { ...DEFAULTS, ...options ?? {} }, termTokens = prepareTermTokens(term, opts);
1617
+ if (termTokens.length === 0)
1618
+ return { matched: !1, term, reason: "No substantive tokens after filtering." };
1619
+ const termFreq = freqMap(termTokens), requiredCount = Math.ceil(termTokens.length * opts.minTokenMatchRatio), windowSize = Math.max(1, termTokens.length + opts.windowSlack), interesting = new Set(termFreq.keys()), windowCounts = /* @__PURE__ */ new Map();
1620
+ let satisfiedCount = 0;
1621
+ const addToken = (tok) => {
1622
+ if (!interesting.has(tok)) return;
1623
+ const before = windowCounts.get(tok) ?? 0, after = before + 1;
1624
+ windowCounts.set(tok, after);
1625
+ const cap = termFreq.get(tok);
1626
+ before < cap && (satisfiedCount += 1);
1627
+ }, removeToken = (tok) => {
1628
+ if (!interesting.has(tok)) return;
1629
+ const before = windowCounts.get(tok) ?? 0;
1630
+ if (before === 0) return;
1631
+ const after = before - 1;
1632
+ windowCounts.set(tok, after);
1633
+ const cap = termFreq.get(tok);
1634
+ before <= cap && (satisfiedCount -= 1);
1635
+ }, { tokens } = source;
1636
+ for (let i = 0; i < Math.min(windowSize, tokens.length); i++) addToken(tokens[i]);
1637
+ if (satisfiedCount >= requiredCount)
1638
+ return {
1639
+ matched: !0,
1640
+ term,
1641
+ windowStart: 0,
1642
+ windowEnd: Math.min(windowSize, tokens.length) - 1,
1643
+ matchedRatio: satisfiedCount / termTokens.length
1644
+ };
1645
+ for (let start = 1; start + windowSize <= tokens.length; start++) {
1646
+ const prev = tokens[start - 1], next = tokens[start + windowSize - 1];
1647
+ if (removeToken(prev), addToken(next), satisfiedCount >= requiredCount)
1648
+ return {
1649
+ matched: !0,
1650
+ term,
1651
+ windowStart: start,
1652
+ windowEnd: start + windowSize - 1,
1653
+ matchedRatio: satisfiedCount / termTokens.length
1654
+ };
1655
+ }
1656
+ return { matched: !1, term, reason: "No window satisfied the token threshold." };
1657
+ }
1658
+ const fetchTranslationPrompts = async ({
1107
1659
  client,
1108
1660
  sourceLanguage,
1109
1661
  sourceDocument,
1110
1662
  targetLanguages
1111
1663
  }) => {
1112
- const sourceDocumentString = JSON.stringify(sourceDocument), translationSettings2 = await client.fetch(
1664
+ const sourceDocumentString = documentToPlainText(sourceDocument), translationSettings2 = await client.fetch(
1113
1665
  `*[_id == "translation-settings"][0] {
1114
1666
  globalPrompt,
1115
1667
  localePrompts[locale in $targetLanguages],
@@ -1132,12 +1684,15 @@ const ICON_SIZE = 64, progressIconMap = {
1132
1684
  return !from || !to ? null : { from, to };
1133
1685
  }).filter((item) => item !== null);
1134
1686
  sourceDocument && (relevantTranslations = relevantTranslations.filter(
1135
- ({ from }) => termAppearsInDocument(from, sourceDocumentString)
1687
+ ({ from }) => glossaryTermAppearsInDocument(from, sourceDocumentString)
1136
1688
  )), glossaryTermsByLocale[targetLanguage] = relevantTranslations;
1137
1689
  }
1138
- return {
1690
+ let filteredProtectedPhrases = translationSettings2?.protectedPhrases;
1691
+ return sourceDocument && filteredProtectedPhrases && (filteredProtectedPhrases = filteredProtectedPhrases.filter(
1692
+ (phrase) => protectedPhraseAppearsInDocument(phrase, sourceDocumentString)
1693
+ )), {
1139
1694
  globalPrompt: translationSettings2?.globalPrompt,
1140
- protectedPhrases: translationSettings2?.protectedPhrases,
1695
+ protectedPhrases: filteredProtectedPhrases,
1141
1696
  localeInstructions: Object.fromEntries(
1142
1697
  targetLanguages.map((locale) => [
1143
1698
  locale,
@@ -1148,17 +1703,36 @@ const ICON_SIZE = 64, progressIconMap = {
1148
1703
  ])
1149
1704
  )
1150
1705
  };
1151
- }, termAppearsInDocument = (term, documentString) => {
1152
- const baseTerm = term.split(":")[0].trim();
1153
- return documentString.includes(baseTerm.toLowerCase());
1706
+ }, glossaryTermAppearsInDocument = (term, documentString) => {
1707
+ const baseTerm = term.split(":")[0].trim(), source = preprocessSource(documentString);
1708
+ return matchTermInDocument(baseTerm, source, {
1709
+ windowSlack: 10
1710
+ }).matched;
1711
+ }, protectedPhraseAppearsInDocument = (phrase, documentString) => {
1712
+ const source = preprocessSource(documentString);
1713
+ return matchTermInDocument(phrase, source, {
1714
+ windowSlack: 10
1715
+ }).matched;
1154
1716
  }, formatGlossaryTermInstruction = (terms) => {
1717
+ if (!terms || terms.length === 0)
1718
+ return;
1155
1719
  const instructions = [
1156
1720
  "## Glossary Terms",
1157
- "Translate the following terms exactly as described below.",
1158
- "When a term includes a colon, the suffix following the colon describes how the term should be translated for the female gender."
1721
+ "Use the translations below whenever the matching source term appears. Treat these mappings as required terminology.",
1722
+ "Guidelines:",
1723
+ "- Apply glossary terms when the meaning matches, even if there are slight variations in wording, punctuation, or word order.",
1724
+ "- Preserve surrounding punctuation and keep the sentence natural.",
1725
+ "- Match the source casing when possible; only adjust when required for correct grammar.",
1726
+ "- If the source term meaning is absent, do not introduce a translation from this list.",
1727
+ "- When a term includes a colon, translate the text before the colon for general use; use the text after the colon when the feminine form is required.",
1728
+ "- Do not remove important words or phrases from the source text when they are essential to the meaning.",
1729
+ "- Never add punctuation or symbols that are not present in the source text.",
1730
+ "- Never apply glossary terms in code examples."
1159
1731
  ];
1160
1732
  return terms.forEach(({ from, to }) => {
1161
- instructions.push(`Always translate "${from}" to "${to}".`);
1733
+ instructions.push(
1734
+ `When text conveys the same meaning as "${from}", translate it as "${to}" unless the term is used in a code example.`
1735
+ );
1162
1736
  }), instructions.join(`
1163
1737
  `);
1164
1738
  }, findKeyValues = (obj, targetKey) => {
@@ -1185,12 +1759,19 @@ const ICON_SIZE = 64, progressIconMap = {
1185
1759
  try {
1186
1760
  const doc = await client.fetch("*[_id == $documentId][0]", { documentId });
1187
1761
  if (!doc) {
1188
- console.error(documentId, "not found");
1762
+ devLog.error("Document not found", new Error("Document not found"), {
1763
+ documentId,
1764
+ component: "localizeReferences"
1765
+ });
1189
1766
  return;
1190
1767
  }
1191
1768
  const targetLanguage = doc[languageFieldName];
1192
1769
  if (!targetLanguage) {
1193
- console.error(documentId, "no value found for language field:", languageFieldName);
1770
+ devLog.error("No value found for language field", new Error("Missing language field"), {
1771
+ documentId,
1772
+ languageFieldName,
1773
+ component: "localizeReferences"
1774
+ });
1194
1775
  return;
1195
1776
  }
1196
1777
  const refs = findKeyValues(doc, "_ref");
@@ -1198,94 +1779,46 @@ const ICON_SIZE = 64, progressIconMap = {
1198
1779
  return;
1199
1780
  await updateReferences(documentId, client, refs, targetLanguage);
1200
1781
  } catch (error) {
1201
- console.error("Error localizing references:", error);
1782
+ devLog.error("Error localizing references", error instanceof Error ? error : new Error(String(error)), {
1783
+ documentId,
1784
+ component: "localizeReferences"
1785
+ });
1202
1786
  }
1203
1787
  }, updateReferences = async (documentId, client, refs, targetLanguage) => {
1204
- const patch = client.patch(documentId);
1205
1788
  if (!refs?.length)
1206
1789
  return [];
1790
+ const refIds = refs.map((r) => undraftId(r.value)), translations = await client.fetch(
1791
+ `*[_type == $translationMetadataSchemaType && count(translations[value._ref in $refIds]) > 0]
1792
+ .translations[value._ref in $refIds]{
1793
+ "sourceId": value._ref,
1794
+ "targetId": ^.translations[_key == $targetLanguage][0].value._ref
1795
+ }[defined(targetId)]`,
1796
+ {
1797
+ translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
1798
+ refIds,
1799
+ targetLanguage
1800
+ }
1801
+ ), translationMap = /* @__PURE__ */ new Map();
1802
+ for (const t of translations)
1803
+ t.sourceId && t.targetId && translationMap.set(t.sourceId, t.targetId);
1804
+ const patch = client.patch(documentId);
1805
+ let hasChanges = !1;
1207
1806
  for (const ref of refs) {
1208
- const newId = await client.fetch(
1209
- "(*[_type == $translationMetadataSchemaType && references($sourceId)].translations[_key == $targetLanguage].value._ref)[0]",
1210
- {
1211
- translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
1212
- sourceId: undraftId(ref.value),
1213
- targetLanguage
1214
- }
1215
- );
1807
+ const sourceId = undraftId(ref.value), newId = translationMap.get(sourceId);
1216
1808
  newId && (patch.set({ [pathToString(ref.path)]: undraftId(newId) }), patch.set({ [`${pathToString(ref.path.slice(0, -1))}._weak`]: !0 }), patch.set({
1217
1809
  [`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
1218
1810
  _type: "reference",
1219
1811
  _weak: !1
1220
1812
  }
1221
- }), await patch.commit());
1813
+ }), hasChanges = !0);
1222
1814
  }
1223
- };
1224
- class Semaphore {
1225
- permits;
1226
- queue = [];
1227
- constructor(permits) {
1228
- this.permits = permits;
1229
- }
1230
- /**
1231
- * Acquire a permit from the semaphore
1232
- * If no permits are available, the caller will wait until one becomes available
1233
- */
1234
- async acquire() {
1235
- return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
1236
- this.queue.push(resolve);
1237
- });
1238
- }
1239
- /**
1240
- * Release a permit back to the semaphore
1241
- * If there are waiting callers, one will be woken up
1242
- */
1243
- release() {
1244
- const next = this.queue.shift();
1245
- next ? next() : this.permits++;
1246
- }
1247
- /**
1248
- * Execute a function with semaphore protection
1249
- * Automatically acquires and releases the permit
1250
- */
1251
- async withPermit(fn) {
1252
- await this.acquire();
1253
- try {
1254
- return await fn();
1255
- } finally {
1256
- this.release();
1257
- }
1258
- }
1259
- /**
1260
- * Get the number of available permits
1261
- */
1262
- availablePermits() {
1263
- return this.permits;
1264
- }
1265
- /**
1266
- * Get the number of waiting callers
1267
- */
1268
- queueLength() {
1269
- return this.queue.length;
1270
- }
1271
- /**
1272
- * Update the number of permits
1273
- * If increasing permits, waiting callers will be notified
1274
- * If decreasing permits below current available, available permits will be reduced
1275
- */
1276
- updatePermits(newPermits) {
1277
- const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
1278
- if (difference > 0) {
1279
- for (let i = 0; i < difference && this.queue.length > 0; i++) {
1280
- const next = this.queue.shift();
1281
- next && next();
1282
- }
1283
- const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
1284
- remainingIncrease > 0 && (this.permits += remainingIncrease);
1285
- } else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
1286
- }
1287
- }
1288
- const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
1815
+ hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
1816
+ documentId,
1817
+ count: refs.length,
1818
+ translatedCount: translationMap.size,
1819
+ component: "updateReferences"
1820
+ }));
1821
+ }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
1289
1822
  for (const sourceDocument of sourceDocuments)
1290
1823
  for (const lang of targetLanguages) {
1291
1824
  const payload = {
@@ -1300,21 +1833,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1300
1833
  payload
1301
1834
  });
1302
1835
  }
1303
- }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1304
- (translation) => translation._key === targetLanguage
1305
- )?.value._ref || null, determineTranslationTarget = ({
1306
- translationMetadata,
1307
- targetLanguage
1308
- }) => {
1309
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
1310
- return existingTranslationId ? {
1311
- // Update existing translation
1312
- operation: "createOrReplace",
1313
- _id: existingTranslationId
1314
- } : {
1315
- // Create new translation
1316
- operation: "create"
1317
- };
1318
1836
  }, getTranslationMetadata = async ({
1319
1837
  client,
1320
1838
  sourceDocument,
@@ -1349,6 +1867,52 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1349
1867
  } catch (error) {
1350
1868
  throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
1351
1869
  }
1870
+ }, updateTranslationMetadata = async ({
1871
+ client,
1872
+ targetLanguage,
1873
+ sourceDocument,
1874
+ sourceLanguage,
1875
+ newDocument,
1876
+ translationMetadata,
1877
+ isNewTranslation
1878
+ }) => {
1879
+ try {
1880
+ const refToNewTranslation = {
1881
+ _key: targetLanguage,
1882
+ _type: "internationalizedArrayReferenceValue",
1883
+ value: {
1884
+ _type: "reference",
1885
+ _ref: undraftId(newDocument._id),
1886
+ _weak: !0,
1887
+ _strengthenOnPublish: {
1888
+ _type: "reference",
1889
+ _weak: !1
1890
+ }
1891
+ }
1892
+ }, newTranslationHistory = {
1893
+ _key: targetLanguage,
1894
+ language: targetLanguage,
1895
+ sourceId: undraftId(sourceDocument._id),
1896
+ sourceRev: sourceDocument._rev,
1897
+ sourceLanguage,
1898
+ sourceSnapshot: JSON.stringify(sourceDocument),
1899
+ targetId: newDocument._id,
1900
+ targetRev: newDocument._rev,
1901
+ targetSnapshot: JSON.stringify(newDocument)
1902
+ }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
1903
+ isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1904
+ const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1905
+ translationMetadata.translationHistory?.some(
1906
+ (history) => history.language === targetLanguage
1907
+ ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
1908
+ } catch (error) {
1909
+ throw devLog.error("Error updating translation metadata", error instanceof Error ? error : new Error(String(error)), {
1910
+ sourceDocumentId: sourceDocument._id,
1911
+ targetLanguage,
1912
+ newDocumentId: newDocument._id,
1913
+ component: "updateTranslationMetadata"
1914
+ }), error;
1915
+ }
1352
1916
  }, setJobStatus = (dispatch, _id, language, status, payload) => {
1353
1917
  dispatch({
1354
1918
  type: "SET_TRANSLATION_JOB_STATUS",
@@ -1358,45 +1922,44 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1358
1922
  status
1359
1923
  })
1360
1924
  });
1361
- }, translateDocumentInPlace = async ({
1362
- client,
1363
- schemaId,
1364
- dispatch,
1365
- sourceDocument,
1366
- languageFieldName,
1367
- targetLanguage,
1368
- targetId,
1369
- patch
1925
+ }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1926
+ (translation) => translation._key === targetLanguage
1927
+ )?.value._ref || null, determineTranslationTarget = ({
1928
+ translationMetadata,
1929
+ targetLanguage
1370
1930
  }) => {
1371
- try {
1372
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), await client.agent.action.translate({
1373
- schemaId,
1374
- documentId: targetId,
1375
- toLanguage: {
1376
- title: targetLanguage,
1377
- id: targetLanguage
1378
- },
1379
- fromLanguage: {
1380
- title: sourceDocument[languageFieldName],
1381
- id: sourceDocument[languageFieldName]
1382
- },
1383
- languageFieldPath: [languageFieldName],
1384
- styleGuide: `
1385
- **IMPORTANT:** Much of this document is already translated into the target language. The original version of this document in the source language
1386
- was updated. The following patch instruction shows precisely how the source document was edited: $patchInstruction. Your job is to update this
1387
- translation to including matching changes.
1388
- `,
1389
- styleGuideParams: {
1390
- patchInstruction: JSON.stringify(patch, null, 2)
1391
- }
1392
- });
1393
- } catch (error) {
1394
- const errorMessage = error instanceof Error ? error.message : String(error);
1395
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
1396
- errorMessage
1397
- }), console.error("Error translating document:", error);
1398
- return;
1399
- }
1931
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
1932
+ return existingTranslationId ? {
1933
+ // Update existing translation
1934
+ operation: "createOrReplace",
1935
+ _id: existingTranslationId
1936
+ } : {
1937
+ // Create new translation
1938
+ operation: "create"
1939
+ };
1940
+ }, analyzeDocumentContent = (document) => {
1941
+ let hasCodeBlocks = !1, hasInlineCode = !1, codeBlockCount = 0, inlineCodeCount = 0;
1942
+ const hasMarkdown = !1;
1943
+ let hasPortableText = !1, estimatedFieldCount = 0;
1944
+ const traverse = (value, depth = 0) => {
1945
+ value != null && (depth > 0 && typeof value == "object" && estimatedFieldCount++, Array.isArray(value) ? value.forEach((item) => {
1946
+ item && typeof item == "object" && "_type" in item && item._type === "code" && (hasCodeBlocks = !0, codeBlockCount++, hasPortableText = !0), item && typeof item == "object" && "_type" in item && item._type === "block" && "children" in item && Array.isArray(item.children) && (hasPortableText = !0, item.children.forEach((child) => {
1947
+ child && typeof child == "object" && "marks" in child && Array.isArray(child.marks) && child.marks.includes("code") && (hasInlineCode = !0, inlineCodeCount++);
1948
+ })), traverse(item, depth + 1);
1949
+ }) : typeof value == "object" && Object.values(value).forEach((val) => traverse(val, depth + 1)));
1950
+ };
1951
+ traverse(document);
1952
+ let complexity = "simple";
1953
+ return estimatedFieldCount > 50 ? complexity = "complex" : estimatedFieldCount > 20 && (complexity = "moderate"), {
1954
+ hasCodeBlocks,
1955
+ hasInlineCode,
1956
+ codeBlockCount,
1957
+ inlineCodeCount,
1958
+ hasMarkdown,
1959
+ hasPortableText,
1960
+ complexity,
1961
+ estimatedFieldCount
1962
+ };
1400
1963
  }, assembleLanguageSpecs = ({
1401
1964
  sourceDocument,
1402
1965
  languageFieldName,
@@ -1415,6 +1978,441 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1415
1978
  title: targetLanguage
1416
1979
  }
1417
1980
  };
1981
+ }, callGPT5 = async (apiKey, request) => {
1982
+ const model = request.model || "gpt-5", isGPT5 = model === "gpt-5", useStreaming = !!request.onReasoningChunk, requestBody = {
1983
+ model,
1984
+ messages: request.messages,
1985
+ stream: useStreaming
1986
+ };
1987
+ isGPT5 || (requestBody.temperature = request.temperature ?? 0.3), isGPT5 && (requestBody.reasoning_effort = request.reasoningEffort || "medium", requestBody.verbosity = request.verbosity || "medium");
1988
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
1989
+ method: "POST",
1990
+ headers: {
1991
+ "Content-Type": "application/json",
1992
+ Authorization: `Bearer ${apiKey}`
1993
+ },
1994
+ body: JSON.stringify(requestBody)
1995
+ });
1996
+ if (!response.ok) {
1997
+ const error = await response.json();
1998
+ throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
1999
+ }
2000
+ if (useStreaming && response.body && request.onReasoningChunk)
2001
+ return await handleStreamingResponse(response, request.onReasoningChunk, model);
2002
+ const result = await response.json(), usage = result.usage || {}, tokensUsed = {
2003
+ prompt: usage.prompt_tokens || 0,
2004
+ completion: usage.completion_tokens || 0,
2005
+ reasoning: usage.reasoning_tokens || 0,
2006
+ // New in GPT-5
2007
+ total: usage.total_tokens || 0
2008
+ }, cost = calculateGPT5Cost(tokensUsed, request.model || "gpt-5"), messageContent = result.choices[0].message.content;
2009
+ let reasoningSummary, actualContent = messageContent;
2010
+ const reasoningMatch = messageContent.match(/^<reasoning>([\s\S]*?)<\/reasoning>\n\n([\s\S]*)$/i);
2011
+ return reasoningMatch && (reasoningSummary = reasoningMatch[1].trim(), actualContent = reasoningMatch[2]), {
2012
+ content: actualContent,
2013
+ tokensUsed,
2014
+ cost,
2015
+ reasoningSummary
2016
+ };
2017
+ }, handleStreamingResponse = async (response, onReasoningChunk, model) => {
2018
+ if (!response.body)
2019
+ throw new Error("Response body is null");
2020
+ const reader = response.body.getReader(), decoder = new TextDecoder();
2021
+ let fullContent = "", inReasoning = !1, reasoningBuffer = "", contentBuffer = "", tokensUsed = { prompt: 0, completion: 0, reasoning: 0, total: 0 };
2022
+ try {
2023
+ for (; ; ) {
2024
+ const { done, value } = await reader.read();
2025
+ if (done) break;
2026
+ const lines = decoder.decode(value, { stream: !0 }).split(`
2027
+ `).filter((line) => line.trim() !== "");
2028
+ for (const line of lines)
2029
+ if (line.startsWith("data: ")) {
2030
+ const data = line.slice(6);
2031
+ if (data === "[DONE]") continue;
2032
+ try {
2033
+ const parsed = JSON.parse(data);
2034
+ parsed.usage && (tokensUsed = {
2035
+ prompt: parsed.usage.prompt_tokens || 0,
2036
+ completion: parsed.usage.completion_tokens || 0,
2037
+ reasoning: parsed.usage.reasoning_tokens || 0,
2038
+ total: parsed.usage.total_tokens || 0
2039
+ });
2040
+ const delta = parsed.choices?.[0]?.delta?.content;
2041
+ if (delta)
2042
+ if (fullContent += delta, delta.includes("<reasoning>")) {
2043
+ inReasoning = !0;
2044
+ const parts = delta.split("<reasoning>");
2045
+ reasoningBuffer += parts[1] || "";
2046
+ } else if (delta.includes("</reasoning>")) {
2047
+ inReasoning = !1;
2048
+ const parts = delta.split("</reasoning>");
2049
+ reasoningBuffer += parts[0] || "", contentBuffer += parts[1] || "";
2050
+ } else inReasoning ? (reasoningBuffer += delta, onReasoningChunk(reasoningBuffer)) : contentBuffer += delta;
2051
+ } catch {
2052
+ }
2053
+ }
2054
+ }
2055
+ } finally {
2056
+ reader.releaseLock();
2057
+ }
2058
+ const cost = calculateGPT5Cost(tokensUsed, model);
2059
+ return {
2060
+ content: contentBuffer || fullContent,
2061
+ tokensUsed,
2062
+ cost,
2063
+ reasoningSummary: reasoningBuffer || void 0
2064
+ };
2065
+ }, calculateGPT5Cost = (tokens, model) => {
2066
+ const pricing = {
2067
+ "gpt-5": {
2068
+ input: 2e-6,
2069
+ // $0.002 per 1k input tokens
2070
+ output: 1e-5,
2071
+ // $0.010 per 1k output tokens
2072
+ reasoning: 5e-6
2073
+ // $0.005 per 1k reasoning tokens
2074
+ },
2075
+ "gpt-4o": {
2076
+ input: 5e-6,
2077
+ output: 14999999999999999e-21,
2078
+ reasoning: 0
2079
+ // No reasoning tokens in GPT-4
2080
+ },
2081
+ "gpt-4-turbo": {
2082
+ input: 1e-5,
2083
+ output: 29999999999999997e-21,
2084
+ reasoning: 0
2085
+ },
2086
+ "gpt-4": {
2087
+ input: 29999999999999997e-21,
2088
+ output: 59999999999999995e-21,
2089
+ reasoning: 0
2090
+ }
2091
+ }, modelPricing = pricing[model] || pricing["gpt-5"];
2092
+ return tokens.prompt * modelPricing.input + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
2093
+ }, deserializeDocument = (originalDoc, reviewedText) => {
2094
+ const parsed = parseReviewFormat(reviewedText), result = cloneDeep(originalDoc);
2095
+ return applyTextChanges(result, parsed), result;
2096
+ }, parseReviewFormat = (text) => {
2097
+ const root = [], stack = [], lines = text.split(`
2098
+ `);
2099
+ for (const line of lines) {
2100
+ const trimmed = line.trim();
2101
+ if (!trimmed) continue;
2102
+ const openMatch = trimmed.match(/^<(field|block|span)([^>]*)>(.*)$/);
2103
+ if (openMatch) {
2104
+ const [, tagName, attrsStr, content] = openMatch, attributes = parseAttributes(attrsStr), node = {
2105
+ type: tagName,
2106
+ name: attributes.name,
2107
+ text: content ? unescapeXml(content) : void 0,
2108
+ attributes,
2109
+ children: []
2110
+ }, closingTag = `</${tagName}>`;
2111
+ if (content.endsWith(closingTag.replace(/[<>]/g, ""))) {
2112
+ const textContent = content.substring(0, content.lastIndexOf("<"));
2113
+ if (node.text = unescapeXml(textContent), stack.length > 0) {
2114
+ const parent = stack[stack.length - 1];
2115
+ parent && parent.children.push(node);
2116
+ } else
2117
+ root.push(node);
2118
+ } else if (content.includes(closingTag))
2119
+ if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
2120
+ const parent = stack[stack.length - 1];
2121
+ parent && parent.children.push(node);
2122
+ } else
2123
+ root.push(node);
2124
+ else
2125
+ stack.push(node);
2126
+ continue;
2127
+ }
2128
+ if (trimmed.match(/^<\/(field|block|span)>$/) && stack.length > 0) {
2129
+ const node = stack.pop();
2130
+ if (node)
2131
+ if (stack.length > 0) {
2132
+ const parent = stack[stack.length - 1];
2133
+ parent && parent.children.push(node);
2134
+ } else
2135
+ root.push(node);
2136
+ }
2137
+ }
2138
+ return root;
2139
+ }, parseAttributes = (attrStr) => {
2140
+ const attrs = {}, attrRegex = /(\w+)="([^"]*)"/g;
2141
+ let match = attrRegex.exec(attrStr);
2142
+ for (; match !== null; )
2143
+ attrs[match[1]] = unescapeXml(match[2]), match = attrRegex.exec(attrStr);
2144
+ return attrs;
2145
+ }, unescapeXml = (str) => str.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&"), applyTextChanges = (doc, parsedNodes) => {
2146
+ for (const node of parsedNodes)
2147
+ if (node.type === "field" && node.name) {
2148
+ const path = [node.name];
2149
+ applyFieldChanges(doc, path, node);
2150
+ }
2151
+ }, applyFieldChanges = (doc, path, node) => {
2152
+ if (node.attributes.type === "array") {
2153
+ node.children.forEach((child, idx) => {
2154
+ applyFieldChanges(doc, [...path, idx], child);
2155
+ });
2156
+ return;
2157
+ }
2158
+ if (node.attributes.type === "object") {
2159
+ for (const child of node.children)
2160
+ child.name && applyFieldChanges(doc, [...path, child.name], child);
2161
+ return;
2162
+ }
2163
+ if (node.children.some((c) => c.type === "block")) {
2164
+ const fieldValue = get(doc, path);
2165
+ Array.isArray(fieldValue) && node.children.forEach((blockNode, idx) => {
2166
+ blockNode.type === "block" && applyBlockChanges(fieldValue[idx], blockNode);
2167
+ });
2168
+ return;
2169
+ }
2170
+ node.text !== void 0 && !node.attributes.readonly && set(doc, path, node.text);
2171
+ }, applyBlockChanges = (block, blockNode) => {
2172
+ if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
2173
+ devLog.warn("Block key mismatch, skipping", {
2174
+ expected: block._key,
2175
+ found: blockNode.attributes._key,
2176
+ component: "deserializeDocument"
2177
+ });
2178
+ return;
2179
+ }
2180
+ const children = block.children;
2181
+ Array.isArray(children) && blockNode.children.forEach((spanNode, idx) => {
2182
+ const childItem = children[idx];
2183
+ spanNode.type === "span" && childItem && applySpanChanges(childItem, spanNode);
2184
+ });
2185
+ }, applySpanChanges = (span, spanNode) => {
2186
+ if (spanNode.attributes._key && span._key !== spanNode.attributes._key) {
2187
+ devLog.warn("Span key mismatch, skipping", {
2188
+ expected: span._key,
2189
+ found: spanNode.attributes._key,
2190
+ component: "deserializeDocument"
2191
+ });
2192
+ return;
2193
+ }
2194
+ spanNode.text !== void 0 && (span.text = spanNode.text);
2195
+ }, serializeDocument = (doc) => {
2196
+ const lines = [], serialize = (value, path = [], indent = 0) => {
2197
+ const indentStr = " ".repeat(indent);
2198
+ if (path.length === 0) {
2199
+ if (isPlainObject(value))
2200
+ for (const [key, val] of Object.entries(value))
2201
+ !key.startsWith("_") && key !== "language" && serialize(val, [key], indent);
2202
+ return;
2203
+ }
2204
+ const fieldName = path[path.length - 1];
2205
+ if (Array.isArray(value)) {
2206
+ value.some((item) => item?._type === "block") ? (lines.push(`${indentStr}<field name="${fieldName}">`), value.forEach((block, idx) => {
2207
+ serializeBlock(block, [...path, idx], indent + 1, lines);
2208
+ }), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
2209
+ serialize(item, [...path, idx], indent + 1);
2210
+ }), lines.push(`${indentStr}</field>`));
2211
+ return;
2212
+ }
2213
+ if (isPlainObject(value)) {
2214
+ lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
2215
+ for (const [key, val] of Object.entries(value))
2216
+ key.startsWith("_") || serialize(val, [...path, key], indent + 1);
2217
+ lines.push(`${indentStr}</field>`);
2218
+ return;
2219
+ }
2220
+ if (typeof value == "string") {
2221
+ value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
2222
+ return;
2223
+ }
2224
+ (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
2225
+ };
2226
+ return serialize(doc), lines.join(`
2227
+ `);
2228
+ }, serializeBlock = (block, path, indent, lines) => {
2229
+ const indentStr = " ".repeat(indent), metadata = {};
2230
+ block._type && (metadata._type = block._type), block._key && (metadata._key = block._key), block.style && (metadata.style = block.style), block.listItem && (metadata.listItem = block.listItem), block.level && (metadata.level = block.level), block.markDefs && (metadata.markDefs = block.markDefs);
2231
+ const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(typeof v == "string" ? v : JSON.stringify(v))}"`).join(" ");
2232
+ lines.push(`${indentStr}<block ${metaStr}>`), Array.isArray(block.children) && block.children.forEach((child, idx) => {
2233
+ serializeSpan(child, [...path, "children", idx], indent + 1, lines);
2234
+ }), lines.push(`${indentStr}</block>`);
2235
+ }, serializeSpan = (span, _path, indent, lines) => {
2236
+ const indentStr = " ".repeat(indent);
2237
+ if (!span || typeof span != "object") return;
2238
+ const metadata = {};
2239
+ span._type && (metadata._type = span._type), span._key && (metadata._key = span._key), span.marks && Array.isArray(span.marks) && (metadata.marks = span.marks.join(","));
2240
+ const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(String(v))}"`).join(" "), text = typeof span.text == "string" ? span.text : "";
2241
+ metaStr ? lines.push(`${indentStr}<span ${metaStr}>${escapeXml(text)}</span>`) : lines.push(`${indentStr}<span>${escapeXml(text)}</span>`);
2242
+ }, escapeXml = (str) => str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;"), isPlainObject = (val) => typeof val == "object" && val !== null && !Array.isArray(val), validateStructureIntegrity = (original, modified) => {
2243
+ const errors = [], criticalFields = ["_id", "_type", "_rev", "_createdAt", "_updatedAt"];
2244
+ for (const field of criticalFields)
2245
+ original[field] !== modified[field] && errors.push({
2246
+ path: [field],
2247
+ expected: original[field],
2248
+ actual: modified[field],
2249
+ type: "metadata_changed"
2250
+ });
2251
+ return validateStructureRecursive(original, modified, [], errors), {
2252
+ valid: errors.length === 0,
2253
+ errors
2254
+ };
2255
+ }, validateStructureRecursive = (original, modified, path, errors) => {
2256
+ if (typeof original != typeof modified) {
2257
+ errors.push({
2258
+ path,
2259
+ expected: typeof original,
2260
+ actual: typeof modified,
2261
+ type: "type_mismatch"
2262
+ });
2263
+ return;
2264
+ }
2265
+ if (Array.isArray(original)) {
2266
+ if (!Array.isArray(modified)) {
2267
+ errors.push({
2268
+ path,
2269
+ expected: "array",
2270
+ actual: typeof modified,
2271
+ type: "type_mismatch"
2272
+ });
2273
+ return;
2274
+ }
2275
+ if (original.length !== modified.length) {
2276
+ errors.push({
2277
+ path,
2278
+ expected: `length ${original.length}`,
2279
+ actual: `length ${modified.length}`,
2280
+ type: "type_mismatch"
2281
+ });
2282
+ return;
2283
+ }
2284
+ original.forEach((item, idx) => {
2285
+ validateStructureRecursive(item, modified[idx], [...path, String(idx)], errors);
2286
+ });
2287
+ return;
2288
+ }
2289
+ if (typeof original == "object" && original !== null && typeof modified == "object" && modified !== null) {
2290
+ const origKeys = Object.keys(original).sort(), modKeys = Object.keys(modified).sort();
2291
+ for (const key of origKeys)
2292
+ key in modified || errors.push({
2293
+ path: [...path, key],
2294
+ expected: "present",
2295
+ actual: "missing",
2296
+ type: "missing"
2297
+ });
2298
+ for (const key of modKeys)
2299
+ key in original || errors.push({
2300
+ path: [...path, key],
2301
+ expected: "absent",
2302
+ actual: "present",
2303
+ type: "extra"
2304
+ });
2305
+ const origObj = original, modObj = modified;
2306
+ for (const key of origKeys)
2307
+ key in modObj && (key.startsWith("_") || key === "marks" || key === "style" || key === "markDefs" ? JSON.stringify(origObj[key]) !== JSON.stringify(modObj[key]) && errors.push({
2308
+ path: [...path, key],
2309
+ expected: origObj[key],
2310
+ actual: modObj[key],
2311
+ type: "metadata_changed"
2312
+ }) : key !== "text" && validateStructureRecursive(origObj[key], modObj[key], [...path, key], errors));
2313
+ }
2314
+ }, assembleTranslationPrompt = (context, components) => {
2315
+ const sections = [
2316
+ {
2317
+ id: "agentDirectives",
2318
+ content: buildAgentDirectivesSection(context),
2319
+ required: !0,
2320
+ priority: 0
2321
+ },
2322
+ {
2323
+ id: "criticalRules",
2324
+ content: buildCriticalRulesSection(context),
2325
+ required: !0,
2326
+ priority: 1
2327
+ }
2328
+ ];
2329
+ components.glossaryTerms?.trim() && sections.push({
2330
+ id: "glossaryTerms",
2331
+ content: buildGlossarySection(components.glossaryTerms),
2332
+ required: !1,
2333
+ priority: 3
2334
+ });
2335
+ const styleSection = buildStyleGuideSection(components.globalPrompt, components.localePrompt);
2336
+ styleSection && sections.push({
2337
+ id: "styleGuide",
2338
+ content: styleSection,
2339
+ required: !1,
2340
+ priority: 4
2341
+ }), sections.sort((a, b) => a.priority - b.priority);
2342
+ let styleGuide = "";
2343
+ const omittedSections = [], truncatedSections = [];
2344
+ for (const section of sections) {
2345
+ const prefix = styleGuide.length > 0 ? `
2346
+
2347
+ ---
2348
+
2349
+ ` : "", availableCharacters = 2e3 - (styleGuide.length + prefix.length);
2350
+ if (availableCharacters <= 0) {
2351
+ if (section.required) {
2352
+ const truncatedContent = truncateSection(section.content, 0);
2353
+ truncatedContent && (styleGuide += prefix + truncatedContent, truncatedSections.push(section.id));
2354
+ } else
2355
+ omittedSections.push(section.id);
2356
+ continue;
2357
+ }
2358
+ if (section.content.length <= availableCharacters) {
2359
+ styleGuide += prefix + section.content;
2360
+ continue;
2361
+ }
2362
+ if (section.required) {
2363
+ const truncatedContent = truncateSection(section.content, availableCharacters);
2364
+ styleGuide += prefix + truncatedContent, truncatedSections.push(section.id);
2365
+ } else
2366
+ omittedSections.push(section.id);
2367
+ }
2368
+ return {
2369
+ styleGuide,
2370
+ omittedSections,
2371
+ truncatedSections
2372
+ };
2373
+ }, truncateSection = (content, limit) => {
2374
+ if (limit <= 0)
2375
+ return "";
2376
+ if (content.length <= limit)
2377
+ return content;
2378
+ if (limit <= 1)
2379
+ return content.slice(0, limit);
2380
+ const ellipsis = "\u2026";
2381
+ if (limit <= ellipsis.length)
2382
+ return content.slice(0, limit);
2383
+ const sliceEnd = limit - ellipsis.length, sliced = content.slice(0, sliceEnd), trimmed = sliced.replace(/\s+\S*$/, "").trimEnd();
2384
+ return `${trimmed.length >= sliceEnd / 2 ? trimmed : sliced}${ellipsis}`;
2385
+ }, buildAgentDirectivesSection = (context) => {
2386
+ const { targetLanguage, sourceLanguage } = context;
2387
+ return `# AGENT WORKFLOW DIRECTIVES
2388
+
2389
+ - Use only the provided ${sourceLanguage || "the source language"} source; never fetch outside context.
2390
+ - Scan each field once, then move directly into translation work.
2391
+ - Plan updates before editing so document structure remains intact.
2392
+ - Default to medium reasoning effort and escalate only for ambiguous passages.
2393
+ - Output just the ${targetLanguage || "the target language"} translation and mirror the source formatting exactly.`;
2394
+ }, buildCriticalRulesSection = (context) => {
2395
+ const { sourceLanguage, targetLanguage } = context, fromLanguage = sourceLanguage || "the source language", toLanguage = targetLanguage || "the target language";
2396
+ return `# CRITICAL TRANSLATION RULES
2397
+
2398
+ Follow these without exception:
2399
+
2400
+ 1. Translate from ${fromLanguage} into ${toLanguage}; every output token must be ${toLanguage} unless flagged as protected.
2401
+ 2. Preserve meaning exactly\u2014no additions, deletions, or tone shifts.
2402
+ 3. Maintain all layout, markdown, rich-text marks, and data structure.
2403
+ 4. Never invent metadata or field values that are absent in the source.
2404
+ 5. Leave technical tokens (code, IDs, filenames) untouched.
2405
+ 6. Keep punctuation, casing, and spacing unless ${toLanguage} grammar forces a change.`;
2406
+ }, buildGlossarySection = (glossaryTerms) => `# TERMINOLOGY RULES
2407
+
2408
+ Apply these mappings immediately after protected phrases and before style preferences.
2409
+
2410
+ ${glossaryTerms}`, buildStyleGuideSection = (globalPrompt, localePrompt) => {
2411
+ const components = [];
2412
+ return (globalPrompt || localePrompt) && (components.push("# TRANSLATION STYLE GUIDE (LOWEST PRIORITY)"), components.push(
2413
+ "Apply these directions only after honoring critical rules, protected phrases, and glossary terminology."
2414
+ ), components.push("")), globalPrompt && (components.push("## General Guidelines"), components.push(globalPrompt), components.push("")), localePrompt && (components.push("## Language-Specific Guidelines"), components.push(localePrompt)), components.length > 0 ? components.join(`
2415
+ `) : null;
1418
2416
  }, translateEntireDocument = async ({
1419
2417
  dispatch,
1420
2418
  client,
@@ -1422,17 +2420,175 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1422
2420
  sourceDocument,
1423
2421
  targetLanguage,
1424
2422
  languageFieldName,
1425
- translationTarget,
2423
+ translationTarget,
2424
+ translationPrompts,
2425
+ translationMode,
2426
+ openaiConfig,
2427
+ releaseId,
2428
+ isNewTranslation
2429
+ }) => {
2430
+ const logContext = {
2431
+ documentId: sourceDocument._id,
2432
+ targetLanguage,
2433
+ schemaId,
2434
+ operation: translationTarget.operation,
2435
+ targetId: translationTarget._id,
2436
+ translationMode,
2437
+ releaseId,
2438
+ isNewTranslation
2439
+ };
2440
+ devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
2441
+ try {
2442
+ let versionedDocumentId, baseDocumentId;
2443
+ if (isNewTranslation) {
2444
+ const { _id, _createdAt, _updatedAt, _rev, ...docWithoutMeta } = sourceDocument, newTranslationDoc = {
2445
+ ...docWithoutMeta,
2446
+ _type: sourceDocument._type,
2447
+ [languageFieldName]: targetLanguage
2448
+ };
2449
+ devLog.info("Creating new translation document in release", logContext), baseDocumentId = translationTarget._id || `new-translation-${Date.now()}`, await client.action({
2450
+ actionType: "sanity.action.document.version.create",
2451
+ publishedId: baseDocumentId,
2452
+ document: {
2453
+ ...newTranslationDoc,
2454
+ _type: sourceDocument._type,
2455
+ _id: `versions.${releaseId}.${baseDocumentId}`
2456
+ }
2457
+ }), versionedDocumentId = `versions.${releaseId}.${baseDocumentId}`, devLog.info("New translation version created in release", {
2458
+ ...logContext,
2459
+ versionedDocumentId,
2460
+ baseDocumentId
2461
+ });
2462
+ } else {
2463
+ const existingTranslationId = translationTarget._id;
2464
+ if (!existingTranslationId)
2465
+ throw new Error("Translation target ID not found for update operation");
2466
+ devLog.info("Creating version of existing translation in release", {
2467
+ ...logContext,
2468
+ existingTranslationId
2469
+ });
2470
+ const existingDoc = await client.getDocument(existingTranslationId);
2471
+ if (!existingDoc)
2472
+ throw new Error(`Existing translation document not found: ${existingTranslationId}`);
2473
+ await client.action({
2474
+ actionType: "sanity.action.document.version.create",
2475
+ publishedId: existingTranslationId,
2476
+ document: {
2477
+ ...existingDoc,
2478
+ _id: `versions.${releaseId}.${existingTranslationId}`
2479
+ }
2480
+ }), versionedDocumentId = `versions.${releaseId}.${existingTranslationId}`, baseDocumentId = existingTranslationId, devLog.info("Existing translation version created", {
2481
+ ...logContext,
2482
+ versionedDocumentId
2483
+ });
2484
+ const { _id, _createdAt, _updatedAt, _rev, ...sourceContent } = sourceDocument;
2485
+ await client.createOrReplace({
2486
+ ...sourceContent,
2487
+ _id: versionedDocumentId,
2488
+ [languageFieldName]: targetLanguage
2489
+ }), devLog.info("Source content cloned onto version", {
2490
+ ...logContext,
2491
+ versionedDocumentId
2492
+ });
2493
+ }
2494
+ let translatedDocument;
2495
+ switch (translationMode) {
2496
+ case "openai-only": {
2497
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2498
+ throw new Error("OpenAI is not configured but openai-only mode was selected");
2499
+ devLog.info("Using OpenAI-only translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai");
2500
+ const versionedDoc = await client.getDocument(versionedDocumentId);
2501
+ if (!versionedDoc)
2502
+ throw new Error(`Versioned document not found: ${versionedDocumentId}`);
2503
+ const translatedDoc = await translateInPlaceWithOpenAI({
2504
+ dispatch,
2505
+ sourceDocument,
2506
+ targetDocument: versionedDoc,
2507
+ targetLanguage,
2508
+ languageFieldName,
2509
+ translationPrompts,
2510
+ openaiConfig,
2511
+ isReview: !1
2512
+ });
2513
+ translatedDoc && (translatedDocument = await client.createOrReplace({
2514
+ ...translatedDoc,
2515
+ _id: versionedDocumentId
2516
+ }));
2517
+ break;
2518
+ }
2519
+ case "sanity-with-review":
2520
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2521
+ throw new Error("OpenAI is not configured but sanity-with-review mode was selected");
2522
+ if (devLog.info("Using Sanity + OpenAI review", logContext), translatedDocument = await translateWithSanityAgent({
2523
+ dispatch,
2524
+ client,
2525
+ schemaId,
2526
+ sourceDocument,
2527
+ targetLanguage,
2528
+ languageFieldName,
2529
+ versionedDocumentId,
2530
+ translationPrompts
2531
+ }), translatedDocument) {
2532
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, "reviewing_with_openai");
2533
+ const reviewedDoc = await translateInPlaceWithOpenAI({
2534
+ dispatch,
2535
+ sourceDocument,
2536
+ targetDocument: translatedDocument,
2537
+ targetLanguage,
2538
+ languageFieldName,
2539
+ translationPrompts,
2540
+ openaiConfig,
2541
+ isReview: !0
2542
+ });
2543
+ reviewedDoc && (translatedDocument = await client.createOrReplace({
2544
+ ...reviewedDoc,
2545
+ _id: versionedDocumentId
2546
+ }));
2547
+ }
2548
+ break;
2549
+ default:
2550
+ devLog.info("Using Sanity-only translation", logContext), translatedDocument = await translateWithSanityAgent({
2551
+ dispatch,
2552
+ client,
2553
+ schemaId,
2554
+ sourceDocument,
2555
+ targetLanguage,
2556
+ languageFieldName,
2557
+ versionedDocumentId,
2558
+ translationPrompts
2559
+ });
2560
+ break;
2561
+ }
2562
+ return devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), translatedDocument ? {
2563
+ ...translatedDocument,
2564
+ _id: baseDocumentId
2565
+ } : void 0;
2566
+ } catch (error) {
2567
+ const errorMessage = error instanceof Error ? error.message : String(error);
2568
+ devLog.error(
2569
+ "translateEntireDocument failed",
2570
+ error instanceof Error ? error : new Error(String(error)),
2571
+ logContext
2572
+ ), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
2573
+ errorMessage
2574
+ }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
2575
+ return;
2576
+ }
2577
+ }, translateWithSanityAgent = async ({
2578
+ dispatch,
2579
+ client,
2580
+ schemaId,
2581
+ sourceDocument,
2582
+ targetLanguage,
2583
+ languageFieldName,
2584
+ versionedDocumentId,
1426
2585
  translationPrompts
1427
2586
  }) => {
1428
2587
  const logContext = {
1429
2588
  documentId: sourceDocument._id,
1430
2589
  targetLanguage,
1431
- schemaId,
1432
- operation: translationTarget.operation,
1433
- targetId: translationTarget._id
2590
+ versionedDocumentId
1434
2591
  };
1435
- devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
1436
2592
  try {
1437
2593
  const { toLanguage, fromLanguage } = assembleLanguageSpecs({
1438
2594
  sourceDocument,
@@ -1444,104 +2600,263 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1444
2600
  toLanguage,
1445
2601
  fromLanguage
1446
2602
  });
1447
- let extantTranslation = "";
1448
- translationTarget._id && (devLog.debug("Checking for existing translation", {
2603
+ const documentAnalysis = analyzeDocumentContent(sourceDocument);
2604
+ devLog.debug("Document analysis completed", {
1449
2605
  ...logContext,
1450
- targetId: translationTarget._id
1451
- }), extantTranslation = await client.withConfig({
1452
- perspective: "raw"
1453
- }).fetch('*[_id in $ids] | order((_id match "drafts*") desc)[0]._id', {
1454
- ids: [undraftId(translationTarget._id), draftId(translationTarget._id)]
1455
- }), devLog.info("Extant translation found - will preserve existing content where possible", {
2606
+ analysis: documentAnalysis
2607
+ }), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
2608
+ const { styleGuide, omittedSections, truncatedSections } = assembleTranslationPrompt(
2609
+ {
2610
+ documentAnalysis,
2611
+ isUpdate: !1,
2612
+ // Always fresh translation in a version
2613
+ targetLanguage,
2614
+ sourceLanguage: sourceDocument[languageFieldName]
2615
+ },
2616
+ {
2617
+ globalPrompt: translationPrompts.globalPrompt,
2618
+ localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
2619
+ glossaryTerms: translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
2620
+ protectedPhrases: translationPrompts.protectedPhrases
2621
+ }
2622
+ );
2623
+ devLog.info("Structured style guide generated", {
1456
2624
  ...logContext,
1457
- hasExtantTranslation: !!extantTranslation
1458
- })), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
1459
- const styleGuide = [
1460
- translationPrompts.globalPrompt,
1461
- translationPrompts.localeInstructions[targetLanguage]?.prompt,
1462
- translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
1463
- extantTranslation ? UPDATE_EXISTING_TRANSLATION_PROMPT : null
1464
- ].filter(Boolean).join(`
1465
-
1466
- `);
1467
- devLog.info("Style guide generated", {
2625
+ styleGuideLength: styleGuide.length,
2626
+ omittedSections,
2627
+ truncatedSections
2628
+ }), (omittedSections.length > 0 || truncatedSections.length > 0) && devLog.warn("Style guide adjusted to fit character limit", {
1468
2629
  ...logContext,
1469
- styleGuide
2630
+ omittedSections,
2631
+ truncatedSections
1470
2632
  });
1471
2633
  const newDocument = await client.agent.action.translate({
1472
2634
  schemaId,
1473
2635
  toLanguage,
1474
2636
  fromLanguage,
1475
- documentId: sourceDocument._id,
1476
- targetDocument: translationTarget,
2637
+ documentId: versionedDocumentId,
2638
+ // Use versioned document ID
1477
2639
  languageFieldPath: [languageFieldName],
1478
2640
  styleGuide,
1479
- styleGuideParams: {
1480
- extantTranslation
1481
- },
1482
2641
  protectedPhrases: translationPrompts.protectedPhrases || [],
1483
2642
  conditionalPaths: {
1484
2643
  defaultHidden: !1,
1485
2644
  defaultReadOnly: !1
1486
2645
  }
1487
2646
  });
1488
- return devLog.info("Document translation completed", {
2647
+ return devLog.info("Sanity agent translation completed", {
1489
2648
  ...logContext,
1490
2649
  newDocumentId: newDocument._id,
1491
2650
  newDocumentRev: newDocument._rev
1492
- }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), newDocument;
2651
+ }), newDocument;
1493
2652
  } catch (error) {
1494
2653
  const errorMessage = error instanceof Error ? error.message : String(error);
1495
2654
  devLog.error(
1496
- "translateEntireDocument failed",
2655
+ "Sanity agent translation failed",
1497
2656
  error instanceof Error ? error : new Error(String(error)),
1498
2657
  logContext
1499
2658
  ), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
1500
2659
  errorMessage
1501
- }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
2660
+ });
1502
2661
  return;
1503
2662
  }
1504
- }, updateTranslationMetadata = async ({
1505
- client,
1506
- targetLanguage,
2663
+ }, translateInPlaceWithOpenAI = async ({
2664
+ dispatch,
1507
2665
  sourceDocument,
1508
- sourceLanguage,
1509
- newDocument,
1510
- translationMetadata,
1511
- isNewTranslation
2666
+ targetDocument,
2667
+ targetLanguage,
2668
+ languageFieldName,
2669
+ translationPrompts,
2670
+ openaiConfig,
2671
+ isReview
1512
2672
  }) => {
2673
+ const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2674
+ sourceId: sourceDocument._id,
2675
+ targetId: targetDocument._id,
2676
+ targetLanguage,
2677
+ sourceLanguage,
2678
+ isReview
2679
+ };
1513
2680
  try {
1514
- const refToNewTranslation = {
1515
- _key: targetLanguage,
1516
- _type: "internationalizedArrayReferenceValue",
1517
- value: {
1518
- _type: "reference",
1519
- _ref: undraftId(newDocument._id),
1520
- _weak: !0,
1521
- _strengthenOnPublish: {
1522
- _type: "reference",
1523
- _weak: !1
1524
- }
1525
- }
1526
- }, newTranslationHistory = {
1527
- _key: targetLanguage,
1528
- language: targetLanguage,
1529
- sourceId: undraftId(sourceDocument._id),
1530
- sourceRev: sourceDocument._rev,
2681
+ const sourceText = serializeDocument(sourceDocument), targetText = serializeDocument(targetDocument);
2682
+ devLog.debug("Documents serialized for OpenAI", {
2683
+ ...logContext,
2684
+ sourceLength: sourceText.length,
2685
+ targetLength: targetText.length
2686
+ });
2687
+ const prompt = isReview ? buildReviewPrompt({
2688
+ sourceText,
2689
+ translatedText: targetText,
1531
2690
  sourceLanguage,
1532
- sourceSnapshot: JSON.stringify(sourceDocument),
1533
- targetId: newDocument._id,
1534
- targetRev: newDocument._rev,
1535
- targetSnapshot: JSON.stringify(newDocument)
1536
- }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
1537
- isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1538
- const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1539
- translationMetadata.translationHistory?.some(
1540
- (history) => history.language === targetLanguage
1541
- ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
2691
+ targetLanguage,
2692
+ translationPrompts
2693
+ }) : buildTranslationPrompt({
2694
+ sourceText,
2695
+ sourceLanguage,
2696
+ targetLanguage,
2697
+ translationPrompts
2698
+ });
2699
+ devLog.debug("Calling GPT-5", {
2700
+ ...logContext,
2701
+ promptLength: prompt.length
2702
+ });
2703
+ const response = await callGPT5(openaiConfig.apiKey, {
2704
+ messages: [
2705
+ {
2706
+ role: "system",
2707
+ content: isReview ? "You are a translation quality reviewer. Review and refine translations while preserving structure." : "You are a professional translator. Translate documents while preserving their exact structure."
2708
+ },
2709
+ { role: "user", content: prompt }
2710
+ ],
2711
+ model: openaiConfig.model,
2712
+ reasoningEffort: openaiConfig.reasoningEffort,
2713
+ verbosity: openaiConfig.verbosity,
2714
+ onReasoningChunk: (reasoning) => {
2715
+ const status2 = isReview ? "reviewing_with_openai" : "translating_with_openai";
2716
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, status2, {
2717
+ reasoningProgress: reasoning
2718
+ });
2719
+ }
2720
+ });
2721
+ devLog.info("OpenAI operation completed", {
2722
+ ...logContext,
2723
+ tokensUsed: response.tokensUsed.total,
2724
+ cost: response.cost
2725
+ });
2726
+ const result = deserializeDocument(targetDocument, response.content), validation = validateStructureIntegrity(targetDocument, result);
2727
+ if (!validation.valid) {
2728
+ devLog.error("Structure validation failed", {
2729
+ ...logContext,
2730
+ errors: validation.errors
2731
+ });
2732
+ const unexpectedErrors = validation.errors.filter(
2733
+ (err) => !(err.path.length === 1 && err.path[0] === languageFieldName)
2734
+ );
2735
+ if (unexpectedErrors.length > 0)
2736
+ throw new Error(
2737
+ `Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
2738
+ );
2739
+ }
2740
+ devLog.info("Structure validation passed", logContext);
2741
+ const status = isReview ? "reviewing_with_openai" : "translating_with_openai";
2742
+ return setJobStatus(dispatch, sourceDocument._id, targetLanguage, status, {
2743
+ tokensUsed: response.tokensUsed.total,
2744
+ estimatedCost: response.cost,
2745
+ reasoningSummary: response.reasoningSummary
2746
+ }), result;
1542
2747
  } catch (error) {
1543
- throw console.error("Error updating translation metadata:", error), error;
2748
+ throw devLog.error("OpenAI translation failed", error instanceof Error ? error : new Error(String(error)), logContext), error;
1544
2749
  }
2750
+ }, buildTranslationPrompt = ({
2751
+ sourceText,
2752
+ sourceLanguage,
2753
+ targetLanguage,
2754
+ translationPrompts
2755
+ }) => {
2756
+ const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2757
+ return `# TRANSLATION TASK
2758
+
2759
+ Translate this ${sourceLanguage} document into ${targetLanguage}.
2760
+
2761
+ ## Guidelines
2762
+
2763
+ ${globalPrompt ? `### General Guidelines
2764
+ ${globalPrompt}
2765
+ ` : ""}
2766
+ ${localePrompt ? `### ${targetLanguage} Guidelines
2767
+ ${localePrompt}
2768
+ ` : ""}
2769
+ ${glossaryTerms ? `### Required Terminology
2770
+ ${glossaryTerms}
2771
+ ` : ""}
2772
+ ${protectedPhrases?.length ? `### Protected Phrases (do not translate)
2773
+ ${protectedPhrases.join(", ")}
2774
+ ` : ""}
2775
+
2776
+ ## Source Document (${sourceLanguage})
2777
+
2778
+ \`\`\`
2779
+ ${sourceText}
2780
+ \`\`\`
2781
+
2782
+ ## Instructions
2783
+
2784
+ 1. Translate all text content into ${targetLanguage}
2785
+ 2. Follow the guidelines and terminology requirements above
2786
+ 3. Preserve protected phrases exactly as they appear
2787
+ 4. Return the document in THE EXACT SAME FORMAT
2788
+ 5. **CRITICAL**: Only modify text content between tags
2789
+ 6. Never add, remove, or change any tags, attributes, or structure
2790
+
2791
+ If you used significant reasoning to make translation decisions, you may include a brief summary:
2792
+ <reasoning>
2793
+ Brief summary of key translation decisions and reasoning
2794
+ </reasoning>
2795
+
2796
+ Then return the translated document:`;
2797
+ }, buildReviewPrompt = ({
2798
+ sourceText,
2799
+ translatedText,
2800
+ sourceLanguage,
2801
+ targetLanguage,
2802
+ translationPrompts
2803
+ }) => {
2804
+ const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2805
+ return `# TRANSLATION QUALITY REVIEW
2806
+
2807
+ Review this ${targetLanguage} translation and return a corrected version.
2808
+
2809
+ ## Review Criteria
2810
+
2811
+ 1. **Accuracy**: Does it accurately convey the ${sourceLanguage} source meaning?
2812
+ 2. **Terminology**: Are glossary terms used correctly?
2813
+ 3. **Protected Phrases**: Are protected phrases preserved?
2814
+ 4. **Fluency**: Is it natural and fluent in ${targetLanguage}?
2815
+ 5. **Style**: Does it follow the style guide?
2816
+ 6. **Completeness**: Is all content translated?
2817
+
2818
+ ## Guidelines
2819
+
2820
+ ${globalPrompt ? `### General Guidelines
2821
+ ${globalPrompt}
2822
+ ` : ""}
2823
+ ${localePrompt ? `### ${targetLanguage} Guidelines
2824
+ ${localePrompt}
2825
+ ` : ""}
2826
+ ${glossaryTerms ? `### Required Terminology
2827
+ ${glossaryTerms}
2828
+ ` : ""}
2829
+ ${protectedPhrases?.length ? `### Protected Phrases (must preserve)
2830
+ ${protectedPhrases.join(", ")}
2831
+ ` : ""}
2832
+
2833
+ ## Source Document (${sourceLanguage})
2834
+
2835
+ \`\`\`
2836
+ ${sourceText}
2837
+ \`\`\`
2838
+
2839
+ ## Current Translation (${targetLanguage})
2840
+
2841
+ \`\`\`
2842
+ ${translatedText}
2843
+ \`\`\`
2844
+
2845
+ ## Instructions
2846
+
2847
+ 1. Review the translation for quality issues
2848
+ 2. Make corrections where needed to improve accuracy, fluency, or adherence to guidelines
2849
+ 3. Keep good translations as-is - only fix what needs fixing
2850
+ 4. Return the corrected translation in THE EXACT SAME FORMAT
2851
+ 5. **CRITICAL**: Only modify text content between tags
2852
+ 6. Never add, remove, or change any tags, attributes, or structure
2853
+
2854
+ If you made corrections or have insights about the translation quality, include a brief summary:
2855
+ <reasoning>
2856
+ Brief summary of review findings and corrections made
2857
+ </reasoning>
2858
+
2859
+ Then return the corrected translation document:`;
1545
2860
  }, translateDocument = async ({
1546
2861
  dispatch,
1547
2862
  client,
@@ -1550,15 +2865,17 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1550
2865
  languageFieldName,
1551
2866
  shouldOverwriteFreshTranslations,
1552
2867
  schemaId,
1553
- shouldTranslateDiffs,
1554
- translationPrompts
2868
+ translationPrompts,
2869
+ translationMode,
2870
+ openaiConfig,
2871
+ releaseId
1555
2872
  }) => {
1556
2873
  const logContext = {
1557
2874
  documentId: sourceDocument._id,
1558
2875
  targetLanguage,
1559
2876
  schemaId,
1560
- shouldTranslateDiffs,
1561
- shouldOverwriteFreshTranslations
2877
+ shouldOverwriteFreshTranslations,
2878
+ translationMode
1562
2879
  };
1563
2880
  devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
1564
2881
  try {
@@ -1598,48 +2915,20 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1598
2915
  operation: translationTarget.operation,
1599
2916
  isNewTranslation
1600
2917
  });
1601
- let newDocument;
1602
- if (!isNewTranslation && shouldTranslateDiffs) {
1603
- devLog.info("Starting diff-based translation", logContext);
1604
- const { currentTargetId, patch } = await patchTranslationTarget({
1605
- client,
1606
- currentSourceDocument: sourceDocument,
1607
- sourceLanguage,
1608
- translationMetadata,
1609
- targetLanguage
1610
- });
1611
- if (!currentTargetId) {
1612
- devLog.warn("No target ID found for diff translation", logContext), devLog.timeEnd("translateDocument"), devLog.groupEnd();
1613
- return;
1614
- }
1615
- devLog.debug("Patching translation target", {
1616
- ...logContext,
1617
- currentTargetId,
1618
- hasPatch: !!patch
1619
- }), newDocument = await translateDocumentInPlace({
1620
- client,
1621
- schemaId,
1622
- dispatch,
1623
- sourceDocument,
1624
- languageFieldName,
1625
- targetLanguage,
1626
- targetId: currentTargetId,
1627
- patch
1628
- });
1629
- } else
1630
- devLog.info("Starting full document translation", {
1631
- ...logContext,
1632
- translationTarget: translationTarget.operation
1633
- }), newDocument = await translateEntireDocument({
1634
- dispatch,
1635
- client,
1636
- schemaId,
1637
- sourceDocument,
1638
- targetLanguage,
1639
- languageFieldName,
1640
- translationTarget,
1641
- translationPrompts
1642
- });
2918
+ const newDocument = await translateEntireDocument({
2919
+ dispatch,
2920
+ client,
2921
+ schemaId,
2922
+ sourceDocument,
2923
+ targetLanguage,
2924
+ languageFieldName,
2925
+ translationTarget,
2926
+ translationPrompts,
2927
+ translationMode,
2928
+ openaiConfig,
2929
+ releaseId,
2930
+ isNewTranslation
2931
+ });
1643
2932
  if (!newDocument)
1644
2933
  throw devLog.error("Failed to create translation document", void 0, logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd(), new Error("Failed to create translation document");
1645
2934
  return devLog.debug("Translation document created", {
@@ -1666,30 +2955,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1666
2955
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
1667
2956
  return;
1668
2957
  }
1669
- }, patchTranslationTarget = async ({
1670
- client,
1671
- currentSourceDocument,
1672
- translationMetadata,
1673
- targetLanguage
1674
- }) => {
1675
- const sourceSnapshotString = translationMetadata.translationHistory.find(
1676
- (h) => undraftId(h.sourceId) === undraftId(currentSourceDocument._id)
1677
- )?.sourceSnapshot;
1678
- if (!sourceSnapshotString)
1679
- return {};
1680
- const sourceSnapshot = JSON.parse(sourceSnapshotString), targetId = translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref;
1681
- if (!targetId)
1682
- return {};
1683
- const currentTargetId = await client.fetch(
1684
- '*[_id in $ids] | order((_id match "drafts*") desc)[0]._id',
1685
- { ids: [undraftId(targetId), draftId(targetId)] }
1686
- );
1687
- if (!currentTargetId)
1688
- return console.log(`No document found for ${targetId}`), {};
1689
- const patch = diffPatch(sourceSnapshot, currentSourceDocument, {
1690
- id: draftId(currentTargetId)
1691
- });
1692
- return { currentTargetId, patch };
1693
2958
  }, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
1694
2959
  client,
1695
2960
  sourceDocument,
@@ -1700,17 +2965,19 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1700
2965
  schemaId,
1701
2966
  references = [],
1702
2967
  maxConcurrentTranslationJobs,
1703
- shouldTranslateDiffs
2968
+ translationMode,
2969
+ openaiConfig
1704
2970
  }) => {
1705
2971
  const logContext = {
1706
2972
  documentId: sourceDocument._id,
1707
2973
  targetLanguages,
1708
2974
  referencesCount: references.length,
1709
2975
  maxConcurrentJobs: maxConcurrentTranslationJobs,
1710
- shouldTranslateDiffs,
2976
+ translationMode,
1711
2977
  shouldOverwriteFreshTranslations
1712
2978
  };
1713
2979
  devLog.group("translateDocumentSet", logContext), devLog.time("translateDocumentSet");
2980
+ let releaseId;
1714
2981
  try {
1715
2982
  const sourceLanguage = sourceDocument[languageFieldName];
1716
2983
  if (!sourceLanguage)
@@ -1722,6 +2989,22 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1722
2989
  type: "SET_OVERALL_STATUS",
1723
2990
  payload: "translating"
1724
2991
  });
2992
+ const releaseTitle = `[AI Translation] ${sourceDocument.title || sourceDocument._id.split("-").slice(0, 2).join("-")} (${sourceDocument._type}) ${sourceLanguage} \u2192 ${targetLanguages.join(", ")}`, releaseDescription = `AI translation batch created on ${(/* @__PURE__ */ new Date()).toLocaleDateString()} at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
2993
+ devLog.info("Creating Content Release for translation batch", {
2994
+ ...logContext,
2995
+ releaseTitle
2996
+ });
2997
+ const { releaseId: newReleaseId } = await client.releases.create({
2998
+ metadata: {
2999
+ title: releaseTitle,
3000
+ description: releaseDescription,
3001
+ releaseType: "asap"
3002
+ }
3003
+ });
3004
+ releaseId = newReleaseId, devLog.info("Content Release created", {
3005
+ ...logContext,
3006
+ releaseId
3007
+ });
1725
3008
  const sourceDocuments = [
1726
3009
  {
1727
3010
  _id: sourceDocument._id,
@@ -1753,7 +3036,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1753
3036
  }).fetch("*[_id == $id][0]", {
1754
3037
  id: documentId
1755
3038
  });
1756
- if (document) {
3039
+ if (document && releaseId) {
1757
3040
  const targetDocumentId = await translateDocument({
1758
3041
  dispatch,
1759
3042
  client,
@@ -1762,8 +3045,10 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1762
3045
  languageFieldName,
1763
3046
  shouldOverwriteFreshTranslations,
1764
3047
  schemaId,
1765
- shouldTranslateDiffs,
1766
- translationPrompts
3048
+ translationPrompts,
3049
+ translationMode,
3050
+ openaiConfig,
3051
+ releaseId
1767
3052
  });
1768
3053
  targetDocumentId && targetDocumentIds.push(targetDocumentId);
1769
3054
  }
@@ -1811,11 +3096,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1811
3096
  failed
1812
3097
  }), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })) : (devLog.info("Document set translation completed successfully", logContext), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
1813
3098
  } catch (error) {
1814
- throw devLog.error(
3099
+ if (devLog.error(
1815
3100
  "translateDocumentSet failed with unexpected error",
1816
3101
  error instanceof Error ? error : new Error(String(error)),
1817
3102
  logContext
1818
- ), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
3103
+ ), releaseId)
3104
+ try {
3105
+ await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
3106
+ ...logContext,
3107
+ releaseId
3108
+ });
3109
+ } catch (cleanupError) {
3110
+ devLog.error(
3111
+ "Failed to cleanup release",
3112
+ cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
3113
+ logContext
3114
+ );
3115
+ }
3116
+ throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
1819
3117
  }
1820
3118
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
1821
3119
  const {
@@ -1826,22 +3124,50 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1826
3124
  references,
1827
3125
  selectedTargetLanguages,
1828
3126
  shouldOverwriteFreshTranslations,
1829
- shouldTranslateDiffs
1830
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" });
3127
+ translationMode
3128
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
3129
+ translateDocumentSet({
3130
+ sourceDocument: document,
3131
+ targetLanguages: selectedTargetLanguages,
3132
+ client,
3133
+ dispatch,
3134
+ languageFieldName: config.languageFieldName,
3135
+ schemaId: config.schemaId,
3136
+ references,
3137
+ shouldOverwriteFreshTranslations,
3138
+ maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
3139
+ maxReferenceDepth: config.maxReferenceDepth,
3140
+ translationMode,
3141
+ openaiConfig: config.openai
3142
+ });
3143
+ }, [
3144
+ document,
3145
+ selectedTargetLanguages,
3146
+ client,
3147
+ dispatch,
3148
+ config.languageFieldName,
3149
+ config.schemaId,
3150
+ config.maxConcurrentTranslationJobs,
3151
+ config.maxReferenceDepth,
3152
+ config.openai,
3153
+ references,
3154
+ shouldOverwriteFreshTranslations,
3155
+ translationMode
3156
+ ]), handleSelectAll = useCallback(() => {
3157
+ dispatch({
3158
+ type: "SET_SELECTED_TARGET_LANGUAGES",
3159
+ payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
3160
+ });
3161
+ }, [dispatch, config.supportedLanguages, document.language]), handleSelectNone = useCallback(() => {
3162
+ dispatch({
3163
+ type: "SET_SELECTED_TARGET_LANGUAGES",
3164
+ payload: []
3165
+ });
3166
+ }, [dispatch]);
1831
3167
  return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
1832
3168
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
1833
- /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
1834
- dispatch({
1835
- type: "SET_SELECTED_TARGET_LANGUAGES",
1836
- payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
1837
- });
1838
- }, children: "Select All" }),
1839
- /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
1840
- dispatch({
1841
- type: "SET_SELECTED_TARGET_LANGUAGES",
1842
- payload: []
1843
- });
1844
- }, children: "Select None" })
3169
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
3170
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
1845
3171
  ] }),
1846
3172
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
1847
3173
  /* @__PURE__ */ jsx(Button, { onClick: () => setIsDialogOpen(!1), mode: "ghost", size: 2, children: "Cancel" }),
@@ -1851,21 +3177,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1851
3177
  mode: "ghost",
1852
3178
  tone: "positive",
1853
3179
  size: 2,
1854
- onClick: () => {
1855
- translateDocumentSet({
1856
- sourceDocument: document,
1857
- targetLanguages: selectedTargetLanguages,
1858
- client,
1859
- dispatch,
1860
- languageFieldName: config.languageFieldName,
1861
- schemaId: config.schemaId,
1862
- references,
1863
- shouldOverwriteFreshTranslations,
1864
- maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
1865
- maxReferenceDepth: config.maxReferenceDepth,
1866
- shouldTranslateDiffs
1867
- });
1868
- },
3180
+ onClick: handleTranslate,
1869
3181
  disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
1870
3182
  children: "Translate"
1871
3183
  }
@@ -1910,6 +3222,88 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1910
3222
  documentId: document._id
1911
3223
  });
1912
3224
  return /* @__PURE__ */ jsx(Card, { padding: 4, muted: !0, border: !0, children: /* @__PURE__ */ jsx(Grid, { columns: [1, 2], gap: 3, children: config?.supportedLanguages.map((lang) => /* @__PURE__ */ jsx(LanguageCheckbox, { lang, isLoading: loading }, lang.id)) }) });
3225
+ }, TranslationModePanel = () => {
3226
+ const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
3227
+ if (!config?.openai?.enabled || !config?.openai?.apiKey)
3228
+ return null;
3229
+ if (!openaiValidated)
3230
+ return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3231
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Configuration" }),
3232
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Validating API key..." })
3233
+ ] }) });
3234
+ const handleModeChange = (mode) => {
3235
+ dispatch({
3236
+ type: "SET_TRANSLATION_MODE",
3237
+ payload: mode
3238
+ });
3239
+ };
3240
+ return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3241
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3242
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
3243
+ /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
3244
+ ] }),
3245
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3246
+ /* @__PURE__ */ jsx(
3247
+ Button,
3248
+ {
3249
+ type: "button",
3250
+ mode: translationMode === "sanity-only" ? "ghost" : "bleed",
3251
+ tone: translationMode === "sanity-only" ? "primary" : "default",
3252
+ onClick: () => handleModeChange("sanity-only"),
3253
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3254
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-only", readOnly: !0 }),
3255
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3256
+ /* @__PURE__ */ jsx(Text, { children: "Sanity Agent Only" }),
3257
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Fast, reliable, and included with Sanity." })
3258
+ ] })
3259
+ ] })
3260
+ }
3261
+ ),
3262
+ /* @__PURE__ */ jsx(
3263
+ Button,
3264
+ {
3265
+ type: "button",
3266
+ mode: translationMode === "openai-only" ? "ghost" : "bleed",
3267
+ tone: translationMode === "openai-only" ? "primary" : "default",
3268
+ onClick: () => handleModeChange("openai-only"),
3269
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3270
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
3271
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3272
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3273
+ /* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
3274
+ /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
3275
+ ] }),
3276
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
3277
+ ] })
3278
+ ] })
3279
+ }
3280
+ ),
3281
+ /* @__PURE__ */ jsx(
3282
+ Button,
3283
+ {
3284
+ type: "button",
3285
+ mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
3286
+ tone: translationMode === "sanity-with-review" ? "primary" : "default",
3287
+ onClick: () => handleModeChange("sanity-with-review"),
3288
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3289
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
3290
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3291
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3292
+ /* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
3293
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
3294
+ ] }),
3295
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
3296
+ ] })
3297
+ ] })
3298
+ }
3299
+ )
3300
+ ] }),
3301
+ translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3302
+ "\u{1F4B0} ",
3303
+ /* @__PURE__ */ jsx("strong", { children: "OpenAI costs apply:" }),
3304
+ " You'll be charged for API usage based on tokens processed."
3305
+ ] }) })
3306
+ ] }) });
1913
3307
  }, assembleReferences = async ({
1914
3308
  client,
1915
3309
  sourceDocument,
@@ -1934,7 +3328,14 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1934
3328
  typeof refId == "string" && refId.length > 0 && !references.has(refId) && !documentCache.has(refId) && allRefIds.add(refId);
1935
3329
  }
1936
3330
  } catch (error) {
1937
- console.error("assembleReferences: Error finding references in document:", error);
3331
+ devLog.error(
3332
+ "Error finding references in document",
3333
+ error instanceof Error ? error : new Error(String(error)),
3334
+ {
3335
+ documentId: document._id,
3336
+ component: "assembleReferences"
3337
+ }
3338
+ );
1938
3339
  }
1939
3340
  if (allRefIds.size === 0)
1940
3341
  break;
@@ -1953,16 +3354,27 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1953
3354
  }
1954
3355
  documentCache.set(refId, fetchedDoc), references.add(refId), nextDepthDocuments.push(fetchedDoc);
1955
3356
  } else
1956
- documentCache.set(refId, null), console.warn(`assembleReferences: Document with _id "${refId}" not found`);
3357
+ documentCache.set(refId, null), devLog.warn(`Document with _id "${refId}" not found`, {
3358
+ refId,
3359
+ component: "assembleReferences"
3360
+ });
1957
3361
  }
1958
3362
  currentDepthDocuments = nextDepthDocuments;
1959
3363
  } catch (fetchError) {
1960
3364
  const error = fetchError instanceof Error ? fetchError : new Error(String(fetchError));
1961
- errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), console.error("assembleReferences: Failed to batch fetch documents:", error.message);
3365
+ errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), devLog.error("Failed to batch fetch documents", error, {
3366
+ refIdCount: refIdArray.length,
3367
+ depth,
3368
+ component: "assembleReferences"
3369
+ });
1962
3370
  break;
1963
3371
  }
1964
3372
  }
1965
- errors.length > 0 && console.warn(`assembleReferences: Encountered ${errors.length} error(s) while fetching references`);
3373
+ errors.length > 0 && devLog.warn(`Encountered ${errors.length} error(s) while fetching references`, {
3374
+ errorCount: errors.length,
3375
+ errors: errors.map((e) => ({ refId: e.refId, message: e.error.message })),
3376
+ component: "assembleReferences"
3377
+ });
1966
3378
  const result = [];
1967
3379
  for (const refId of references) {
1968
3380
  const cachedDoc = documentCache.get(refId);
@@ -2047,7 +3459,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2047
3459
  shouldTranslateReferences ? /* @__PURE__ */ jsx(Flex, { padding: 2, justify: "flex-end", children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingReferences ? "Loading references..." : references.length === 0 ? "No translatable references found." : `${references.length} translatable references found.` }) }) : null
2048
3460
  ] });
2049
3461
  }, WriteSettingsPanel = () => {
2050
- const { dispatch, shouldOverwriteFreshTranslations, shouldTranslateDiffs } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
3462
+ const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
2051
3463
  dispatch({
2052
3464
  type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
2053
3465
  payload: !shouldOverwriteFreshTranslations
@@ -2078,38 +3490,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2078
3490
  ] })
2079
3491
  ] })
2080
3492
  }
2081
- ) }),
2082
- /* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
2083
- Button,
2084
- {
2085
- type: "button",
2086
- mode: shouldTranslateDiffs ? "ghost" : "bleed",
2087
- size: 2,
2088
- onClick: () => {
2089
- dispatch({
2090
- type: "SET_SHOULD_TRANSLATE_DIFFS",
2091
- payload: !shouldTranslateDiffs
2092
- });
2093
- },
2094
- tone: shouldTranslateDiffs ? "primary" : "neutral",
2095
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
2096
- /* @__PURE__ */ jsx(
2097
- Checkbox,
2098
- {
2099
- checked: shouldTranslateDiffs,
2100
- onChange: handleShouldOverwriteFreshTranslationsChange,
2101
- label: shouldTranslateDiffs ? "Disable" : "Enable"
2102
- }
2103
- ),
2104
- /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2105
- /* @__PURE__ */ jsxs(Inline, { space: 1, children: [
2106
- /* @__PURE__ */ jsx(Text, { children: "Translate Diffs" }),
2107
- /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Experimental" })
2108
- ] }),
2109
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Only re-translate content that has changed in the source document." })
2110
- ] })
2111
- ] })
2112
- }
2113
3493
  ) })
2114
3494
  ] });
2115
3495
  }, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
@@ -2117,16 +3497,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2117
3497
  /* @__PURE__ */ jsx(Label, { children: "Languages" }),
2118
3498
  /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
2119
3499
  ] }),
3500
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3501
+ /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
3502
+ /* @__PURE__ */ jsx(TranslationModePanel, {})
3503
+ ] }),
2120
3504
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
2121
3505
  /* @__PURE__ */ jsx(Label, { children: "Settings" }),
2122
3506
  /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
2123
3507
  ] }),
2124
3508
  /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
2125
3509
  ] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
2126
- const { overallStatus, translationJobs } = useTranslationContext(), headline = useMemo(
3510
+ const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
2127
3511
  () => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
2128
3512
  [overallStatus]
2129
- ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]);
3513
+ ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
3514
+ if (translationMode === "sanity-only") return null;
3515
+ const completedJobs = jobs.filter((j) => j.status === "completed"), totalTokens = completedJobs.reduce((sum, job) => sum + (job.tokensUsed || 0), 0), totalCost = completedJobs.reduce((sum, job) => sum + (job.estimatedCost || 0), 0);
3516
+ return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
3517
+ }, [jobs, translationMode]);
2130
3518
  return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2131
3519
  /* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
2132
3520
  /* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
@@ -2146,6 +3534,25 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2146
3534
  " \u2014 ",
2147
3535
  job._id.slice(-6)
2148
3536
  ] }),
3537
+ !["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3538
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
3539
+ job.reasoningProgress && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3540
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "\u{1F914} GPT-5 Reasoning" }),
3541
+ /* @__PURE__ */ jsx(Text, { size: 1, style: { fontStyle: "italic" }, children: job.reasoningProgress })
3542
+ ] }) })
3543
+ ] }),
3544
+ job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3545
+ /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3546
+ job.tokensUsed.toLocaleString(),
3547
+ " tokens (~$",
3548
+ job.estimatedCost?.toFixed(4),
3549
+ ")"
3550
+ ] }),
3551
+ job.reasoningSummary && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3552
+ "\u{1F4A1} ",
3553
+ job.reasoningSummary
3554
+ ] }) })
3555
+ ] }) : null,
2149
3556
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2150
3557
  "Error: ",
2151
3558
  job.errorMessage
@@ -2166,7 +3573,23 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2166
3573
  ) : null
2167
3574
  ] })
2168
3575
  ] }) }) }, job._id)) }) }) })
2169
- ] })
3576
+ ] }),
3577
+ openaiStats && openaiStats.jobsWithUsage > 0 && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3578
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
3579
+ /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
3580
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3581
+ "\u{1F4CA} Total Tokens: ",
3582
+ /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
3583
+ ] }),
3584
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3585
+ "\u{1F4B0} Total Cost: ",
3586
+ /* @__PURE__ */ jsxs("strong", { children: [
3587
+ "$",
3588
+ openaiStats.totalCost.toFixed(4)
3589
+ ] })
3590
+ ] })
3591
+ ] })
3592
+ ] }) })
2170
3593
  ] });
2171
3594
  }, ProgressBar = ({ progress, max = 100 }) => {
2172
3595
  const percentage = Math.min(progress / max * 100, 100);
@@ -2225,7 +3648,18 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2225
3648
  content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
2226
3649
  children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
2227
3650
  }
2228
- ), STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
3651
+ ), getStatusLabel = (status) => ({
3652
+ idle: "Idle",
3653
+ queued: "Queued...",
3654
+ preparing_translation: "Preparing translation...",
3655
+ translating: "Translating with Sanity Agent...",
3656
+ translating_with_openai: "Translating with GPT-5...",
3657
+ reviewing_with_openai: "Reviewing translation with GPT-5...",
3658
+ updating_metadata: "Saving metadata...",
3659
+ error: "Error",
3660
+ skipped: "Skipped",
3661
+ completed: "Completed"
3662
+ })[status] || status, STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
2229
3663
  "translating",
2230
3664
  "linking_references",
2231
3665
  "completed"