@braze/sanity-plugin-ai-translation 0.2.37 → 0.2.51

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,15 +1,15 @@
1
- import { useClient, getPublishedId, pathToString, defineType, defineField, definePlugin } from "sanity";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, WarningOutlineIcon, InfoOutlineIcon, ArrowLeftIcon, EditIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
4
- import { createContext, useReducer, useEffect, useContext, useState, useCallback, useMemo } from "react";
1
+ import { getPublishedId, pathToString, useClient, defineType, defineField, definePlugin } from "sanity";
2
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
+ import { WarningOutlineIcon, SearchIcon, CheckmarkIcon, ErrorOutlineIcon, CircleIcon, ArrowRightIcon, ResetIcon, PanelRightIcon, TranslateIcon } from "@sanity/icons";
4
+ import { createContext, useReducer, useEffect, useContext, useMemo, useState, useCallback } from "react";
5
5
  import defaults from "lodash.defaults";
6
- import { Card, Flex, Stack, Text, Spinner, Button, Box, Tooltip, Checkbox, Select, TextInput, TextArea, Heading, Inline, Badge, Grid, Radio, Label, Code } from "@sanity/ui";
7
- import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
6
+ import { Card, Stack, Flex, Text, Box, Heading, Button, Spinner, Inline, Checkbox, Grid, Radio, TextInput, Badge, Select, Label, Code, Tooltip } from "@sanity/ui";
8
7
  import { toPlainText } from "@portabletext/react";
9
8
  import { uuid } from "@sanity/uuid";
10
9
  import cloneDeep from "lodash.clonedeep";
11
10
  import get from "lodash.get";
12
11
  import set from "lodash.set";
12
+ import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
13
13
  const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true" || process.env.SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true", devLog = {
14
14
  info: (message, context) => {
15
15
  isLoggingEnabled() && console.info("[AI Translation]", message, context ? JSON.stringify(context, null, 2) : "");
@@ -62,19 +62,63 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
62
62
  error: error instanceof Error ? error.message : "Network error"
63
63
  };
64
64
  }
65
+ }, activeTranslations = /* @__PURE__ */ new Map(), registerActiveTranslation = (documentId, releaseId) => {
66
+ const entry = {
67
+ state: {
68
+ documentId,
69
+ releaseId,
70
+ overallStatus: "translating",
71
+ translationJobs: /* @__PURE__ */ new Map(),
72
+ startedAt: /* @__PURE__ */ new Date()
73
+ },
74
+ subscribers: /* @__PURE__ */ new Set()
75
+ };
76
+ activeTranslations.set(documentId, entry);
77
+ }, updateActiveTranslationStatus = (documentId, status) => {
78
+ const entry = activeTranslations.get(documentId);
79
+ entry && (entry.state.overallStatus = status, notifySubscribers(entry));
80
+ }, updateActiveTranslationJob = (documentId, job) => {
81
+ const entry = activeTranslations.get(documentId);
82
+ if (entry) {
83
+ const jobKey = `${job._id}_${job.language}`, existingJob = entry.state.translationJobs.get(jobKey), mergedJob = existingJob ? { ...existingJob, ...job } : job;
84
+ entry.state.translationJobs.set(jobKey, mergedJob), notifySubscribers(entry);
85
+ }
86
+ }, getActiveTranslation = (documentId) => activeTranslations.get(documentId)?.state ?? null, subscribeToActiveTranslation = (documentId, callback) => {
87
+ const entry = activeTranslations.get(documentId);
88
+ return entry ? (entry.subscribers.add(callback), callback(entry.state), () => {
89
+ entry.subscribers.delete(callback);
90
+ }) : () => {
91
+ };
92
+ }, cleanupActiveTranslation = (documentId, delayMs = 6e4) => {
93
+ setTimeout(() => {
94
+ const entry = activeTranslations.get(documentId);
95
+ entry && ["completed", "error"].includes(entry.state.overallStatus) && activeTranslations.delete(documentId);
96
+ }, delayMs);
97
+ }, notifySubscribers = (entry) => {
98
+ for (const subscriber of entry.subscribers)
99
+ try {
100
+ subscriber(entry.state);
101
+ } catch (error) {
102
+ console.error("Error in active translation subscriber:", error);
103
+ }
104
+ }, forceCleanupActiveTranslation = (documentId) => {
105
+ activeTranslations.delete(documentId);
65
106
  }, TranslationContext = createContext({
66
107
  document: null,
67
108
  config: null,
68
109
  overallStatus: "idle",
69
110
  selectedTargetLanguages: [],
70
111
  translationJobs: /* @__PURE__ */ new Map(),
71
- shouldOverwriteFreshTranslations: !1,
72
112
  shouldTranslateReferences: !1,
73
113
  maxReferenceDepth: 1,
74
114
  references: [],
75
115
  translationMetadata: null,
76
116
  translationMode: "sanity-only",
77
117
  openaiValidated: !1,
118
+ releaseConfig: {
119
+ mode: "new",
120
+ newReleaseTitle: ""
121
+ },
78
122
  dispatch: () => {
79
123
  }
80
124
  }), translationReducer = (context, action) => {
@@ -85,7 +129,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
85
129
  };
86
130
  switch (devLog.debug("Translation action dispatched", {
87
131
  ...logContext,
88
- payload: action.payload
132
+ payload: "payload" in action ? action.payload : void 0
89
133
  }), action.type) {
90
134
  case "SET_OVERALL_STATUS":
91
135
  return { ...context, overallStatus: action.payload };
@@ -116,11 +160,6 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
116
160
  ...context,
117
161
  maxReferenceDepth: action.payload
118
162
  };
119
- case "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS":
120
- return {
121
- ...context,
122
- shouldOverwriteFreshTranslations: action.payload
123
- };
124
163
  case "SET_REFERENCES":
125
164
  return {
126
165
  ...context,
@@ -133,31 +172,82 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
133
172
  translationJobs: newTranslationJobs
134
173
  };
135
174
  }
175
+ case "SET_RELEASE_CONFIG":
176
+ return {
177
+ ...context,
178
+ releaseConfig: {
179
+ ...context.releaseConfig,
180
+ ...action.payload
181
+ }
182
+ };
183
+ case "RESET_TRANSLATION_STATE":
184
+ return devLog.info("Resetting translation state for new translation", {
185
+ ...logContext,
186
+ previousStatus: context.overallStatus,
187
+ previousJobCount: context.translationJobs.size
188
+ }), {
189
+ ...context,
190
+ overallStatus: "idle",
191
+ translationJobs: /* @__PURE__ */ new Map(),
192
+ // Clear any release conflicts from previous run
193
+ releaseConfig: {
194
+ ...context.releaseConfig,
195
+ conflicts: void 0,
196
+ replaceExisting: void 0
197
+ }
198
+ };
136
199
  default:
137
200
  return context;
138
201
  }
