@braze/sanity-plugin-ai-translation 0.2.0 → 0.2.2

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,24 +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
8
  import { toPlainText } from "@portabletext/react";
9
- import { diffPatch } from "@sanity/diff-patch";
9
+ import cloneDeep from "lodash.clonedeep";
10
+ import get from "lodash.get";
11
+ import set from "lodash.set";
10
12
  const TranslationContext = createContext({
11
13
  document: null,
12
14
  config: null,
13
15
  overallStatus: "idle",
14
16
  selectedTargetLanguages: [],
15
17
  translationJobs: /* @__PURE__ */ new Map(),
16
- shouldTranslateDiffs: !1,
17
18
  shouldOverwriteFreshTranslations: !1,
18
19
  shouldTranslateReferences: !1,
19
20
  maxReferenceDepth: 1,
20
21
  references: [],
21
22
  translationMetadata: null,
23
+ translationMode: "sanity-only",
24
+ openaiValidated: !1,
22
25
  dispatch: () => {
23
26
  }
24
27
  }), useTranslationContext = () => {
@@ -28,7 +31,7 @@ const TranslationContext = createContext({
28
31
  "useTranslationContext must be used within a TranslationContextProvider with document and config props"
29
32
  );
30
33
  return context;
31
- }, 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 }) => {
32
35
  const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
33
36
  params: {
34
37
  documentId: undraftId(documentId),
@@ -81,6 +84,102 @@ const TranslationContext = createContext({
81
84
  isLoggingEnabled() && console.timeEnd(`[AI Translation] ${label}`);
82
85
  }
83
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
+ }
84
183
  async function analyzeFeedback({
85
184
  client,
86
185
  sourceLanguage,
@@ -108,8 +207,25 @@ async function analyzeFeedback({
108
207
  currentDocument
109
208
  }), analysis = await analyzeWithAI(client, analysisPrompt);
110
209
  devLog.info("AI analysis complete", { analysis });
111
- const suggestions = convertAnalysisToSuggestions(analysis);
112
- 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;
113
229
  }
114
230
  async function applySuggestions({
115
231
  client,
@@ -217,11 +333,67 @@ function buildAnalysisPrompt({
217
333
  "",
218
334
  "Be conservative: only suggest changes when there is clear evidence.",
219
335
  "If in doubt, prefer locale-specific updates over global updates.",
220
- "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."
221
393
  ), parts.join(`
222
394
  `);
223
395
  }
224
- function convertAnalysisToSuggestions(analysis) {
396
+ function convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage) {
225
397
  const suggestions = [];
226
398
  if (analysis.globalPromptUpdates && suggestions.push({
227
399
  id: crypto.randomUUID(),
@@ -248,9 +420,10 @@ function convertAnalysisToSuggestions(analysis) {
248
420
  type: "glossaryTerm",
249
421
  description: `${term.sourceValue} \u2192 ${term.targetValue}`,
250
422
  approved: !0,
251
- sourceLocale: term.sourceLocale,
423
+ // Use the provided source/target languages instead of relying on AI to provide them
424
+ sourceLocale: sourceLanguage,
252
425
  sourceValue: term.sourceValue,
253
- targetLocale: term.targetLocale,
426
+ targetLocale: targetLanguage,
254
427
  targetValue: term.targetValue
255
428
  });
256
429
  return suggestions;
@@ -297,13 +470,35 @@ function parseAIResponse(responseText) {
297
470
  }
298
471
  }
299
472
  async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage, suggestions, onProgress) {
300
- 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
+ });
301
485
  const settingsId = "translation-settings";
302
486
  await client.fetch("*[_id == $id][0]", { id: settingsId }) || await client.createIfNotExists({
303
487
  _id: settingsId,
304
488
  _type: "translationSettings"
305
489
  });
306
- 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(
307
502
  (s) => s.type === "glossaryTerm" && s.sourceLocale?.trim() && s.sourceValue?.trim() && s.targetLocale?.trim() && s.targetValue?.trim()
308
503
  );
309
504
  if (devLog.info("Filtered suggestions by type", {
@@ -314,7 +509,7 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
314
509
  }), (globalPrompts.length > 0 || localePrompts.length > 0) && (onProgress?.({
315
510
  stage: "applying",
316
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...`
317
- }), 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) {
318
513
  onProgress?.({ stage: "applying", message: "Adding protected phrases..." });
319
514
  const phrases = protectedPhrases.map((s) => s.description);
320
515
  devLog.debug("Applying protected phrases", { phrases }), updates.push(addProtectedPhrases(client, settingsId, phrases));
@@ -331,14 +526,17 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
331
526
  }));
332
527
  devLog.debug("Applying glossary terms", { terms }), updates.push(addGlossaryTerms(client, terms));
333
528
  }
334
- 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");
335
535
  }
336
- async function updatePromptsWithTransform(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
337
- 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", {
338
538
  settingsId,
339
539
  targetLanguage,
340
- targetLanguageType: typeof targetLanguage,
341
- targetLanguageValue: targetLanguage,
342
540
  hasGlobalUpdates: globalPrompts.length > 0,
343
541
  hasLocaleUpdates: localePrompts.length > 0
344
542
  }), !targetLanguage)
@@ -347,91 +545,56 @@ async function updatePromptsWithTransform(client, schemaId, settingsId, targetLa
347
545
  globalPrompts: globalPrompts.length,
348
546
  localePrompts: localePrompts.length
349
547
  }), new Error("targetLanguage is required but was undefined");
350
- const targets = [];
548
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], currentSettings = await client.fetch("*[_id == $settingsId][0]{ globalPrompt, localePrompts }", { settingsId });
351
549
  if (globalPrompts.length > 0) {
352
- const combinedGlobalUpdate = globalPrompts.map((s) => s.description).join(`
353
-
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(`
354
557
  `);
355
- targets.push({
356
- path: "globalPrompt",
357
- instruction: `You are updating a translation prompt document that contains rules and guidance for future AI translations.
358
-
359
- 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.
360
-
361
- 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.
362
-
363
- Integration guidelines:
364
- - Add the new guidance as additional instructions/rules in the prompt
365
- - If it conflicts with existing guidance, update or replace the conflicting parts
366
- - If it's already covered, don't add redundant text
367
- - Maintain clear structure and consistent tone
368
- - Preserve all existing guidance that is still relevant
369
- - Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
370
-
371
- New guidance to incorporate:
372
-
373
- \`\`\`
374
- ${combinedGlobalUpdate}
375
- \`\`\``
376
- }), 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
+ });
377
563
  }
378
564
  if (localePrompts.length > 0) {
379
- const combinedLocaleUpdate = localePrompts.map((s) => s.description).join(`
380
-
381
- `), existingLocalePrompts = await client.fetch(
382
- "*[_id == $settingsId][0].localePrompts",
383
- { settingsId }
384
- ), hasExistingLocalePrompt = existingLocalePrompts?.some((lp) => lp._key === targetLanguage);
385
- devLog.debug("Checking existing locale prompt", {
386
- targetLanguage,
387
- hasExistingLocalePrompt,
388
- existingCount: existingLocalePrompts?.length || 0
389
- }), hasExistingLocalePrompt || (devLog.info("Creating new locale prompt entry", { targetLanguage }), await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
390
- {
391
- _type: "internationalizedArrayTextValue",
392
- _key: targetLanguage,
393
- value: ""
394
- // Start with empty value, transform will fill it
395
- }
396
- ]).commit());
397
- const localePromptPath = ["localePrompts", { _key: targetLanguage }, "value"];
398
- targets.push({
399
- // @ts-expect-error
400
- path: localePromptPath,
401
- instruction: `You are updating a translation prompt document that contains rules and guidance for future AI translations.
402
-
403
- 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.
404
-
405
- 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.
406
-
407
- Integration guidelines:
408
- - Add the new guidance as additional instructions/rules in the prompt
409
- - If it conflicts with existing guidance, update or replace the conflicting parts
410
- - If it's already covered, don't add redundant text
411
- - Maintain clear structure and consistent tone
412
- - Preserve all existing guidance that is still relevant
413
- - Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
414
-
415
- New guidance to incorporate:
416
-
417
- \`\`\`
418
- ${combinedLocaleUpdate}
419
- \`\`\``
420
- }), devLog.debug("Added locale prompt target", {
421
- targetLanguage,
422
- localePromptPath,
423
- combinedUpdate: combinedLocaleUpdate
424
- });
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
+ }
425
596
  }
426
- const result = await client.agent.action.transform({
427
- schemaId,
428
- documentId: settingsId,
429
- instruction: "Update translation prompts based on user feedback to improve future translations.",
430
- target: targets,
431
- forcePublishedWrite: !0
432
- });
433
- devLog.info("Prompts updated via transform action", {
434
- resultId: result._id,
597
+ devLog.info("Prompts updated successfully", {
435
598
  globalUpdated: globalPrompts.length > 0,
436
599
  localeUpdated: localePrompts.length > 0
437
600
  });
@@ -440,8 +603,14 @@ async function addProtectedPhrases(client, settingsId, phrases) {
440
603
  devLog.info("Adding protected phrases", { settingsId, phrasesCount: phrases.length, phrases });
441
604
  const currentPhrases = await client.fetch("*[_id == $settingsId][0].protectedPhrases", { settingsId });
442
605
  devLog.debug("Fetched current protected phrases", { currentCount: currentPhrases?.length });
443
- const newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
444
- 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) {
445
614
  devLog.info("No new protected phrases to add (all already exist)");
446
615
  return;
447
616
  }
@@ -449,7 +618,13 @@ async function addProtectedPhrases(client, settingsId, phrases) {
449
618
  devLog.info("Protected phrases added", { count: newPhrases.length, phrases: newPhrases, resultId: result._id });
450
619
  }
451
620
  async function addGlossaryTerms(client, terms) {
452
- 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", {
453
628
  sourceValue,
454
629
  targetValue
455
630
  }), !1) : !0);
@@ -466,32 +641,47 @@ async function addGlossaryTerms(client, terms) {
466
641
  }`,
467
642
  { sourceLocale, sourceValue }
468
643
  );
469
- existingTerm ? existingTerm.translations.some((t) => t._key === targetLocale) ? devLog.info("Glossary term already has this translation", {
470
- sourceLocale,
471
- sourceValue,
472
- targetLocale,
473
- targetValue
474
- }) : (await client.patch(existingTerm._id).append("translations", [
475
- {
476
- _type: "internationalizedArrayStringValue",
477
- _key: targetLocale,
478
- value: targetValue
479
- }
480
- ]).commit(), devLog.info("Updated existing glossary term", { sourceLocale, sourceValue, targetLocale, targetValue })) : (await client.create({
481
- _type: "translationGlossaryTerm",
482
- translations: [
483
- {
484
- _type: "internationalizedArrayStringValue",
485
- _key: sourceLocale,
486
- value: sourceValue
487
- },
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", [
488
647
  {
489
648
  _type: "internationalizedArrayStringValue",
490
649
  _key: targetLocale,
491
650
  value: targetValue
492
651
  }
493
- ]
494
- }), 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 });
495
685
  }
496
686
  devLog.info("Glossary terms processed", {
497
687
  totalRequested: terms.length,
@@ -499,6 +689,20 @@ async function addGlossaryTerms(client, terms) {
499
689
  skipped: terms.length - validTerms.length
500
690
  });
501
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
+ }
502
706
  const ICON_SIZE = 64, progressIconMap = {
503
707
  in_progress: /* @__PURE__ */ jsx(Spinner, {}),
504
708
  completed: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: ICON_SIZE }),
@@ -521,7 +725,7 @@ const ICON_SIZE = 64, progressIconMap = {
521
725
  localePrompt: "Locale Prompt",
522
726
  protectedPhrase: "Protected Phrase",
523
727
  glossaryTerm: "Glossary Term"
524
- }, createNewSuggestion = (type = "protectedPhrase") => {
728
+ }, createNewSuggestion = (type = "protectedPhrase", sourceLanguage, targetLanguage) => {
525
729
  const baseSuggestion = {
526
730
  id: crypto.randomUUID(),
527
731
  type,
@@ -531,11 +735,18 @@ const ICON_SIZE = 64, progressIconMap = {
531
735
  return type === "glossaryTerm" ? {
532
736
  ...baseSuggestion,
533
737
  sourceValue: "",
534
- targetValue: ""
738
+ targetValue: "",
739
+ sourceLocale: sourceLanguage,
740
+ targetLocale: targetLanguage
535
741
  } : baseSuggestion;
536
- }, SuggestionReviewList = ({ suggestions, onSuggestionsChange }) => {
742
+ }, SuggestionReviewList = ({
743
+ suggestions,
744
+ onSuggestionsChange,
745
+ sourceLanguage,
746
+ targetLanguage
747
+ }) => {
537
748
  const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
538
- 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);
539
750
  onSuggestionsChange(updated);
540
751
  }, handleTypeChange = (id, newType) => {
541
752
  const updated = suggestions.map((s) => {
@@ -547,7 +758,9 @@ const ICON_SIZE = 64, progressIconMap = {
547
758
  ...s,
548
759
  type: newType,
549
760
  sourceValue: match ? match[1].trim() : "",
550
- targetValue: match ? match[2].trim() : ""
761
+ targetValue: match ? match[2].trim() : "",
762
+ sourceLocale: sourceLanguage,
763
+ targetLocale: targetLanguage
551
764
  };
552
765
  }
553
766
  return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
@@ -568,6 +781,7 @@ const ICON_SIZE = 64, progressIconMap = {
568
781
  ...s,
569
782
  sourceValue: newSource,
570
783
  description: `${newSource} \u2192 ${s.targetValue || ""}`
784
+ // Preserve sourceLocale and targetLocale
571
785
  } : s
572
786
  );
573
787
  onSuggestionsChange(updated);
@@ -577,6 +791,7 @@ const ICON_SIZE = 64, progressIconMap = {
577
791
  ...s,
578
792
  targetValue: newTarget,
579
793
  description: `${s.sourceValue || ""} \u2192 ${newTarget}`
794
+ // Preserve sourceLocale and targetLocale
580
795
  } : s
581
796
  );
582
797
  onSuggestionsChange(updated);
@@ -584,7 +799,7 @@ const ICON_SIZE = 64, progressIconMap = {
584
799
  const updated = suggestions.filter((s) => s.id !== id);
585
800
  onSuggestionsChange(updated);
586
801
  }, handleAddSuggestion = (type) => {
587
- const newSuggestion = createNewSuggestion(type);
802
+ const newSuggestion = createNewSuggestion(type, sourceLanguage, targetLanguage);
588
803
  onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
589
804
  };
590
805
  return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
@@ -611,16 +826,24 @@ const ICON_SIZE = 64, progressIconMap = {
611
826
  ) })
612
827
  ] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
613
828
  suggestions.map((suggestion) => {
614
- 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";
615
830
  return /* @__PURE__ */ jsx(
616
831
  Card,
617
832
  {
618
833
  padding: 4,
619
834
  border: !0,
620
- tone: suggestion.approved ? "default" : "transparent",
835
+ tone: isInvalid ? "critical" : hasWarning ? "caution" : suggestion.approved ? "default" : "transparent",
621
836
  style: { opacity: suggestion.approved ? 1 : 0.5 },
622
837
  children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
623
- /* @__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) }) }),
624
847
  /* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
625
848
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
626
849
  /* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
@@ -629,6 +852,7 @@ const ICON_SIZE = 64, progressIconMap = {
629
852
  fontSize: 1,
630
853
  value: suggestion.type,
631
854
  onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
855
+ "aria-label": "Suggestion type",
632
856
  children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
633
857
  }
634
858
  ) }),
@@ -640,31 +864,42 @@ const ICON_SIZE = 64, progressIconMap = {
640
864
  icon: TrashIcon,
641
865
  fontSize: 1,
642
866
  padding: 2,
643
- onClick: () => handleRemove(suggestion.id)
867
+ onClick: () => handleRemove(suggestion.id),
868
+ "aria-label": "Remove suggestion",
869
+ title: "Remove suggestion"
644
870
  }
645
871
  )
646
872
  ] }),
647
- isGlossaryTerm ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
648
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
649
- TextInput,
650
- {
651
- fontSize: 1,
652
- placeholder: "Source term",
653
- value: suggestion.sourceValue || "",
654
- onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value)
655
- }
656
- ) }),
657
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "\u2192" }),
658
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
659
- TextInput,
660
- {
661
- fontSize: 1,
662
- placeholder: "Target term",
663
- value: suggestion.targetValue || "",
664
- onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value)
665
- }
666
- ) })
667
- ] }) }) : 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(
668
903
  TextArea,
669
904
  {
670
905
  fontSize: 1,
@@ -673,7 +908,8 @@ const ICON_SIZE = 64, progressIconMap = {
673
908
  onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
674
909
  onFocus: () => setEditingId(suggestion.id),
675
910
  onBlur: () => setEditingId(null),
676
- placeholder: "Enter prompt guidance..."
911
+ placeholder: "Enter prompt guidance...",
912
+ "aria-label": `${IMPROVEMENT_TYPE_LABELS[suggestion.type]} content`
677
913
  }
678
914
  ) }) : /* @__PURE__ */ jsx(
679
915
  TextInput,
@@ -681,9 +917,17 @@ const ICON_SIZE = 64, progressIconMap = {
681
917
  fontSize: 1,
682
918
  value: suggestion.description,
683
919
  onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
684
- placeholder: "Enter protected phrase..."
920
+ placeholder: "Enter protected phrase...",
921
+ "aria-label": "Protected phrase"
685
922
  }
686
- )
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
+ ] }) })
687
931
  ] })
688
932
  ] }) })
689
933
  },
@@ -705,7 +949,7 @@ const ICON_SIZE = 64, progressIconMap = {
705
949
  }, GiveFeedbackDialog = ({ setIsDialogOpen }) => {
706
950
  const client = useClient({ apiVersion: "vX" }), { document, config, translationMetadata } = useTranslationContext(), { loading } = useTranslationMetadata({
707
951
  documentId: document._id
708
- }), [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 = () => {
709
953
  setShouldIncludeEdits(!shouldIncludeEdits);
710
954
  }, handleCancel = () => {
711
955
  setIsDialogOpen(!1);
@@ -727,14 +971,16 @@ const ICON_SIZE = 64, progressIconMap = {
727
971
  throw new Error(
728
972
  "No translation history found for this document. Please ensure this document was translated using the AI Translation plugin."
729
973
  );
730
- const { sourceSnapshot, targetSnapshot, sourceLanguage } = translationHistory2;
974
+ const { sourceSnapshot, targetSnapshot, sourceLanguage: srcLang } = translationHistory2;
731
975
  if (!sourceSnapshot || !targetSnapshot)
732
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);
733
979
  const generatedSuggestions = await analyzeFeedback({
734
980
  client,
735
981
  schemaId: config.schemaId,
736
- sourceLanguage: sourceLanguage || "en-us",
737
- targetLanguage: document[config.languageFieldName],
982
+ sourceLanguage: srcLanguage,
983
+ targetLanguage: tgtLanguage,
738
984
  userFeedback: feedback || void 0,
739
985
  shouldIncludeEdits,
740
986
  sourceSnapshot,
@@ -788,7 +1034,15 @@ const ICON_SIZE = 64, progressIconMap = {
788
1034
  /* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
789
1035
  /* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
790
1036
  ] }),
791
- /* @__PURE__ */ jsx(SuggestionReviewList, { suggestions, onSuggestionsChange: setSuggestions }),
1037
+ /* @__PURE__ */ jsx(
1038
+ SuggestionReviewList,
1039
+ {
1040
+ suggestions,
1041
+ onSuggestionsChange: setSuggestions,
1042
+ sourceLanguage,
1043
+ targetLanguage
1044
+ }
1045
+ ),
792
1046
  error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
793
1047
  /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
794
1048
  /* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
@@ -896,6 +1150,33 @@ const ICON_SIZE = 64, progressIconMap = {
896
1150
  )
897
1151
  ] })
898
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
+ }
899
1180
  }, translationReducer = (context, action) => {
900
1181
  const logContext = {
901
1182
  documentId: context.document?._id,
@@ -915,10 +1196,15 @@ const ICON_SIZE = 64, progressIconMap = {
915
1196
  ...context,
916
1197
  selectedTargetLanguages: action.payload
917
1198
  };
918
- case "SET_SHOULD_TRANSLATE_DIFFS":
1199
+ case "SET_TRANSLATION_MODE":
1200
+ return {
1201
+ ...context,
1202
+ translationMode: action.payload
1203
+ };
1204
+ case "SET_OPENAI_VALIDATED":
919
1205
  return {
920
1206
  ...context,
921
- shouldTranslateDiffs: action.payload
1207
+ openaiValidated: action.payload
922
1208
  };
923
1209
  case "SET_SHOULD_TRANSLATE_REFERENCES":
924
1210
  return {
@@ -963,18 +1249,44 @@ const ICON_SIZE = 64, progressIconMap = {
963
1249
  overallStatus: "idle",
964
1250
  translationMetadata: null,
965
1251
  selectedTargetLanguages: [],
966
- shouldTranslateDiffs: !1,
967
1252
  shouldOverwriteFreshTranslations: !1,
968
1253
  shouldTranslateReferences: !1,
969
1254
  maxReferenceDepth: 1,
970
1255
  references: [],
971
- translationJobs: /* @__PURE__ */ new Map()
1256
+ translationJobs: /* @__PURE__ */ new Map(),
1257
+ translationMode: config?.openai?.defaultMode || "sanity-only",
1258
+ openaiValidated: !1
972
1259
  }, [context, dispatch] = useReducer(translationReducer, initialContext);
973
- 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", {
974
1285
  documentId: document._id,
975
1286
  overallStatus: context.overallStatus,
976
1287
  selectedLanguages: context.selectedTargetLanguages,
977
- shouldTranslateDiffs: context.shouldTranslateDiffs,
1288
+ translationMode: context.translationMode,
1289
+ openaiEnabled: config?.openai?.enabled,
978
1290
  component: "TranslationContextProvider"
979
1291
  }), /* @__PURE__ */ jsx(TranslationContext.Provider, { value: { ...context, dispatch }, children });
980
1292
  }, getGiveTranslationFeedbackAction = (config) => ({ draft, published }) => {
@@ -1077,26 +1389,98 @@ const ICON_SIZE = 64, progressIconMap = {
1077
1389
  title: "Translation Settings",
1078
1390
  icon: TranslateIcon,
1079
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
+ ],
1080
1403
  fields: [
1081
1404
  defineField({
1082
1405
  name: "globalPrompt",
1083
1406
  title: "Global Translation Prompt",
1084
1407
  description: "A prompt that will given to the Sanity AI Agent for all translation tasks.",
1085
1408
  type: "text",
1086
- rows: 5
1409
+ rows: 5,
1410
+ group: "general"
1087
1411
  }),
1088
1412
  defineField({
1089
1413
  name: "localePrompts",
1090
1414
  title: "Locale Prompts",
1091
1415
  description: "Prompts that will be given to the Sanity AI Agent for each locale.",
1092
- type: "internationalizedArrayText"
1416
+ type: "internationalizedArrayText",
1417
+ group: "general"
1093
1418
  }),
1094
1419
  defineField({
1095
1420
  name: "protectedPhrases",
1096
1421
  title: "Protected Phrases",
1097
1422
  description: "Phrases that will never be translated.",
1098
1423
  type: "array",
1099
- 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
+ ]
1100
1484
  })
1101
1485
  ],
1102
1486
  preview: {
@@ -1104,7 +1488,81 @@ const ICON_SIZE = 64, progressIconMap = {
1104
1488
  title: "Translation Settings"
1105
1489
  })
1106
1490
  }
1107
- }), DEFAULTS = {
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 = {
1108
1566
  windowSlack: 2,
1109
1567
  minTokenMatchRatio: 1,
1110
1568
  stopwords: [
@@ -1154,7 +1612,7 @@ function freqMap(tokens) {
1154
1612
  for (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);
1155
1613
  return m;
1156
1614
  }
1157
- function matchGlossaryTerm(term, source, options) {
1615
+ function matchTermInDocument(term, source, options) {
1158
1616
  const opts = { ...DEFAULTS, ...options ?? {} }, termTokens = prepareTermTokens(term, opts);
1159
1617
  if (termTokens.length === 0)
1160
1618
  return { matched: !1, term, reason: "No substantive tokens after filtering." };
@@ -1197,15 +1655,6 @@ function matchGlossaryTerm(term, source, options) {
1197
1655
  }
1198
1656
  return { matched: !1, term, reason: "No window satisfied the token threshold." };
1199
1657
  }
1200
- function documentToPlainText(document) {
1201
- if (!document) return "";
1202
- const result = {};
1203
- for (const [key, value] of Object.entries(document))
1204
- Array.isArray(value) && value.some(
1205
- (item) => typeof item == "object" && item !== null && "_type" in item && item._type === "block"
1206
- ) ? result[key] = toPlainText(value) : result[key] = value;
1207
- return JSON.stringify(result);
1208
- }
1209
1658
  const fetchTranslationPrompts = async ({
1210
1659
  client,
1211
1660
  sourceLanguage,
@@ -1235,12 +1684,15 @@ const fetchTranslationPrompts = async ({
1235
1684
  return !from || !to ? null : { from, to };
1236
1685
  }).filter((item) => item !== null);
1237
1686
  sourceDocument && (relevantTranslations = relevantTranslations.filter(
1238
- ({ from }) => termAppearsInDocument(from, sourceDocumentString)
1687
+ ({ from }) => glossaryTermAppearsInDocument(from, sourceDocumentString)
1239
1688
  )), glossaryTermsByLocale[targetLanguage] = relevantTranslations;
1240
1689
  }
1241
- return {
1690
+ let filteredProtectedPhrases = translationSettings2?.protectedPhrases;
1691
+ return sourceDocument && filteredProtectedPhrases && (filteredProtectedPhrases = filteredProtectedPhrases.filter(
1692
+ (phrase) => protectedPhraseAppearsInDocument(phrase, sourceDocumentString)
1693
+ )), {
1242
1694
  globalPrompt: translationSettings2?.globalPrompt,
1243
- protectedPhrases: translationSettings2?.protectedPhrases,
1695
+ protectedPhrases: filteredProtectedPhrases,
1244
1696
  localeInstructions: Object.fromEntries(
1245
1697
  targetLanguages.map((locale) => [
1246
1698
  locale,
@@ -1251,9 +1703,14 @@ const fetchTranslationPrompts = async ({
1251
1703
  ])
1252
1704
  )
1253
1705
  };
1254
- }, termAppearsInDocument = (term, documentString) => {
1706
+ }, glossaryTermAppearsInDocument = (term, documentString) => {
1255
1707
  const baseTerm = term.split(":")[0].trim(), source = preprocessSource(documentString);
1256
- return matchGlossaryTerm(baseTerm, source, {
1708
+ return matchTermInDocument(baseTerm, source, {
1709
+ windowSlack: 10
1710
+ }).matched;
1711
+ }, protectedPhraseAppearsInDocument = (phrase, documentString) => {
1712
+ const source = preprocessSource(documentString);
1713
+ return matchTermInDocument(phrase, source, {
1257
1714
  windowSlack: 10
1258
1715
  }).matched;
1259
1716
  }, formatGlossaryTermInstruction = (terms) => {
@@ -1268,10 +1725,14 @@ const fetchTranslationPrompts = async ({
1268
1725
  "- Match the source casing when possible; only adjust when required for correct grammar.",
1269
1726
  "- If the source term meaning is absent, do not introduce a translation from this list.",
1270
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.",
1271
- "- Do not remove important words or phrases from the source text when they are essential to the meaning."
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."
1272
1731
  ];
1273
1732
  return terms.forEach(({ from, to }) => {
1274
- instructions.push(`When text conveys the same meaning as "${from}", translate it as "${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
+ );
1275
1736
  }), instructions.join(`
1276
1737
  `);
1277
1738
  }, findKeyValues = (obj, targetKey) => {
@@ -1298,12 +1759,19 @@ const fetchTranslationPrompts = async ({
1298
1759
  try {
1299
1760
  const doc = await client.fetch("*[_id == $documentId][0]", { documentId });
1300
1761
  if (!doc) {
1301
- console.error(documentId, "not found");
1762
+ devLog.error("Document not found", new Error("Document not found"), {
1763
+ documentId,
1764
+ component: "localizeReferences"
1765
+ });
1302
1766
  return;
1303
1767
  }
1304
1768
  const targetLanguage = doc[languageFieldName];
1305
1769
  if (!targetLanguage) {
1306
- 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
+ });
1307
1775
  return;
1308
1776
  }
1309
1777
  const refs = findKeyValues(doc, "_ref");
@@ -1311,94 +1779,46 @@ const fetchTranslationPrompts = async ({
1311
1779
  return;
1312
1780
  await updateReferences(documentId, client, refs, targetLanguage);
1313
1781
  } catch (error) {
1314
- 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
+ });
1315
1786
  }
1316
1787
  }, updateReferences = async (documentId, client, refs, targetLanguage) => {
1317
- const patch = client.patch(documentId);
1318
1788
  if (!refs?.length)
1319
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;
1320
1806
  for (const ref of refs) {
1321
- const newId = await client.fetch(
1322
- "(*[_type == $translationMetadataSchemaType && references($sourceId)].translations[_key == $targetLanguage].value._ref)[0]",
1323
- {
1324
- translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
1325
- sourceId: undraftId(ref.value),
1326
- targetLanguage
1327
- }
1328
- );
1807
+ const sourceId = undraftId(ref.value), newId = translationMap.get(sourceId);
1329
1808
  newId && (patch.set({ [pathToString(ref.path)]: undraftId(newId) }), patch.set({ [`${pathToString(ref.path.slice(0, -1))}._weak`]: !0 }), patch.set({
1330
1809
  [`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
1331
1810
  _type: "reference",
1332
1811
  _weak: !1
1333
1812
  }
1334
- }), await patch.commit());
1335
- }
1336
- };
1337
- class Semaphore {
1338
- permits;
1339
- queue = [];
1340
- constructor(permits) {
1341
- this.permits = permits;
1342
- }
1343
- /**
1344
- * Acquire a permit from the semaphore
1345
- * If no permits are available, the caller will wait until one becomes available
1346
- */
1347
- async acquire() {
1348
- return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
1349
- this.queue.push(resolve);
1350
- });
1351
- }
1352
- /**
1353
- * Release a permit back to the semaphore
1354
- * If there are waiting callers, one will be woken up
1355
- */
1356
- release() {
1357
- const next = this.queue.shift();
1358
- next ? next() : this.permits++;
1359
- }
1360
- /**
1361
- * Execute a function with semaphore protection
1362
- * Automatically acquires and releases the permit
1363
- */
1364
- async withPermit(fn) {
1365
- await this.acquire();
1366
- try {
1367
- return await fn();
1368
- } finally {
1369
- this.release();
1370
- }
1371
- }
1372
- /**
1373
- * Get the number of available permits
1374
- */
1375
- availablePermits() {
1376
- return this.permits;
1377
- }
1378
- /**
1379
- * Get the number of waiting callers
1380
- */
1381
- queueLength() {
1382
- return this.queue.length;
1383
- }
1384
- /**
1385
- * Update the number of permits
1386
- * If increasing permits, waiting callers will be notified
1387
- * If decreasing permits below current available, available permits will be reduced
1388
- */
1389
- updatePermits(newPermits) {
1390
- const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
1391
- if (difference > 0) {
1392
- for (let i = 0; i < difference && this.queue.length > 0; i++) {
1393
- const next = this.queue.shift();
1394
- next && next();
1395
- }
1396
- const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
1397
- remainingIncrease > 0 && (this.permits += remainingIncrease);
1398
- } else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
1813
+ }), hasChanges = !0);
1399
1814
  }
1400
- }
1401
- 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) => {
1402
1822
  for (const sourceDocument of sourceDocuments)
1403
1823
  for (const lang of targetLanguages) {
1404
1824
  const payload = {
@@ -1413,21 +1833,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1413
1833
  payload
1414
1834
  });
1415
1835
  }
1416
- }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1417
- (translation) => translation._key === targetLanguage
1418
- )?.value._ref || null, determineTranslationTarget = ({
1419
- translationMetadata,
1420
- targetLanguage
1421
- }) => {
1422
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
1423
- return existingTranslationId ? {
1424
- // Update existing translation
1425
- operation: "createOrReplace",
1426
- _id: existingTranslationId
1427
- } : {
1428
- // Create new translation
1429
- operation: "create"
1430
- };
1431
1836
  }, getTranslationMetadata = async ({
1432
1837
  client,
1433
1838
  sourceDocument,
@@ -1462,6 +1867,52 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1462
1867
  } catch (error) {
1463
1868
  throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
1464
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
+ }
1465
1916
  }, setJobStatus = (dispatch, _id, language, status, payload) => {
1466
1917
  dispatch({
1467
1918
  type: "SET_TRANSLATION_JOB_STATUS",
@@ -1471,45 +1922,44 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1471
1922
  status
1472
1923
  })
1473
1924
  });
1474
- }, translateDocumentInPlace = async ({
1475
- client,
1476
- schemaId,
1477
- dispatch,
1478
- sourceDocument,
1479
- languageFieldName,
1480
- targetLanguage,
1481
- targetId,
1482
- 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
1483
1930
  }) => {
1484
- try {
1485
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), await client.agent.action.translate({
1486
- schemaId,
1487
- documentId: targetId,
1488
- toLanguage: {
1489
- title: targetLanguage,
1490
- id: targetLanguage
1491
- },
1492
- fromLanguage: {
1493
- title: sourceDocument[languageFieldName],
1494
- id: sourceDocument[languageFieldName]
1495
- },
1496
- languageFieldPath: [languageFieldName],
1497
- styleGuide: `
1498
- **IMPORTANT:** Much of this document is already translated into the target language. The original version of this document in the source language
1499
- was updated. The following patch instruction shows precisely how the source document was edited: $patchInstruction. Your job is to update this
1500
- translation to including matching changes.
1501
- `,
1502
- styleGuideParams: {
1503
- patchInstruction: JSON.stringify(patch, null, 2)
1504
- }
1505
- });
1506
- } catch (error) {
1507
- const errorMessage = error instanceof Error ? error.message : String(error);
1508
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
1509
- errorMessage
1510
- }), console.error("Error translating document:", error);
1511
- return;
1512
- }
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
+ };
1513
1963
  }, assembleLanguageSpecs = ({
1514
1964
  sourceDocument,
1515
1965
  languageFieldName,
@@ -1528,6 +1978,485 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1528
1978
  title: targetLanguage
1529
1979
  }
1530
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.some((c) => c.type === "span") ? blockNode.children.forEach((spanNode, idx) => {
2182
+ const childItem = children[idx];
2183
+ spanNode.type === "span" && childItem && applySpanChanges(childItem, spanNode);
2184
+ }) : blockNode.children.some((c) => c.type === "field") && blockNode.children.forEach((fieldNode) => {
2185
+ fieldNode.type === "field" && fieldNode.name && applyBlockFieldChanges(block, fieldNode.name, fieldNode);
2186
+ });
2187
+ }, applyBlockFieldChanges = (block, fieldName, fieldNode) => {
2188
+ const currentValue = block[fieldName];
2189
+ if (fieldNode.attributes.type === "array") {
2190
+ Array.isArray(currentValue) && fieldNode.children.forEach((childNode, idx) => {
2191
+ childNode.type === "block" && currentValue[idx] ? applyBlockChanges(currentValue[idx], childNode) : childNode.type === "field" && applyBlockFieldChanges(currentValue[idx], childNode.name || String(idx), childNode);
2192
+ });
2193
+ return;
2194
+ }
2195
+ if (fieldNode.attributes.type === "object") {
2196
+ currentValue && typeof currentValue == "object" && fieldNode.children.forEach((childNode) => {
2197
+ childNode.name && applyBlockFieldChanges(currentValue, childNode.name, childNode);
2198
+ });
2199
+ return;
2200
+ }
2201
+ fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (block[fieldName] = fieldNode.text);
2202
+ }, applySpanChanges = (span, spanNode) => {
2203
+ if (spanNode.attributes._key && span._key !== spanNode.attributes._key) {
2204
+ devLog.warn("Span key mismatch, skipping", {
2205
+ expected: span._key,
2206
+ found: spanNode.attributes._key,
2207
+ component: "deserializeDocument"
2208
+ });
2209
+ return;
2210
+ }
2211
+ spanNode.text !== void 0 && (span.text = spanNode.text);
2212
+ }, serializeDocument = (doc) => {
2213
+ const lines = [], serialize = (value, path = [], indent = 0) => {
2214
+ const indentStr = " ".repeat(indent);
2215
+ if (path.length === 0) {
2216
+ if (isPlainObject(value))
2217
+ for (const [key, val] of Object.entries(value))
2218
+ !key.startsWith("_") && key !== "language" && serialize(val, [key], indent);
2219
+ return;
2220
+ }
2221
+ const fieldName = path[path.length - 1];
2222
+ if (Array.isArray(value)) {
2223
+ value.some((item) => item?._type === "block") ? (lines.push(`${indentStr}<field name="${fieldName}">`), value.forEach((block, idx) => {
2224
+ serializeBlock(block, [...path, idx], indent + 1, lines);
2225
+ }), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
2226
+ serialize(item, [...path, idx], indent + 1);
2227
+ }), lines.push(`${indentStr}</field>`));
2228
+ return;
2229
+ }
2230
+ if (isPlainObject(value)) {
2231
+ lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
2232
+ for (const [key, val] of Object.entries(value))
2233
+ key.startsWith("_") || serialize(val, [...path, key], indent + 1);
2234
+ lines.push(`${indentStr}</field>`);
2235
+ return;
2236
+ }
2237
+ if (typeof value == "string") {
2238
+ value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
2239
+ return;
2240
+ }
2241
+ (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
2242
+ };
2243
+ return serialize(doc), lines.join(`
2244
+ `);
2245
+ }, serializeBlock = (block, path, indent, lines) => {
2246
+ const indentStr = " ".repeat(indent), metadata = {};
2247
+ 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);
2248
+ const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(typeof v == "string" ? v : JSON.stringify(v))}"`).join(" ");
2249
+ if (lines.push(`${indentStr}<block ${metaStr}>`), Array.isArray(block.children) && block._type === "block")
2250
+ block.children.forEach((child, idx) => {
2251
+ serializeSpan(child, [...path, "children", idx], indent + 1, lines);
2252
+ });
2253
+ else
2254
+ for (const [key, value] of Object.entries(block))
2255
+ key.startsWith("_") || key === "style" || key === "listItem" || key === "level" || key === "markDefs" || serializeBlockField(value, key, [...path, key], indent + 1, lines);
2256
+ lines.push(`${indentStr}</block>`);
2257
+ }, serializeBlockField = (value, fieldName, path, indent, lines) => {
2258
+ const indentStr = " ".repeat(indent);
2259
+ if (Array.isArray(value)) {
2260
+ value.some((item) => item?._type) ? (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
2261
+ item?._type === "block" && item?.children ? serializeBlock(item, [...path, idx], indent + 1, lines) : item?._type ? serializeBlock(item, [...path, idx], indent + 1, lines) : serializeBlockField(item, String(idx), [...path, idx], indent + 1, lines);
2262
+ }), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
2263
+ serializeBlockField(item, String(idx), [...path, idx], indent + 1, lines);
2264
+ }), lines.push(`${indentStr}</field>`));
2265
+ return;
2266
+ }
2267
+ if (isPlainObject(value)) {
2268
+ lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
2269
+ for (const [key, val] of Object.entries(value))
2270
+ key.startsWith("_") || serializeBlockField(val, key, [...path, key], indent + 1, lines);
2271
+ lines.push(`${indentStr}</field>`);
2272
+ return;
2273
+ }
2274
+ if (typeof value == "string" && value.length > 0) {
2275
+ lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
2276
+ return;
2277
+ }
2278
+ (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
2279
+ }, serializeSpan = (span, _path, indent, lines) => {
2280
+ const indentStr = " ".repeat(indent);
2281
+ if (!span || typeof span != "object") return;
2282
+ const metadata = {};
2283
+ span._type && (metadata._type = span._type), span._key && (metadata._key = span._key), span.marks && Array.isArray(span.marks) && (metadata.marks = span.marks.join(","));
2284
+ const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(String(v))}"`).join(" "), text = typeof span.text == "string" ? span.text : "";
2285
+ metaStr ? lines.push(`${indentStr}<span ${metaStr}>${escapeXml(text)}</span>`) : lines.push(`${indentStr}<span>${escapeXml(text)}</span>`);
2286
+ }, 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) => {
2287
+ const errors = [], criticalFields = ["_id", "_type", "_rev", "_createdAt", "_updatedAt"];
2288
+ for (const field of criticalFields)
2289
+ original[field] !== modified[field] && errors.push({
2290
+ path: [field],
2291
+ expected: original[field],
2292
+ actual: modified[field],
2293
+ type: "metadata_changed"
2294
+ });
2295
+ return validateStructureRecursive(original, modified, [], errors), {
2296
+ valid: errors.length === 0,
2297
+ errors
2298
+ };
2299
+ }, validateStructureRecursive = (original, modified, path, errors) => {
2300
+ if (typeof original != typeof modified) {
2301
+ errors.push({
2302
+ path,
2303
+ expected: typeof original,
2304
+ actual: typeof modified,
2305
+ type: "type_mismatch"
2306
+ });
2307
+ return;
2308
+ }
2309
+ if (Array.isArray(original)) {
2310
+ if (!Array.isArray(modified)) {
2311
+ errors.push({
2312
+ path,
2313
+ expected: "array",
2314
+ actual: typeof modified,
2315
+ type: "type_mismatch"
2316
+ });
2317
+ return;
2318
+ }
2319
+ if (original.length !== modified.length) {
2320
+ errors.push({
2321
+ path,
2322
+ expected: `length ${original.length}`,
2323
+ actual: `length ${modified.length}`,
2324
+ type: "type_mismatch"
2325
+ });
2326
+ return;
2327
+ }
2328
+ original.forEach((item, idx) => {
2329
+ validateStructureRecursive(item, modified[idx], [...path, String(idx)], errors);
2330
+ });
2331
+ return;
2332
+ }
2333
+ if (typeof original == "object" && original !== null && typeof modified == "object" && modified !== null) {
2334
+ const origKeys = Object.keys(original).sort(), modKeys = Object.keys(modified).sort();
2335
+ for (const key of origKeys)
2336
+ key in modified || errors.push({
2337
+ path: [...path, key],
2338
+ expected: "present",
2339
+ actual: "missing",
2340
+ type: "missing"
2341
+ });
2342
+ for (const key of modKeys)
2343
+ key in original || errors.push({
2344
+ path: [...path, key],
2345
+ expected: "absent",
2346
+ actual: "present",
2347
+ type: "extra"
2348
+ });
2349
+ const origObj = original, modObj = modified;
2350
+ for (const key of origKeys)
2351
+ key in modObj && (key.startsWith("_") || key === "marks" || key === "style" || key === "markDefs" ? JSON.stringify(origObj[key]) !== JSON.stringify(modObj[key]) && errors.push({
2352
+ path: [...path, key],
2353
+ expected: origObj[key],
2354
+ actual: modObj[key],
2355
+ type: "metadata_changed"
2356
+ }) : key !== "text" && validateStructureRecursive(origObj[key], modObj[key], [...path, key], errors));
2357
+ }
2358
+ }, assembleTranslationPrompt = (context, components) => {
2359
+ const sections = [
2360
+ {
2361
+ id: "agentDirectives",
2362
+ content: buildAgentDirectivesSection(context),
2363
+ required: !0,
2364
+ priority: 0
2365
+ },
2366
+ {
2367
+ id: "criticalRules",
2368
+ content: buildCriticalRulesSection(context),
2369
+ required: !0,
2370
+ priority: 1
2371
+ }
2372
+ ];
2373
+ components.glossaryTerms?.trim() && sections.push({
2374
+ id: "glossaryTerms",
2375
+ content: buildGlossarySection(components.glossaryTerms),
2376
+ required: !1,
2377
+ priority: 3
2378
+ });
2379
+ const styleSection = buildStyleGuideSection(components.globalPrompt, components.localePrompt);
2380
+ styleSection && sections.push({
2381
+ id: "styleGuide",
2382
+ content: styleSection,
2383
+ required: !1,
2384
+ priority: 4
2385
+ }), sections.sort((a, b) => a.priority - b.priority);
2386
+ let styleGuide = "";
2387
+ const omittedSections = [], truncatedSections = [];
2388
+ for (const section of sections) {
2389
+ const prefix = styleGuide.length > 0 ? `
2390
+
2391
+ ---
2392
+
2393
+ ` : "", availableCharacters = 2e3 - (styleGuide.length + prefix.length);
2394
+ if (availableCharacters <= 0) {
2395
+ if (section.required) {
2396
+ const truncatedContent = truncateSection(section.content, 0);
2397
+ truncatedContent && (styleGuide += prefix + truncatedContent, truncatedSections.push(section.id));
2398
+ } else
2399
+ omittedSections.push(section.id);
2400
+ continue;
2401
+ }
2402
+ if (section.content.length <= availableCharacters) {
2403
+ styleGuide += prefix + section.content;
2404
+ continue;
2405
+ }
2406
+ if (section.required) {
2407
+ const truncatedContent = truncateSection(section.content, availableCharacters);
2408
+ styleGuide += prefix + truncatedContent, truncatedSections.push(section.id);
2409
+ } else
2410
+ omittedSections.push(section.id);
2411
+ }
2412
+ return {
2413
+ styleGuide,
2414
+ omittedSections,
2415
+ truncatedSections
2416
+ };
2417
+ }, truncateSection = (content, limit) => {
2418
+ if (limit <= 0)
2419
+ return "";
2420
+ if (content.length <= limit)
2421
+ return content;
2422
+ if (limit <= 1)
2423
+ return content.slice(0, limit);
2424
+ const ellipsis = "\u2026";
2425
+ if (limit <= ellipsis.length)
2426
+ return content.slice(0, limit);
2427
+ const sliceEnd = limit - ellipsis.length, sliced = content.slice(0, sliceEnd), trimmed = sliced.replace(/\s+\S*$/, "").trimEnd();
2428
+ return `${trimmed.length >= sliceEnd / 2 ? trimmed : sliced}${ellipsis}`;
2429
+ }, buildAgentDirectivesSection = (context) => {
2430
+ const { targetLanguage, sourceLanguage } = context;
2431
+ return `# AGENT WORKFLOW DIRECTIVES
2432
+
2433
+ - Use only the provided ${sourceLanguage || "the source language"} source; never fetch outside context.
2434
+ - Scan each field once, then move directly into translation work.
2435
+ - Plan updates before editing so document structure remains intact.
2436
+ - Default to medium reasoning effort and escalate only for ambiguous passages.
2437
+ - Output just the ${targetLanguage || "the target language"} translation and mirror the source formatting exactly.`;
2438
+ }, buildCriticalRulesSection = (context) => {
2439
+ const { sourceLanguage, targetLanguage } = context, fromLanguage = sourceLanguage || "the source language", toLanguage = targetLanguage || "the target language";
2440
+ return `# CRITICAL TRANSLATION RULES
2441
+
2442
+ Follow these without exception:
2443
+
2444
+ 1. Translate from ${fromLanguage} into ${toLanguage}; every output token must be ${toLanguage} unless flagged as protected.
2445
+ 2. Preserve meaning exactly\u2014no additions, deletions, or tone shifts.
2446
+ 3. Maintain all layout, markdown, rich-text marks, and data structure.
2447
+ 4. Never invent metadata or field values that are absent in the source.
2448
+ 5. Leave technical tokens (code, IDs, filenames) untouched.
2449
+ 6. Keep punctuation, casing, and spacing unless ${toLanguage} grammar forces a change.`;
2450
+ }, buildGlossarySection = (glossaryTerms) => `# TERMINOLOGY RULES
2451
+
2452
+ Apply these mappings immediately after protected phrases and before style preferences.
2453
+
2454
+ ${glossaryTerms}`, buildStyleGuideSection = (globalPrompt, localePrompt) => {
2455
+ const components = [];
2456
+ return (globalPrompt || localePrompt) && (components.push("# TRANSLATION STYLE GUIDE (LOWEST PRIORITY)"), components.push(
2457
+ "Apply these directions only after honoring critical rules, protected phrases, and glossary terminology."
2458
+ ), 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(`
2459
+ `) : null;
1531
2460
  }, translateEntireDocument = async ({
1532
2461
  dispatch,
1533
2462
  client,
@@ -1535,17 +2464,175 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1535
2464
  sourceDocument,
1536
2465
  targetLanguage,
1537
2466
  languageFieldName,
1538
- translationTarget,
2467
+ translationTarget,
2468
+ translationPrompts,
2469
+ translationMode,
2470
+ openaiConfig,
2471
+ releaseId,
2472
+ isNewTranslation
2473
+ }) => {
2474
+ const logContext = {
2475
+ documentId: sourceDocument._id,
2476
+ targetLanguage,
2477
+ schemaId,
2478
+ operation: translationTarget.operation,
2479
+ targetId: translationTarget._id,
2480
+ translationMode,
2481
+ releaseId,
2482
+ isNewTranslation
2483
+ };
2484
+ devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
2485
+ try {
2486
+ let versionedDocumentId, baseDocumentId;
2487
+ if (isNewTranslation) {
2488
+ const { _id, _createdAt, _updatedAt, _rev, ...docWithoutMeta } = sourceDocument, newTranslationDoc = {
2489
+ ...docWithoutMeta,
2490
+ _type: sourceDocument._type,
2491
+ [languageFieldName]: targetLanguage
2492
+ };
2493
+ devLog.info("Creating new translation document in release", logContext), baseDocumentId = translationTarget._id || `new-translation-${Date.now()}`, await client.action({
2494
+ actionType: "sanity.action.document.version.create",
2495
+ publishedId: baseDocumentId,
2496
+ document: {
2497
+ ...newTranslationDoc,
2498
+ _type: sourceDocument._type,
2499
+ _id: `versions.${releaseId}.${baseDocumentId}`
2500
+ }
2501
+ }), versionedDocumentId = `versions.${releaseId}.${baseDocumentId}`, devLog.info("New translation version created in release", {
2502
+ ...logContext,
2503
+ versionedDocumentId,
2504
+ baseDocumentId
2505
+ });
2506
+ } else {
2507
+ const existingTranslationId = translationTarget._id;
2508
+ if (!existingTranslationId)
2509
+ throw new Error("Translation target ID not found for update operation");
2510
+ devLog.info("Creating version of existing translation in release", {
2511
+ ...logContext,
2512
+ existingTranslationId
2513
+ });
2514
+ const existingDoc = await client.getDocument(existingTranslationId);
2515
+ if (!existingDoc)
2516
+ throw new Error(`Existing translation document not found: ${existingTranslationId}`);
2517
+ await client.action({
2518
+ actionType: "sanity.action.document.version.create",
2519
+ publishedId: existingTranslationId,
2520
+ document: {
2521
+ ...existingDoc,
2522
+ _id: `versions.${releaseId}.${existingTranslationId}`
2523
+ }
2524
+ }), versionedDocumentId = `versions.${releaseId}.${existingTranslationId}`, baseDocumentId = existingTranslationId, devLog.info("Existing translation version created", {
2525
+ ...logContext,
2526
+ versionedDocumentId
2527
+ });
2528
+ const { _id, _createdAt, _updatedAt, _rev, ...sourceContent } = sourceDocument;
2529
+ await client.createOrReplace({
2530
+ ...sourceContent,
2531
+ _id: versionedDocumentId,
2532
+ [languageFieldName]: targetLanguage
2533
+ }), devLog.info("Source content cloned onto version", {
2534
+ ...logContext,
2535
+ versionedDocumentId
2536
+ });
2537
+ }
2538
+ let translatedDocument;
2539
+ switch (translationMode) {
2540
+ case "openai-only": {
2541
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2542
+ throw new Error("OpenAI is not configured but openai-only mode was selected");
2543
+ devLog.info("Using OpenAI-only translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai");
2544
+ const versionedDoc = await client.getDocument(versionedDocumentId);
2545
+ if (!versionedDoc)
2546
+ throw new Error(`Versioned document not found: ${versionedDocumentId}`);
2547
+ const translatedDoc = await translateInPlaceWithOpenAI({
2548
+ dispatch,
2549
+ sourceDocument,
2550
+ targetDocument: versionedDoc,
2551
+ targetLanguage,
2552
+ languageFieldName,
2553
+ translationPrompts,
2554
+ openaiConfig,
2555
+ isReview: !1
2556
+ });
2557
+ translatedDoc && (translatedDocument = await client.createOrReplace({
2558
+ ...translatedDoc,
2559
+ _id: versionedDocumentId
2560
+ }));
2561
+ break;
2562
+ }
2563
+ case "sanity-with-review":
2564
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2565
+ throw new Error("OpenAI is not configured but sanity-with-review mode was selected");
2566
+ if (devLog.info("Using Sanity + OpenAI review", logContext), translatedDocument = await translateWithSanityAgent({
2567
+ dispatch,
2568
+ client,
2569
+ schemaId,
2570
+ sourceDocument,
2571
+ targetLanguage,
2572
+ languageFieldName,
2573
+ versionedDocumentId,
2574
+ translationPrompts
2575
+ }), translatedDocument) {
2576
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, "reviewing_with_openai");
2577
+ const reviewedDoc = await translateInPlaceWithOpenAI({
2578
+ dispatch,
2579
+ sourceDocument,
2580
+ targetDocument: translatedDocument,
2581
+ targetLanguage,
2582
+ languageFieldName,
2583
+ translationPrompts,
2584
+ openaiConfig,
2585
+ isReview: !0
2586
+ });
2587
+ reviewedDoc && (translatedDocument = await client.createOrReplace({
2588
+ ...reviewedDoc,
2589
+ _id: versionedDocumentId
2590
+ }));
2591
+ }
2592
+ break;
2593
+ default:
2594
+ devLog.info("Using Sanity-only translation", logContext), translatedDocument = await translateWithSanityAgent({
2595
+ dispatch,
2596
+ client,
2597
+ schemaId,
2598
+ sourceDocument,
2599
+ targetLanguage,
2600
+ languageFieldName,
2601
+ versionedDocumentId,
2602
+ translationPrompts
2603
+ });
2604
+ break;
2605
+ }
2606
+ return devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), translatedDocument ? {
2607
+ ...translatedDocument,
2608
+ _id: baseDocumentId
2609
+ } : void 0;
2610
+ } catch (error) {
2611
+ const errorMessage = error instanceof Error ? error.message : String(error);
2612
+ devLog.error(
2613
+ "translateEntireDocument failed",
2614
+ error instanceof Error ? error : new Error(String(error)),
2615
+ logContext
2616
+ ), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
2617
+ errorMessage
2618
+ }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
2619
+ return;
2620
+ }
2621
+ }, translateWithSanityAgent = async ({
2622
+ dispatch,
2623
+ client,
2624
+ schemaId,
2625
+ sourceDocument,
2626
+ targetLanguage,
2627
+ languageFieldName,
2628
+ versionedDocumentId,
1539
2629
  translationPrompts
1540
2630
  }) => {
1541
2631
  const logContext = {
1542
2632
  documentId: sourceDocument._id,
1543
2633
  targetLanguage,
1544
- schemaId,
1545
- operation: translationTarget.operation,
1546
- targetId: translationTarget._id
2634
+ versionedDocumentId
1547
2635
  };
1548
- devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
1549
2636
  try {
1550
2637
  const { toLanguage, fromLanguage } = assembleLanguageSpecs({
1551
2638
  sourceDocument,
@@ -1557,104 +2644,263 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1557
2644
  toLanguage,
1558
2645
  fromLanguage
1559
2646
  });
1560
- let extantTranslation = "";
1561
- translationTarget._id && (devLog.debug("Checking for existing translation", {
2647
+ const documentAnalysis = analyzeDocumentContent(sourceDocument);
2648
+ devLog.debug("Document analysis completed", {
1562
2649
  ...logContext,
1563
- targetId: translationTarget._id
1564
- }), extantTranslation = await client.withConfig({
1565
- perspective: "raw"
1566
- }).fetch('*[_id in $ids] | order((_id match "drafts*") desc)[0]._id', {
1567
- ids: [undraftId(translationTarget._id), draftId(translationTarget._id)]
1568
- }), devLog.info("Extant translation found - will preserve existing content where possible", {
2650
+ analysis: documentAnalysis
2651
+ }), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
2652
+ const { styleGuide, omittedSections, truncatedSections } = assembleTranslationPrompt(
2653
+ {
2654
+ documentAnalysis,
2655
+ isUpdate: !1,
2656
+ // Always fresh translation in a version
2657
+ targetLanguage,
2658
+ sourceLanguage: sourceDocument[languageFieldName]
2659
+ },
2660
+ {
2661
+ globalPrompt: translationPrompts.globalPrompt,
2662
+ localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
2663
+ glossaryTerms: translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
2664
+ protectedPhrases: translationPrompts.protectedPhrases
2665
+ }
2666
+ );
2667
+ devLog.info("Structured style guide generated", {
1569
2668
  ...logContext,
1570
- hasExtantTranslation: !!extantTranslation
1571
- })), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
1572
- const styleGuide = [
1573
- translationPrompts.globalPrompt,
1574
- translationPrompts.localeInstructions[targetLanguage]?.prompt,
1575
- translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
1576
- extantTranslation ? UPDATE_EXISTING_TRANSLATION_PROMPT : null
1577
- ].filter(Boolean).join(`
1578
-
1579
- `);
1580
- devLog.info("Style guide generated", {
2669
+ styleGuideLength: styleGuide.length,
2670
+ omittedSections,
2671
+ truncatedSections
2672
+ }), (omittedSections.length > 0 || truncatedSections.length > 0) && devLog.warn("Style guide adjusted to fit character limit", {
1581
2673
  ...logContext,
1582
- styleGuide
2674
+ omittedSections,
2675
+ truncatedSections
1583
2676
  });
1584
2677
  const newDocument = await client.agent.action.translate({
1585
2678
  schemaId,
1586
2679
  toLanguage,
1587
2680
  fromLanguage,
1588
- documentId: sourceDocument._id,
1589
- targetDocument: translationTarget,
2681
+ documentId: versionedDocumentId,
2682
+ // Use versioned document ID
1590
2683
  languageFieldPath: [languageFieldName],
1591
2684
  styleGuide,
1592
- styleGuideParams: {
1593
- extantTranslation
1594
- },
1595
2685
  protectedPhrases: translationPrompts.protectedPhrases || [],
1596
2686
  conditionalPaths: {
1597
2687
  defaultHidden: !1,
1598
2688
  defaultReadOnly: !1
1599
2689
  }
1600
2690
  });
1601
- return devLog.info("Document translation completed", {
2691
+ return devLog.info("Sanity agent translation completed", {
1602
2692
  ...logContext,
1603
2693
  newDocumentId: newDocument._id,
1604
2694
  newDocumentRev: newDocument._rev
1605
- }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), newDocument;
2695
+ }), newDocument;
1606
2696
  } catch (error) {
1607
2697
  const errorMessage = error instanceof Error ? error.message : String(error);
1608
2698
  devLog.error(
1609
- "translateEntireDocument failed",
2699
+ "Sanity agent translation failed",
1610
2700
  error instanceof Error ? error : new Error(String(error)),
1611
2701
  logContext
1612
2702
  ), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
1613
2703
  errorMessage
1614
- }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
2704
+ });
1615
2705
  return;
1616
2706
  }