139
- }, TranslationContextProvider = ({ document, config, children }) => {
202
+ }, generateDefaultReleaseTitle = (document) => `[AI Translation] ${document.title || document.name || document._id.split("-").slice(0, 2).join("-")}`, TranslationContextProvider = ({ document, config, children }) => {
140
203
  devLog.debug("TranslationContextProvider initialized", {
141
204
  documentId: document._id,
142
205
  documentType: document._type,
143
206
  supportedLanguages: config.supportedLanguages?.map((l) => l.id),
144
207
  component: "TranslationContextProvider"
145
208
  });
146
- const initialContext = {
209
+ const defaultReleaseTitle = generateDefaultReleaseTitle(document), activeTranslation = getActiveTranslation(document._id), initialContext = {
147
210
  document,
148
211
  config,
149
- overallStatus: "idle",
212
+ // If there's an active translation, restore its state
213
+ overallStatus: activeTranslation?.overallStatus ?? "idle",
150
214
  translationMetadata: null,
151
215
  selectedTargetLanguages: [],
152
- shouldOverwriteFreshTranslations: !1,
153
216
  shouldTranslateReferences: !1,
154
217
  maxReferenceDepth: 1,
155
218
  references: [],
156
- translationJobs: /* @__PURE__ */ new Map(),
219
+ // Restore jobs from active translation if available
220
+ translationJobs: activeTranslation?.translationJobs ?? /* @__PURE__ */ new Map(),
157
221
  translationMode: config?.openai?.defaultMode || "sanity-only",
158
- openaiValidated: !1
159
- }, [context, dispatch] = useReducer(translationReducer, initialContext);
222
+ openaiValidated: !1,
223
+ releaseConfig: {
224
+ mode: "new",
225
+ newReleaseTitle: defaultReleaseTitle
226
+ }
227
+ };
228
+ activeTranslation && devLog.info("Reconnecting to active translation", {
229
+ documentId: document._id,
230
+ status: activeTranslation.overallStatus,
231
+ jobCount: activeTranslation.translationJobs.size,
232
+ component: "TranslationContextProvider"
233
+ });
234
+ const [context, dispatch] = useReducer(translationReducer, initialContext);
160
235
  return useEffect(() => {
236
+ const handleActiveTranslationUpdate = (state) => {
237
+ dispatch({
238
+ type: "SET_OVERALL_STATUS",
239
+ payload: state.overallStatus
240
+ });
241
+ for (const job of state.translationJobs.values())
242
+ dispatch({
243
+ type: "SET_TRANSLATION_JOB_STATUS",
244
+ payload: job
245
+ });
246
+ }, unsubscribe = subscribeToActiveTranslation(document._id, handleActiveTranslationUpdate);
247
+ return () => {
248
+ unsubscribe();
249
+ };
250
+ }, [document._id]), useEffect(() => {
161
251
  (async () => {
162
252
  if (config?.openai?.enabled && config?.openai?.apiKey)
163
253
  try {
@@ -196,1044 +286,238 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
196
286
  "useTranslationContext must be used within a TranslationContextProvider with document and config props"
197
287
  );
198
288
  return context;
199
- }, undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_TYPE = "translation.metadata", TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
200
- const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
201
- params: {
202
- documentId: undraftId(documentId),
203
- translationMetadataType: TRANSLATION_METADATA_TYPE
204
- },
205
- initialValue: null
289
+ }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
290
+ (translation) => translation._key === targetLanguage
291
+ )?.value._ref || null, undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_TYPE = "translation.metadata", getTranslationMetadata = async ({
292
+ client,
293
+ sourceDocument,
294
+ sourceLanguage
295
+ }) => {
296
+ try {
297
+ return await client.fetch(
298
+ "*[_type == $tmType && references($sourceDocumentId)][0]",
299
+ {
300
+ tmType: TRANSLATION_METADATA_TYPE,
301
+ sourceDocumentId: undraftId(sourceDocument._id)
302
+ }
303
+ ) || await client.create({
304
+ _type: TRANSLATION_METADATA_TYPE,
305
+ translations: [
306
+ {
307
+ _key: sourceLanguage,
308
+ _type: "internationalizedArrayReferenceValue",
309
+ value: {
310
+ _type: "reference",
311
+ _ref: undraftId(sourceDocument._id),
312
+ _weak: !0,
313
+ _strengthenOnPublish: {
314
+ _type: sourceDocument._type,
315
+ _weak: !1
316
+ }
317
+ }
318
+ }
319
+ ],
320
+ translationHistory: []
321
+ });
322
+ } catch (error) {
323
+ throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
324
+ }
325
+ }, checkDocumentConflicts = async ({
326
+ client,
327
+ releaseId,
328
+ documentId,
329
+ documentTitle,
330
+ documentType,
331
+ targetLanguages,
332
+ languageFieldName
333
+ }) => {
334
+ const conflicts = [], document = await client.withConfig({ perspective: "drafts" }).getDocument(documentId);
335
+ if (!document)
336
+ return conflicts;
337
+ const sourceLanguage = document[languageFieldName], translationMetadata = await getTranslationMetadata({
338
+ client,
339
+ sourceDocument: document,
340
+ sourceLanguage
206
341
  });
207
- return useEffect(() => {
208
- if (loading) {
209
- dispatch({ type: "SET_OVERALL_STATUS", payload: "fetching_metadata" });
210
- return;
211
- }
212
- if (error) {
213
- dispatch({ type: "SET_OVERALL_STATUS", payload: "error" });
214
- return;
215
- }
216
- if (data) {
217
- dispatch({ type: "SET_TRANSLATION_METADATA", payload: data }), dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
218
- return;
342
+ for (const targetLanguage of targetLanguages) {
343
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
344
+ if (existingTranslationId) {
345
+ const versionedId = `versions.${releaseId}.${existingTranslationId}`;
346
+ await client.getDocument(versionedId) && (conflicts.push({
347
+ sourceDocumentId: documentId,
348
+ sourceDocumentTitle: documentTitle || document.title || document.name,
349
+ sourceDocumentType: documentType || document._type,
350
+ targetLanguage,
351
+ existingVersionId: versionedId
352
+ }), devLog.debug("Found existing version in release", {
353
+ releaseId,
354
+ sourceDocumentId: documentId,
355
+ targetLanguage,
356
+ versionedId
357
+ }));
219
358
  }
220
- dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
221
- }, [data, loading, error, dispatch]), {
222
- data,
223
- loading,
224
- error
225
- };
226
- };
227
- async function validateSuggestion(client, suggestion) {
228
- 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" };
229
- }
230
- async function validateGlossaryTerm(client, suggestion) {
231
- const { sourceValue, targetValue, sourceLocale, targetLocale } = suggestion;
232
- if (!sourceValue || !targetValue || !sourceLocale || !targetLocale)
233
- return {
234
- valid: !1,
235
- status: "invalid",
236
- reason: "Missing required fields for glossary term"
237
- };
238
- if (sourceValue.trim() === targetValue.trim())
239
- return {
240
- valid: !1,
241
- status: "invalid",
242
- reason: `Source and target are identical ("${sourceValue}") - this should be a protected phrase instead`
243
- };
244
- const protectedPhrases = await client.fetch('*[_id == "translation-settings"][0].protectedPhrases');
245
- if (protectedPhrases?.some((phrase) => phrase.toLowerCase() === sourceValue.toLowerCase()))
246
- return {
247
- valid: !1,
248
- status: "invalid",
249
- 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.`
250
- };
251
- if (targetValue && protectedPhrases?.some((phrase) => phrase.toLowerCase() === targetValue.toLowerCase()))
252
- return {
253
- valid: !1,
254
- status: "invalid",
255
- reason: `Cannot use "${targetValue}" as a translation target because it is a protected phrase. Protected phrases should not appear as translation targets.`
256
- };
257
- const existingTerm = await client.fetch(
258
- `*[_type == "translationGlossaryTerm" && count(translations[_key == $sourceLocale && value == $sourceValue]) > 0][0] {
259
- translations
260
- }`,
261
- { sourceLocale, sourceValue }
262
- );
263
- if (existingTerm) {
264
- const existingTarget = existingTerm.translations.find((t) => t._key === targetLocale);
265
- if (existingTarget?.value === targetValue)
266
- return {
267
- valid: !0,
268
- status: "warning",
269
- reason: `This translation already exists in the glossary: "${sourceValue}" \u2192 "${targetValue}"`
270
- };
271
- if (existingTarget && existingTarget.value !== targetValue)
272
- return {
273
- valid: !0,
274
- status: "warning",
275
- reason: `"${sourceValue}" already has a different translation in the glossary: "${existingTarget.value}". Applying this will update it to "${targetValue}".`
276
- };
277
359
  }
278
- return { valid: !0, status: "valid" };
279
- }
280
- async function validateProtectedPhrase(client, suggestion) {
281
- const phrase = suggestion.description.trim();
282
- if (!phrase)
283
- return {
284
- valid: !1,
285
- status: "invalid",
286
- reason: "Protected phrase cannot be empty"
287
- };
288
- if ((await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.some((p) => p.toLowerCase() === phrase.toLowerCase()))
289
- return {
290
- valid: !0,
291
- status: "warning",
292
- reason: `"${phrase}" is already in protected phrases`
293
- };
294
- const glossaryTermWithPhrase = await client.fetch(
295
- `*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0][0] {
296
- _id,
297
- translations
298
- }`,
299
- { phrase }
300
- );
301
- if (glossaryTermWithPhrase) {
302
- const matchingTranslations = glossaryTermWithPhrase.translations.filter((t) => t.value.toLowerCase() === phrase.toLowerCase()).map((t) => t._key).join(", ");
303
- return {
304
- valid: !1,
305
- status: "invalid",
306
- 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.`
307
- };
308
- }
309
- return { valid: !0, status: "valid" };
310
- }
311
- function validatePrompt(suggestion) {
312
- const update = suggestion.description.trim();
313
- return update ? update.toLowerCase().includes("has been") || update.toLowerCase().includes("was changed") || update.toLowerCase().includes("corrected to") ? {
314
- valid: !0,
315
- status: "warning",
316
- reason: "This update may be describing past changes rather than adding new rules"
317
- } : { valid: !0, status: "valid" } : {
318
- valid: !1,
319
- status: "invalid",
320
- reason: "Prompt update cannot be empty"
321
- };
322
- }
323
- async function analyzeFeedback({
324
- client,
325
- sourceLanguage,
326
- targetLanguage,
327
- userFeedback,
328
- shouldIncludeEdits,
329
- sourceSnapshot,
330
- targetSnapshot,
331
- currentDocument,
332
- onProgress
333
- }) {
334
- onProgress?.({ stage: "analyzing", message: "Analyzing feedback and edits..." });
335
- const analysisPrompt = buildAnalysisPrompt({
336
- sourceLanguage,
337
- targetLanguage,
338
- userFeedback,
339
- shouldIncludeEdits,
340
- sourceSnapshot,
341
- targetSnapshot,
342
- currentDocument
343
- }), analysis = await analyzeWithAI(client, analysisPrompt), suggestions = convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage);
344
- onProgress?.({ stage: "analyzing", message: "Validating suggestions..." });
345
- const validatedSuggestions = await Promise.all(
346
- suggestions.map(async (suggestion) => {
347
- const validation = await validateSuggestion(client, suggestion);
348
- return {
349
- ...suggestion,
350
- warning: validation.reason,
351
- validationStatus: validation.status,
352
- // Only auto-approve valid suggestions; invalid ones cannot be approved
353
- approved: validation.status === "valid"
354
- };
355
- })
356
- );
357
- return devLog.info("Feedback analysis complete", {
358
- sourceLanguage,
359
- targetLanguage,
360
- total: validatedSuggestions.length,
361
- valid: validatedSuggestions.filter((s) => s.validationStatus === "valid").length,
362
- warnings: validatedSuggestions.filter((s) => s.validationStatus === "warning").length,
363
- invalid: validatedSuggestions.filter((s) => s.validationStatus === "invalid").length
364
- }), validatedSuggestions;
365
- }
366
- async function applySuggestions({
360
+ return conflicts;
361
+ }, checkReleaseConflicts = async ({
367
362
  client,
368
- schemaId,
369
- sourceLanguage,
370
- targetLanguage,
371
- suggestions,
372
- onProgress
373
- }) {
374
- if (!targetLanguage)
375
- throw devLog.error("targetLanguage is undefined in applySuggestions!", {
376
- sourceLanguage,
377
- targetLanguage,
378
- suggestionsCount: suggestions.length
379
- }), new Error("targetLanguage is required but was undefined");
380
- const approvedSuggestions = suggestions.filter((s) => s.approved);
381
- approvedSuggestions.length !== 0 && (onProgress?.({ stage: "applying", message: "Applying updates to translation settings..." }), await applyImprovementsFromSuggestions(client, schemaId, targetLanguage, approvedSuggestions, onProgress), devLog.info("Suggestions applied successfully", {
382
- sourceLanguage,
383
- targetLanguage,
384
- count: approvedSuggestions.length
385
- }));
386
- }
387
- function buildAnalysisPrompt({
388
- sourceLanguage,
389
- targetLanguage,
390
- userFeedback,
391
- shouldIncludeEdits,
392
- sourceSnapshot,
393
- targetSnapshot,
394
- currentDocument
395
- }) {
396
- const parts = [
397
- "You are analyzing feedback about AI translation quality to improve future translations.",
398
- `Source language: ${sourceLanguage}`,
399
- `Target language: ${targetLanguage}`,
400
- "",
401
- "Your task is to analyze the feedback and infer specific improvements to:",
402
- "1. Global translation prompt (general guidance for all translations)",
403
- "2. Locale-specific prompt (guidance specific to this target language)",
404
- "3. Protected phrases (terms that should never be translated)",
405
- "4. Glossary terms (specific term-to-term translation mappings)",
406
- ""
407
- ];
408
- return userFeedback && parts.push("USER FEEDBACK:", userFeedback, ""), shouldIncludeEdits && sourceSnapshot && targetSnapshot && currentDocument && parts.push(
409
- "EDIT ANALYSIS:",
410
- "Compare the original translation with the edited version to understand what the user changed.",
411
- "",
412
- "Original source document:",
413
- sourceSnapshot,
414
- "",
415
- "Original AI translation:",
416
- targetSnapshot,
417
- "",
418
- "Current edited document:",
419
- JSON.stringify(currentDocument, null, 2),
420
- ""
421
- ), parts.push(
422
- "INSTRUCTIONS:",
423
- "Analyze the feedback and/or edits to identify patterns and improvements.",
424
- "",
425
- "For edits, look for:",
426
- "- Terminology changes that suggest glossary additions",
427
- "- Phrases that were protected/unprotected incorrectly",
428
- "- Style, tone, or formatting issues that suggest prompt improvements",
429
- "- Consistent patterns across multiple changes",
430
- "",
431
- "IMPORTANT RULES:",
432
- "1. DO NOT create redundant updates. Choose ONE mechanism for each issue:",
433
- " - Protected phrases = terms that should NEVER be translated (language-agnostic)",
434
- ' - Glossary terms = specific translation mappings (e.g., "chat" \u2192 "cat" in French)',
435
- " - Locale prompts = guidance specific to this source\u2192target language pair",
436
- " - Global prompts = guidance that applies to ALL translations",
437
- "",
438
- "2. Be locale-specific:",
439
- ` - This feedback is specifically about ${sourceLanguage} \u2192 ${targetLanguage} translation`,
440
- " - DO NOT assume rules apply to other language pairs unless explicitly stated",
441
- " - Prefer locale-specific prompts over global prompts unless universally applicable",
442
- "",
443
- "3. Protected phrases vs. Locale prompts:",
444
- " - Use protected phrases ONLY for brand names, product names, proper nouns, technical terms that should NEVER be translated in ANY language",
445
- ' - Use locale prompts for language-pair-specific guidance (e.g., "Keep technical terms in English when translating to German")',
446
- " - NEVER mention specific terms in both protected phrases AND prompts",
447
- "",
448
- "4. Glossary vs. Protected phrases:",
449
- ' - Glossary = "Always translate X as Y" (provides the correct translation)',
450
- ' - Protected = "Never translate X" (keeps it as-is)',
451
- " - CRITICAL: If sourceValue === targetValue, use PROTECTED PHRASES, NOT glossary",
452
- " - Only use glossary when the term actually changes between languages",
453
- " - Use protected when the term should remain unchanged across all languages",
454
- "",
455
- "Output a JSON object with this structure:",
456
- "{",
457
- ' "globalPromptUpdates": "string | null - ONLY if this applies to ALL language pairs",',
458
- ` "localePromptUpdates": "string | null - ONLY if this is specific to ${sourceLanguage}\u2192${targetLanguage}",`,
459
- ' "protectedPhrasesToAdd": ["ONLY brand names, products, proper nouns that should NEVER translate"],',
460
- ' "glossaryTermsToAdd": [{"sourceLocale": "sourceLocale","sourceValue": "term", "targetLocale": "targetLocale", "targetValue": "translation"} - ONLY specific term mappings],',
461
- ' "reasoning": "string - Explain your analysis and which mechanism you chose for each issue"',
462
- "}",
463
- "",
464
- "Be conservative: only suggest changes when there is clear evidence.",
465
- "If in doubt, prefer locale-specific updates over global updates.",
466
- "Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts.",
467
- "",
468
- "CONCRETE EXAMPLES:",
469
- "",
470
- "Example 1 - Brand name protection:",
471
- 'User feedback: "Braze should never be translated"',
472
- "Correct output:",
473
- "{",
474
- ' "protectedPhrasesToAdd": ["Braze"],',
475
- ' "reasoning": "User explicitly stated Braze is a brand name that should not be translated"',
476
- "}",
477
- "WRONG: Do not add this to glossary or prompts.",
478
- "",
479
- "Example 2 - Terminology correction:",
480
- 'Edit analysis: User changed "discussion" to "chat" consistently in French translation',
481
- "Correct output:",
482
- "{",
483
- ` "glossaryTermsToAdd": [{"sourceLocale": "${sourceLanguage}", "sourceValue": "discussion", "targetLocale": "${targetLanguage}", "targetValue": "chat"}],`,
484
- ' "reasoning": "User consistently changed discussion\u2192chat, indicating preferred terminology"',
485
- "}",
486
- "WRONG: Do not add to protected phrases (values differ) or prompts (specific mapping).",
487
- "",
488
- "Example 3 - Style guidance:",
489
- 'User feedback: "Translation is too formal, use casual tone"',
490
- "Correct output:",
491
- "{",
492
- ` "localePromptUpdates": "Use a casual, conversational tone when translating to ${targetLanguage}. Avoid overly formal language.",`,
493
- ' "reasoning": "This is style guidance specific to this language pair"',
494
- "}",
495
- "WRONG: Do not make global (applies only to this target language).",
496
- "",
497
- "Example 4 - Technical term in English:",
498
- `User feedback: "Keep technical terms like 'API' and 'webhook' in English"`,
499
- "Correct output:",
500
- "{",
501
- ` "localePromptUpdates": "When translating to ${targetLanguage}, keep technical terms like 'API', 'webhook', and similar technical jargon in English.",`,
502
- ' "reasoning": "This is language-pair-specific guidance about handling technical terms"',
503
- "}",
504
- 'WRONG: Do not add "API" or "webhook" to protected phrases (already covered by prompt guidance).',
505
- "",
506
- "Example 5 - Multiple issues:",
507
- 'User feedback: "MyApp is our product name. Also, use more concise language."',
508
- "Correct output:",
509
- "{",
510
- ' "protectedPhrasesToAdd": ["MyApp"],',
511
- ` "localePromptUpdates": "Use concise language when translating to ${targetLanguage}. Prefer shorter phrases over lengthy explanations.",`,
512
- ' "reasoning": "MyApp is a product name (protected). Conciseness is a style preference for this language pair (locale prompt)."',
513
- "}",
514
- "",
515
- "Example 6 - Identical source and target:",
516
- 'Edit analysis: User kept "dashboard" as "dashboard" in Spanish',
517
- "Correct output:",
518
- "{",
519
- ' "protectedPhrasesToAdd": ["dashboard"],',
520
- ' "reasoning": "User kept the term unchanged, indicating it should not be translated"',
521
- "}",
522
- "WRONG: Do not add to glossary when source === target."
523
- ), parts.join(`
524
- `);
525
- }
526
- function convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage) {
527
- const suggestions = [];
528
- if (analysis.globalPromptUpdates && suggestions.push({
529
- id: crypto.randomUUID(),
530
- type: "globalPrompt",
531
- description: analysis.globalPromptUpdates,
532
- approved: !0
533
- }), analysis.localePromptUpdates && suggestions.push({
534
- id: crypto.randomUUID(),
535
- type: "localePrompt",
536
- description: analysis.localePromptUpdates,
537
- approved: !0
538
- }), analysis.protectedPhrasesToAdd)
539
- for (const phrase of analysis.protectedPhrasesToAdd)
540
- suggestions.push({
541
- id: crypto.randomUUID(),
542
- type: "protectedPhrase",
543
- description: phrase,
544
- approved: !0
545
- });
546
- if (analysis.glossaryTermsToAdd)
547
- for (const term of analysis.glossaryTermsToAdd)
548
- suggestions.push({
549
- id: crypto.randomUUID(),
550
- type: "glossaryTerm",
551
- description: `${term.sourceValue} \u2192 ${term.targetValue}`,
552
- approved: !0,
553
- // Use explicit source/target languages from history instead of AI-provided values
554
- sourceLocale: sourceLanguage,
555
- sourceValue: term.sourceValue,
556
- targetLocale: targetLanguage,
557
- targetValue: term.targetValue
558
- });
559
- return suggestions;
560
- }
561
- async function analyzeWithAI(client, prompt) {
363
+ releaseId,
364
+ sourceDocument,
365
+ targetLanguages,
366
+ languageFieldName,
367
+ references = []
368
+ }) => {
369
+ const allConflicts = [];
562
370
  try {
563
- const response = await client.agent.action.prompt({
564
- instruction: prompt,
565
- format: "json"
371
+ const documentsToCheck = [
372
+ {
373
+ _id: sourceDocument._id,
374
+ _type: sourceDocument._type,
375
+ title: sourceDocument.title || sourceDocument.name
376
+ },
377
+ ...references.map((ref) => ({
378
+ _id: ref._id,
379
+ _type: ref._type,
380
+ title: ref.title
381
+ }))
382
+ ], conflictChecks = await Promise.all(
383
+ documentsToCheck.map(
384
+ (doc) => checkDocumentConflicts({
385
+ client,
386
+ releaseId,
387
+ documentId: doc._id,
388
+ documentTitle: doc.title,
389
+ documentType: doc._type,
390
+ targetLanguages,
391
+ languageFieldName
392
+ })
393
+ )
394
+ );
395
+ for (const conflicts of conflictChecks)
396
+ allConflicts.push(...conflicts);
397
+ devLog.debug("Release conflict check complete", {
398
+ releaseId,
399
+ documentsChecked: documentsToCheck.length,
400
+ conflictsFound: allConflicts.length
566
401
  });
567
- devLog.debug("AI response received", { responseType: typeof response });
568
- const responseText = typeof response == "string" ? response : response && typeof response == "object" ? JSON.stringify(response) : (() => {
569
- throw new Error(`Unexpected response type: ${typeof response}`);
570
- })();
571
- return parseAIResponse(responseText);
572
402
  } catch (error) {
573
- throw devLog.error("Error during AI analysis", { error }), new Error(`Failed to analyze feedback: ${error instanceof Error ? error.message : "Unknown error"}`);
403
+ devLog.error("Error checking release conflicts", error instanceof Error ? error : new Error(String(error)), {
404
+ releaseId,
405
+ sourceDocumentId: sourceDocument._id,
406
+ targetLanguages,
407
+ referencesCount: references.length
408
+ });
574
409
  }
575
- }
576
- function parseAIResponse(responseText) {
577
- try {
578
- let parsed;
579
- try {
580
- parsed = JSON.parse(responseText);
581
- } catch {
582
- const jsonMatch = responseText.match(/\{[\s\S]*\}/);
583
- if (!jsonMatch) throw new Error("No JSON found in AI response");
584
- parsed = JSON.parse(jsonMatch[0]);
585
- }
586
- if (!parsed || typeof parsed != "object")
587
- throw new Error("Response is not a valid object");
588
- const analysis = parsed;
589
- if (typeof analysis.reasoning != "string")
590
- throw new Error("Invalid response structure: missing reasoning");
591
- return {
592
- globalPromptUpdates: analysis.globalPromptUpdates || void 0,
593
- localePromptUpdates: analysis.localePromptUpdates || void 0,
594
- protectedPhrasesToAdd: Array.isArray(analysis.protectedPhrasesToAdd) ? analysis.protectedPhrasesToAdd : [],
595
- glossaryTermsToAdd: Array.isArray(analysis.glossaryTermsToAdd) ? analysis.glossaryTermsToAdd : [],
596
- reasoning: analysis.reasoning
597
- };
598
- } catch (error) {
599
- throw devLog.error("Error parsing AI response", { error, responseText }), new Error("Failed to parse AI response as JSON");
410
+ return allConflicts;
411
+ };
412
+ function isNetworkError(error) {
413
+ if (!error) return !1;
414
+ if (error instanceof TypeError) {
415
+ const message = error.message.toLowerCase();
416
+ return message.includes("fetch") || message.includes("network") || message.includes("connection") || message.includes("failed to fetch") || message.includes("networkerror");
600
417
  }
601
- }
602
- async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage, suggestions, onProgress) {
603
- const settingsId = "translation-settings";
604
- await client.fetch("*[_id == $id][0]", { id: settingsId }) || await client.createIfNotExists({
605
- _id: settingsId,
606
- _type: "translationSettings"
607
- });
608
- 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(
609
- (s) => s.type === "glossaryTerm" && s.sourceLocale?.trim() && s.sourceValue?.trim() && s.targetLocale?.trim() && s.targetValue?.trim()
610
- );
611
- if ((globalPrompts.length > 0 || localePrompts.length > 0) && (onProgress?.({
612
- stage: "applying",
613
- message: globalPrompts.length > 0 && localePrompts.length > 0 ? "Updating global and locale translation prompts..." : globalPrompts.length > 0 ? "Updating global translation prompt..." : `Updating ${targetLanguage} translation prompt...`
614
- }), updates.push(updatePromptsWithTemplate(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts))), protectedPhrases.length > 0) {
615
- onProgress?.({ stage: "applying", message: "Adding protected phrases..." });
616
- const phrases = protectedPhrases.map((s) => s.description);
617
- updates.push(addProtectedPhrases(client, settingsId, phrases));
418
+ if (error && typeof error == "object" && "code" in error) {
419
+ const code = error.code;
420
+ return ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "ECONNREFUSED", "EHOSTUNREACH"].includes(code);
618
421
  }
619
- if (glossaryTerms.length > 0) {
620
- onProgress?.({ stage: "applying", message: "Adding glossary terms..." });
621
- const terms = glossaryTerms.map((s) => ({
622
- // biome-ignore-start lint/style/noNonNullAssertion: safely handled
623
- sourceLocale: s.sourceLocale,
624
- sourceValue: s.sourceValue,
625
- targetLocale: s.targetLocale,
626
- targetValue: s.targetValue
627
- // biome-ignore-end lint/style/noNonNullAssertion: safely handled
628
- }));
629
- updates.push(addGlossaryTerms(client, terms));
422
+ if (error && typeof error == "object" && "response" in error) {
423
+ const response = error.response;
424
+ if (response?.status)
425
+ return response.status >= 500 || response.status === 429;
630
426
  }
631
- 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(
632
- client,
633
- settingsId,
634
- targetLanguage,
635
- suggestions.filter((s) => s.approved)
636
- );
637
- }
638
- async function updatePromptsWithTemplate(client, _schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
639
- if (!targetLanguage)
640
- throw devLog.error("targetLanguage is undefined or empty!", {
641
- targetLanguage,
642
- globalPrompts: globalPrompts.length,
643
- localePrompts: localePrompts.length
644
- }), new Error("targetLanguage is required but was undefined");
645
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], currentSettings = await client.fetch("*[_id == $settingsId][0]{ globalPrompt, localePrompts }", { settingsId });
646
- if (globalPrompts.length > 0) {
647
- const currentGlobalPrompt = currentSettings?.globalPrompt || "", updates = globalPrompts.map((s) => s.description.trim()).filter(Boolean), newGlobalPrompt = [
648
- currentGlobalPrompt,
649
- currentGlobalPrompt ? `
650
- ` : "",
651
- `## User Feedback (${timestamp})`,
652
- ...updates.map((update) => `- ${update}`)
653
- ].filter(Boolean).join(`
654
- `);
655
- await client.patch(settingsId).set({ globalPrompt: newGlobalPrompt }).commit();
427
+ if (error instanceof Error) {
428
+ const message = error.message;
429
+ if (/\b(50[0-9]|429)\b/.test(message))
430
+ return !0;
656
431
  }
657
- if (localePrompts.length > 0) {
658
- const updates = localePrompts.map((s) => s.description.trim()).filter(Boolean), existingLocalePrompts = currentSettings?.localePrompts || [], existingLocalePrompt = existingLocalePrompts.find((lp) => lp._key === targetLanguage);
659
- if (existingLocalePrompt) {
660
- const currentValue = existingLocalePrompt.value || "", newValue = [
661
- currentValue,
662
- currentValue ? `
663
- ` : "",
664
- `## User Feedback (${timestamp})`,
665
- ...updates.map((update) => `- ${update}`)
666
- ].filter(Boolean).join(`
667
- `), localeIndex = existingLocalePrompts.findIndex((lp) => lp._key === targetLanguage);
668
- await client.patch(settingsId).set({ [`localePrompts[${localeIndex}].value`]: newValue }).commit();
669
- } else {
670
- const newValue = [`## User Feedback (${timestamp})`, ...updates.map((update) => `- ${update}`)].filter(Boolean).join(`
671
- `);
672
- await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
673
- {
674
- _type: "internationalizedArrayTextValue",
675
- _key: targetLanguage,
676
- value: newValue
677
- }
678
- ]).commit();
432
+ return !1;
433
+ }
434
+ function isSanityMutationError(error) {
435
+ if (!error) return !1;
436
+ if (error instanceof Error) {
437
+ const message = error.message.toLowerCase();
438
+ return [
439
+ "validation",
440
+ "schema",
441
+ "invalid document",
442
+ "invalid field",
443
+ "type mismatch",
444
+ "reference",
445
+ "patch",
446
+ "mutation",
447
+ "document structure",
448
+ "required field",
449
+ "field type",
450
+ "sanity"
451
+ ].some((pattern) => message.includes(pattern));
452
+ }
453
+ if (error && typeof error == "object") {
454
+ const errorObj = error;
455
+ if (("statusCode" in errorObj || "response" in errorObj || "details" in errorObj) && "statusCode" in errorObj) {
456
+ const statusCode = errorObj.statusCode;
457
+ if (statusCode >= 400 && statusCode < 500 && statusCode !== 429)
458
+ return !0;
679
459
  }
680
460
  }
681
- devLog.info("Prompts updated successfully", {
682
- targetLanguage,
683
- globalUpdated: globalPrompts.length > 0,
684
- localeUpdated: localePrompts.length > 0
685
- });
686
- }
687
- async function addProtectedPhrases(client, settingsId, phrases) {
688
- const currentPhrases = await client.fetch("*[_id == $settingsId][0].protectedPhrases", { settingsId });
689
- let newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
690
- const conflictingPhrases = [];
691
- for (const phrase of newPhrases)
692
- await client.fetch(
693
- 'count(*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0]) > 0',
694
- { phrase }
695
- ) && (conflictingPhrases.push(phrase), devLog.warn("Skipping protected phrase due to glossary conflict", { phrase }));
696
- newPhrases = newPhrases.filter((phrase) => !conflictingPhrases.includes(phrase)), newPhrases.length !== 0 && (await client.patch(settingsId).setIfMissing({ protectedPhrases: [] }).append("protectedPhrases", newPhrases).commit(), devLog.info("Protected phrases added", { count: newPhrases.length }));
461
+ return !1;
697
462
  }
698
- async function addGlossaryTerms(client, terms) {
699
- 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)", {
700
- sourceValue,
701
- targetValue
702
- }), !1) : protectedPhrasesLower.includes(sourceValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because source value is a protected phrase", {
703
- sourceValue,
704
- targetValue
705
- }), !1) : protectedPhrasesLower.includes(targetValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because target value is a protected phrase", {
706
- sourceValue,
707
- targetValue
708
- }), !1) : !0);
709
- if (validTerms.length === 0) {
710
- devLog.info("No valid glossary terms to add");
711
- return;
712
- }
713
- for (const { sourceLocale, sourceValue, targetLocale, targetValue } of validTerms) {
714
- const existingTerm = await client.fetch(
715
- `*[_type == "translationGlossaryTerm" && count(translations[_key == $sourceLocale && value == $sourceValue]) > 0
716
- ][0] {
717
- _id,
718
- translations
719
- }`,
720
- { sourceLocale, sourceValue }
721
- );
722
- if (existingTerm) {
723
- const existingTargetIndex = existingTerm.translations.findIndex((t) => t._key === targetLocale), existingTargetValue = existingTargetIndex >= 0 ? existingTerm.translations[existingTargetIndex].value : null;
724
- existingTargetIndex === -1 ? await client.patch(existingTerm._id).append("translations", [
725
- {
726
- _type: "internationalizedArrayStringValue",
727
- _key: targetLocale,
728
- value: targetValue
729
- }
730
- ]).commit() : existingTargetValue !== targetValue && await client.patch(existingTerm._id).set({ [`translations[${existingTargetIndex}].value`]: targetValue }).commit();
731
- } else
732
- await client.create({
733
- _type: "translationGlossaryTerm",
734
- translations: [
735
- {
736
- _type: "internationalizedArrayStringValue",
737
- _key: sourceLocale,
738
- value: sourceValue
739
- },
740
- {
741
- _type: "internationalizedArrayStringValue",
742
- _key: targetLocale,
743
- value: targetValue
744
- }
745
- ]
746
- });
747
- }
748
- devLog.info("Glossary terms processed", {
749
- processed: validTerms.length,
750
- skipped: terms.length - validTerms.length
751
- });
463
+ const DEFAULT_RETRY_OPTIONS = {
464
+ maxAttempts: 2,
465
+ initialDelayMs: 0,
466
+ maxDelayMs: 5e3,
467
+ backoffMultiplier: 2,
468
+ retryableErrors: isNetworkError,
469
+ logRetries: !0
470
+ };
471
+ function calculateDelay(attempt, options) {
472
+ const delay = options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt);
473
+ return Math.min(delay, options.maxDelayMs);
752
474
  }
753
- async function recordChangeHistory(client, settingsId, targetLanguage, suggestions) {
754
- const timestamp = (/* @__PURE__ */ new Date()).toISOString(), historyEntries = suggestions.map((suggestion) => ({
755
- _type: "object",
756
- _key: crypto.randomUUID(),
757
- timestamp,
758
- changeType: suggestion.type,
759
- description: suggestion.description,
760
- locale: suggestion.type === "localePrompt" ? targetLanguage : void 0
761
- }));
762
- historyEntries.length !== 0 && await client.patch(settingsId).setIfMissing({ changeHistory: [] }).append("changeHistory", historyEntries).commit();
475
+ function sleep(ms) {
476
+ return new Promise((resolve) => setTimeout(resolve, ms));
763
477
  }
764
- const ICON_SIZE = 64, progressIconMap = {
765
- in_progress: /* @__PURE__ */ jsx(Spinner, {}),
766
- completed: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: ICON_SIZE }),
767
- error: /* @__PURE__ */ jsx(ErrorOutlineIcon, { fontSize: ICON_SIZE })
768
- }, ProgressPanel = ({
769
- title,
770
- description,
771
- tone,
772
- status = "in_progress",
773
- icon,
774
- ...props
775
- }) => /* @__PURE__ */ jsx(Card, { tone, padding: 4, ...props, children: /* @__PURE__ */ jsxs(Flex, { gap: 4, align: "center", justify: "center", children: [
776
- icon || progressIconMap[status],
777
- /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
778
- title && /* @__PURE__ */ jsx(Text, { size: 2, weight: "bold", children: title }),
779
- description && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: description })
780
- ] })
781
- ] }) }), IMPROVEMENT_TYPE_LABELS = {
782
- globalPrompt: "Global Prompt",
783
- localePrompt: "Locale Prompt",
784
- protectedPhrase: "Protected Phrase",
785
- glossaryTerm: "Glossary Term"
786
- }, createNewSuggestion = (type = "protectedPhrase", sourceLanguage, targetLanguage) => {
787
- const baseSuggestion = {
788
- id: crypto.randomUUID(),
789
- type,
790
- description: "",
791
- approved: !0
478
+ async function retryWithBackoff(operation, options = {}) {
479
+ const opts = {
480
+ ...DEFAULT_RETRY_OPTIONS,
481
+ ...options,
482
+ retryableErrors: options.retryableErrors || DEFAULT_RETRY_OPTIONS.retryableErrors
792
483
  };
793
- return type === "glossaryTerm" ? {
794
- ...baseSuggestion,
795
- sourceValue: "",
796
- targetValue: "",
797
- sourceLocale: sourceLanguage,
798
- targetLocale: targetLanguage
799
- } : baseSuggestion;
800
- }, SuggestionReviewList = ({
801
- suggestions,
802
- onSuggestionsChange,
803
- sourceLanguage,
804
- targetLanguage
805
- }) => {
806
- const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
807
- const updated = suggestions.map((s) => s.id === id && s.validationStatus !== "invalid" ? { ...s, approved: !s.approved } : s);
808
- onSuggestionsChange(updated);
809
- }, handleTypeChange = (id, newType) => {
810
- const updated = suggestions.map((s) => {
811
- if (s.id !== id)
812
- return s;
813
- if (newType === "glossaryTerm" && s.type !== "glossaryTerm") {
814
- const match = s.description.match(/^(.+?)\s*→\s*(.+)$/);
815
- return {
816
- ...s,
817
- type: newType,
818
- sourceValue: match ? match[1].trim() : "",
819
- targetValue: match ? match[2].trim() : "",
820
- sourceLocale: sourceLanguage,
821
- targetLocale: targetLanguage
822
- };
823
- }
824
- return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
825
- ...s,
826
- type: newType,
827
- description: s.sourceValue && s.targetValue ? `${s.sourceValue} \u2192 ${s.targetValue}` : s.description,
828
- sourceValue: void 0,
829
- targetValue: void 0
830
- } : { ...s, type: newType };
831
- });
832
- onSuggestionsChange(updated);
833
- }, handleDescriptionChange = (id, newDescription) => {
834
- const updated = suggestions.map((s) => s.id === id ? { ...s, description: newDescription } : s);
835
- onSuggestionsChange(updated);
836
- }, handleGlossarySourceChange = (id, newSource) => {
837
- const updated = suggestions.map(
838
- (s) => s.id === id ? {
839
- ...s,
840
- sourceValue: newSource,
841
- description: `${newSource} \u2192 ${s.targetValue || ""}`
842
- // Preserve sourceLocale and targetLocale
843
- } : s
844
- );
845
- onSuggestionsChange(updated);
846
- }, handleGlossaryTargetChange = (id, newTarget) => {
847
- const updated = suggestions.map(
848
- (s) => s.id === id ? {
849
- ...s,
850
- targetValue: newTarget,
851
- description: `${s.sourceValue || ""} \u2192 ${newTarget}`
852
- // Preserve sourceLocale and targetLocale
853
- } : s
854
- );
855
- onSuggestionsChange(updated);
856
- }, handleRemove = (id) => {
857
- const updated = suggestions.filter((s) => s.id !== id);
858
- onSuggestionsChange(updated);
859
- }, handleAddSuggestion = (type) => {
860
- const newSuggestion = createNewSuggestion(type, sourceLanguage, targetLanguage);
861
- onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
862
- };
863
- return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
864
- /* @__PURE__ */ jsx(
865
- ProgressPanel,
866
- {
867
- status: "error",
868
- tone: "transparent",
869
- title: "No suggestions generated",
870
- description: 'Try providing more specific feedback or enabling "Include Edits".',
871
- icon: /* @__PURE__ */ jsx(EmptyIcon, { fontSize: 24 })
872
- }
873
- ),
874
- /* @__PURE__ */ jsx(Flex, { justify: "flex-start", children: /* @__PURE__ */ jsx(
875
- Button,
876
- {
877
- mode: "ghost",
878
- tone: "primary",
879
- icon: AddIcon,
880
- text: "Add Suggestion",
881
- fontSize: 1,
882
- onClick: () => handleAddSuggestion("protectedPhrase")
883
- }
884
- ) })
885
- ] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
886
- suggestions.map((suggestion) => {
887
- 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";
888
- return /* @__PURE__ */ jsx(
889
- Card,
890
- {
891
- padding: 4,
892
- border: !0,
893
- tone: isInvalid ? "critical" : hasWarning ? "caution" : suggestion.approved ? "default" : "transparent",
894
- style: { opacity: suggestion.approved ? 1 : 0.5 },
895
- children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
896
- /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: isInvalid ? /* @__PURE__ */ jsx(
897
- Tooltip,
898
- {
899
- 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." }) }),
900
- placement: "top",
901
- portal: !0,
902
- children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Checkbox, { checked: !1, disabled: !0, readOnly: !0 }) })
903
- }
904
- ) : /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
905
- /* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
906
- /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
907
- /* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
908
- Select,
909
- {
910
- fontSize: 1,
911
- value: suggestion.type,
912
- onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
913
- "aria-label": "Suggestion type",
914
- children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
915
- }
916
- ) }),
917
- /* @__PURE__ */ jsx(
918
- Button,
919
- {
920
- mode: "bleed",
921
- tone: "critical",
922
- icon: TrashIcon,
923
- fontSize: 1,
924
- padding: 2,
925
- onClick: () => handleRemove(suggestion.id),
926
- "aria-label": "Remove suggestion",
927
- title: "Remove suggestion"
928
- }
929
- )
930
- ] }),
931
- isGlossaryTerm ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
932
- (suggestion.sourceLocale || suggestion.targetLocale) && /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
933
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.sourceLocale ? `Source (${suggestion.sourceLocale})` : "Source" }) }),
934
- /* @__PURE__ */ jsx(Box, { style: { width: "20px" } }),
935
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.targetLocale ? `Target (${suggestion.targetLocale})` : "Target" }) })
936
- ] }),
937
- /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
938
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
939
- TextInput,
940
- {
941
- fontSize: 1,
942
- placeholder: "Source term",
943
- value: suggestion.sourceValue || "",
944
- onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value),
945
- "aria-label": `Source term in ${suggestion.sourceLocale || "source language"}`
946
- }
947
- ) }),
948
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, "aria-hidden": "true", children: "\u2192" }),
949
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
950
- TextInput,
951
- {
952
- fontSize: 1,
953
- placeholder: "Target term",
954
- value: suggestion.targetValue || "",
955
- onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value),
956
- "aria-label": `Target term in ${suggestion.targetLocale || "target language"}`
957
- }
958
- ) })
959
- ] })
960
- ] }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
961
- TextArea,
962
- {
963
- fontSize: 1,
964
- rows: isEditing ? 6 : 3,
965
- value: suggestion.description,
966
- onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
967
- onFocus: () => setEditingId(suggestion.id),
968
- onBlur: () => setEditingId(null),
969
- placeholder: "Enter prompt guidance...",
970
- "aria-label": `${IMPROVEMENT_TYPE_LABELS[suggestion.type]} content`
971
- }
972
- ) }) : /* @__PURE__ */ jsx(
973
- TextInput,
974
- {
975
- fontSize: 1,
976
- value: suggestion.description,
977
- onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
978
- placeholder: "Enter protected phrase...",
979
- "aria-label": "Protected phrase"
980
- }
981
- ),
982
- hasWarning && /* @__PURE__ */ jsx(Card, { padding: 3, tone: isInvalid ? "critical" : "caution", border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 2, children: [
983
- /* @__PURE__ */ jsx(Text, { size: 1, children: isInvalid ? /* @__PURE__ */ jsx(WarningOutlineIcon, {}) : /* @__PURE__ */ jsx(InfoOutlineIcon, {}) }),
984
- /* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
985
- /* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: isInvalid ? "Invalid Suggestion" : "Warning" }),
986
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: suggestion.warning })
987
- ] })
988
- ] }) })
989
- ] })
990
- ] }) })
991
- },
992
- suggestion.id
993
- );
994
- }),
995
- /* @__PURE__ */ jsx(Flex, { justify: "flex-start", children: /* @__PURE__ */ jsx(
996
- Button,
997
- {
998
- mode: "ghost",
999
- tone: "neutral",
1000
- icon: AddIcon,
1001
- text: "Add Suggestion",
1002
- fontSize: 1,
1003
- onClick: () => handleAddSuggestion("protectedPhrase")
1004
- }
1005
- ) })
1006
- ] });
1007
- }, GiveFeedbackDialog = ({ setIsDialogOpen }) => {
1008
- const client = useClient({ apiVersion: "vX" }), { document, config, translationMetadata } = useTranslationContext(), { loading } = useTranslationMetadata({
1009
- documentId: document._id
1010
- }), [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 = () => {
1011
- setShouldIncludeEdits(!shouldIncludeEdits);
1012
- }, handleCancel = () => {
1013
- setIsDialogOpen(!1);
1014
- }, handleAnalyze = async () => {
1015
- if (!feedback && !shouldIncludeEdits) {
1016
- setError('Please provide feedback or enable "Include Edits"');
1017
- return;
1018
- }
1019
- if (!config) {
1020
- setError("Missing plugin configuration");
1021
- return;
1022
- }
1023
- setError(""), setProcessingState("analyzing"), setChangeSummary("");
484
+ let lastError;
485
+ const maxAttempts = opts.maxAttempts + 1;
486
+ for (let attempt = 1; attempt <= maxAttempts; attempt++)
1024
487
  try {
1025
- const translationHistory2 = translationMetadata?.translationHistory?.find(
1026
- (history) => history.language === document[config.languageFieldName]
1027
- );
1028
- if (!translationHistory2)
1029
- throw new Error(
1030
- "No translation history found for this document. Please ensure this document was translated using the AI Translation plugin."
1031
- );
1032
- const { sourceSnapshot, targetSnapshot, sourceLanguage: srcLang } = translationHistory2;
1033
- if (!sourceSnapshot || !targetSnapshot)
1034
- throw new Error("Translation snapshots are missing from history.");
1035
- const srcLanguage = srcLang || "en-us", tgtLanguage = document[config.languageFieldName];
1036
- setSourceLanguage(srcLanguage), setTargetLanguage(tgtLanguage);
1037
- const generatedSuggestions = await analyzeFeedback({
1038
- client,
1039
- schemaId: config.schemaId,
1040
- sourceLanguage: srcLanguage,
1041
- targetLanguage: tgtLanguage,
1042
- userFeedback: feedback || void 0,
1043
- shouldIncludeEdits,
1044
- sourceSnapshot,
1045
- targetSnapshot,
1046
- currentDocument: shouldIncludeEdits ? document : void 0,
1047
- onProgress: ({ stage, message }) => {
1048
- setProcessingState(stage), setChangeSummary(message);
1049
- }
1050
- });
1051
- setSuggestions(generatedSuggestions), setProcessingState("reviewing");
1052
- } catch (err) {
1053
- console.error("Error analyzing feedback:", err), setError(err instanceof Error ? err.message : "An unexpected error occurred"), setProcessingState("error");
1054
- }
1055
- }, handleApplySuggestions = async () => {
1056
- if (!config) {
1057
- setError("Missing plugin configuration");
1058
- return;
1059
- }
1060
- if (suggestions.filter((s) => s.approved).length === 0) {
1061
- setError("Please approve at least one suggestion to apply");
1062
- return;
1063
- }
1064
- setError(""), setProcessingState("applying");
1065
- try {
1066
- const translationHistory2 = translationMetadata?.translationHistory?.find(
1067
- (history) => history.language === document[config.languageFieldName]
1068
- );
1069
- if (!translationHistory2)
1070
- throw new Error("Translation history not found");
1071
- await applySuggestions({
1072
- client,
1073
- schemaId: config.schemaId,
1074
- sourceLanguage: translationHistory2.sourceLanguage || "en-us",
1075
- targetLanguage: translationHistory2.language,
1076
- suggestions,
1077
- onProgress: ({ stage, message }) => {
1078
- setProcessingState(stage), setChangeSummary(message);
1079
- }
1080
- }), setProcessingState("completed");
1081
- } catch (err) {
1082
- console.error("Error applying suggestions:", err), setError(err instanceof Error ? err.message : "An unexpected error occurred"), setProcessingState("error");
1083
- }
1084
- };
1085
- if (loading)
1086
- return /* @__PURE__ */ jsx(ProgressPanel, { title: "Loading translation metadata" });
1087
- if (processingState === "analyzing")
1088
- return /* @__PURE__ */ jsx(ProgressPanel, { title: "Analyzing feedback with AI...", description: changeSummary });
1089
- if (processingState === "reviewing")
1090
- return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1091
- /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1092
- /* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
1093
- /* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
1094
- ] }),
1095
- /* @__PURE__ */ jsx(
1096
- SuggestionReviewList,
1097
- {
1098
- suggestions,
1099
- onSuggestionsChange: setSuggestions,
1100
- sourceLanguage,
1101
- targetLanguage
1102
- }
1103
- ),
1104
- error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
1105
- /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
1106
- /* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
1107
- Button,
1108
- {
1109
- type: "button",
1110
- mode: "bleed",
1111
- tone: "neutral",
1112
- onClick: () => setProcessingState("idle"),
1113
- icon: /* @__PURE__ */ jsx(ArrowLeftIcon, {}),
1114
- text: "Back"
1115
- }
1116
- ) }),
1117
- /* @__PURE__ */ jsxs(
1118
- Button,
1119
- {
1120
- type: "button",
1121
- mode: "ghost",
1122
- tone: "positive",
1123
- size: 1,
1124
- onClick: handleApplySuggestions,
1125
- disabled: suggestions.filter((s) => s.approved).length === 0,
1126
- children: [
1127
- "Apply ",
1128
- suggestions.filter((s) => s.approved).length,
1129
- " Suggestion",
1130
- suggestions.filter((s) => s.approved).length !== 1 ? "s" : ""
1131
- ]
1132
- }
1133
- )
1134
- ] })
1135
- ] });
1136
- if (processingState === "applying")
1137
- return /* @__PURE__ */ jsx(ProgressPanel, { title: "Updating translation settings...", description: changeSummary, icon: /* @__PURE__ */ jsx(EditIcon, {}) });
1138
- if (processingState === "completed") {
1139
- const appliedCount = suggestions.filter((s) => s.approved).length, description = `${appliedCount} suggestion${appliedCount !== 1 ? "s" : ""} applied. Translation prompts and glossary havebeen updated based on your feedback.`;
1140
- return /* @__PURE__ */ jsxs(Box, { padding: 4, children: [
1141
- /* @__PURE__ */ jsx(ProgressPanel, { title: "Translation settings updated", description, status: "completed" }),
1142
- /* @__PURE__ */ jsx(Flex, { justify: "flex-end", paddingTop: 3, children: /* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", tone: "primary", size: 1, onClick: handleCancel, children: "Close" }) })
1143
- ] });
1144
- }
1145
- return processingState === "error" ? /* @__PURE__ */ jsx(Box, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1146
- /* @__PURE__ */ jsx(ProgressPanel, { status: "error", title: "Error processing feedback", description: error, tone: "critical" }),
1147
- /* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
1148
- /* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", onClick: handleCancel, children: "Close" }),
1149
- /* @__PURE__ */ jsx(Button, { type: "reset", mode: "ghost", tone: "primary", onClick: () => setProcessingState("idle"), children: "Reset" })
1150
- ] })
1151
- ] }) }) : /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1152
- /* @__PURE__ */ jsx(Card, { tone: "transparent", padding: 4, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1153
- /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1154
- /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
1155
- /* @__PURE__ */ jsx(Text, { size: 2, muted: !0, children: "Enter your feedback for the translation AI agent." }),
1156
- /* @__PURE__ */ jsx(
1157
- Tooltip,
1158
- {
1159
- portal: !0,
1160
- content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "The AI agent will use your feedback to improve translation prompts, update protected phrases, and modify the glossary. Be specific about what should be changed." }) }),
1161
- children: /* @__PURE__ */ jsx(Text, { size: 1, children: /* @__PURE__ */ jsx(InfoOutlineIcon, {}) })
1162
- }
1163
- )
1164
- ] }),
1165
- /* @__PURE__ */ jsx(
1166
- TextArea,
488
+ const result = await operation();
489
+ return attempt > 1 && opts.logRetries && devLog.info(`Operation succeeded after ${attempt} attempts`, {
490
+ ...opts.context,
491
+ attempt,
492
+ totalAttempts: attempt
493
+ }), result;
494
+ } catch (error) {
495
+ if (lastError = error, !opts.retryableErrors(error))
496
+ throw opts.logRetries && devLog.warn("Error is not retryable, aborting retries", {
497
+ ...opts.context,
498
+ attempt,
499
+ error: error instanceof Error ? error.message : String(error)
500
+ }), error;
501
+ if (attempt >= maxAttempts)
502
+ throw opts.logRetries && devLog.error(
503
+ `Operation failed after ${maxAttempts} attempts`,
504
+ error instanceof Error ? error : new Error(String(error)),
1167
505
  {
1168
- rows: 8,
1169
- fontSize: 2,
1170
- onChange: (event) => setFeedback(event.currentTarget.value),
1171
- padding: 3,
1172
- placeholder: 'Example: "Braze" is a branded term and should not be translated.',
1173
- value: feedback
506
+ ...opts.context,
507
+ totalAttempts: maxAttempts
1174
508
  }
1175
- )
1176
- ] }) }),
1177
- /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsx(
1178
- Button,
1179
- {
1180
- type: "button",
1181
- mode: shouldIncludeEdits ? "ghost" : "bleed",
1182
- size: 2,
1183
- onClick: handleShouldIncludeEditsChange,
1184
- tone: shouldIncludeEdits ? "primary" : "default",
1185
- children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
1186
- /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Checkbox, { checked: shouldIncludeEdits, readOnly: !0, style: { pointerEvents: "none" } }) }),
1187
- /* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
1188
- /* @__PURE__ */ jsx(Text, { size: 2, weight: "medium", children: "Include Edits in Feedback" }),
1189
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, style: { textAlign: "left" }, children: "The AI will analyze edits you made to the translation." })
1190
- ] })
1191
- ] })
1192
- }
1193
- ) }) })
1194
- ] }) }),
1195
- /* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, paddingTop: 2, children: [
1196
- /* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", size: 1, onClick: handleCancel, children: "Cancel" }),
1197
- /* @__PURE__ */ jsx(
1198
- Button,
1199
- {
1200
- type: "submit",
1201
- mode: "ghost",
1202
- tone: "positive",
1203
- size: 1,
1204
- onClick: handleAnalyze,
1205
- disabled: !feedback.trim() && !shouldIncludeEdits,
1206
- children: "Analyze Feedback"
1207
- }
1208
- )
1209
- ] })
1210
- ] });
1211
- }, getGiveTranslationFeedbackAction = (config) => ({ draft, published }) => {
1212
- const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1);
1213
- return devLog.debug("ImproveTranslationPromptsAction initialized", {
1214
- documentId: document?._id,
1215
- documentType: document?._type
1216
- }), !document || !config.translatableSchemaTypes?.includes(document._type) ? (devLog.warn("Document not in translatable schema types", {
1217
- documentType: document?._type,
1218
- translatableTypes: config.translatableSchemaTypes
1219
- }), null) : config ? {
1220
- label: "Give Translation Feedback",
1221
- icon: TranslateIcon,
1222
- onHandle: () => {
1223
- devLog.info("Give Translation Feedback dialog opened", {
1224
- documentId: document._id,
1225
- documentType: document._type,
1226
- component: "GiveTranslationFeedbackAction"
1227
- }), setIsDialogOpen(!0);
1228
- },
1229
- dialog: isDialogOpen && {
1230
- type: "dialog",
1231
- onClose: () => setIsDialogOpen(!1),
1232
- header: "Give Translation Feedback",
1233
- content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(GiveFeedbackDialog, { setIsDialogOpen }) })
509
+ ), error;
510
+ const delay = calculateDelay(attempt - 1, opts);
511
+ opts.logRetries && devLog.warn(`Operation failed, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`, {
512
+ ...opts.context,
513
+ attempt,
514
+ totalAttempts: maxAttempts,
515
+ delayMs: delay,
516
+ error: error instanceof Error ? error.message : String(error)
517
+ }), delay > 0 && await sleep(delay);
1234
518
  }