1617
- }, updateTranslationMetadata = async ({
1618
- client,
1619
- targetLanguage,
2707
+ }, translateInPlaceWithOpenAI = async ({
2708
+ dispatch,
1620
2709
  sourceDocument,
1621
- sourceLanguage,
1622
- newDocument,
1623
- translationMetadata,
1624
- isNewTranslation
2710
+ targetDocument,
2711
+ targetLanguage,
2712
+ languageFieldName,
2713
+ translationPrompts,
2714
+ openaiConfig,
2715
+ isReview
1625
2716
  }) => {
2717
+ const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2718
+ sourceId: sourceDocument._id,
2719
+ targetId: targetDocument._id,
2720
+ targetLanguage,
2721
+ sourceLanguage,
2722
+ isReview
2723
+ };
1626
2724
  try {
1627
- const refToNewTranslation = {
1628
- _key: targetLanguage,
1629
- _type: "internationalizedArrayReferenceValue",
1630
- value: {
1631
- _type: "reference",
1632
- _ref: undraftId(newDocument._id),
1633
- _weak: !0,
1634
- _strengthenOnPublish: {
1635
- _type: "reference",
1636
- _weak: !1
1637
- }
1638
- }
1639
- }, newTranslationHistory = {
1640
- _key: targetLanguage,
1641
- language: targetLanguage,
1642
- sourceId: undraftId(sourceDocument._id),
1643
- sourceRev: sourceDocument._rev,
2725
+ const sourceText = serializeDocument(sourceDocument), targetText = serializeDocument(targetDocument);
2726
+ devLog.debug("Documents serialized for OpenAI", {
2727
+ ...logContext,
2728
+ sourceLength: sourceText.length,
2729
+ targetLength: targetText.length
2730
+ });
2731
+ const prompt = isReview ? buildReviewPrompt({
2732
+ sourceText,
2733
+ translatedText: targetText,
1644
2734
  sourceLanguage,
1645
- sourceSnapshot: JSON.stringify(sourceDocument),
1646
- targetId: newDocument._id,
1647
- targetRev: newDocument._rev,
1648
- targetSnapshot: JSON.stringify(newDocument)
1649
- }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
1650
- isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1651
- const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1652
- translationMetadata.translationHistory?.some(
1653
- (history) => history.language === targetLanguage
1654
- ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
2735
+ targetLanguage,
2736
+ translationPrompts
2737
+ }) : buildTranslationPrompt({
2738
+ sourceText,
2739
+ sourceLanguage,
2740
+ targetLanguage,
2741
+ translationPrompts
2742
+ });
2743
+ devLog.debug("Calling GPT-5", {
2744
+ ...logContext,
2745
+ promptLength: prompt.length
2746
+ });
2747
+ const response = await callGPT5(openaiConfig.apiKey, {
2748
+ messages: [
2749
+ {
2750
+ role: "system",
2751
+ 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."
2752
+ },
2753
+ { role: "user", content: prompt }
2754
+ ],
2755
+ model: openaiConfig.model,
2756
+ reasoningEffort: openaiConfig.reasoningEffort,
2757
+ verbosity: openaiConfig.verbosity,
2758
+ onReasoningChunk: (reasoning) => {
2759
+ const status2 = isReview ? "reviewing_with_openai" : "translating_with_openai";
2760
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, status2, {
2761
+ reasoningProgress: reasoning
2762
+ });
2763
+ }
2764
+ });
2765
+ devLog.info("OpenAI operation completed", {
2766
+ ...logContext,
2767
+ tokensUsed: response.tokensUsed.total,
2768
+ cost: response.cost
2769
+ });
2770
+ const result = deserializeDocument(targetDocument, response.content), validation = validateStructureIntegrity(targetDocument, result);
2771
+ if (!validation.valid) {
2772
+ devLog.error("Structure validation failed", {
2773
+ ...logContext,
2774
+ errors: validation.errors
2775
+ });
2776
+ const unexpectedErrors = validation.errors.filter(
2777
+ (err) => !(err.path.length === 1 && err.path[0] === languageFieldName)
2778
+ );
2779
+ if (unexpectedErrors.length > 0)
2780
+ throw new Error(
2781
+ `Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
2782
+ );
2783
+ }
2784
+ devLog.info("Structure validation passed", logContext);
2785
+ const status = isReview ? "reviewing_with_openai" : "translating_with_openai";
2786
+ return setJobStatus(dispatch, sourceDocument._id, targetLanguage, status, {
2787
+ tokensUsed: response.tokensUsed.total,
2788
+ estimatedCost: response.cost,
2789
+ reasoningSummary: response.reasoningSummary
2790
+ }), result;
1655
2791
  } catch (error) {
1656
- throw console.error("Error updating translation metadata:", error), error;
2792
+ throw devLog.error("OpenAI translation failed", error instanceof Error ? error : new Error(String(error)), logContext), error;
1657
2793
  }
2794
+ }, buildTranslationPrompt = ({
2795
+ sourceText,
2796
+ sourceLanguage,
2797
+ targetLanguage,
2798
+ translationPrompts
2799
+ }) => {
2800
+ const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2801
+ return `# TRANSLATION TASK
2802
+
2803
+ Translate this ${sourceLanguage} document into ${targetLanguage}.
2804
+
2805
+ ## Guidelines
2806
+
2807
+ ${globalPrompt ? `### General Guidelines
2808
+ ${globalPrompt}
2809
+ ` : ""}
2810
+ ${localePrompt ? `### ${targetLanguage} Guidelines
2811
+ ${localePrompt}
2812
+ ` : ""}
2813
+ ${glossaryTerms ? `### Required Terminology
2814
+ ${glossaryTerms}
2815
+ ` : ""}
2816
+ ${protectedPhrases?.length ? `### Protected Phrases (do not translate)
2817
+ ${protectedPhrases.join(", ")}
2818
+ ` : ""}
2819
+
2820
+ ## Source Document (${sourceLanguage})
2821
+
2822
+ \`\`\`
2823
+ ${sourceText}
2824
+ \`\`\`
2825
+
2826
+ ## Instructions
2827
+
2828
+ 1. Translate all text content into ${targetLanguage}
2829
+ 2. Follow the guidelines and terminology requirements above
2830
+ 3. Preserve protected phrases exactly as they appear
2831
+ 4. Return the document in THE EXACT SAME FORMAT
2832
+ 5. **CRITICAL**: Only modify text content between tags
2833
+ 6. Never add, remove, or change any tags, attributes, or structure
2834
+
2835
+ If you used significant reasoning to make translation decisions, you may include a brief summary:
2836
+ <reasoning>
2837
+ Brief summary of key translation decisions and reasoning
2838
+ </reasoning>
2839
+
2840
+ Then return the translated document:`;
2841
+ }, buildReviewPrompt = ({
2842
+ sourceText,
2843
+ translatedText,
2844
+ sourceLanguage,
2845
+ targetLanguage,
2846
+ translationPrompts
2847
+ }) => {
2848
+ const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2849
+ return `# TRANSLATION QUALITY REVIEW
2850
+
2851
+ Review this ${targetLanguage} translation and return a corrected version.
2852
+
2853
+ ## Review Criteria
2854
+
2855
+ 1. **Accuracy**: Does it accurately convey the ${sourceLanguage} source meaning?
2856
+ 2. **Terminology**: Are glossary terms used correctly?
2857
+ 3. **Protected Phrases**: Are protected phrases preserved?
2858
+ 4. **Fluency**: Is it natural and fluent in ${targetLanguage}?
2859
+ 5. **Style**: Does it follow the style guide?
2860
+ 6. **Completeness**: Is all content translated?
2861
+
2862
+ ## Guidelines
2863
+
2864
+ ${globalPrompt ? `### General Guidelines
2865
+ ${globalPrompt}
2866
+ ` : ""}
2867
+ ${localePrompt ? `### ${targetLanguage} Guidelines
2868
+ ${localePrompt}
2869
+ ` : ""}
2870
+ ${glossaryTerms ? `### Required Terminology
2871
+ ${glossaryTerms}
2872
+ ` : ""}
2873
+ ${protectedPhrases?.length ? `### Protected Phrases (must preserve)
2874
+ ${protectedPhrases.join(", ")}
2875
+ ` : ""}
2876
+
2877
+ ## Source Document (${sourceLanguage})
2878
+
2879
+ \`\`\`
2880
+ ${sourceText}
2881
+ \`\`\`
2882
+
2883
+ ## Current Translation (${targetLanguage})
2884
+
2885
+ \`\`\`
2886
+ ${translatedText}
2887
+ \`\`\`
2888
+
2889
+ ## Instructions
2890
+
2891
+ 1. Review the translation for quality issues
2892
+ 2. Make corrections where needed to improve accuracy, fluency, or adherence to guidelines
2893
+ 3. Keep good translations as-is - only fix what needs fixing
2894
+ 4. Return the corrected translation in THE EXACT SAME FORMAT
2895
+ 5. **CRITICAL**: Only modify text content between tags
2896
+ 6. Never add, remove, or change any tags, attributes, or structure
2897
+
2898
+ If you made corrections or have insights about the translation quality, include a brief summary:
2899
+ <reasoning>
2900
+ Brief summary of review findings and corrections made
2901
+ </reasoning>
2902
+
2903
+ Then return the corrected translation document:`;
1658
2904
  }, translateDocument = async ({
1659
2905
  dispatch,
1660
2906
  client,
@@ -1663,15 +2909,17 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1663
2909
  languageFieldName,
1664
2910
  shouldOverwriteFreshTranslations,
1665
2911
  schemaId,
1666
- shouldTranslateDiffs,
1667
- translationPrompts
2912
+ translationPrompts,
2913
+ translationMode,
2914
+ openaiConfig,
2915
+ releaseId
1668
2916
  }) => {
1669
2917
  const logContext = {
1670
2918
  documentId: sourceDocument._id,
1671
2919
  targetLanguage,
1672
2920
  schemaId,
1673
- shouldTranslateDiffs,
1674
- shouldOverwriteFreshTranslations
2921
+ shouldOverwriteFreshTranslations,
2922
+ translationMode
1675
2923
  };
1676
2924
  devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
1677
2925
  try {
@@ -1711,48 +2959,20 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1711
2959
  operation: translationTarget.operation,
1712
2960
  isNewTranslation
1713
2961
  });
1714
- let newDocument;
1715
- if (!isNewTranslation && shouldTranslateDiffs) {
1716
- devLog.info("Starting diff-based translation", logContext);
1717
- const { currentTargetId, patch } = await patchTranslationTarget({
1718
- client,
1719
- currentSourceDocument: sourceDocument,
1720
- sourceLanguage,
1721
- translationMetadata,
1722
- targetLanguage
1723
- });
1724
- if (!currentTargetId) {
1725
- devLog.warn("No target ID found for diff translation", logContext), devLog.timeEnd("translateDocument"), devLog.groupEnd();
1726
- return;
1727
- }
1728
- devLog.debug("Patching translation target", {
1729
- ...logContext,
1730
- currentTargetId,
1731
- hasPatch: !!patch
1732
- }), newDocument = await translateDocumentInPlace({
1733
- client,
1734
- schemaId,
1735
- dispatch,
1736
- sourceDocument,
1737
- languageFieldName,
1738
- targetLanguage,
1739
- targetId: currentTargetId,
1740
- patch
1741
- });
1742
- } else
1743
- devLog.info("Starting full document translation", {
1744
- ...logContext,
1745
- translationTarget: translationTarget.operation
1746
- }), newDocument = await translateEntireDocument({
1747
- dispatch,
1748
- client,
1749
- schemaId,
1750
- sourceDocument,
1751
- targetLanguage,
1752
- languageFieldName,
1753
- translationTarget,
1754
- translationPrompts
1755
- });
2962
+ const newDocument = await translateEntireDocument({
2963
+ dispatch,
2964
+ client,
2965
+ schemaId,
2966
+ sourceDocument,
2967
+ targetLanguage,
2968
+ languageFieldName,
2969
+ translationTarget,
2970
+ translationPrompts,
2971
+ translationMode,
2972
+ openaiConfig,
2973
+ releaseId,
2974
+ isNewTranslation
2975
+ });
1756
2976
  if (!newDocument)
1757
2977
  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");
1758
2978
  return devLog.debug("Translation document created", {
@@ -1779,30 +2999,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1779
2999
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
1780
3000
  return;
1781
3001
  }
1782
- }, patchTranslationTarget = async ({
1783
- client,
1784
- currentSourceDocument,
1785
- translationMetadata,
1786
- targetLanguage
1787
- }) => {
1788
- const sourceSnapshotString = translationMetadata.translationHistory.find(
1789
- (h) => undraftId(h.sourceId) === undraftId(currentSourceDocument._id)
1790
- )?.sourceSnapshot;
1791
- if (!sourceSnapshotString)
1792
- return {};
1793
- const sourceSnapshot = JSON.parse(sourceSnapshotString), targetId = translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref;
1794
- if (!targetId)
1795
- return {};
1796
- const currentTargetId = await client.fetch(
1797
- '*[_id in $ids] | order((_id match "drafts*") desc)[0]._id',
1798
- { ids: [undraftId(targetId), draftId(targetId)] }
1799
- );
1800
- if (!currentTargetId)
1801
- return console.log(`No document found for ${targetId}`), {};
1802
- const patch = diffPatch(sourceSnapshot, currentSourceDocument, {
1803
- id: draftId(currentTargetId)
1804
- });
1805
- return { currentTargetId, patch };
1806
3002
  }, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
1807
3003
  client,
1808
3004
  sourceDocument,
@@ -1813,17 +3009,19 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1813
3009
  schemaId,
1814
3010
  references = [],
1815
3011
  maxConcurrentTranslationJobs,
1816
- shouldTranslateDiffs
3012
+ translationMode,
3013
+ openaiConfig
1817
3014
  }) => {
1818
3015
  const logContext = {
1819
3016
  documentId: sourceDocument._id,
1820
3017
  targetLanguages,
1821
3018
  referencesCount: references.length,
1822
3019
  maxConcurrentJobs: maxConcurrentTranslationJobs,
1823
- shouldTranslateDiffs,
3020
+ translationMode,
1824
3021
  shouldOverwriteFreshTranslations
1825
3022
  };
1826
3023
  devLog.group("translateDocumentSet", logContext), devLog.time("translateDocumentSet");
3024
+ let releaseId;
1827
3025
  try {
1828
3026
  const sourceLanguage = sourceDocument[languageFieldName];
1829
3027
  if (!sourceLanguage)
@@ -1835,6 +3033,22 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1835
3033
  type: "SET_OVERALL_STATUS",
1836
3034
  payload: "translating"
1837
3035
  });
3036
+ 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()}`;
3037
+ devLog.info("Creating Content Release for translation batch", {
3038
+ ...logContext,
3039
+ releaseTitle
3040
+ });
3041
+ const { releaseId: newReleaseId } = await client.releases.create({
3042
+ metadata: {
3043
+ title: releaseTitle,
3044
+ description: releaseDescription,
3045
+ releaseType: "asap"
3046
+ }
3047
+ });
3048
+ releaseId = newReleaseId, devLog.info("Content Release created", {
3049
+ ...logContext,
3050
+ releaseId
3051
+ });
1838
3052
  const sourceDocuments = [
1839
3053
  {
1840
3054
  _id: sourceDocument._id,
@@ -1866,7 +3080,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1866
3080
  }).fetch("*[_id == $id][0]", {
1867
3081
  id: documentId
1868
3082
  });
1869
- if (document) {
3083
+ if (document && releaseId) {
1870
3084
  const targetDocumentId = await translateDocument({
1871
3085
  dispatch,
1872
3086
  client,
@@ -1875,8 +3089,10 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1875
3089
  languageFieldName,
1876
3090
  shouldOverwriteFreshTranslations,
1877
3091
  schemaId,
1878
- shouldTranslateDiffs,
1879
- translationPrompts
3092
+ translationPrompts,
3093
+ translationMode,
3094
+ openaiConfig,
3095
+ releaseId
1880
3096
  });
1881
3097
  targetDocumentId && targetDocumentIds.push(targetDocumentId);
1882
3098
  }
@@ -1924,11 +3140,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1924
3140
  failed
1925
3141
  }), 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();
1926
3142
  } catch (error) {
1927
- throw devLog.error(
3143
+ if (devLog.error(
1928
3144
  "translateDocumentSet failed with unexpected error",
1929
3145
  error instanceof Error ? error : new Error(String(error)),
1930
3146
  logContext
1931
- ), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
3147
+ ), releaseId)
3148
+ try {
3149
+ await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
3150
+ ...logContext,
3151
+ releaseId
3152
+ });
3153
+ } catch (cleanupError) {
3154
+ devLog.error(
3155
+ "Failed to cleanup release",
3156
+ cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
3157
+ logContext
3158
+ );
3159
+ }
3160
+ throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
1932
3161
  }
1933
3162
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
1934
3163
  const {
@@ -1939,22 +3168,50 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1939
3168
  references,
1940
3169
  selectedTargetLanguages,
1941
3170
  shouldOverwriteFreshTranslations,
1942
- shouldTranslateDiffs
1943
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" });
3171
+ translationMode
3172
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
3173
+ translateDocumentSet({
3174
+ sourceDocument: document,
3175
+ targetLanguages: selectedTargetLanguages,
3176
+ client,
3177
+ dispatch,
3178
+ languageFieldName: config.languageFieldName,
3179
+ schemaId: config.schemaId,
3180
+ references,
3181
+ shouldOverwriteFreshTranslations,
3182
+ maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
3183
+ maxReferenceDepth: config.maxReferenceDepth,
3184
+ translationMode,
3185
+ openaiConfig: config.openai
3186
+ });
3187
+ }, [
3188
+ document,
3189
+ selectedTargetLanguages,
3190
+ client,
3191
+ dispatch,
3192
+ config.languageFieldName,
3193
+ config.schemaId,
3194
+ config.maxConcurrentTranslationJobs,
3195
+ config.maxReferenceDepth,
3196
+ config.openai,
3197
+ references,
3198
+ shouldOverwriteFreshTranslations,
3199
+ translationMode
3200
+ ]), handleSelectAll = useCallback(() => {
3201
+ dispatch({
3202
+ type: "SET_SELECTED_TARGET_LANGUAGES",
3203
+ payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
3204
+ });
3205
+ }, [dispatch, config.supportedLanguages, document.language]), handleSelectNone = useCallback(() => {
3206
+ dispatch({
3207
+ type: "SET_SELECTED_TARGET_LANGUAGES",
3208
+ payload: []
3209
+ });
3210
+ }, [dispatch]);
1944
3211
  return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
1945
3212
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
1946
- /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
1947
- dispatch({
1948
- type: "SET_SELECTED_TARGET_LANGUAGES",
1949
- payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
1950
- });
1951
- }, children: "Select All" }),
1952
- /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
1953
- dispatch({
1954
- type: "SET_SELECTED_TARGET_LANGUAGES",
1955
- payload: []
1956
- });
1957
- }, children: "Select None" })
3213
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
3214
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
1958
3215
  ] }),
1959
3216
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
1960
3217
  /* @__PURE__ */ jsx(Button, { onClick: () => setIsDialogOpen(!1), mode: "ghost", size: 2, children: "Cancel" }),
@@ -1964,21 +3221,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1964
3221
  mode: "ghost",
1965
3222
  tone: "positive",
1966
3223
  size: 2,
1967
- onClick: () => {
1968
- translateDocumentSet({
1969
- sourceDocument: document,
1970
- targetLanguages: selectedTargetLanguages,
1971
- client,
1972
- dispatch,
1973
- languageFieldName: config.languageFieldName,
1974
- schemaId: config.schemaId,
1975
- references,
1976
- shouldOverwriteFreshTranslations,
1977
- maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
1978
- maxReferenceDepth: config.maxReferenceDepth,
1979
- shouldTranslateDiffs
1980
- });
1981
- },
3224
+ onClick: handleTranslate,
1982
3225
  disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
1983
3226
  children: "Translate"
1984
3227
  }
@@ -2023,6 +3266,88 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2023
3266
  documentId: document._id
2024
3267
  });
2025
3268
  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)) }) });
3269
+ }, TranslationModePanel = () => {
3270
+ const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
3271
+ if (!config?.openai?.enabled || !config?.openai?.apiKey)
3272
+ return null;
3273
+ if (!openaiValidated)
3274
+ return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3275
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Configuration" }),
3276
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Validating API key..." })
3277
+ ] }) });
3278
+ const handleModeChange = (mode) => {
3279
+ dispatch({
3280
+ type: "SET_TRANSLATION_MODE",
3281
+ payload: mode
3282
+ });
3283
+ };
3284
+ return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3285
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3286
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
3287
+ /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
3288
+ ] }),
3289
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3290
+ /* @__PURE__ */ jsx(
3291
+ Button,
3292
+ {
3293
+ type: "button",
3294
+ mode: translationMode === "sanity-only" ? "ghost" : "bleed",
3295
+ tone: translationMode === "sanity-only" ? "primary" : "default",
3296
+ onClick: () => handleModeChange("sanity-only"),
3297
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3298
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-only", readOnly: !0 }),
3299
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3300
+ /* @__PURE__ */ jsx(Text, { children: "Sanity Agent Only" }),
3301
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Fast, reliable, and included with Sanity." })
3302
+ ] })
3303
+ ] })
3304
+ }
3305
+ ),
3306
+ /* @__PURE__ */ jsx(
3307
+ Button,
3308
+ {
3309
+ type: "button",
3310
+ mode: translationMode === "openai-only" ? "ghost" : "bleed",
3311
+ tone: translationMode === "openai-only" ? "primary" : "default",
3312
+ onClick: () => handleModeChange("openai-only"),
3313
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3314
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
3315
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3316
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3317
+ /* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
3318
+ /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
3319
+ ] }),
3320
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
3321
+ ] })
3322
+ ] })
3323
+ }
3324
+ ),
3325
+ /* @__PURE__ */ jsx(
3326
+ Button,
3327
+ {
3328
+ type: "button",
3329
+ mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
3330
+ tone: translationMode === "sanity-with-review" ? "primary" : "default",
3331
+ onClick: () => handleModeChange("sanity-with-review"),
3332
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3333
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
3334
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3335
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3336
+ /* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
3337
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
3338
+ ] }),
3339
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
3340
+ ] })
3341
+ ] })
3342
+ }
3343
+ )
3344
+ ] }),
3345
+ translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3346
+ "\u{1F4B0} ",
3347
+ /* @__PURE__ */ jsx("strong", { children: "OpenAI costs apply:" }),
3348
+ " You'll be charged for API usage based on tokens processed."
3349
+ ] }) })
3350
+ ] }) });
2026
3351
  }, assembleReferences = async ({
2027
3352
  client,
2028
3353
  sourceDocument,
@@ -2047,7 +3372,14 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2047
3372
  typeof refId == "string" && refId.length > 0 && !references.has(refId) && !documentCache.has(refId) && allRefIds.add(refId);
2048
3373
  }
2049
3374
  } catch (error) {
2050
- console.error("assembleReferences: Error finding references in document:", error);
3375
+ devLog.error(
3376
+ "Error finding references in document",
3377
+ error instanceof Error ? error : new Error(String(error)),
3378
+ {
3379
+ documentId: document._id,
3380
+ component: "assembleReferences"
3381
+ }
3382
+ );
2051
3383
  }
2052
3384
  if (allRefIds.size === 0)
2053
3385
  break;
@@ -2066,16 +3398,27 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2066
3398
  }
2067
3399
  documentCache.set(refId, fetchedDoc), references.add(refId), nextDepthDocuments.push(fetchedDoc);
2068
3400
  } else
2069
- documentCache.set(refId, null), console.warn(`assembleReferences: Document with _id "${refId}" not found`);
3401
+ documentCache.set(refId, null), devLog.warn(`Document with _id "${refId}" not found`, {
3402
+ refId,
3403
+ component: "assembleReferences"
3404
+ });
2070
3405
  }
2071
3406
  currentDepthDocuments = nextDepthDocuments;
2072
3407
  } catch (fetchError) {
2073
3408
  const error = fetchError instanceof Error ? fetchError : new Error(String(fetchError));
2074
- errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), console.error("assembleReferences: Failed to batch fetch documents:", error.message);
3409
+ errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), devLog.error("Failed to batch fetch documents", error, {
3410
+ refIdCount: refIdArray.length,
3411
+ depth,
3412
+ component: "assembleReferences"
3413
+ });
2075
3414
  break;
2076
3415
  }
2077
3416
  }
2078
- errors.length > 0 && console.warn(`assembleReferences: Encountered ${errors.length} error(s) while fetching references`);
3417
+ errors.length > 0 && devLog.warn(`Encountered ${errors.length} error(s) while fetching references`, {
3418
+ errorCount: errors.length,
3419
+ errors: errors.map((e) => ({ refId: e.refId, message: e.error.message })),
3420
+ component: "assembleReferences"
3421
+ });
2079
3422
  const result = [];
2080
3423
  for (const refId of references) {
2081
3424
  const cachedDoc = documentCache.get(refId);
@@ -2160,7 +3503,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2160
3503
  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
2161
3504
  ] });
2162
3505
  }, WriteSettingsPanel = () => {
2163
- const { dispatch, shouldOverwriteFreshTranslations, shouldTranslateDiffs } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
3506
+ const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
2164
3507
  dispatch({
2165
3508
  type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
2166
3509
  payload: !shouldOverwriteFreshTranslations
@@ -2191,38 +3534,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2191
3534
  ] })
2192
3535
  ] })
2193
3536
  }
2194
- ) }),
2195
- /* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
2196
- Button,
2197
- {
2198
- type: "button",
2199
- mode: shouldTranslateDiffs ? "ghost" : "bleed",
2200
- size: 2,
2201
- onClick: () => {
2202
- dispatch({
2203
- type: "SET_SHOULD_TRANSLATE_DIFFS",
2204
- payload: !shouldTranslateDiffs
2205
- });
2206
- },
2207
- tone: shouldTranslateDiffs ? "primary" : "neutral",
2208
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
2209
- /* @__PURE__ */ jsx(
2210
- Checkbox,
2211
- {
2212
- checked: shouldTranslateDiffs,
2213
- onChange: handleShouldOverwriteFreshTranslationsChange,
2214
- label: shouldTranslateDiffs ? "Disable" : "Enable"
2215
- }
2216
- ),
2217
- /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2218
- /* @__PURE__ */ jsxs(Inline, { space: 1, children: [
2219
- /* @__PURE__ */ jsx(Text, { children: "Translate Diffs" }),
2220
- /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Experimental" })
2221
- ] }),
2222
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Only re-translate content that has changed in the source document." })
2223
- ] })
2224
- ] })
2225
- }
2226
3537
  ) })
2227
3538
  ] });
2228
3539
  }, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
@@ -2230,16 +3541,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2230
3541
  /* @__PURE__ */ jsx(Label, { children: "Languages" }),
2231
3542
  /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
2232
3543
  ] }),
3544
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3545
+ /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
3546
+ /* @__PURE__ */ jsx(TranslationModePanel, {})
3547
+ ] }),
2233
3548
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
2234
3549
  /* @__PURE__ */ jsx(Label, { children: "Settings" }),
2235
3550
  /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
2236
3551
  ] }),
2237
3552
  /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
2238
3553
  ] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
2239
- const { overallStatus, translationJobs } = useTranslationContext(), headline = useMemo(
3554
+ const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
2240
3555
  () => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
2241
3556
  [overallStatus]
2242
- ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]);
3557
+ ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
3558
+ if (translationMode === "sanity-only") return null;
3559
+ 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);
3560
+ return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
3561
+ }, [jobs, translationMode]);
2243
3562
  return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2244
3563
  /* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
2245
3564
  /* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
@@ -2259,6 +3578,25 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2259
3578
  " \u2014 ",
2260
3579
  job._id.slice(-6)
2261
3580
  ] }),
3581
+ !["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3582
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
3583
+ job.reasoningProgress && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3584
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "\u{1F914} GPT-5 Reasoning" }),
3585
+ /* @__PURE__ */ jsx(Text, { size: 1, style: { fontStyle: "italic" }, children: job.reasoningProgress })
3586
+ ] }) })
3587
+ ] }),
3588
+ job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3589
+ /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3590
+ job.tokensUsed.toLocaleString(),
3591
+ " tokens (~$",
3592
+ job.estimatedCost?.toFixed(4),
3593
+ ")"
3594
+ ] }),
3595
+ job.reasoningSummary && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3596
+ "\u{1F4A1} ",
3597
+ job.reasoningSummary
3598
+ ] }) })
3599
+ ] }) : null,
2262
3600
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2263
3601
  "Error: ",
2264
3602
  job.errorMessage
@@ -2279,7 +3617,23 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2279
3617
  ) : null
2280
3618
  ] })
2281
3619
  ] }) }) }, job._id)) }) }) })
2282
- ] })
3620
+ ] }),
3621
+ openaiStats && openaiStats.jobsWithUsage > 0 && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3622
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
3623
+ /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
3624
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3625
+ "\u{1F4CA} Total Tokens: ",
3626
+ /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
3627
+ ] }),
3628
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3629
+ "\u{1F4B0} Total Cost: ",
3630
+ /* @__PURE__ */ jsxs("strong", { children: [
3631
+ "$",
3632
+ openaiStats.totalCost.toFixed(4)
3633
+ ] })
3634
+ ] })
3635
+ ] })
3636
+ ] }) })
2283
3637
  ] });
2284
3638
  }, ProgressBar = ({ progress, max = 100 }) => {
2285
3639
  const percentage = Math.min(progress / max * 100, 100);
@@ -2338,7 +3692,18 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2338
3692
  content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
2339
3693
  children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
2340
3694
  }
2341
- ), STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
3695
+ ), getStatusLabel = (status) => ({
3696
+ idle: "Idle",
3697
+ queued: "Queued...",
3698
+ preparing_translation: "Preparing translation...",
3699
+ translating: "Translating with Sanity Agent...",
3700
+ translating_with_openai: "Translating with GPT-5...",
3701
+ reviewing_with_openai: "Reviewing translation with GPT-5...",
3702
+ updating_metadata: "Saving metadata...",
3703
+ error: "Error",
3704
+ skipped: "Skipped",
3705
+ completed: "Completed"
3706
+ })[status] || status, STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
2342
3707
  "translating",
2343
3708
  "linking_references",
2344
3709
  "completed"