1235
- } : (devLog.error("Missing config in TranslateAction"), null);
1236
- };
519
+ throw lastError;
520
+ }
1237
521
  class Semaphore {
1238
522
  permits;
1239
523
  queue = [];
@@ -1584,13 +868,27 @@ const fetchTranslationPrompts = async ({
1584
868
  }
1585
869
  }), hasChanges = !0);
1586
870
  }
1587
- hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
871
+ hasChanges && (await retryWithBackoff(
872
+ async () => {
873
+ await patch.commit();
874
+ },
875
+ {
876
+ maxAttempts: 2,
877
+ initialDelayMs: 0,
878
+ retryableErrors: isNetworkError,
879
+ context: {
880
+ operation: "localizeReferences",
881
+ documentId,
882
+ referenceCount: refs.length
883
+ }
884
+ }
885
+ ), devLog.info("Updated references in batch", {
1588
886
  documentId,
1589
887
  count: refs.length,
1590
888
  translatedCount: translationMap.size,
1591
889
  component: "updateReferences"
1592
890
  }));
1593
- }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
891
+ }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages, mainSourceDocumentId) => {
1594
892
  for (const sourceDocument of sourceDocuments)
1595
893
  for (const lang of targetLanguages) {
1596
894
  const payload = {
@@ -1603,146 +901,8 @@ const fetchTranslationPrompts = async ({
1603
901
  dispatch({
1604
902
  type: "SET_TRANSLATION_JOB_STATUS",
1605
903
  payload
1606
- });
904
+ }), mainSourceDocumentId && updateActiveTranslationJob(mainSourceDocumentId, payload);
1607
905
  }
1608
- }, DEFAULT_OPTIONS = {
1609
- maxRetries: 3,
1610
- initialDelayMs: 1e3,
1611
- maxDelayMs: 3e4,
1612
- backoffMultiplier: 2,
1613
- jitter: !0
1614
- };
1615
- function isRetryableError(error) {
1616
- if (error instanceof Error) {
1617
- const message = error.message.toLowerCase(), name = error.name.toLowerCase(), nonRetryablePatterns = [
1618
- "schema not found",
1619
- "invalid schema",
1620
- "not found",
1621
- "unauthorized",
1622
- "forbidden",
1623
- "invalid api key",
1624
- "missing api key",
1625
- "validation failed",
1626
- "invalid document",
1627
- "source language not found",
1628
- "invalid configuration",
1629
- "type mismatch"
1630
- ];
1631
- for (const pattern of nonRetryablePatterns)
1632
- if (message.includes(pattern) || name.includes(pattern))
1633
- return !1;
1634
- const retryablePatterns = [
1635
- "failed to fetch",
1636
- "network",
1637
- "timeout",
1638
- "econnreset",
1639
- "econnrefused",
1640
- "enotfound",
1641
- "socket",
1642
- "rate limit",
1643
- "too many requests",
1644
- "429",
1645
- "500",
1646
- "502",
1647
- "503",
1648
- "504",
1649
- "internal server error",
1650
- "bad gateway",
1651
- "service unavailable",
1652
- "gateway timeout",
1653
- "temporarily unavailable",
1654
- "try again"
1655
- ];
1656
- for (const pattern of retryablePatterns)
1657
- if (message.includes(pattern) || name.includes(pattern))
1658
- return !0;
1659
- }
1660
- if (typeof error == "object" && error !== null) {
1661
- const statusCode = error.statusCode || error.status;
1662
- if (statusCode) {
1663
- if (statusCode === 429 || statusCode >= 500 && statusCode < 600)
1664
- return !0;
1665
- if (statusCode >= 400 && statusCode < 500)
1666
- return !1;
1667
- }
1668
- }
1669
- return !0;
1670
- }
1671
- function calculateBackoffDelay(attempt, options) {
1672
- const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = options;
1673
- let delay = initialDelayMs * backoffMultiplier ** attempt;
1674
- if (delay = Math.min(delay, maxDelayMs), jitter) {
1675
- const jitterFactor = 0.75 + Math.random() * 0.5;
1676
- delay = Math.round(delay * jitterFactor);
1677
- }
1678
- return delay;
1679
- }
1680
- function sleep(ms) {
1681
- return new Promise((resolve) => setTimeout(resolve, ms));
1682
- }
1683
- async function withRetry(fn, options = {}) {
1684
- const opts = { ...DEFAULT_OPTIONS, ...options }, shouldRetry = opts.isRetryable ?? isRetryableError;
1685
- let lastError, attempts = 0;
1686
- for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
1687
- attempts = attempt + 1;
1688
- try {
1689
- return { success: !0, result: await fn(), attempts };
1690
- } catch (error) {
1691
- if (lastError = error, attempt >= opts.maxRetries) {
1692
- devLog.debug("Max retries exhausted", { attempt: attempts, error });
1693
- break;
1694
- }
1695
- if (!shouldRetry(error)) {
1696
- devLog.debug("Error is not retryable", { attempt: attempts, error });
1697
- break;
1698
- }
1699
- const delay = calculateBackoffDelay(attempt, opts);
1700
- devLog.debug("Scheduling retry", {
1701
- attempt: attempts,
1702
- nextAttempt: attempt + 2,
1703
- delayMs: delay,
1704
- error: error instanceof Error ? error.message : String(error)
1705
- }), opts.onRetry && opts.onRetry(attempt + 1, error, delay), await sleep(delay);
1706
- }
1707
- }
1708
- return { success: !1, error: lastError, attempts };
1709
- }
1710
- const getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1711
- (translation) => translation._key === targetLanguage
1712
- )?.value._ref || null, getTranslationMetadata = async ({
1713
- client,
1714
- sourceDocument,
1715
- sourceLanguage
1716
- }) => {
1717
- try {
1718
- return await client.fetch(
1719
- "*[_type == $tmType && references($sourceDocumentId)][0]",
1720
- {
1721
- tmType: TRANSLATION_METADATA_TYPE,
1722
- sourceDocumentId: undraftId(sourceDocument._id)
1723
- }
1724
- ) || await client.create({
1725
- _type: TRANSLATION_METADATA_TYPE,
1726
- translations: [
1727
- {
1728
- _key: sourceLanguage,
1729
- _type: "internationalizedArrayReferenceValue",
1730
- value: {
1731
- _type: "reference",
1732
- _ref: undraftId(sourceDocument._id),
1733
- _weak: !0,
1734
- _strengthenOnPublish: {
1735
- _type: sourceDocument._type,
1736
- _weak: !1
1737
- }
1738
- }
1739
- }
1740
- ],
1741
- translationHistory: []
1742
- });
1743
- } catch (error) {
1744
- throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
1745
- }
1746
906
  }, updateTranslationMetadata = async ({
1747
907
  client,
1748
908
  targetLanguage,
@@ -1776,11 +936,71 @@ const getExistingTranslationId = (translationMetadata, targetLanguage) => transl
1776
936
  targetRev: newDocument._rev,
1777
937
  targetSnapshot: JSON.stringify(newDocument)
1778
938
  }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
1779
- isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
939
+ isNewTranslation ? await retryWithBackoff(
940
+ async () => {
941
+ await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit();
942
+ },
943
+ {
944
+ maxAttempts: 2,
945
+ initialDelayMs: 0,
946
+ retryableErrors: isNetworkError,
947
+ context: {
948
+ operation: "updateTranslationMetadata.translations",
949
+ sourceDocumentId: sourceDocument._id,
950
+ targetLanguage,
951
+ isNewTranslation: !0
952
+ }
953
+ }
954
+ ) : await retryWithBackoff(
955
+ async () => {
956
+ await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
957
+ },
958
+ {
959
+ maxAttempts: 2,
960
+ initialDelayMs: 0,
961
+ retryableErrors: isNetworkError,
962
+ context: {
963
+ operation: "updateTranslationMetadata.translations",
964
+ sourceDocumentId: sourceDocument._id,
965
+ targetLanguage,
966
+ isNewTranslation: !1
967
+ }
968
+ }
969
+ );
1780
970
  const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1781
971
  translationMetadata.translationHistory?.some(
1782
972
  (history) => history.language === targetLanguage
1783
- ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
973
+ ) ? await retryWithBackoff(
974
+ async () => {
975
+ await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit();
976
+ },
977
+ {
978
+ maxAttempts: 2,
979
+ initialDelayMs: 0,
980
+ retryableErrors: isNetworkError,
981
+ context: {
982
+ operation: "updateTranslationMetadata.history",
983
+ sourceDocumentId: sourceDocument._id,
984
+ targetLanguage,
985
+ isNewHistory: !1
986
+ }
987
+ }
988
+ ) : await retryWithBackoff(
989
+ async () => {
990
+ await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
991
+ },
992
+ {
993
+ maxAttempts: 2,
994
+ initialDelayMs: 0,
995
+ retryableErrors: isNetworkError,
996
+ context: {
997
+ operation: "updateTranslationMetadata.history",
998
+ sourceDocumentId: sourceDocument._id,
999
+ targetLanguage,
1000
+ isNewHistory: !0
1001
+ }
1002
+ }
1003
+ );
1784
1004
  } catch (error) {
1785
1005
  throw devLog.error("Error updating translation metadata", error instanceof Error ? error : new Error(String(error)), {
1786
1006
  sourceDocumentId: sourceDocument._id,
@@ -1789,15 +1009,16 @@ const getExistingTranslationId = (translationMetadata, targetLanguage) => transl
1789
1009
  component: "updateTranslationMetadata"
1790
1010
  }), error;
1791
1011
  }
1792
- }, setJobStatus = (dispatch, _id, language, status, payload) => {
1012
+ }, setJobStatus = (dispatch, _id, language, status, payload, sourceDocumentId) => {
1013
+ const job = defaults(payload, {
1014
+ _id,
1015
+ language,
1016
+ status
1017
+ });
1793
1018
  dispatch({
1794
1019
  type: "SET_TRANSLATION_JOB_STATUS",
1795
- payload: defaults(payload, {
1796
- _id,
1797
- language,
1798
- status
1799
- })
1800
- });
1020
+ payload: job
1021
+ }), sourceDocumentId && updateActiveTranslationJob(sourceDocumentId, job);
1801
1022
  };
1802
1023
  function shouldExcludeField(fieldPath, excludeFields) {
1803
1024
  for (const pattern of excludeFields) {
@@ -2123,14 +1344,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2123
1344
  }), devLog.debug("OpenAI GPT-5 Full Prompt", {
2124
1345
  messages: request.messages
2125
1346
  });
2126
- const response = await fetch("https://api.openai.com/v1/responses", {
2127
- method: "POST",
2128
- headers: {
2129
- "Content-Type": "application/json",
2130
- Authorization: `Bearer ${apiKey}`
1347
+ const response = await retryWithBackoff(
1348
+ async () => {
1349
+ const fetchResponse = await fetch("https://api.openai.com/v1/responses", {
1350
+ method: "POST",
1351
+ headers: {
1352
+ "Content-Type": "application/json",
1353
+ Authorization: `Bearer ${apiKey}`
1354
+ },
1355
+ body: JSON.stringify(requestBody)
1356
+ });
1357
+ if (!fetchResponse.ok) {
1358
+ const errorMessage = `GPT-5 API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
1359
+ throw errorObj.response = { status: fetchResponse.status }, errorObj;
1360
+ }
1361
+ return fetchResponse;
2131
1362
  },
2132
- body: JSON.stringify(requestBody)
2133
- });
1363
+ {
1364
+ maxAttempts: 2,
1365
+ initialDelayMs: 0,
1366
+ retryableErrors: isNetworkError,
1367
+ context: { model: requestBody.model, endpoint: "responses" }
1368
+ }
1369
+ );
2134
1370
  if (!response.ok) {
2135
1371
  const error = await response.json();
2136
1372
  throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
@@ -2169,14 +1405,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2169
1405
  }), devLog.debug("OpenAI Chat Completions Full Prompt", {
2170
1406
  messages: request.messages
2171
1407
  });
2172
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
2173
- method: "POST",
2174
- headers: {
2175
- "Content-Type": "application/json",
2176
- Authorization: `Bearer ${apiKey}`
1408
+ const response = await retryWithBackoff(
1409
+ async () => {
1410
+ const fetchResponse = await fetch("https://api.openai.com/v1/chat/completions", {
1411
+ method: "POST",
1412
+ headers: {
1413
+ "Content-Type": "application/json",
1414
+ Authorization: `Bearer ${apiKey}`
1415
+ },
1416
+ body: JSON.stringify(requestBody)
1417
+ });
1418
+ if (!fetchResponse.ok) {
1419
+ const errorMessage = `OpenAI API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
1420
+ throw errorObj.response = { status: fetchResponse.status }, errorObj;
1421
+ }
1422
+ return fetchResponse;
2177
1423
  },
2178
- body: JSON.stringify(requestBody)
2179
- });
1424
+ {
1425
+ maxAttempts: 2,
1426
+ initialDelayMs: 0,
1427
+ retryableErrors: isNetworkError,
1428
+ context: { model, endpoint: "chat/completions" }
1429
+ }
1430
+ );
2180
1431
  if (!response.ok) {
2181
1432
  const error = await response.json();
2182
1433
  throw new Error(`OpenAI API error: ${error.error?.message || "Unknown error"}`);
@@ -2331,7 +1582,21 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2331
1582
  parent && parent.children.push(node);
2332
1583
  } else
2333
1584
  root.push(node);
2334
- else
1585
+ else if (attributes.type === "string" && !content) {
1586
+ const contentLines = [];
1587
+ for (i++; i < lines.length; ) {
1588
+ const currentLine = lines[i];
1589
+ if (currentLine.trim() === "</field>")
1590
+ break;
1591
+ contentLines.push(currentLine), i++;
1592
+ }
1593
+ if (node.text = contentLines.join(`
1594
+ `).trim(), stack.length > 0) {
1595
+ const parent = stack[stack.length - 1];
1596
+ parent && parent.children.push(node);
1597
+ } else
1598
+ root.push(node);
1599
+ } else
2335
1600
  stack.push(node);
2336
1601
  continue;
2337
1602
  }
@@ -2592,11 +1857,12 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2592
1857
  }, getOpenAITranslation = async ({
2593
1858
  dispatch,
2594
1859
  sourceDocument,
2595
- originalSourceDocument,
2596
1860
  languageFieldName,
2597
1861
  translationPrompts,
2598
1862
  targetLanguage,
2599
- openaiConfig
1863
+ openaiConfig,
1864
+ previousError,
1865
+ errorContext
2600
1866
  }) => {
2601
1867
  const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2602
1868
  sourceId: sourceDocument._id,
@@ -2614,7 +1880,8 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2614
1880
  sourceText,
2615
1881
  sourceLanguage,
2616
1882
  targetLanguage,
2617
- translationPrompts
1883
+ translationPrompts,
1884
+ errorContext
2618
1885
  }), response = await callGPT5(openaiConfig.apiKey, {
2619
1886
  messages: [
2620
1887
  {
@@ -2633,9 +1900,9 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2633
1900
  responseLength: response.content.length,
2634
1901
  responsePreview: response.content.substring(0, 500)
2635
1902
  });
2636
- const baseDocument = originalSourceDocument || sourceDocument, result = deserializeDocument(baseDocument, response.content);
1903
+ const result = deserializeDocument(sourceDocument, response.content);
2637
1904
  result[languageFieldName] = targetLanguage;
2638
- const validation = validateStructureIntegrity(baseDocument, result);
1905
+ const validation = validateStructureIntegrity(sourceDocument, result);
2639
1906
  if (!validation.valid) {
2640
1907
  devLog.error("Structure validation failed", {
2641
1908
  ...logContext,
@@ -2672,14 +1939,21 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2672
1939
  sourceText,
2673
1940
  sourceLanguage,
2674
1941
  targetLanguage,
2675
- translationPrompts
1942
+ translationPrompts,
1943
+ errorContext
2676
1944
  }) => {
2677
1945
  const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2678
1946
  return `# TRANSLATION TASK
2679
1947
 
2680
1948
  Translate this ${sourceLanguage} document into ${targetLanguage}.
2681
1949
 
2682
- ## Guidelines
1950
+ ${errorContext ? `## \u26A0\uFE0F IMPORTANT: Previous Attempt Error
1951
+
1952
+ ${errorContext}
1953
+
1954
+ Please carefully review the error above and ensure your translation fixes the issue.
1955
+
1956
+ ` : ""}## Guidelines
2683
1957
 
2684
1958
  ${globalPrompt ? `### General Guidelines
2685
1959
  ${globalPrompt}
@@ -2853,21 +2127,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2853
2127
  languageFieldName,
2854
2128
  targetLanguage
2855
2129
  });
2856
- return await client.agent.action.translate({
2857
- schemaId,
2858
- toLanguage,
2859
- fromLanguage,
2860
- documentId: sourceDocument._id,
2861
- noWrite: !0,
2862
- // Do not write to the document, just return the translated content
2863
- languageFieldPath: [languageFieldName],
2864
- styleGuide,
2865
- protectedPhrases: translationPrompts.protectedPhrases,
2866
- conditionalPaths: {
2867
- defaultHidden: !1,
2868
- defaultReadOnly: !1
2869
- }
2870
- });
2130
+ return await retryWithBackoff(
2131
+ async () => await client.agent.action.translate({
2132
+ schemaId,
2133
+ toLanguage,
2134
+ fromLanguage,
2135
+ documentId: sourceDocument._id,
2136
+ noWrite: !0,
2137
+ // Do not write to the document, just return the translated content
2138
+ languageFieldPath: [languageFieldName],
2139
+ styleGuide,
2140
+ protectedPhrases: translationPrompts.protectedPhrases,
2141
+ conditionalPaths: {
2142
+ defaultHidden: !1,
2143
+ defaultReadOnly: !1
2144
+ }
2145
+ }),
2146
+ {
2147
+ maxAttempts: 2,
2148
+ initialDelayMs: 0,
2149
+ retryableErrors: isNetworkError,
2150
+ context: {
2151
+ operation: "agent.action.translate",
2152
+ documentId: sourceDocument._id,
2153
+ targetLanguage,
2154
+ schemaId
2155
+ }
2156
+ }
2157
+ );
2871
2158
  }, handleTranslation = async ({
2872
2159
  dispatch,
2873
2160
  client,
@@ -2880,84 +2167,108 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2880
2167
  translationMode,
2881
2168
  openaiConfig,
2882
2169
  releaseId,
2883
- excludeFields = []
2170
+ excludeFields = [],
2171
+ replaceExisting = !1,
2172
+ mainSourceDocumentId
2884
2173
  }) => {
2885
- devLog.info("Filtering excluded fields before translation", {
2174
+ const safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2175
+ excludeFields.length !== safeExcludeFields.length && devLog.warn("Language field was in excludeFields - removing it to prevent translation errors", {
2176
+ documentId: sourceDocument._id,
2177
+ languageFieldName,
2178
+ originalExcludeFields: excludeFields,
2179
+ safeExcludeFields
2180
+ }), devLog.info("Filtering excluded fields before translation", {
2886
2181
  documentId: sourceDocument._id,
2887
2182
  documentType: sourceDocument._type,
2888
- excludeFields,
2889
- excludeFieldsLength: excludeFields?.length ?? 0,
2183
+ excludeFields: safeExcludeFields,
2184
+ excludeFieldsLength: safeExcludeFields?.length ?? 0,
2890
2185
  sourceHasTitle: "title" in sourceDocument,
2891
2186
  sourceTitleValue: sourceDocument.title
2892
2187
  });
2893
- const { filteredDocument, excludedValues } = filterExcludedFields(sourceDocument, excludeFields);
2188
+ const { filteredDocument, excludedValues } = filterExcludedFields(sourceDocument, safeExcludeFields);
2894
2189
  devLog.info("After filtering excluded fields", {
2895
2190
  documentId: sourceDocument._id,
2896
2191
  filteredHasTitle: "title" in filteredDocument,
2897
2192
  filteredTitleValue: filteredDocument.title,
2898
2193
  excludedValuesKeys: Object.keys(excludedValues),
2899
2194
  excludedValues
2900
- }), devLog.debug("Applying field transforms before translation", {
2901
- documentId: sourceDocument._id,
2902
- documentType: sourceDocument._type
2903
- });
2904
- const { transformedDocument, originalStructure } = applyTransforms({
2905
- document: filteredDocument,
2906
- registry: globalTransformRegistry,
2907
- schemaType: sourceDocument._type
2908
2195
  });
2909
- let translatedDoc;
2910
- switch (translationMode) {
2911
- case "sanity-only": {
2912
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), translatedDoc = await getSanityAgentTranslation({
2913
- sourceDocument: transformedDocument,
2914
- targetLanguage,
2915
- languageFieldName,
2916
- schemaId,
2917
- client,
2918
- translationPrompts
2919
- });
2920
- break;
2921
- }
2922
- case "openai-only": {
2923
- if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2924
- throw new Error("OpenAI is not configured but openai-only mode was selected");
2925
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai"), translatedDoc = await getOpenAITranslation({
2926
- dispatch,
2927
- sourceDocument: transformedDocument,
2928
- originalSourceDocument: sourceDocument,
2929
- targetLanguage,
2930
- languageFieldName,
2931
- translationPrompts,
2932
- openaiConfig
2933
- });
2934
- break;
2196
+ let documentToTranslate, originalStructure = {};
2197
+ if (translationMode === "openai-only") {
2198
+ devLog.debug("Applying field transforms before translation (OpenAI mode)", {
2199
+ documentId: sourceDocument._id,
2200
+ documentType: sourceDocument._type
2201
+ });
2202
+ const transformResult = applyTransforms({
2203
+ document: filteredDocument,
2204
+ registry: globalTransformRegistry,
2205
+ schemaType: sourceDocument._type
2206
+ });
2207
+ documentToTranslate = transformResult.transformedDocument, originalStructure = transformResult.originalStructure;
2208
+ } else
2209
+ devLog.debug("Skipping field transforms (Sanity Agent mode)", {
2210
+ documentId: sourceDocument._id,
2211
+ documentType: sourceDocument._type
2212
+ }), documentToTranslate = filteredDocument;
2213
+ let translatedDoc, previousError = null;
2214
+ const performTranslation = async (errorContext) => {
2215
+ switch (translationMode) {
2216
+ case "sanity-only":
2217
+ return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating", void 0, mainSourceDocumentId), await getSanityAgentTranslation({
2218
+ sourceDocument: documentToTranslate,
2219
+ targetLanguage,
2220
+ languageFieldName,
2221
+ schemaId,
2222
+ client,
2223
+ translationPrompts
2224
+ });
2225
+ case "openai-only": {
2226
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2227
+ throw new Error("OpenAI is not configured but openai-only mode was selected");
2228
+ return setJobStatus(
2229
+ dispatch,
2230
+ sourceDocument._id,
2231
+ targetLanguage,
2232
+ "translating_with_openai",
2233
+ void 0,
2234
+ mainSourceDocumentId
2235
+ ), await getOpenAITranslation({
2236
+ dispatch,
2237
+ sourceDocument: documentToTranslate,
2238
+ targetLanguage,
2239
+ languageFieldName,
2240
+ translationPrompts,
2241
+ openaiConfig,
2242
+ previousError: errorContext && previousError ? previousError : void 0,
2243
+ errorContext
2244
+ });
2245
+ }
2246
+ default:
2247
+ throw new Error(`Invalid translation mode: ${translationMode}`);
2935
2248
  }
2936
- case "sanity-with-review":
2937
- throw new Error("Sanity with review translation method is not implemented");
2938
- default:
2939
- throw new Error(`Invalid translation mode: ${translationMode}`);
2940
- }
2941
- if (!translatedDoc)
2249
+ };
2250
+ if (translatedDoc = await performTranslation(), !translatedDoc)
2942
2251
  return null;
2943
- devLog.debug("Restoring field transforms after translation", {
2252
+ let transformedResult;
2253
+ Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
2944
2254
  documentId: sourceDocument._id,
2945
2255
  documentType: sourceDocument._type
2946
- });
2947
- const transformedResult = restoreTransforms({
2256
+ }), transformedResult = restoreTransforms({
2948
2257
  translatedDocument: translatedDoc,
2949
2258
  originalStructure,
2950
2259
  registry: globalTransformRegistry,
2951
2260
  schemaType: sourceDocument._type
2952
- });
2953
- devLog.info("Before restoring excluded fields", {
2261
+ })) : transformedResult = translatedDoc;
2262
+ const excludedValuesToRestore = { ...excludedValues };
2263
+ delete excludedValuesToRestore[languageFieldName], devLog.info("Before restoring excluded fields", {
2954
2264
  documentId: sourceDocument._id,
2955
2265
  excludedFieldCount: Object.keys(excludedValues).length,
2956
2266
  excludedValuesKeys: Object.keys(excludedValues),
2267
+ languageFieldExcluded: languageFieldName in excludedValues,
2957
2268
  transformedResultHasTitle: "title" in transformedResult,
2958
2269
  transformedResultTitle: transformedResult.title
2959
2270
  });
2960
- const finalDoc = restoreExcludedFields(transformedResult, excludedValues);
2271
+ const finalDoc = restoreExcludedFields(transformedResult, excludedValuesToRestore);
2961
2272
  devLog.info("After restoring excluded fields", {
2962
2273
  documentId: sourceDocument._id,
2963
2274
  finalDocHasTitle: "title" in finalDoc,
@@ -2967,11 +2278,76 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2967
2278
  ...finalDoc,
2968
2279
  _id: targetDocumentVersionId
2969
2280
  };
2970
- return await client.action({
2971
- actionType: "sanity.action.document.version.create",
2972
- publishedId: targetDocumentPublishedId,
2973
- document: newDoc
2974
- }), newDoc;
2281
+ replaceExisting && await client.getDocument(targetDocumentVersionId) && (devLog.info("Replacing existing version in release", {
2282
+ documentId: sourceDocument._id,
2283
+ targetDocumentVersionId,
2284
+ releaseId
2285
+ }), await client.delete(targetDocumentVersionId));
2286
+ try {
2287
+ await retryWithBackoff(
2288
+ async () => {
2289
+ await client.action({
2290
+ actionType: "sanity.action.document.version.create",
2291
+ publishedId: targetDocumentPublishedId,
2292
+ document: newDoc
2293
+ });
2294
+ },
2295
+ {
2296
+ maxAttempts: 2,
2297
+ initialDelayMs: 0,
2298
+ retryableErrors: isNetworkError,
2299
+ context: {
2300
+ documentId: sourceDocument._id,
2301
+ targetLanguage,
2302
+ operation: "version.create"
2303
+ }
2304
+ }
2305
+ );
2306
+ } catch (error) {
2307
+ if (translationMode === "openai-only" && isSanityMutationError(error)) {
2308
+ devLog.warn("Mutation error after OpenAI translation, retrying full translation", {
2309
+ documentId: sourceDocument._id,
2310
+ targetLanguage,
2311
+ error: error instanceof Error ? error.message : String(error)
2312
+ }), previousError = error instanceof Error ? error : new Error(String(error));
2313
+ const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`;
2314
+ if (translatedDoc = await performTranslation(errorContext), !translatedDoc)
2315
+ throw new Error("Translation retry failed: no document returned");
2316
+ let retryTransformedResult;
2317
+ Object.keys(originalStructure).length > 0 ? retryTransformedResult = restoreTransforms({
2318
+ translatedDocument: translatedDoc,
2319
+ originalStructure,
2320
+ registry: globalTransformRegistry,
2321
+ schemaType: sourceDocument._type
2322
+ }) : retryTransformedResult = translatedDoc;
2323
+ const retryNewDoc = {
2324
+ ...restoreExcludedFields(retryTransformedResult, excludedValuesToRestore),
2325
+ _id: targetDocumentVersionId
2326
+ };
2327
+ return await retryWithBackoff(
2328
+ async () => {
2329
+ await client.action({
2330
+ actionType: "sanity.action.document.version.create",
2331
+ publishedId: targetDocumentPublishedId,
2332
+ document: retryNewDoc
2333
+ });
2334
+ },
2335
+ {
2336
+ maxAttempts: 2,
2337
+ initialDelayMs: 0,
2338
+ retryableErrors: isNetworkError,
2339
+ context: {
2340
+ documentId: sourceDocument._id,
2341
+ targetLanguage,
2342
+ operation: "version.create",
2343
+ isRetry: !0
2344
+ }
2345
+ }
2346
+ ), retryNewDoc;
2347
+ }
2348
+ throw error;
2349
+ }
2350
+ return newDoc;
2975
2351
  }, translateDocument = async ({
2976
2352
  dispatch,
2977
2353
  client,
@@ -2979,84 +2355,66 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2979
2355
  sourceLanguage,
2980
2356
  targetLanguage,
2981
2357
  languageFieldName,
2982
- shouldOverwriteFreshTranslations,
2983
2358
  schemaId,
2984
2359
  translationPrompts,
2985
2360
  translationMode,
2986
2361
  openaiConfig,
2987
2362
  releaseId,
2988
- excludeFields
2363
+ excludeFields,
2364
+ replaceExisting,
2365
+ mainSourceDocumentId
2989
2366
  }) => {
2990
2367
  const logContext = {
2991
2368
  documentId: sourceDocument._id,
2992
2369
  targetLanguage,
2993
2370
  schemaId,
2994
- shouldOverwriteFreshTranslations,
2995
2371
  translationMode
2996
2372
  };
2997
- devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
2373
+ devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation", void 0, mainSourceDocumentId);
2998
2374
  try {
2999
2375
  const translationMetadata = await getTranslationMetadata({
3000
2376
  client,
3001
2377
  sourceDocument,
3002
2378
  sourceLanguage
3003
- }), isAlreadyFreshTranslation = translationMetadata.translationHistory?.some(
3004
- (history) => history.language === targetLanguage && history.sourceRev === sourceDocument._rev
3005
- );
3006
- if (!shouldOverwriteFreshTranslations && isAlreadyFreshTranslation) {
3007
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "skipped", {
3008
- targetId: translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref
3009
- });
3010
- return;
3011
- }
3012
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await withRetry(
3013
- () => handleTranslation({
2379
+ }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2380
+ dispatch,
2381
+ client,
2382
+ schemaId,
2383
+ sourceDocument,
2384
+ targetLanguage,
2385
+ existingTranslationId,
2386
+ languageFieldName,
2387
+ translationPrompts,
2388
+ translationMode,
2389
+ openaiConfig,
2390
+ releaseId,
2391
+ excludeFields,
2392
+ replaceExisting,
2393
+ mainSourceDocumentId
2394
+ });
2395
+ if (!newDocument) {
2396
+ devLog.error("Translation failed: no document returned", new Error("Translation returned null"), logContext), setJobStatus(
3014
2397
  dispatch,
3015
- client,
3016
- schemaId,
3017
- sourceDocument,
2398
+ sourceDocument._id,
3018
2399
  targetLanguage,
3019
- existingTranslationId,
3020
- languageFieldName,
3021
- translationPrompts,
3022
- translationMode,
3023
- openaiConfig,
3024
- releaseId,
3025
- excludeFields
3026
- }),
3027
- {
3028
- maxRetries: 3,
3029
- initialDelayMs: 1e3,
3030
- isRetryable: isRetryableError,
3031
- onRetry: (attempt, error, delayMs) => {
3032
- devLog.warn("Translation failed, scheduling retry", {
3033
- ...logContext,
3034
- attempt,
3035
- delayMs,
3036
- error: error instanceof Error ? error.message : String(error)
3037
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "retrying", {
3038
- retryCount: attempt,
3039
- errorMessage: error instanceof Error ? error.message : String(error)
3040
- });
3041
- }
3042
- }
3043
- );
3044
- if (!translationResult.success || !translationResult.result) {
3045
- const errorMessage = translationResult.error instanceof Error ? translationResult.error.message : String(translationResult.error || "Translation failed");
3046
- devLog.error("Translation failed after retries", translationResult.error, {
3047
- ...logContext,
3048
- attempts: translationResult.attempts
3049
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
3050
- errorMessage,
3051
- retryCount: translationResult.attempts - 1
3052
- // -1 because attempts includes initial try
3053
- }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2400
+ "error",
2401
+ {
2402
+ errorMessage: "Translation failed"
2403
+ },
2404
+ mainSourceDocumentId
2405
+ ), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3054
2406
  return;
3055
2407
  }
3056
- const newDocument = translationResult.result;
3057
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "updating_metadata", {
3058
- targetId: newDocument._id
3059
- }), await updateTranslationMetadata({
2408
+ return setJobStatus(
2409
+ dispatch,
2410
+ sourceDocument._id,
2411
+ targetLanguage,
2412
+ "updating_metadata",
2413
+ {
2414
+ targetId: newDocument._id
2415
+ },
2416
+ mainSourceDocumentId
2417
+ ), await updateTranslationMetadata({
3060
2418
  client,
3061
2419
  sourceDocument,
3062
2420
  sourceLanguage,
@@ -3064,17 +2422,31 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3064
2422
  targetLanguage,
3065
2423
  translationMetadata,
3066
2424
  isNewTranslation: !existingTranslationId
3067
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "completed", {
3068
- targetId: newDocument._id
3069
- }), devLog.info("Translation completed successfully", {
2425
+ }), setJobStatus(
2426
+ dispatch,
2427
+ sourceDocument._id,
2428
+ targetLanguage,
2429
+ "completed",
2430
+ {
2431
+ targetId: newDocument._id
2432
+ },
2433
+ mainSourceDocumentId
2434
+ ), devLog.info("Translation completed successfully", {
3070
2435
  ...logContext,
3071
2436
  newDocumentId: newDocument._id
3072
2437
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
3073
2438
  } catch (error) {
3074
2439
  const errorMessage = error instanceof Error ? error.message : String(error);
3075
- devLog.error("Translation failed", error instanceof Error ? error : new Error(String(error)), logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
3076
- errorMessage
3077
- }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2440
+ devLog.error("Translation failed", error instanceof Error ? error : new Error(String(error)), logContext), setJobStatus(
2441
+ dispatch,
2442
+ sourceDocument._id,
2443
+ targetLanguage,
2444
+ "error",
2445
+ {
2446
+ errorMessage
2447
+ },
2448
+ mainSourceDocumentId
2449
+ ), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3078
2450
  return;
3079
2451
  }
3080
2452
  }, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
@@ -3083,13 +2455,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3083
2455
  targetLanguages,
3084
2456
  dispatch,
3085
2457
  languageFieldName,
3086
- shouldOverwriteFreshTranslations,
3087
2458
  schemaId,
3088
2459
  references = [],
3089
2460
  maxConcurrentTranslationJobs,
3090
2461
  translationMode,
3091
2462
  openaiConfig,
3092
- excludeFields
2463
+ excludeFields,
2464
+ releaseConfig
3093
2465
  }) => {
3094
2466
  const logContext = {
3095
2467
  documentId: sourceDocument._id,
@@ -3098,28 +2470,48 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3098
2470
  referencesCount: references.length,
3099
2471
  maxConcurrentJobs: maxConcurrentTranslationJobs,
3100
2472
  translationMode,
3101
- shouldOverwriteFreshTranslations
2473
+ releaseMode: releaseConfig.mode
3102
2474
  };
3103
- let releaseId;
2475
+ let releaseId, createdNewRelease = !1;
3104
2476
  try {
3105
2477
  const sourceLanguage = sourceDocument[languageFieldName];
3106
2478
  if (!sourceLanguage)
3107
2479
  throw devLog.error("Source language not found"), new Error("Source language not found");
3108
- maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
2480
+ if (maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
3109
2481
  type: "SET_OVERALL_STATUS",
3110
2482
  payload: "translating"
3111
- });
3112
- 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()}`, { releaseId: releaseId2 } = await client.releases.create({
3113
- metadata: {
3114
- title: releaseTitle,
3115
- description: releaseDescription,
3116
- releaseType: "undecided"
3117
- }
3118
- });
3119
- devLog.info("Content Release created", {
3120
- ...logContext,
3121
- releaseId: releaseId2
3122
- });
2483
+ }), releaseConfig.mode === "existing" && releaseConfig.existingReleaseId)
2484
+ releaseId = releaseConfig.existingReleaseId, devLog.info("Using existing Content Release", {
2485
+ ...logContext,
2486
+ releaseId
2487
+ });
2488
+ else {
2489
+ const releaseTitle = releaseConfig.newReleaseTitle, releaseDescription = `AI translation batch created on ${(/* @__PURE__ */ new Date()).toLocaleDateString()} at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
2490
+ releaseId = (await retryWithBackoff(
2491
+ async () => await client.releases.create({
2492
+ metadata: {
2493
+ title: releaseTitle,
2494
+ description: releaseDescription,
2495
+ releaseType: "undecided"
2496
+ }
2497
+ }),
2498
+ {
2499
+ maxAttempts: 2,
2500
+ initialDelayMs: 0,
2501
+ retryableErrors: isNetworkError,
2502
+ context: {
2503
+ operation: "releases.create",
2504
+ releaseTitle,
2505
+ ...logContext
2506
+ }
2507
+ }
2508
+ )).releaseId, createdNewRelease = !0, devLog.info("Content Release created", {
2509
+ ...logContext,
2510
+ releaseId,
2511
+ releaseTitle
2512
+ });
2513
+ }
2514
+ registerActiveTranslation(sourceDocument._id, releaseId), updateActiveTranslationStatus(sourceDocument._id, "translating");
3123
2515
  const sourceDocuments = [
3124
2516
  {
3125
2517
  _id: sourceDocument._id,
@@ -3134,7 +2526,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3134
2526
  targetLanguages,
3135
2527
  sourceDocument
3136
2528
  });
3137
- setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages);
2529
+ setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages, sourceDocument._id);
3138
2530
  const translationTasks = sourceDocumentIds.flatMap(
3139
2531
  (documentId) => targetLanguages.map(async (targetLanguage) => {
3140
2532
  try {
@@ -3142,7 +2534,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3142
2534
  const document = await client.withConfig({
3143
2535
  perspective: "drafts"
3144
2536
  }).getDocument(documentId);
3145
- if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId2) {
2537
+ if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId) {
3146
2538
  const versionedTargetDocumentId = await translateDocument({
3147
2539
  dispatch,
3148
2540
  client,
@@ -3150,13 +2542,15 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3150
2542
  sourceLanguage,
3151
2543
  targetLanguage,
3152
2544
  languageFieldName,
3153
- shouldOverwriteFreshTranslations,
3154
2545
  schemaId,
3155
2546
  translationPrompts,
3156
2547
  translationMode,
3157
2548
  openaiConfig,
3158
- releaseId: releaseId2,
3159
- excludeFields
2549
+ releaseId,
2550
+ excludeFields,
2551
+ replaceExisting: releaseConfig.replaceExisting,
2552
+ // Pass main source document ID for active translations tracking
2553
+ mainSourceDocumentId: sourceDocument._id
3160
2554
  });
3161
2555
  versionedTargetDocumentId && versionedTargetDocumentIds.push(versionedTargetDocumentId);
3162
2556
  }
@@ -3183,7 +2577,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3183
2577
  }), dispatch({
3184
2578
  type: "SET_OVERALL_STATUS",
3185
2579
  payload: "linking_references"
3186
- }), await Promise.all(
2580
+ }), updateActiveTranslationStatus(sourceDocument._id, "linking_references"), await Promise.all(
3187
2581
  versionedTargetDocumentIds.map(async (documentId) => {
3188
2582
  await localizeReferences({
3189
2583
  client,
@@ -3194,18 +2588,65 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3194
2588
  })
3195
2589
  );
3196
2590
  const hasAnyFailures = failed > 0;
3197
- hasAnyFailures && succeeded === 0 ? (devLog.error("All translation tasks failed", void 0, logContext), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" })) : hasAnyFailures ? (devLog.warn("Translation completed with some failures", {
2591
+ hasAnyFailures && succeeded === 0 ? (devLog.error("All translation tasks failed", void 0, logContext), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id)) : hasAnyFailures ? (devLog.warn("Translation completed with some failures", {
3198
2592
  ...logContext,
3199
2593
  succeeded,
3200
2594
  failed
3201
- }), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })) : dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" }), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
2595
+ }), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" }), updateActiveTranslationStatus(sourceDocument._id, "completed"), cleanupActiveTranslation(sourceDocument._id)) : (dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" }), updateActiveTranslationStatus(sourceDocument._id, "completed"), cleanupActiveTranslation(sourceDocument._id)), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
3202
2596
  } catch (error) {
3203
- throw devLog.error(
2597
+ if (devLog.error(
3204
2598
  "translateDocumentSet failed with unexpected error",
3205
2599
  error instanceof Error ? error : new Error(String(error)),
3206
2600
  logContext
3207
- ), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
3208
- }
2601
+ ), releaseId && createdNewRelease)
2602
+ try {
2603
+ await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
2604
+ ...logContext,
2605
+ releaseId
2606
+ });
2607
+ } catch (cleanupError) {
2608
+ devLog.error(
2609
+ "Failed to cleanup release",
2610
+ cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
2611
+ logContext
2612
+ );
2613
+ }
2614
+ throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id), error;
2615
+ }
2616
+ }, ReleaseConflictWarning = ({ conflicts, onCancel, onReplace }) => {
2617
+ const uniqueDocuments = useMemo(() => new Set(conflicts.map((c) => c.sourceDocumentId)).size, [conflicts]), summaryText = useMemo(() => conflicts.length === 1 ? "1 translation" : uniqueDocuments === 1 ? `${conflicts.length} translations` : `${conflicts.length} translations across ${uniqueDocuments} documents`, [conflicts.length, uniqueDocuments]);
2618
+ return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2619
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2620
+ /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
2621
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "This release already contains translations for this content and target language(s)." })
2622
+ ] }),
2623
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2624
+ "The selected release already has ",
2625
+ summaryText,
2626
+ " that will be overwritten if you continue:"
2627
+ ] }),
2628
+ /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, children: /* @__PURE__ */ jsx(Box, { style: { maxHeight: "500px", overflowY: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 2, children: conflicts.map((conflict) => /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
2629
+ conflict.sourceDocumentTitle || conflict.sourceDocumentId,
2630
+ conflict.sourceDocumentType && ` (${conflict.sourceDocumentType})`,
2631
+ " \u2192 ",
2632
+ conflict.targetLanguage
2633
+ ] }, conflict.existingVersionId)) }) }) }),
2634
+ /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2635
+ /* @__PURE__ */ jsx(Heading, { size: 0, children: "Choose an action:" }),
2636
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2637
+ /* @__PURE__ */ jsx("strong", { children: "Cancel" }),
2638
+ " \u2014 Go back and select a different release, or create a new one."
2639
+ ] }),
2640
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2641
+ /* @__PURE__ */ jsx("strong", { children: "Replace" }),
2642
+ " \u2014 Delete the existing translations in this release and create new ones."
2643
+ ] })
2644
+ ] }),
2645
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
2646
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Cancel", onClick: onCancel }),
2647
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "caution", text: "Replace Existing", onClick: onReplace })
2648
+ ] })
2649
+ ] }) });
3209
2650
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
3210
2651
  const {
3211
2652
  document,
@@ -3214,39 +2655,81 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3214
2655
  dispatch,
3215
2656
  references,
3216
2657
  selectedTargetLanguages,
3217
- shouldOverwriteFreshTranslations,
3218
- translationMode
3219
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
3220
- translateDocumentSet({
3221
- sourceDocument: document,
3222
- targetLanguages: selectedTargetLanguages,
2658
+ translationMode,
2659
+ releaseConfig
2660
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1);
2661
+ useEffect(() => {
2662
+ releaseConfig.mode === "new" && conflictContext && setConflictContext(null);
2663
+ }, [releaseConfig.mode, conflictContext]);
2664
+ const startTranslation = useCallback(
2665
+ (replaceExisting, overrideReleaseId) => {
2666
+ setConflictContext(null), translateDocumentSet({
2667
+ sourceDocument: document,
2668
+ targetLanguages: selectedTargetLanguages,
2669
+ client,
2670
+ dispatch,
2671
+ languageFieldName: config.languageFieldName,
2672
+ schemaId: config.schemaId,
2673
+ references,
2674
+ maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
2675
+ maxReferenceDepth: config.maxReferenceDepth,
2676
+ translationMode,
2677
+ openaiConfig: config.openai,
2678
+ excludeFields: config.excludeFields,
2679
+ releaseConfig: {
2680
+ ...releaseConfig,
2681
+ replaceExisting,
2682
+ // Use the override release ID if provided (from conflict context),
2683
+ // otherwise use the current releaseConfig value
2684
+ existingReleaseId: overrideReleaseId ?? releaseConfig.existingReleaseId
2685
+ }
2686
+ });
2687
+ },
2688
+ [
2689
+ document,
2690
+ selectedTargetLanguages,
3223
2691
  client,
3224
2692
  dispatch,
3225
- languageFieldName: config.languageFieldName,
3226
- schemaId: config.schemaId,
2693
+ config.languageFieldName,
2694
+ config.schemaId,
2695
+ config.maxConcurrentTranslationJobs,
2696
+ config.maxReferenceDepth,
2697
+ config.openai,
2698
+ config.excludeFields,
3227
2699
  references,
3228
- shouldOverwriteFreshTranslations,
3229
- maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
3230
- maxReferenceDepth: config.maxReferenceDepth,
3231
2700
  translationMode,
3232
- openaiConfig: config.openai,
3233
- excludeFields: config.excludeFields
3234
- });
3235
- }, [
3236
- document,
3237
- selectedTargetLanguages,
3238
- client,
3239
- dispatch,
3240
- config.languageFieldName,
3241
- config.schemaId,
3242
- config.maxConcurrentTranslationJobs,
3243
- config.maxReferenceDepth,
3244
- config.openai,
3245
- config.excludeFields,
3246
- references,
3247
- shouldOverwriteFreshTranslations,
3248
- translationMode
3249
- ]), handleSelectAll = useCallback(() => {
2701
+ releaseConfig
2702
+ ]
2703
+ ), handleTranslate = useCallback(async () => {
2704
+ if (releaseConfig.mode === "new") {
2705
+ startTranslation(!1);
2706
+ return;
2707
+ }
2708
+ if (releaseConfig.mode === "existing" && releaseConfig.existingReleaseId) {
2709
+ const checkedReleaseId = releaseConfig.existingReleaseId;
2710
+ setIsCheckingConflicts(!0);
2711
+ const foundConflicts = await checkReleaseConflicts({
2712
+ client,
2713
+ releaseId: checkedReleaseId,
2714
+ sourceDocument: document,
2715
+ targetLanguages: selectedTargetLanguages,
2716
+ languageFieldName: config.languageFieldName,
2717
+ references
2718
+ });
2719
+ if (setIsCheckingConflicts(!1), foundConflicts.length > 0) {
2720
+ setConflictContext({
2721
+ releaseId: checkedReleaseId,
2722
+ conflicts: foundConflicts
2723
+ });
2724
+ return;
2725
+ }
2726
+ }
2727
+ startTranslation(!1);
2728
+ }, [releaseConfig, client, document, selectedTargetLanguages, config.languageFieldName, startTranslation, references]), handleCancelConflict = useCallback(() => {
2729
+ setConflictContext(null);
2730
+ }, []), handleReplaceConflict = useCallback(() => {
2731
+ conflictContext && startTranslation(!0, conflictContext.releaseId);
2732
+ }, [startTranslation, conflictContext]), handleSelectAll = useCallback(() => {
3250
2733
  dispatch({
3251
2734
  type: "SET_SELECTED_TARGET_LANGUAGES",
3252
2735
  payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
@@ -3257,7 +2740,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3257
2740
  payload: []
3258
2741
  });
3259
2742
  }, [dispatch]);
3260
- return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
2743
+ return conflictContext ? /* @__PURE__ */ jsx(Stack, { space: 4, children: /* @__PURE__ */ jsx(
2744
+ ReleaseConflictWarning,
2745
+ {
2746
+ conflicts: conflictContext.conflicts,
2747
+ onCancel: handleCancelConflict,
2748
+ onReplace: handleReplaceConflict
2749
+ }
2750
+ ) }) : /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
3261
2751
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
3262
2752
  /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
3263
2753
  /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
@@ -3271,16 +2761,41 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3271
2761
  tone: "positive",
3272
2762
  size: 2,
3273
2763
  onClick: handleTranslate,
3274
- disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
3275
- children: "Translate"
2764
+ disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus) || releaseConfig.mode === "existing" && !releaseConfig.existingReleaseId || isCheckingConflicts,
2765
+ children: isCheckingConflicts ? "Checking..." : "Translate"
3276
2766
  }
3277
2767
  )
3278
2768
  ] })
3279
2769
  ] });
2770
+ }, TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
2771
+ const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
2772
+ params: {
2773
+ documentId: undraftId(documentId),
2774
+ translationMetadataType: TRANSLATION_METADATA_TYPE
2775
+ },
2776
+ initialValue: null
2777
+ });
2778
+ return useEffect(() => {
2779
+ if (loading) {
2780
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "fetching_metadata" });
2781
+ return;
2782
+ }
2783
+ if (error) {
2784
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "error" });
2785
+ return;
2786
+ }
2787
+ if (data) {
2788
+ dispatch({ type: "SET_TRANSLATION_METADATA", payload: data }), dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
2789
+ return;
2790
+ }
2791
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
2792
+ }, [data, loading, error, dispatch]), {
2793
+ data,
2794
+ loading,
2795
+ error
2796
+ };
3280
2797
  }, LanguageCheckbox = ({ lang, isLoading }) => {
3281
- const { document, dispatch, selectedTargetLanguages, translationMetadata } = useTranslationContext(), isDisabled = lang.id === document.language, isChecked = selectedTargetLanguages.includes(lang.id), hasTranslation = translationMetadata?.translations?.some((translation) => translation._key === lang.id), isStale = hasTranslation && translationMetadata?.translationHistory?.some(
3282
- (history) => history.language === lang.id && history.sourceRev !== document._rev
3283
- ), handleChange = () => {
2798
+ const { document, dispatch, selectedTargetLanguages } = useTranslationContext(), isDisabled = lang.id === document.language, isChecked = selectedTargetLanguages.includes(lang.id), handleChange = () => {
3284
2799
  if (isDisabled) return;
3285
2800
  const updatedLanguages = isChecked ? selectedTargetLanguages.filter((langId) => langId !== lang.id) : [...selectedTargetLanguages, lang.id];
3286
2801
  dispatch({
@@ -3298,16 +2813,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3298
2813
  disabled: isDisabled,
3299
2814
  tone: isChecked ? "primary" : "default",
3300
2815
  muted: !0,
3301
- children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, style: { cursor: "pointer" }, children: [
3302
- /* @__PURE__ */ jsxs(Inline, { space: 3, children: [
3303
- /* @__PURE__ */ jsx(Checkbox, { checked: isChecked, onChange: handleChange, label: lang.title, disabled: isDisabled }),
3304
- /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
3305
- /* @__PURE__ */ jsx(Text, { children: lang.title }),
3306
- isDisabled ? /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "The current language" }) : /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: hasTranslation && !isDisabled ? "Already translated" : "Not translated" })
3307
- ] })
3308
- ] }),
3309
- !isDisabled && hasTranslation && /* @__PURE__ */ jsx(Flex, { flex: 1, justify: "flex-end", children: /* @__PURE__ */ jsx(Badge, { tone: isStale ? "caution" : "positive", size: 3, muted: !0, children: isStale ? "Stale" : "Fresh" }) })
3310
- ] })
2816
+ children: /* @__PURE__ */ jsx(Flex, { align: "center", gap: 2, style: { cursor: "pointer" }, children: /* @__PURE__ */ jsxs(Inline, { space: 3, children: [
2817
+ /* @__PURE__ */ jsx(Checkbox, { checked: isChecked, onChange: handleChange, label: lang.title, disabled: isDisabled }),
2818
+ /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
2819
+ /* @__PURE__ */ jsx(Text, { children: lang.title }),
2820
+ isDisabled && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "The current language" })
2821
+ ] })
2822
+ ] }) })
3311
2823
  }
3312
2824
  );
3313
2825
  }, LanguageSelectionPanel = () => {
@@ -3315,6 +2827,134 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3315
2827
  documentId: document._id
3316
2828
  });
3317
2829
  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)) }) });
2830
+ }, ReleaseSelectionPanel = () => {
2831
+ const { dispatch, releaseConfig } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [releases, setReleases] = useState([]), [isLoading, setIsLoading] = useState(!1), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState("");
2832
+ useEffect(() => {
2833
+ (async () => {
2834
+ setIsLoading(!0), setError(null);
2835
+ try {
2836
+ const result = await client.fetch(
2837
+ 'releases::all()[state == "active"] | order(_createdAt desc)'
2838
+ );
2839
+ setReleases(result), devLog.debug("Fetched active releases", {
2840
+ count: result.length,
2841
+ component: "ReleaseSelectionPanel"
2842
+ });
2843
+ } catch (err) {
2844
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch releases";
2845
+ setError(errorMessage), devLog.error("Failed to fetch releases", err instanceof Error ? err : new Error(errorMessage), {
2846
+ component: "ReleaseSelectionPanel"
2847
+ });
2848
+ } finally {
2849
+ setIsLoading(!1);
2850
+ }
2851
+ })();
2852
+ }, [client]);
2853
+ const handleModeChange = useCallback(
2854
+ (mode) => {
2855
+ dispatch({
2856
+ type: "SET_RELEASE_CONFIG",
2857
+ payload: { mode }
2858
+ });
2859
+ },
2860
+ [dispatch]
2861
+ ), handleTitleChange = useCallback(
2862
+ (event) => {
2863
+ dispatch({
2864
+ type: "SET_RELEASE_CONFIG",
2865
+ payload: { newReleaseTitle: event.target.value }
2866
+ });
2867
+ },
2868
+ [dispatch]
2869
+ ), handleSelectRelease = useCallback(
2870
+ (releaseId) => {
2871
+ dispatch({
2872
+ type: "SET_RELEASE_CONFIG",
2873
+ payload: { existingReleaseId: releaseId }
2874
+ });
2875
+ },
2876
+ [dispatch]
2877
+ ), handleSearchChange = useCallback((event) => {
2878
+ setSearchQuery(event.target.value);
2879
+ }, []), filteredReleases = useMemo(() => {
2880
+ if (!searchQuery.trim())
2881
+ return releases;
2882
+ const query = searchQuery.toLowerCase();
2883
+ return releases.filter((release) => release.metadata.title.toLowerCase().includes(query));
2884
+ }, [releases, searchQuery]), formatDate = (dateString) => new Date(dateString).toLocaleDateString(void 0, {
2885
+ month: "short",
2886
+ day: "numeric",
2887
+ year: "numeric"
2888
+ });
2889
+ return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2890
+ /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2891
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, as: "label", style: { cursor: "pointer" }, onClick: () => handleModeChange("new"), children: [
2892
+ /* @__PURE__ */ jsx(Radio, { checked: releaseConfig.mode === "new", readOnly: !0 }),
2893
+ /* @__PURE__ */ jsx(Text, { weight: "medium", children: "Create new release" })
2894
+ ] }),
2895
+ /* @__PURE__ */ jsxs(
2896
+ Flex,
2897
+ {
2898
+ align: "center",
2899
+ gap: 3,
2900
+ as: "label",
2901
+ style: { cursor: "pointer" },
2902
+ onClick: () => handleModeChange("existing"),
2903
+ children: [
2904
+ /* @__PURE__ */ jsx(Radio, { checked: releaseConfig.mode === "existing", readOnly: !0 }),
2905
+ /* @__PURE__ */ jsx(Text, { weight: "medium", children: "Add to existing release" })
2906
+ ]
2907
+ }
2908
+ )
2909
+ ] }),
2910
+ releaseConfig.mode === "new" && /* @__PURE__ */ jsx(
2911
+ TextInput,
2912
+ {
2913
+ value: releaseConfig.newReleaseTitle,
2914
+ onChange: handleTitleChange,
2915
+ placeholder: "Release title..."
2916
+ }
2917
+ ),
2918
+ releaseConfig.mode === "existing" && /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2919
+ isLoading && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2920
+ /* @__PURE__ */ jsx(Spinner, { muted: !0 }),
2921
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Loading..." })
2922
+ ] }),
2923
+ error && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: error }),
2924
+ !isLoading && !error && releases.length === 0 && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "No active releases found." }),
2925
+ !isLoading && !error && releases.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
2926
+ /* @__PURE__ */ jsx(
2927
+ TextInput,
2928
+ {
2929
+ icon: SearchIcon,
2930
+ value: searchQuery,
2931
+ onChange: handleSearchChange,
2932
+ placeholder: "Search releases..."
2933
+ }
2934
+ ),
2935
+ /* @__PURE__ */ jsx(Box, { style: { maxHeight: "200px", overflowY: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 1, children: filteredReleases.length === 0 ? /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "No releases match your search." }) : filteredReleases.map((release) => {
2936
+ const releaseName = release._id.replace("_.releases.", ""), isSelected = releaseConfig.existingReleaseId === releaseName;
2937
+ return /* @__PURE__ */ jsx(
2938
+ Card,
2939
+ {
2940
+ padding: 3,
2941
+ radius: 2,
2942
+ tone: isSelected ? "primary" : "default",
2943
+ style: { cursor: "pointer" },
2944
+ onClick: () => handleSelectRelease(releaseName),
2945
+ border: !0,
2946
+ muted: isSelected,
2947
+ children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
2948
+ /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
2949
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
2950
+ ] })
2951
+ },
2952
+ release._id
2953
+ );
2954
+ }) }) })
2955
+ ] })
2956
+ ] })
2957
+ ] }) });
3318
2958
  }, TranslationModePanel = () => {
3319
2959
  const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
3320
2960
  if (!config?.openai?.enabled || !config?.openai?.apiKey)
@@ -3333,7 +2973,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3333
2973
  return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3334
2974
  /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3335
2975
  /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
3336
- /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
2976
+ /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "OpenAI Available" })
3337
2977
  ] }),
3338
2978
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3339
2979
  /* @__PURE__ */ jsx(
@@ -3363,32 +3003,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3363
3003
  /* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
3364
3004
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3365
3005
  /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3366
- /* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
3006
+ /* @__PURE__ */ jsx(Text, { children: "OpenAI Only" }),
3367
3007
  /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
3368
3008
  ] }),
3369
3009
  /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
3370
3010
  ] })
3371
3011
  ] })
3372
3012
  }
3373
- ),
3374
- /* @__PURE__ */ jsx(
3375
- Button,
3376
- {
3377
- type: "button",
3378
- mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
3379
- tone: translationMode === "sanity-with-review" ? "primary" : "default",
3380
- onClick: () => handleModeChange("sanity-with-review"),
3381
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3382
- /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
3383
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3384
- /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3385
- /* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
3386
- /* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
3387
- ] }),
3388
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
3389
- ] })
3390
- ] })
3391
- }
3392
3013
  )
3393
3014
  ] }),
3394
3015
  translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
@@ -3551,41 +3172,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3551
3172
  ] }),
3552
3173
  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
3553
3174
  ] });
3554
- }, WriteSettingsPanel = () => {
3555
- const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
3556
- dispatch({
3557
- type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
3558
- payload: !shouldOverwriteFreshTranslations
3559
- });
3560
- };
3561
- return /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3562
- /* @__PURE__ */ jsx(ReferenceConfigPanel, {}),
3563
- /* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
3564
- Button,
3565
- {
3566
- type: "button",
3567
- mode: shouldOverwriteFreshTranslations ? "ghost" : "bleed",
3568
- size: 2,
3569
- onClick: handleShouldOverwriteFreshTranslationsChange,
3570
- tone: shouldOverwriteFreshTranslations ? "primary" : "neutral",
3571
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3572
- /* @__PURE__ */ jsx(
3573
- Checkbox,
3574
- {
3575
- checked: shouldOverwriteFreshTranslations,
3576
- onChange: handleShouldOverwriteFreshTranslationsChange,
3577
- label: shouldOverwriteFreshTranslations ? "Disable" : "Enable"
3578
- }
3579
- ),
3580
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3581
- /* @__PURE__ */ jsx(Text, { children: "Overwrite Fresh Translations" }),
3582
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "All existing translations will be overwritten even if they are fresh." })
3583
- ] })
3584
- ] })
3585
- }
3586
- ) })
3587
- ] });
3588
- }, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3175
+ }, WriteSettingsPanel = () => /* @__PURE__ */ jsx(ReferenceConfigPanel, {}), TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3589
3176
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3590
3177
  /* @__PURE__ */ jsx(Label, { children: "Languages" }),
3591
3178
  /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
@@ -3594,16 +3181,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3594
3181
  /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
3595
3182
  /* @__PURE__ */ jsx(TranslationModePanel, {})
3596
3183
  ] }),
3184
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3185
+ /* @__PURE__ */ jsx(Label, { children: "Content Release" }),
3186
+ /* @__PURE__ */ jsx(ReleaseSelectionPanel, {})
3187
+ ] }),
3597
3188
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3598
3189
  /* @__PURE__ */ jsx(Label, { children: "Settings" }),
3599
3190
  /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
3600
3191
  ] }),
3601
3192
  /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
3602
3193
  ] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
3603
- const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
3604
- () => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
3605
- [overallStatus]
3606
- ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
3194
+ const { overallStatus, translationJobs, translationMode, dispatch, document } = useTranslationContext(), headline = useMemo(() => overallStatus === "completed" ? "Translation completed" : overallStatus === "error" ? "Translation failed" : "Translation in progress", [overallStatus]), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), isTerminalState = overallStatus === "completed" || overallStatus === "error", handleReset = useCallback(() => {
3195
+ forceCleanupActiveTranslation(document._id), dispatch({ type: "RESET_TRANSLATION_STATE" });
3196
+ }, [dispatch, document._id]), openaiStats = useMemo(() => {
3607
3197
  if (translationMode === "sanity-only") return null;
3608
3198
  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);
3609
3199
  return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
@@ -3637,17 +3227,12 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3637
3227
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3638
3228
  "Error: ",
3639
3229
  job.errorMessage
3640
- ] }) }) : null,
3641
- job.status === "retrying" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3642
- "Retrying after: ",
3643
- job.errorMessage
3644
3230
  ] }) }) : null
3645
3231
  ] })
3646
3232
  ] }),
3647
3233
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3648
3234
  job.status === "skipped" ? /* @__PURE__ */ jsx(SkippedBadge, {}) : null,
3649
- job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, { retryCount: job.retryCount }) : null,
3650
- job.status === "retrying" ? /* @__PURE__ */ jsx(RetryingBadge, { retryCount: job.retryCount }) : null,
3235
+ job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, {}) : null,
3651
3236
  job.targetId ? /* @__PURE__ */ jsx(
3652
3237
  TranslationOpenerButton,
3653
3238
  {
@@ -3664,18 +3249,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3664
3249
  /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
3665
3250
  /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
3666
3251
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3667
- "\u{1F4CA} Total Tokens: ",
3252
+ "Total Tokens: ",
3668
3253
  /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
3669
3254
  ] }),
3670
3255
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3671
- "\u{1F4B0} Total Cost: ",
3256
+ "Total Cost: ",
3672
3257
  /* @__PURE__ */ jsxs("strong", { children: [
3673
3258
  "$",
3674
3259
  openaiStats.totalCost.toFixed(4)
3675
3260
  ] })
3676
3261
  ] })
3677
3262
  ] })
3678
- ] }) })
3263
+ ] }) }),
3264
+ isTerminalState && /* @__PURE__ */ jsx(Flex, { justify: "flex-end", children: /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "primary", text: "Translate More", icon: ResetIcon, onClick: handleReset }) })
3679
3265
  ] });
3680
3266
  }, ProgressBar = ({ progress, max = 100 }) => {
3681
3267
  const percentage = Math.min(progress / max * 100, 100);
@@ -3727,35 +3313,20 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3727
3313
  content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "There was already a fresh translation for this document and language." }) }),
3728
3314
  children: /* @__PURE__ */ jsx(Badge, { tone: "neutral", children: "Skipped" })
3729
3315
  }
3730
- ), ErrorBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3731
- Tooltip,
3732
- {
3733
- portal: !0,
3734
- content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: retryCount && retryCount > 0 ? `This translation failed after ${retryCount} retry attempt${retryCount > 1 ? "s" : ""}.` : "This translation failed. Check the error message for details." }) }),
3735
- children: /* @__PURE__ */ jsxs(Badge, { tone: "critical", children: [
3736
- "Failed",
3737
- retryCount && retryCount > 0 ? ` (${retryCount} retries)` : ""
3738
- ] })
3739
- }
3740
- ), RetryingBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3316
+ ), ErrorBadge = () => /* @__PURE__ */ jsx(
3741
3317
  Tooltip,
3742
3318
  {
3743
3319
  portal: !0,
3744
- content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Retrying translation after a temporary failure." }) }),
3745
- children: /* @__PURE__ */ jsxs(Badge, { tone: "caution", children: [
3746
- "Retry ",
3747
- retryCount || 1
3748
- ] })
3320
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
3321
+ children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
3749
3322
  }
3750
3323
  ), getStatusLabel = (status) => ({
3751
3324
  idle: "Idle",
3752
3325
  queued: "Queued...",
3753
3326
  preparing_translation: "Preparing translation...",
3754
3327
  translating: "Translating with Sanity Agent...",
3755
- translating_with_openai: "Translating with GPT-5...",
3756
- reviewing_with_openai: "Reviewing translation with GPT-5...",
3328
+ translating_with_openai: "Translating with OpenAI...",
3757
3329
  updating_metadata: "Saving metadata...",
3758
- retrying: "Retrying...",
3759
3330
  error: "Error",
3760
3331
  skipped: "Skipped",
3761
3332
  completed: "Completed"
@@ -3763,14 +3334,30 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3763
3334
  "translating",
3764
3335
  "linking_references",
3765
3336
  "completed"
3766
- ], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen }) => {
3337
+ ], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen, onStatusChange }) => {
3767
3338
  const { overallStatus, document } = useTranslationContext();
3768
- return STATES_IN_WHICH_TO_SHOW_CONFIG.includes(overallStatus) ? /* @__PURE__ */ jsx(TranslationConfigView, { setIsDialogOpen }) : STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS.includes(overallStatus) ? /* @__PURE__ */ jsx(TranslationStatusPanel, {}) : STATES_IN_WHICH_TO_SHOW_ERROR.includes(overallStatus) ? /* @__PURE__ */ jsx("div", { children: "Error occurred" }) : (devLog.warn("Unhandled translation dialog state", {
3339
+ return useEffect(() => {
3340
+ onStatusChange?.(overallStatus);
3341
+ }, [overallStatus, onStatusChange]), STATES_IN_WHICH_TO_SHOW_CONFIG.includes(overallStatus) ? /* @__PURE__ */ jsx(TranslationConfigView, { setIsDialogOpen }) : STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS.includes(overallStatus) ? /* @__PURE__ */ jsx(TranslationStatusPanel, {}) : STATES_IN_WHICH_TO_SHOW_ERROR.includes(overallStatus) ? /* @__PURE__ */ jsx("div", { children: "Error occurred" }) : (devLog.warn("Unhandled translation dialog state", {
3769
3342
  documentId: document._id,
3770
3343
  overallStatus
3771
3344
  }), /* @__PURE__ */ jsx("div", { children: "Unhandled overall state" }));
3772
- }, getTranslateAction = (config) => ({ draft, published }) => {
3773
- const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1);
3345
+ }, IN_PROGRESS_STATUSES = ["translating", "linking_references"], getButtonLabel = (status) => status === "completed" ? "Translation Complete" : status && IN_PROGRESS_STATUSES.includes(status) ? "Translating..." : "Translate", getTranslateAction = (config) => ({ draft, published }) => {
3346
+ const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1), [currentStatus, setCurrentStatus] = useState(null), isTranslating = currentStatus !== null && IN_PROGRESS_STATUSES.includes(currentStatus), handleStatusChange = useCallback((status) => {
3347
+ setCurrentStatus(status);
3348
+ }, []), handleClose = useCallback(() => {
3349
+ if (isTranslating) {
3350
+ if (!window.confirm(
3351
+ "Translation is in progress. Closing this dialog will not stop the translation, but refreshing the page will cancel the translation. You can reopen the Translate dialog to see updated progress."
3352
+ ))
3353
+ return;
3354
+ devLog.warn("User closed dialog during active translation", {
3355
+ documentId: document?._id,
3356
+ component: "TranslateAction"
3357
+ });
3358
+ }
3359
+ setIsDialogOpen(!1);
3360
+ }, [isTranslating, document?._id]);
3774
3361
  return devLog.debug("TranslateAction initialized", {
3775
3362
  documentId: document?._id,
3776
3363
  documentType: document?._type
@@ -3778,7 +3365,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3778
3365
  documentType: document?._type,
3779
3366
  translatableTypes: config.translatableSchemaTypes
3780
3367
  }), null) : config ? {
3781
- label: "Translate",
3368
+ label: getButtonLabel(currentStatus),
3782
3369
  icon: TranslateIcon,
3783
3370
  onHandle: () => {
3784
3371
  devLog.info("Translation dialog opened", {
@@ -3789,9 +3376,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3789
3376
  },
3790
3377
  dialog: isDialogOpen && {
3791
3378
  type: "dialog",
3792
- onClose: () => setIsDialogOpen(!1),
3379
+ onClose: handleClose,
3793
3380
  header: "Translate Document",
3794
- content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen }) })
3381
+ content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen, onStatusChange: handleStatusChange }) })
3795
3382
  }
3796
3383
  } : (devLog.error("Missing config in TranslateAction"), null);
3797
3384
  }, translationGlossaryTerm = defineType({
@@ -3907,7 +3494,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3907
3494
  defineField({
3908
3495
  name: "changeHistory",
3909
3496
  title: "Change History",
3910
- description: "History of changes made via user feedback.",
3497
+ description: "History of changes made to translation settings.",
3911
3498
  type: "array",
3912
3499
  readOnly: !0,
3913
3500
  group: "history",
@@ -4217,7 +3804,7 @@ const aiTranslationPlugin = definePlugin((config) => (config.fieldTransforms?.le
4217
3804
  })), {
4218
3805
  name: "sanity-plugin-ai-translation",
4219
3806
  document: {
4220
- actions: [getTranslateAction(config), getGiveTranslationFeedbackAction(config)]
3807
+ actions: [getTranslateAction(config)]
4221
3808
  },
4222
3809
  schema: {
4223
3810
  types: [translationSettings, translationGlossaryTerm]