@braze/sanity-plugin-ai-translation 0.2.4 → 0.2.6

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, useRef, 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,66 @@ 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
+ }, undraftId = (id) => id.replace(/^drafts\./, ""), 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 = `${undraftId(job._id)}_${job.language}`, existingJob = entry.state.translationJobs.get(jobKey), jobWithDefinedValues = Object.fromEntries(
84
+ Object.entries(job).filter(([_, value]) => value !== void 0)
85
+ ), mergedJob = existingJob ? { ...existingJob, ...jobWithDefinedValues } : jobWithDefinedValues;
86
+ entry.state.translationJobs.set(jobKey, mergedJob), notifySubscribers(entry);
87
+ }
88
+ }, getActiveTranslation = (documentId) => activeTranslations.get(documentId)?.state ?? null, subscribeToActiveTranslation = (documentId, callback) => {
89
+ const entry = activeTranslations.get(documentId);
90
+ return entry ? (entry.subscribers.add(callback), callback(entry.state), () => {
91
+ entry.subscribers.delete(callback);
92
+ }) : () => {
93
+ };
94
+ }, cleanupActiveTranslation = (documentId, delayMs = 6e4) => {
95
+ setTimeout(() => {
96
+ const entry = activeTranslations.get(documentId);
97
+ entry && ["completed", "error"].includes(entry.state.overallStatus) && activeTranslations.delete(documentId);
98
+ }, delayMs);
99
+ }, notifySubscribers = (entry) => {
100
+ for (const subscriber of entry.subscribers)
101
+ try {
102
+ subscriber(entry.state);
103
+ } catch (error) {
104
+ console.error("Error in active translation subscriber:", error);
105
+ }
106
+ }, forceCleanupActiveTranslation = (documentId) => {
107
+ activeTranslations.delete(documentId);
65
108
  }, TranslationContext = createContext({
66
109
  document: null,
67
110
  config: null,
68
111
  overallStatus: "idle",
69
112
  selectedTargetLanguages: [],
70
113
  translationJobs: /* @__PURE__ */ new Map(),
71
- shouldOverwriteFreshTranslations: !1,
72
114
  shouldTranslateReferences: !1,
73
115
  maxReferenceDepth: 1,
74
116
  references: [],
117
+ selectedReferenceIds: /* @__PURE__ */ new Set(),
75
118
  translationMetadata: null,
76
119
  translationMode: "sanity-only",
77
120
  openaiValidated: !1,
121
+ releaseConfig: {
122
+ mode: "new",
123
+ newReleaseTitle: ""
124
+ },
78
125
  dispatch: () => {
79
126
  }
80
127
  }), translationReducer = (context, action) => {
@@ -85,7 +132,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
85
132
  };
86
133
  switch (devLog.debug("Translation action dispatched", {
87
134
  ...logContext,
88
- payload: action.payload
135
+ payload: "payload" in action ? action.payload : void 0
89
136
  }), action.type) {
90
137
  case "SET_OVERALL_STATUS":
91
138
  return { ...context, overallStatus: action.payload };
@@ -116,48 +163,106 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
116
163
  ...context,
117
164
  maxReferenceDepth: action.payload
118
165
  };
119
- case "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS":
166
+ case "SET_REFERENCES":
120
167
  return {
121
168
  ...context,
122
- shouldOverwriteFreshTranslations: action.payload
169
+ references: action.payload
123
170
  };
124
- case "SET_REFERENCES":
171
+ case "SET_SELECTED_REFERENCE_IDS":
125
172
  return {
126
173
  ...context,
127
- references: action.payload
174
+ selectedReferenceIds: action.payload
128
175
  };
129
176
  case "SET_TRANSLATION_JOB_STATUS": {
130
- const jobKey = `${action.payload._id}_${action.payload.language}`, newTranslationJobs = new Map(context.translationJobs), existingJob = newTranslationJobs.get(jobKey), mergedJob = defaults(action.payload, existingJob);
177
+ if (!action.payload._id)
178
+ return devLog.warn("SET_TRANSLATION_JOB_STATUS action missing _id", {
179
+ ...logContext,
180
+ payload: action.payload
181
+ }), context;
182
+ const jobKey = `${undraftId(action.payload._id)}_${action.payload.language}`, newTranslationJobs = new Map(context.translationJobs), existingJob = newTranslationJobs.get(jobKey), mergedJob = defaults(action.payload, existingJob);
131
183
  return newTranslationJobs.set(jobKey, mergedJob), {
132
184
  ...context,
133
185
  translationJobs: newTranslationJobs
134
186
  };
135
187
  }
188
+ case "SET_RELEASE_CONFIG":
189
+ return {
190
+ ...context,
191
+ releaseConfig: {
192
+ ...context.releaseConfig,
193
+ ...action.payload
194
+ }
195
+ };
196
+ case "RESET_TRANSLATION_STATE":
197
+ return devLog.info("Resetting translation state for new translation", {
198
+ ...logContext,
199
+ previousStatus: context.overallStatus,
200
+ previousJobCount: context.translationJobs.size
201
+ }), {
202
+ ...context,
203
+ overallStatus: "idle",
204
+ translationJobs: /* @__PURE__ */ new Map(),
205
+ selectedReferenceIds: /* @__PURE__ */ new Set(),
206
+ // Clear any release conflicts from previous run
207
+ releaseConfig: {
208
+ ...context.releaseConfig,
209
+ conflicts: void 0,
210
+ replaceExisting: void 0
211
+ }
212
+ };
136
213
  default:
137
214
  return context;
138
215
  }
139
- }, TranslationContextProvider = ({ document, config, children }) => {
216
+ }, generateDefaultReleaseTitle = (document) => `[AI Translation] ${document.title || document.name || document._id.split("-").slice(0, 2).join("-")}`, TranslationContextProvider = ({ document, config, children }) => {
140
217
  devLog.debug("TranslationContextProvider initialized", {
141
218
  documentId: document._id,
142
219
  documentType: document._type,
143
220
  supportedLanguages: config.supportedLanguages?.map((l) => l.id),
144
221
  component: "TranslationContextProvider"
145
222
  });
146
- const initialContext = {
223
+ const defaultReleaseTitle = generateDefaultReleaseTitle(document), activeTranslation = getActiveTranslation(document._id), initialContext = {
147
224
  document,
148
225
  config,
149
- overallStatus: "idle",
226
+ // If there's an active translation, restore its state
227
+ overallStatus: activeTranslation?.overallStatus ?? "idle",
150
228
  translationMetadata: null,
151
229
  selectedTargetLanguages: [],
152
- shouldOverwriteFreshTranslations: !1,
153
230
  shouldTranslateReferences: !1,
154
231
  maxReferenceDepth: 1,
155
232
  references: [],
156
- translationJobs: /* @__PURE__ */ new Map(),
233
+ selectedReferenceIds: /* @__PURE__ */ new Set(),
234
+ // Restore jobs from active translation if available
235
+ translationJobs: activeTranslation?.translationJobs ?? /* @__PURE__ */ new Map(),
157
236
  translationMode: config?.openai?.defaultMode || "sanity-only",
158
- openaiValidated: !1
159
- }, [context, dispatch] = useReducer(translationReducer, initialContext);
237
+ openaiValidated: !1,
238
+ releaseConfig: {
239
+ mode: "new",
240
+ newReleaseTitle: defaultReleaseTitle
241
+ }
242
+ };
243
+ activeTranslation && devLog.info("Reconnecting to active translation", {
244
+ documentId: document._id,
245
+ status: activeTranslation.overallStatus,
246
+ jobCount: activeTranslation.translationJobs.size,
247
+ component: "TranslationContextProvider"
248
+ });
249
+ const [context, dispatch] = useReducer(translationReducer, initialContext), lastProcessedRefsRef = useRef("");
160
250
  return useEffect(() => {
251
+ const handleActiveTranslationUpdate = (state) => {
252
+ dispatch({
253
+ type: "SET_OVERALL_STATUS",
254
+ payload: state.overallStatus
255
+ });
256
+ for (const job of state.translationJobs.values())
257
+ dispatch({
258
+ type: "SET_TRANSLATION_JOB_STATUS",
259
+ payload: job
260
+ });
261
+ }, unsubscribe = subscribeToActiveTranslation(document._id, handleActiveTranslationUpdate);
262
+ return () => {
263
+ unsubscribe();
264
+ };
265
+ }, [document._id]), useEffect(() => {
161
266
  (async () => {
162
267
  if (config?.openai?.enabled && config?.openai?.apiKey)
163
268
  try {
@@ -181,7 +286,28 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
181
286
  });
182
287
  }
183
288
  })();
184
- }, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), devLog.debug("Translation context state initialized", {
289
+ }, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), useEffect(() => {
290
+ const currentRefsKey = context.references.map((ref) => ref._id).sort().join(",");
291
+ if (currentRefsKey !== lastProcessedRefsRef.current) {
292
+ if (context.references.length > 0) {
293
+ const currentReferenceIds = new Set(context.references.map((ref) => ref._id)), previousReferenceIds = new Set(
294
+ lastProcessedRefsRef.current ? lastProcessedRefsRef.current.split(",") : []
295
+ ), hasNewReferences = Array.from(currentReferenceIds).some((id) => !previousReferenceIds.has(id)), currentSelection = context.selectedReferenceIds;
296
+ if (currentSelection.size === 0 || hasNewReferences) {
297
+ const newSelection = new Set(currentSelection);
298
+ currentReferenceIds.forEach((id) => {
299
+ newSelection.add(id);
300
+ });
301
+ const currentIds = Array.from(currentSelection).sort(), newIds = Array.from(newSelection).sort();
302
+ (currentIds.length !== newIds.length || !currentIds.every((id, i) => id === newIds[i])) && dispatch({
303
+ type: "SET_SELECTED_REFERENCE_IDS",
304
+ payload: newSelection
305
+ });
306
+ }
307
+ }
308
+ lastProcessedRefsRef.current = currentRefsKey;
309
+ }
310
+ }, [context.references, context.selectedReferenceIds]), devLog.debug("Translation context state initialized", {
185
311
  documentId: document._id,
186
312
  overallStatus: context.overallStatus,
187
313
  selectedLanguages: context.selectedTargetLanguages,
@@ -196,1044 +322,238 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
196
322
  "useTranslationContext must be used within a TranslationContextProvider with document and config props"
197
323
  );
198
324
  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
325
+ }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
326
+ (translation) => translation._key === targetLanguage
327
+ )?.value._ref || null, TRANSLATION_METADATA_TYPE = "translation.metadata", getTranslationMetadata = async ({
328
+ client,
329
+ sourceDocument,
330
+ sourceLanguage
331
+ }) => {
332
+ try {
333
+ return await client.fetch(
334
+ "*[_type == $tmType && references($sourceDocumentId)][0]",
335
+ {
336
+ tmType: TRANSLATION_METADATA_TYPE,
337
+ sourceDocumentId: undraftId(sourceDocument._id)
338
+ }
339
+ ) || await client.create({
340
+ _type: TRANSLATION_METADATA_TYPE,
341
+ translations: [
342
+ {
343
+ _key: sourceLanguage,
344
+ _type: "internationalizedArrayReferenceValue",
345
+ value: {
346
+ _type: "reference",
347
+ _ref: undraftId(sourceDocument._id),
348
+ _weak: !0,
349
+ _strengthenOnPublish: {
350
+ _type: sourceDocument._type,
351
+ _weak: !1
352
+ }
353
+ }
354
+ }
355
+ ],
356
+ translationHistory: []
357
+ });
358
+ } catch (error) {
359
+ throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
360
+ }
361
+ }, checkDocumentConflicts = async ({
362
+ client,
363
+ releaseId,
364
+ documentId,
365
+ documentTitle,
366
+ documentType,
367
+ targetLanguages,
368
+ languageFieldName
369
+ }) => {
370
+ const conflicts = [], document = await client.withConfig({ perspective: "drafts" }).getDocument(documentId);
371
+ if (!document)
372
+ return conflicts;
373
+ const sourceLanguage = document[languageFieldName], translationMetadata = await getTranslationMetadata({
374
+ client,
375
+ sourceDocument: document,
376
+ sourceLanguage
206
377
  });
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;
378
+ for (const targetLanguage of targetLanguages) {
379
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
380
+ if (existingTranslationId) {
381
+ const versionedId = `versions.${releaseId}.${existingTranslationId}`;
382
+ await client.getDocument(versionedId) && (conflicts.push({
383
+ sourceDocumentId: documentId,
384
+ sourceDocumentTitle: documentTitle || document.title || document.name,
385
+ sourceDocumentType: documentType || document._type,
386
+ targetLanguage,
387
+ existingVersionId: versionedId
388
+ }), devLog.debug("Found existing version in release", {
389
+ releaseId,
390
+ sourceDocumentId: documentId,
391
+ targetLanguage,
392
+ versionedId
393
+ }));
219
394
  }
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
395
  }
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({
396
+ return conflicts;
397
+ }, checkReleaseConflicts = async ({
367
398
  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) {
399
+ releaseId,
400
+ sourceDocument,
401
+ targetLanguages,
402
+ languageFieldName,
403
+ references = []
404
+ }) => {
405
+ const allConflicts = [];
562
406
  try {
563
- const response = await client.agent.action.prompt({
564
- instruction: prompt,
565
- format: "json"
407
+ const documentsToCheck = [
408
+ {
409
+ _id: sourceDocument._id,
410
+ _type: sourceDocument._type,
411
+ title: sourceDocument.title || sourceDocument.name
412
+ },
413
+ ...references.map((ref) => ({
414
+ _id: ref._id,
415
+ _type: ref._type,
416
+ title: ref.title
417
+ }))
418
+ ], conflictChecks = await Promise.all(
419
+ documentsToCheck.map(
420
+ (doc) => checkDocumentConflicts({
421
+ client,
422
+ releaseId,
423
+ documentId: doc._id,
424
+ documentTitle: doc.title,
425
+ documentType: doc._type,
426
+ targetLanguages,
427
+ languageFieldName
428
+ })
429
+ )
430
+ );
431
+ for (const conflicts of conflictChecks)
432
+ allConflicts.push(...conflicts);
433
+ devLog.debug("Release conflict check complete", {
434
+ releaseId,
435
+ documentsChecked: documentsToCheck.length,
436
+ conflictsFound: allConflicts.length
566
437
  });
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
438
  } catch (error) {
573
- throw devLog.error("Error during AI analysis", { error }), new Error(`Failed to analyze feedback: ${error instanceof Error ? error.message : "Unknown error"}`);
439
+ devLog.error("Error checking release conflicts", error instanceof Error ? error : new Error(String(error)), {
440
+ releaseId,
441
+ sourceDocumentId: sourceDocument._id,
442
+ targetLanguages,
443
+ referencesCount: references.length
444
+ });
574
445
  }
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");
446
+ return allConflicts;
447
+ };
448
+ function isNetworkError(error) {
449
+ if (!error) return !1;
450
+ if (error instanceof TypeError) {
451
+ const message = error.message.toLowerCase();
452
+ return message.includes("fetch") || message.includes("network") || message.includes("connection") || message.includes("failed to fetch") || message.includes("networkerror");
600
453
  }
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));
454
+ if (error && typeof error == "object" && "code" in error) {
455
+ const code = error.code;
456
+ return ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "ECONNREFUSED", "EHOSTUNREACH"].includes(code);
618
457
  }
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));
458
+ if (error && typeof error == "object" && "response" in error) {
459
+ const response = error.response;
460
+ if (response?.status)
461
+ return response.status >= 500 || response.status === 429;
630
462
  }
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
- );
463
+ if (error instanceof Error) {
464
+ const message = error.message;
465
+ if (/\b(50[0-9]|429)\b/.test(message))
466
+ return !0;
467
+ }
468
+ return !1;
637
469
  }
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();
470
+ function isSanityMutationError(error) {
471
+ if (!error) return !1;
472
+ if (error instanceof Error) {
473
+ const message = error.message.toLowerCase();
474
+ return [
475
+ "validation",
476
+ "schema",
477
+ "invalid document",
478
+ "invalid field",
479
+ "type mismatch",
480
+ "reference",
481
+ "patch",
482
+ "mutation",
483
+ "document structure",
484
+ "required field",
485
+ "field type",
486
+ "sanity"
487
+ ].some((pattern) => message.includes(pattern));
656
488
  }
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();
489
+ if (error && typeof error == "object") {
490
+ const errorObj = error;
491
+ if (("statusCode" in errorObj || "response" in errorObj || "details" in errorObj) && "statusCode" in errorObj) {
492
+ const statusCode = errorObj.statusCode;
493
+ if (statusCode >= 400 && statusCode < 500 && statusCode !== 429)
494
+ return !0;
679
495
  }
680
496
  }
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 }));
497
+ return !1;
697
498
  }
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
- });
499
+ const DEFAULT_RETRY_OPTIONS = {
500
+ maxAttempts: 2,
501
+ initialDelayMs: 0,
502
+ maxDelayMs: 5e3,
503
+ backoffMultiplier: 2,
504
+ retryableErrors: isNetworkError,
505
+ logRetries: !0
506
+ };
507
+ function calculateDelay(attempt, options) {
508
+ const delay = options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt);
509
+ return Math.min(delay, options.maxDelayMs);
752
510
  }
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();
511
+ function sleep(ms) {
512
+ return new Promise((resolve) => setTimeout(resolve, ms));
763
513
  }
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
792
- };
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);
514
+ async function retryWithBackoff(operation, options = {}) {
515
+ const opts = {
516
+ ...DEFAULT_RETRY_OPTIONS,
517
+ ...options,
518
+ retryableErrors: options.retryableErrors || DEFAULT_RETRY_OPTIONS.retryableErrors
862
519
  };
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("");
1024
- 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");
520
+ let lastError;
521
+ const maxAttempts = opts.maxAttempts + 1;
522
+ for (let attempt = 1; attempt <= maxAttempts; attempt++)
1065
523
  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,
524
+ const result = await operation();
525
+ return attempt > 1 && opts.logRetries && devLog.info(`Operation succeeded after ${attempt} attempts`, {
526
+ ...opts.context,
527
+ attempt,
528
+ totalAttempts: attempt
529
+ }), result;
530
+ } catch (error) {
531
+ if (lastError = error, !opts.retryableErrors(error))
532
+ throw opts.logRetries && devLog.warn("Error is not retryable, aborting retries", {
533
+ ...opts.context,
534
+ attempt,
535
+ error: error instanceof Error ? error.message : String(error)
536
+ }), error;
537
+ if (attempt >= maxAttempts)
538
+ throw opts.logRetries && devLog.error(
539
+ `Operation failed after ${maxAttempts} attempts`,
540
+ error instanceof Error ? error : new Error(String(error)),
1167
541
  {
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
542
+ ...opts.context,
543
+ totalAttempts: maxAttempts
1174
544
  }
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 }) })
545
+ ), error;
546
+ const delay = calculateDelay(attempt - 1, opts);
547
+ opts.logRetries && devLog.warn(`Operation failed, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`, {
548
+ ...opts.context,
549
+ attempt,
550
+ totalAttempts: maxAttempts,
551
+ delayMs: delay,
552
+ error: error instanceof Error ? error.message : String(error)
553
+ }), delay > 0 && await sleep(delay);
1234
554
  }
1235
- } : (devLog.error("Missing config in TranslateAction"), null);
1236
- };
555
+ throw lastError;
556
+ }
1237
557
  class Semaphore {
1238
558
  permits;
1239
559
  queue = [];
@@ -1584,13 +904,27 @@ const fetchTranslationPrompts = async ({
1584
904
  }
1585
905
  }), hasChanges = !0);
1586
906
  }
1587
- hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
907
+ hasChanges && (await retryWithBackoff(
908
+ async () => {
909
+ await patch.commit();
910
+ },
911
+ {
912
+ maxAttempts: 2,
913
+ initialDelayMs: 0,
914
+ retryableErrors: isNetworkError,
915
+ context: {
916
+ operation: "localizeReferences",
917
+ documentId,
918
+ referenceCount: refs.length
919
+ }
920
+ }
921
+ ), devLog.info("Updated references in batch", {
1588
922
  documentId,
1589
923
  count: refs.length,
1590
924
  translatedCount: translationMap.size,
1591
925
  component: "updateReferences"
1592
926
  }));
1593
- }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
927
+ }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages, mainSourceDocumentId) => {
1594
928
  for (const sourceDocument of sourceDocuments)
1595
929
  for (const lang of targetLanguages) {
1596
930
  const payload = {
@@ -1603,146 +937,8 @@ const fetchTranslationPrompts = async ({
1603
937
  dispatch({
1604
938
  type: "SET_TRANSLATION_JOB_STATUS",
1605
939
  payload
1606
- });
1607
- }
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);
940
+ }), mainSourceDocumentId && updateActiveTranslationJob(mainSourceDocumentId, payload);
1706
941
  }
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
942
  }, updateTranslationMetadata = async ({
1747
943
  client,
1748
944
  targetLanguage,
@@ -1776,11 +972,71 @@ const getExistingTranslationId = (translationMetadata, targetLanguage) => transl
1776
972
  targetRev: newDocument._rev,
1777
973
  targetSnapshot: JSON.stringify(newDocument)
1778
974
  }, 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();
975
+ isNewTranslation ? await retryWithBackoff(
976
+ async () => {
977
+ await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit();
978
+ },
979
+ {
980
+ maxAttempts: 2,
981
+ initialDelayMs: 0,
982
+ retryableErrors: isNetworkError,
983
+ context: {
984
+ operation: "updateTranslationMetadata.translations",
985
+ sourceDocumentId: sourceDocument._id,
986
+ targetLanguage,
987
+ isNewTranslation: !0
988
+ }
989
+ }
990
+ ) : await retryWithBackoff(
991
+ async () => {
992
+ await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
993
+ },
994
+ {
995
+ maxAttempts: 2,
996
+ initialDelayMs: 0,
997
+ retryableErrors: isNetworkError,
998
+ context: {
999
+ operation: "updateTranslationMetadata.translations",
1000
+ sourceDocumentId: sourceDocument._id,
1001
+ targetLanguage,
1002
+ isNewTranslation: !1
1003
+ }
1004
+ }
1005
+ );
1780
1006
  const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1781
1007
  translationMetadata.translationHistory?.some(
1782
1008
  (history) => history.language === targetLanguage
1783
- ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
1009
+ ) ? await retryWithBackoff(
1010
+ async () => {
1011
+ await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit();
1012
+ },
1013
+ {
1014
+ maxAttempts: 2,
1015
+ initialDelayMs: 0,
1016
+ retryableErrors: isNetworkError,
1017
+ context: {
1018
+ operation: "updateTranslationMetadata.history",
1019
+ sourceDocumentId: sourceDocument._id,
1020
+ targetLanguage,
1021
+ isNewHistory: !1
1022
+ }
1023
+ }
1024
+ ) : await retryWithBackoff(
1025
+ async () => {
1026
+ await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
1027
+ },
1028
+ {
1029
+ maxAttempts: 2,
1030
+ initialDelayMs: 0,
1031
+ retryableErrors: isNetworkError,
1032
+ context: {
1033
+ operation: "updateTranslationMetadata.history",
1034
+ sourceDocumentId: sourceDocument._id,
1035
+ targetLanguage,
1036
+ isNewHistory: !0
1037
+ }
1038
+ }
1039
+ );
1784
1040
  } catch (error) {
1785
1041
  throw devLog.error("Error updating translation metadata", error instanceof Error ? error : new Error(String(error)), {
1786
1042
  sourceDocumentId: sourceDocument._id,
@@ -1789,15 +1045,16 @@ const getExistingTranslationId = (translationMetadata, targetLanguage) => transl
1789
1045
  component: "updateTranslationMetadata"
1790
1046
  }), error;
1791
1047
  }
1792
- }, setJobStatus = (dispatch, _id, language, status, payload) => {
1048
+ }, setJobStatus = (dispatch, _id, language, status, payload, sourceDocumentId) => {
1049
+ const job = defaults(payload, {
1050
+ _id,
1051
+ language,
1052
+ status
1053
+ });
1793
1054
  dispatch({
1794
1055
  type: "SET_TRANSLATION_JOB_STATUS",
1795
- payload: defaults(payload, {
1796
- _id,
1797
- language,
1798
- status
1799
- })
1800
- });
1056
+ payload: job
1057
+ }), sourceDocumentId && updateActiveTranslationJob(sourceDocumentId, job);
1801
1058
  };
1802
1059
  function shouldExcludeField(fieldPath, excludeFields) {
1803
1060
  for (const pattern of excludeFields) {
@@ -2030,7 +1287,9 @@ function restoreTransforms({
2030
1287
  schemaType,
2031
1288
  document: translatedDocument
2032
1289
  };
2033
- devLog.debug("Restoring transform for field", { fieldPath, schemaType }), restoredObj[key] = transform.fromTranslatable(value, originalValue, context);
1290
+ devLog.debug("Restoring transform for field", { fieldPath, schemaType });
1291
+ const restoredValue = transform.fromTranslatable(value, originalValue, context);
1292
+ restoredObj[key] = restoredValue;
2034
1293
  } else
2035
1294
  restoredObj[key] = restoreTransforms({
2036
1295
  translatedDocument: value,
@@ -2123,14 +1382,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2123
1382
  }), devLog.debug("OpenAI GPT-5 Full Prompt", {
2124
1383
  messages: request.messages
2125
1384
  });
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}`
1385
+ const response = await retryWithBackoff(
1386
+ async () => {
1387
+ const fetchResponse = await fetch("https://api.openai.com/v1/responses", {
1388
+ method: "POST",
1389
+ headers: {
1390
+ "Content-Type": "application/json",
1391
+ Authorization: `Bearer ${apiKey}`
1392
+ },
1393
+ body: JSON.stringify(requestBody)
1394
+ });
1395
+ if (!fetchResponse.ok) {
1396
+ const errorMessage = `GPT-5 API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
1397
+ throw errorObj.response = { status: fetchResponse.status }, errorObj;
1398
+ }
1399
+ return fetchResponse;
2131
1400
  },
2132
- body: JSON.stringify(requestBody)
2133
- });
1401
+ {
1402
+ maxAttempts: 2,
1403
+ initialDelayMs: 0,
1404
+ retryableErrors: isNetworkError,
1405
+ context: { model: requestBody.model, endpoint: "responses" }
1406
+ }
1407
+ );
2134
1408
  if (!response.ok) {
2135
1409
  const error = await response.json();
2136
1410
  throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
@@ -2169,14 +1443,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2169
1443
  }), devLog.debug("OpenAI Chat Completions Full Prompt", {
2170
1444
  messages: request.messages
2171
1445
  });
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}`
1446
+ const response = await retryWithBackoff(
1447
+ async () => {
1448
+ const fetchResponse = await fetch("https://api.openai.com/v1/chat/completions", {
1449
+ method: "POST",
1450
+ headers: {
1451
+ "Content-Type": "application/json",
1452
+ Authorization: `Bearer ${apiKey}`
1453
+ },
1454
+ body: JSON.stringify(requestBody)
1455
+ });
1456
+ if (!fetchResponse.ok) {
1457
+ const errorMessage = `OpenAI API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
1458
+ throw errorObj.response = { status: fetchResponse.status }, errorObj;
1459
+ }
1460
+ return fetchResponse;
2177
1461
  },
2178
- body: JSON.stringify(requestBody)
2179
- });
1462
+ {
1463
+ maxAttempts: 2,
1464
+ initialDelayMs: 0,
1465
+ retryableErrors: isNetworkError,
1466
+ context: { model, endpoint: "chat/completions" }
1467
+ }
1468
+ );
2180
1469
  if (!response.ok) {
2181
1470
  const error = await response.json();
2182
1471
  throw new Error(`OpenAI API error: ${error.error?.message || "Unknown error"}`);
@@ -2238,16 +1527,63 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2238
1527
  }, modelPricing = pricing[model] || pricing["gpt-5"], cachedInputRate = model === "gpt-5.1" ? 0.125 / 1e6 : modelPricing.input;
2239
1528
  return Math.max(0, tokens.prompt - tokens.cached) * modelPricing.input + tokens.cached * cachedInputRate + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
2240
1529
  }, deserializeDocument = (originalDoc, reviewedText) => {
2241
- const parsed = parseReviewFormat(reviewedText), result = cloneDeep(originalDoc);
1530
+ devLog.debug("Raw GPT-5 response received", {
1531
+ length: reviewedText.length,
1532
+ preview: reviewedText.substring(0, 1e3),
1533
+ hasReasoning: reviewedText.includes("<reasoning>"),
1534
+ hasCodeBlocks: reviewedText.includes("```")
1535
+ });
1536
+ const cleanedText = preprocessResponse(reviewedText);
1537
+ cleanedText.length !== reviewedText.length && devLog.debug("Response after preprocessing", {
1538
+ originalLength: reviewedText.length,
1539
+ cleanedLength: cleanedText.length,
1540
+ preview: cleanedText.substring(0, 500)
1541
+ });
1542
+ const parsed = parseReviewFormat(cleanedText, originalDoc);
1543
+ devLog.debug("Parsed response structure", {
1544
+ rootNodeCount: parsed.length,
1545
+ rootNodeNames: parsed.map((n) => n.type === "field" && n.name ? n.name : "unknown")
1546
+ });
1547
+ const result = cloneDeep(originalDoc);
2242
1548
  return applyTextChanges(result, parsed), result;
2243
- }, parseReviewFormat = (text) => {
1549
+ }, preprocessResponse = (text) => {
1550
+ const originalLength = text.length;
1551
+ let cleaned = text;
1552
+ const beforeReasoning = cleaned.length;
1553
+ cleaned = cleaned.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, "");
1554
+ const reasoningRemoved = beforeReasoning !== cleaned.length, beforeCodeBlocks = cleaned.length;
1555
+ cleaned = cleaned.replace(/^```[a-z]*\s*$/gim, ""), cleaned = cleaned.replace(/^```\s*$/gim, "");
1556
+ const codeBlocksRemoved = beforeCodeBlocks !== cleaned.length, finalLength = cleaned.trim().length;
1557
+ return (reasoningRemoved || codeBlocksRemoved) && devLog.debug("Preprocessed GPT-5 response", {
1558
+ originalLength,
1559
+ finalLength,
1560
+ reasoningRemoved,
1561
+ codeBlocksRemoved,
1562
+ preview: cleaned.trim().substring(0, 200)
1563
+ }), cleaned.trim();
1564
+ }, parseReviewFormat = (text, originalDoc) => {
2244
1565
  const root = [], stack = [], lines = text.split(`
2245
1566
  `);
1567
+ devLog.debug("Parsing response text", {
1568
+ lineCount: lines.length,
1569
+ firstLines: lines.slice(0, 10).map((l, idx) => ({ line: idx, content: l.substring(0, 100) })),
1570
+ hasFieldTags: text.includes("<field"),
1571
+ hasBlockTags: text.includes("<block"),
1572
+ hasSpanTags: text.includes("<span"),
1573
+ textLength: text.length,
1574
+ textPreview: text.substring(0, 500)
1575
+ });
1576
+ let processedLineCount = 0, skippedEmptyLineCount = 0, matchedPatternCount = 0;
2246
1577
  for (let i = 0; i < lines.length; i++) {
2247
1578
  const trimmed = lines[i].trim();
2248
- if (!trimmed) continue;
1579
+ if (!trimmed) {
1580
+ skippedEmptyLineCount++;
1581
+ continue;
1582
+ }
1583
+ processedLineCount++;
2249
1584
  const compactStringMatch = trimmed.match(/^(\w+):\s*(.*)$/);
2250
1585
  if (compactStringMatch && !trimmed.includes("<")) {
1586
+ matchedPatternCount++;
2251
1587
  const [, fieldName, text2] = compactStringMatch, node = {
2252
1588
  type: "field",
2253
1589
  name: fieldName,
@@ -2264,6 +1600,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2264
1600
  }
2265
1601
  const compactStartMatch = trimmed.match(/^---\s+(\w+)\s+---$/);
2266
1602
  if (compactStartMatch) {
1603
+ matchedPatternCount++;
2267
1604
  const fieldName = compactStartMatch[1], contentLines = [];
2268
1605
  for (i++; i < lines.length; ) {
2269
1606
  const currentLine = lines[i];
@@ -2273,8 +1610,16 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2273
1610
  }
2274
1611
  const content = contentLines.join(`
2275
1612
  `);
2276
- let contentType = "translatable.vtt.captions";
2277
- (content.includes("<block") || content.includes("<span")) && (contentType = "translatable.richtext");
1613
+ let contentType;
1614
+ try {
1615
+ const fieldValue = get(originalDoc, [fieldName]);
1616
+ if (fieldValue && typeof fieldValue == "object" && fieldValue !== null) {
1617
+ const fieldType = fieldValue._type;
1618
+ typeof fieldType == "string" && fieldType.startsWith("translatable.") && (contentType = fieldType);
1619
+ }
1620
+ } catch {
1621
+ }
1622
+ contentType || (content.includes("<block") || content.includes("<span") ? contentType = "translatable.richtext" : contentType = "translatable.vtt.captions");
2278
1623
  const node = {
2279
1624
  type: "field",
2280
1625
  name: fieldName,
@@ -2291,6 +1636,12 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2291
1636
  }
2292
1637
  const openMatch = trimmed.match(/^<(field|block|span)([^>]*)>(.*)$/);
2293
1638
  if (openMatch) {
1639
+ matchedPatternCount++, devLog.debug("Matched opening tag", {
1640
+ line: i,
1641
+ tagName: openMatch[1],
1642
+ attributes: openMatch[2],
1643
+ contentPreview: openMatch[3]?.substring(0, 50)
1644
+ });
2294
1645
  const [, tagName, attrsStr, content] = openMatch, attributes = parseAttributes(attrsStr), node = {
2295
1646
  type: tagName,
2296
1647
  name: attributes.name,
@@ -2311,7 +1662,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2311
1662
  parent && parent.children.push(node);
2312
1663
  } else
2313
1664
  root.push(node);
2314
- else if (attributes.type === "translatable.vtt.captions" || attributes.type === "translatable.richtext") {
1665
+ else if (attributes.type && typeof attributes.type == "string" && attributes.type.startsWith("translatable.")) {
2315
1666
  const contentLines = [];
2316
1667
  for (i++; i < lines.length; ) {
2317
1668
  const currentLine = lines[i];
@@ -2331,11 +1682,26 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2331
1682
  parent && parent.children.push(node);
2332
1683
  } else
2333
1684
  root.push(node);
2334
- else
1685
+ else if (attributes.type === "string" && !content) {
1686
+ const contentLines = [];
1687
+ for (i++; i < lines.length; ) {
1688
+ const currentLine = lines[i];
1689
+ if (currentLine.trim() === "</field>")
1690
+ break;
1691
+ contentLines.push(currentLine), i++;
1692
+ }
1693
+ if (node.text = contentLines.join(`
1694
+ `).trim(), stack.length > 0) {
1695
+ const parent = stack[stack.length - 1];
1696
+ parent && parent.children.push(node);
1697
+ } else
1698
+ root.push(node);
1699
+ } else
2335
1700
  stack.push(node);
2336
1701
  continue;
2337
1702
  }
2338
1703
  if (trimmed.match(/^<\/(field|block|span)>$/) && stack.length > 0) {
1704
+ matchedPatternCount++;
2339
1705
  const node = stack.pop();
2340
1706
  if (node)
2341
1707
  if (stack.length > 0) {
@@ -2343,9 +1709,24 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2343
1709
  parent && parent.children.push(node);
2344
1710
  } else
2345
1711
  root.push(node);
1712
+ continue;
2346
1713
  }
1714
+ processedLineCount <= 20 && devLog.debug("Line did not match any pattern", {
1715
+ line: i,
1716
+ trimmed,
1717
+ startsWithLessThan: trimmed.startsWith("<"),
1718
+ startsWithField: trimmed.startsWith("<field"),
1719
+ startsWithBacktick: trimmed.startsWith("`")
1720
+ });
2347
1721
  }
2348
- return root;
1722
+ return devLog.debug("Parse complete", {
1723
+ totalLines: lines.length,
1724
+ processedLines: processedLineCount,
1725
+ skippedEmptyLines: skippedEmptyLineCount,
1726
+ rootNodeCount: root.length,
1727
+ stackLength: stack.length,
1728
+ matchedPatterns: matchedPatternCount
1729
+ }), root;
2349
1730
  }, parseAttributes = (attrStr) => {
2350
1731
  const attrs = {}, attrRegex = /(\w+)="([^"]*)"/g;
2351
1732
  let match = attrRegex.exec(attrStr);
@@ -2353,20 +1734,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2353
1734
  attrs[match[1]] = unescapeXml(match[2]), match = attrRegex.exec(attrStr);
2354
1735
  return attrs;
2355
1736
  }, unescapeXml = (str) => str.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&"), applyTextChanges = (doc, parsedNodes) => {
1737
+ devLog.debug("Applying text changes from parsed nodes", {
1738
+ nodeCount: parsedNodes.length,
1739
+ nodeNames: parsedNodes.map((n) => n.type === "field" && n.name ? n.name : "unknown")
1740
+ });
2356
1741
  for (const node of parsedNodes)
2357
1742
  if (node.type === "field" && node.name) {
2358
- const path = [node.name];
1743
+ const path = [node.name], beforeValue = get(doc, path);
2359
1744
  applyFieldChanges(doc, path, node);
1745
+ const afterValue = get(doc, path);
1746
+ beforeValue !== afterValue && devLog.debug("Field updated during deserialization", {
1747
+ path: path.join("."),
1748
+ before: typeof beforeValue == "string" ? beforeValue.substring(0, 50) : beforeValue,
1749
+ after: typeof afterValue == "string" ? afterValue.substring(0, 50) : afterValue
1750
+ });
2360
1751
  }
2361
1752
  }, applyFieldChanges = (doc, path, node) => {
2362
- if (node.attributes.type === "translatable.vtt.captions") {
2363
- const fieldValue = get(doc, path);
2364
- fieldValue && typeof fieldValue == "object" && fieldValue._type === "translatable.vtt.captions" && node.text && set(doc, [...path, "translationText"], node.text);
2365
- return;
2366
- }
2367
- if (node.attributes.type === "translatable.richtext") {
1753
+ const nodeType = node.attributes.type;
1754
+ if (typeof nodeType == "string" && nodeType.startsWith("translatable.")) {
2368
1755
  const fieldValue = get(doc, path);
2369
- fieldValue && typeof fieldValue == "object" && fieldValue._type === "translatable.richtext" && node.text && set(doc, [...path, "translationText"], node.text);
1756
+ if (fieldValue && typeof fieldValue == "object" && fieldValue !== null) {
1757
+ const fieldType = fieldValue._type;
1758
+ typeof fieldType == "string" && fieldType === nodeType && node.text && set(doc, [...path, "translationText"], node.text);
1759
+ }
2370
1760
  return;
2371
1761
  }
2372
1762
  if (node.attributes.type === "array") {
@@ -2454,12 +1844,9 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2454
1844
  return;
2455
1845
  }
2456
1846
  if (isPlainObject(value)) {
2457
- if (value._type === "translatable.vtt.captions" && typeof value.translationText == "string") {
2458
- lines.push(`${indentStr}--- ${fieldName} ---`), lines.push(value.translationText), lines.push(`${indentStr}--- /${fieldName} ---`);
2459
- return;
2460
- }
2461
- if (value._type === "translatable.richtext" && typeof value.translationText == "string") {
2462
- lines.push(`${indentStr}--- ${fieldName} ---`), lines.push(value.translationText), lines.push(`${indentStr}--- /${fieldName} ---`);
1847
+ const valueObj = value, valueType = valueObj._type, translationText = valueObj.translationText;
1848
+ if (typeof valueType == "string" && valueType.startsWith("translatable.") && typeof translationText == "string") {
1849
+ lines.push(`${indentStr}--- ${fieldName} ---`), lines.push(translationText), lines.push(`${indentStr}--- /${fieldName} ---`);
2463
1850
  return;
2464
1851
  }
2465
1852
  lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
@@ -2595,7 +1982,9 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2595
1982
  languageFieldName,
2596
1983
  translationPrompts,
2597
1984
  targetLanguage,
2598
- openaiConfig
1985
+ openaiConfig,
1986
+ previousError,
1987
+ errorContext
2599
1988
  }) => {
2600
1989
  const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2601
1990
  sourceId: sourceDocument._id,
@@ -2613,7 +2002,8 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2613
2002
  sourceText,
2614
2003
  sourceLanguage,
2615
2004
  targetLanguage,
2616
- translationPrompts
2005
+ translationPrompts,
2006
+ errorContext
2617
2007
  }), response = await callGPT5(openaiConfig.apiKey, {
2618
2008
  messages: [
2619
2009
  {
@@ -2671,14 +2061,21 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2671
2061
  sourceText,
2672
2062
  sourceLanguage,
2673
2063
  targetLanguage,
2674
- translationPrompts
2064
+ translationPrompts,
2065
+ errorContext
2675
2066
  }) => {
2676
2067
  const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2677
2068
  return `# TRANSLATION TASK
2678
2069
 
2679
2070
  Translate this ${sourceLanguage} document into ${targetLanguage}.
2680
2071
 
2681
- ## Guidelines
2072
+ ${errorContext ? `## \u26A0\uFE0F IMPORTANT: Previous Attempt Error
2073
+
2074
+ ${errorContext}
2075
+
2076
+ Please carefully review the error above and ensure your translation fixes the issue.
2077
+
2078
+ ` : ""}## Guidelines
2682
2079
 
2683
2080
  ${globalPrompt ? `### General Guidelines
2684
2081
  ${globalPrompt}
@@ -2852,21 +2249,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2852
2249
  languageFieldName,
2853
2250
  targetLanguage
2854
2251
  });
2855
- return await client.agent.action.translate({
2856
- schemaId,
2857
- toLanguage,
2858
- fromLanguage,
2859
- documentId: sourceDocument._id,
2860
- noWrite: !0,
2861
- // Do not write to the document, just return the translated content
2862
- languageFieldPath: [languageFieldName],
2863
- styleGuide,
2864
- protectedPhrases: translationPrompts.protectedPhrases,
2865
- conditionalPaths: {
2866
- defaultHidden: !1,
2867
- defaultReadOnly: !1
2252
+ return await retryWithBackoff(
2253
+ async () => await client.agent.action.translate({
2254
+ schemaId,
2255
+ toLanguage,
2256
+ fromLanguage,
2257
+ documentId: sourceDocument._id,
2258
+ noWrite: !0,
2259
+ // Do not write to the document, just return the translated content
2260
+ languageFieldPath: [languageFieldName],
2261
+ styleGuide,
2262
+ protectedPhrases: translationPrompts.protectedPhrases,
2263
+ conditionalPaths: {
2264
+ defaultHidden: !1,
2265
+ defaultReadOnly: !1
2266
+ }
2267
+ }),
2268
+ {
2269
+ maxAttempts: 2,
2270
+ initialDelayMs: 0,
2271
+ retryableErrors: isNetworkError,
2272
+ context: {
2273
+ operation: "agent.action.translate",
2274
+ documentId: sourceDocument._id,
2275
+ targetLanguage,
2276
+ schemaId
2277
+ }
2868
2278
  }
2869
- });
2279
+ );
2870
2280
  }, handleTranslation = async ({
2871
2281
  dispatch,
2872
2282
  client,
@@ -2879,7 +2289,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2879
2289
  translationMode,
2880
2290
  openaiConfig,
2881
2291
  releaseId,
2882
- excludeFields = []
2292
+ excludeFields = [],
2293
+ replaceExisting = !1,
2294
+ mainSourceDocumentId
2883
2295
  }) => {
2884
2296
  const safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2885
2297
  excludeFields.length !== safeExcludeFields.length && devLog.warn("Language field was in excludeFields - removing it to prevent translation errors", {
@@ -2902,58 +2314,74 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2902
2314
  filteredTitleValue: filteredDocument.title,
2903
2315
  excludedValuesKeys: Object.keys(excludedValues),
2904
2316
  excludedValues
2905
- }), devLog.debug("Applying field transforms before translation", {
2906
- documentId: sourceDocument._id,
2907
- documentType: sourceDocument._type
2908
2317
  });
2909
- const { transformedDocument, originalStructure } = applyTransforms({
2910
- document: filteredDocument,
2911
- registry: globalTransformRegistry,
2912
- schemaType: sourceDocument._type
2913
- });
2914
- let translatedDoc;
2915
- switch (translationMode) {
2916
- case "sanity-only": {
2917
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), translatedDoc = await getSanityAgentTranslation({
2918
- sourceDocument: transformedDocument,
2919
- targetLanguage,
2920
- languageFieldName,
2921
- schemaId,
2922
- client,
2923
- translationPrompts
2924
- });
2925
- break;
2926
- }
2927
- case "openai-only": {
2928
- if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2929
- throw new Error("OpenAI is not configured but openai-only mode was selected");
2930
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai"), translatedDoc = await getOpenAITranslation({
2931
- dispatch,
2932
- sourceDocument: transformedDocument,
2933
- targetLanguage,
2934
- languageFieldName,
2935
- translationPrompts,
2936
- openaiConfig
2937
- });
2938
- break;
2318
+ let documentToTranslate, originalStructure = {};
2319
+ if (translationMode === "openai-only") {
2320
+ devLog.debug("Applying field transforms before translation (OpenAI mode)", {
2321
+ documentId: sourceDocument._id,
2322
+ documentType: sourceDocument._type
2323
+ });
2324
+ const transformResult = applyTransforms({
2325
+ document: filteredDocument,
2326
+ registry: globalTransformRegistry,
2327
+ schemaType: sourceDocument._type
2328
+ });
2329
+ documentToTranslate = transformResult.transformedDocument, originalStructure = transformResult.originalStructure;
2330
+ } else
2331
+ devLog.debug("Skipping field transforms (Sanity Agent mode)", {
2332
+ documentId: sourceDocument._id,
2333
+ documentType: sourceDocument._type
2334
+ }), documentToTranslate = filteredDocument;
2335
+ let translatedDoc, previousError = null;
2336
+ const performTranslation = async (errorContext) => {
2337
+ switch (translationMode) {
2338
+ case "sanity-only":
2339
+ return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating", void 0, mainSourceDocumentId), await getSanityAgentTranslation({
2340
+ sourceDocument: documentToTranslate,
2341
+ targetLanguage,
2342
+ languageFieldName,
2343
+ schemaId,
2344
+ client,
2345
+ translationPrompts
2346
+ });
2347
+ case "openai-only": {
2348
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2349
+ throw new Error("OpenAI is not configured but openai-only mode was selected");
2350
+ return setJobStatus(
2351
+ dispatch,
2352
+ sourceDocument._id,
2353
+ targetLanguage,
2354
+ "translating_with_openai",
2355
+ void 0,
2356
+ mainSourceDocumentId
2357
+ ), await getOpenAITranslation({
2358
+ dispatch,
2359
+ sourceDocument: documentToTranslate,
2360
+ targetLanguage,
2361
+ languageFieldName,
2362
+ translationPrompts,
2363
+ openaiConfig,
2364
+ previousError: errorContext && previousError ? previousError : void 0,
2365
+ errorContext
2366
+ });
2367
+ }
2368
+ default:
2369
+ throw new Error(`Invalid translation mode: ${translationMode}`);
2939
2370
  }
2940
- case "sanity-with-review":
2941
- throw new Error("Sanity with review translation method is not implemented");
2942
- default:
2943
- throw new Error(`Invalid translation mode: ${translationMode}`);
2944
- }
2945
- if (!translatedDoc)
2371
+ };
2372
+ if (translatedDoc = await performTranslation(), !translatedDoc)
2946
2373
  return null;
2947
- devLog.debug("Restoring field transforms after translation", {
2374
+ let transformedResult;
2375
+ Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
2948
2376
  documentId: sourceDocument._id,
2949
2377
  documentType: sourceDocument._type
2950
- });
2951
- const transformedResult = restoreTransforms({
2378
+ }), transformedResult = restoreTransforms({
2952
2379
  translatedDocument: translatedDoc,
2953
2380
  originalStructure,
2954
2381
  registry: globalTransformRegistry,
2955
2382
  schemaType: sourceDocument._type
2956
- }), excludedValuesToRestore = { ...excludedValues };
2383
+ })) : transformedResult = translatedDoc;
2384
+ const excludedValuesToRestore = { ...excludedValues };
2957
2385
  delete excludedValuesToRestore[languageFieldName], devLog.info("Before restoring excluded fields", {
2958
2386
  documentId: sourceDocument._id,
2959
2387
  excludedFieldCount: Object.keys(excludedValues).length,
@@ -2972,11 +2400,76 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2972
2400
  ...finalDoc,
2973
2401
  _id: targetDocumentVersionId
2974
2402
  };
2975
- return await client.action({
2976
- actionType: "sanity.action.document.version.create",
2977
- publishedId: targetDocumentPublishedId,
2978
- document: newDoc
2979
- }), newDoc;
2403
+ replaceExisting && await client.getDocument(targetDocumentVersionId) && (devLog.info("Replacing existing version in release", {
2404
+ documentId: sourceDocument._id,
2405
+ targetDocumentVersionId,
2406
+ releaseId
2407
+ }), await client.delete(targetDocumentVersionId));
2408
+ try {
2409
+ await retryWithBackoff(
2410
+ async () => {
2411
+ await client.action({
2412
+ actionType: "sanity.action.document.version.create",
2413
+ publishedId: targetDocumentPublishedId,
2414
+ document: newDoc
2415
+ });
2416
+ },
2417
+ {
2418
+ maxAttempts: 2,
2419
+ initialDelayMs: 0,
2420
+ retryableErrors: isNetworkError,
2421
+ context: {
2422
+ documentId: sourceDocument._id,
2423
+ targetLanguage,
2424
+ operation: "version.create"
2425
+ }
2426
+ }
2427
+ );
2428
+ } catch (error) {
2429
+ if (translationMode === "openai-only" && isSanityMutationError(error)) {
2430
+ devLog.warn("Mutation error after OpenAI translation, retrying full translation", {
2431
+ documentId: sourceDocument._id,
2432
+ targetLanguage,
2433
+ error: error instanceof Error ? error.message : String(error)
2434
+ }), previousError = error instanceof Error ? error : new Error(String(error));
2435
+ const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`;
2436
+ if (translatedDoc = await performTranslation(errorContext), !translatedDoc)
2437
+ throw new Error("Translation retry failed: no document returned");
2438
+ let retryTransformedResult;
2439
+ Object.keys(originalStructure).length > 0 ? retryTransformedResult = restoreTransforms({
2440
+ translatedDocument: translatedDoc,
2441
+ originalStructure,
2442
+ registry: globalTransformRegistry,
2443
+ schemaType: sourceDocument._type
2444
+ }) : retryTransformedResult = translatedDoc;
2445
+ const retryNewDoc = {
2446
+ ...restoreExcludedFields(retryTransformedResult, excludedValuesToRestore),
2447
+ _id: targetDocumentVersionId
2448
+ };
2449
+ return await retryWithBackoff(
2450
+ async () => {
2451
+ await client.action({
2452
+ actionType: "sanity.action.document.version.create",
2453
+ publishedId: targetDocumentPublishedId,
2454
+ document: retryNewDoc
2455
+ });
2456
+ },
2457
+ {
2458
+ maxAttempts: 2,
2459
+ initialDelayMs: 0,
2460
+ retryableErrors: isNetworkError,
2461
+ context: {
2462
+ documentId: sourceDocument._id,
2463
+ targetLanguage,
2464
+ operation: "version.create",
2465
+ isRetry: !0
2466
+ }
2467
+ }
2468
+ ), retryNewDoc;
2469
+ }
2470
+ throw error;
2471
+ }
2472
+ return newDoc;
2980
2473
  }, translateDocument = async ({
2981
2474
  dispatch,
2982
2475
  client,
@@ -2984,84 +2477,66 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2984
2477
  sourceLanguage,
2985
2478
  targetLanguage,
2986
2479
  languageFieldName,
2987
- shouldOverwriteFreshTranslations,
2988
2480
  schemaId,
2989
2481
  translationPrompts,
2990
2482
  translationMode,
2991
2483
  openaiConfig,
2992
2484
  releaseId,
2993
- excludeFields
2485
+ excludeFields,
2486
+ replaceExisting,
2487
+ mainSourceDocumentId
2994
2488
  }) => {
2995
2489
  const logContext = {
2996
2490
  documentId: sourceDocument._id,
2997
2491
  targetLanguage,
2998
2492
  schemaId,
2999
- shouldOverwriteFreshTranslations,
3000
2493
  translationMode
3001
2494
  };
3002
- devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
2495
+ devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation", void 0, mainSourceDocumentId);
3003
2496
  try {
3004
2497
  const translationMetadata = await getTranslationMetadata({
3005
2498
  client,
3006
2499
  sourceDocument,
3007
2500
  sourceLanguage
3008
- }), isAlreadyFreshTranslation = translationMetadata.translationHistory?.some(
3009
- (history) => history.language === targetLanguage && history.sourceRev === sourceDocument._rev
3010
- );
3011
- if (!shouldOverwriteFreshTranslations && isAlreadyFreshTranslation) {
3012
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "skipped", {
3013
- targetId: translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref
3014
- });
3015
- return;
3016
- }
3017
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await withRetry(
3018
- () => handleTranslation({
2501
+ }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2502
+ dispatch,
2503
+ client,
2504
+ schemaId,
2505
+ sourceDocument,
2506
+ targetLanguage,
2507
+ existingTranslationId,
2508
+ languageFieldName,
2509
+ translationPrompts,
2510
+ translationMode,
2511
+ openaiConfig,
2512
+ releaseId,
2513
+ excludeFields,
2514
+ replaceExisting,
2515
+ mainSourceDocumentId
2516
+ });
2517
+ if (!newDocument) {
2518
+ devLog.error("Translation failed: no document returned", new Error("Translation returned null"), logContext), setJobStatus(
3019
2519
  dispatch,
3020
- client,
3021
- schemaId,
3022
- sourceDocument,
2520
+ sourceDocument._id,
3023
2521
  targetLanguage,
3024
- existingTranslationId,
3025
- languageFieldName,
3026
- translationPrompts,
3027
- translationMode,
3028
- openaiConfig,
3029
- releaseId,
3030
- excludeFields
3031
- }),
3032
- {
3033
- maxRetries: 3,
3034
- initialDelayMs: 1e3,
3035
- isRetryable: isRetryableError,
3036
- onRetry: (attempt, error, delayMs) => {
3037
- devLog.warn("Translation failed, scheduling retry", {
3038
- ...logContext,
3039
- attempt,
3040
- delayMs,
3041
- error: error instanceof Error ? error.message : String(error)
3042
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "retrying", {
3043
- retryCount: attempt,
3044
- errorMessage: error instanceof Error ? error.message : String(error)
3045
- });
3046
- }
3047
- }
3048
- );
3049
- if (!translationResult.success || !translationResult.result) {
3050
- const errorMessage = translationResult.error instanceof Error ? translationResult.error.message : String(translationResult.error || "Translation failed");
3051
- devLog.error("Translation failed after retries", translationResult.error, {
3052
- ...logContext,
3053
- attempts: translationResult.attempts
3054
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
3055
- errorMessage,
3056
- retryCount: translationResult.attempts - 1
3057
- // -1 because attempts includes initial try
3058
- }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2522
+ "error",
2523
+ {
2524
+ errorMessage: "Translation failed"
2525
+ },
2526
+ mainSourceDocumentId
2527
+ ), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3059
2528
  return;
3060
2529
  }
3061
- const newDocument = translationResult.result;
3062
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "updating_metadata", {
3063
- targetId: newDocument._id
3064
- }), await updateTranslationMetadata({
2530
+ return setJobStatus(
2531
+ dispatch,
2532
+ sourceDocument._id,
2533
+ targetLanguage,
2534
+ "updating_metadata",
2535
+ {
2536
+ targetId: newDocument._id
2537
+ },
2538
+ mainSourceDocumentId
2539
+ ), await updateTranslationMetadata({
3065
2540
  client,
3066
2541
  sourceDocument,
3067
2542
  sourceLanguage,
@@ -3069,17 +2544,31 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3069
2544
  targetLanguage,
3070
2545
  translationMetadata,
3071
2546
  isNewTranslation: !existingTranslationId
3072
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "completed", {
3073
- targetId: newDocument._id
3074
- }), devLog.info("Translation completed successfully", {
2547
+ }), setJobStatus(
2548
+ dispatch,
2549
+ sourceDocument._id,
2550
+ targetLanguage,
2551
+ "completed",
2552
+ {
2553
+ targetId: newDocument._id
2554
+ },
2555
+ mainSourceDocumentId
2556
+ ), devLog.info("Translation completed successfully", {
3075
2557
  ...logContext,
3076
2558
  newDocumentId: newDocument._id
3077
2559
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
3078
2560
  } catch (error) {
3079
2561
  const errorMessage = error instanceof Error ? error.message : String(error);
3080
- devLog.error("Translation failed", error instanceof Error ? error : new Error(String(error)), logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
3081
- errorMessage
3082
- }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2562
+ devLog.error("Translation failed", error instanceof Error ? error : new Error(String(error)), logContext), setJobStatus(
2563
+ dispatch,
2564
+ sourceDocument._id,
2565
+ targetLanguage,
2566
+ "error",
2567
+ {
2568
+ errorMessage
2569
+ },
2570
+ mainSourceDocumentId
2571
+ ), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3083
2572
  return;
3084
2573
  }
3085
2574
  }, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
@@ -3088,13 +2577,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3088
2577
  targetLanguages,
3089
2578
  dispatch,
3090
2579
  languageFieldName,
3091
- shouldOverwriteFreshTranslations,
3092
2580
  schemaId,
3093
2581
  references = [],
3094
2582
  maxConcurrentTranslationJobs,
3095
2583
  translationMode,
3096
2584
  openaiConfig,
3097
- excludeFields
2585
+ excludeFields,
2586
+ releaseConfig
3098
2587
  }) => {
3099
2588
  const logContext = {
3100
2589
  documentId: sourceDocument._id,
@@ -3103,28 +2592,48 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3103
2592
  referencesCount: references.length,
3104
2593
  maxConcurrentJobs: maxConcurrentTranslationJobs,
3105
2594
  translationMode,
3106
- shouldOverwriteFreshTranslations
2595
+ releaseMode: releaseConfig.mode
3107
2596
  };
3108
- let releaseId;
2597
+ let releaseId, createdNewRelease = !1;
3109
2598
  try {
3110
2599
  const sourceLanguage = sourceDocument[languageFieldName];
3111
2600
  if (!sourceLanguage)
3112
2601
  throw devLog.error("Source language not found"), new Error("Source language not found");
3113
- maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
2602
+ if (maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
3114
2603
  type: "SET_OVERALL_STATUS",
3115
2604
  payload: "translating"
3116
- });
3117
- 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({
3118
- metadata: {
3119
- title: releaseTitle,
3120
- description: releaseDescription,
3121
- releaseType: "undecided"
3122
- }
3123
- });
3124
- devLog.info("Content Release created", {
3125
- ...logContext,
3126
- releaseId: releaseId2
3127
- });
2605
+ }), releaseConfig.mode === "existing" && releaseConfig.existingReleaseId)
2606
+ releaseId = releaseConfig.existingReleaseId, devLog.info("Using existing Content Release", {
2607
+ ...logContext,
2608
+ releaseId
2609
+ });
2610
+ else {
2611
+ const releaseTitle = releaseConfig.newReleaseTitle, releaseDescription = `AI translation batch created on ${(/* @__PURE__ */ new Date()).toLocaleDateString()} at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
2612
+ releaseId = (await retryWithBackoff(
2613
+ async () => await client.releases.create({
2614
+ metadata: {
2615
+ title: releaseTitle,
2616
+ description: releaseDescription,
2617
+ releaseType: "undecided"
2618
+ }
2619
+ }),
2620
+ {
2621
+ maxAttempts: 2,
2622
+ initialDelayMs: 0,
2623
+ retryableErrors: isNetworkError,
2624
+ context: {
2625
+ operation: "releases.create",
2626
+ releaseTitle,
2627
+ ...logContext
2628
+ }
2629
+ }
2630
+ )).releaseId, createdNewRelease = !0, devLog.info("Content Release created", {
2631
+ ...logContext,
2632
+ releaseId,
2633
+ releaseTitle
2634
+ });
2635
+ }
2636
+ registerActiveTranslation(sourceDocument._id, releaseId), updateActiveTranslationStatus(sourceDocument._id, "translating");
3128
2637
  const sourceDocuments = [
3129
2638
  {
3130
2639
  _id: sourceDocument._id,
@@ -3139,7 +2648,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3139
2648
  targetLanguages,
3140
2649
  sourceDocument
3141
2650
  });
3142
- setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages);
2651
+ setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages, sourceDocument._id);
3143
2652
  const translationTasks = sourceDocumentIds.flatMap(
3144
2653
  (documentId) => targetLanguages.map(async (targetLanguage) => {
3145
2654
  try {
@@ -3147,7 +2656,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3147
2656
  const document = await client.withConfig({
3148
2657
  perspective: "drafts"
3149
2658
  }).getDocument(documentId);
3150
- if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId2) {
2659
+ if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId) {
3151
2660
  const versionedTargetDocumentId = await translateDocument({
3152
2661
  dispatch,
3153
2662
  client,
@@ -3155,13 +2664,15 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3155
2664
  sourceLanguage,
3156
2665
  targetLanguage,
3157
2666
  languageFieldName,
3158
- shouldOverwriteFreshTranslations,
3159
2667
  schemaId,
3160
2668
  translationPrompts,
3161
2669
  translationMode,
3162
2670
  openaiConfig,
3163
- releaseId: releaseId2,
3164
- excludeFields
2671
+ releaseId,
2672
+ excludeFields,
2673
+ replaceExisting: releaseConfig.replaceExisting,
2674
+ // Pass main source document ID for active translations tracking
2675
+ mainSourceDocumentId: sourceDocument._id
3165
2676
  });
3166
2677
  versionedTargetDocumentId && versionedTargetDocumentIds.push(versionedTargetDocumentId);
3167
2678
  }
@@ -3188,7 +2699,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3188
2699
  }), dispatch({
3189
2700
  type: "SET_OVERALL_STATUS",
3190
2701
  payload: "linking_references"
3191
- }), await Promise.all(
2702
+ }), updateActiveTranslationStatus(sourceDocument._id, "linking_references"), await Promise.all(
3192
2703
  versionedTargetDocumentIds.map(async (documentId) => {
3193
2704
  await localizeReferences({
3194
2705
  client,
@@ -3199,18 +2710,65 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3199
2710
  })
3200
2711
  );
3201
2712
  const hasAnyFailures = failed > 0;
3202
- 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", {
2713
+ 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", {
3203
2714
  ...logContext,
3204
2715
  succeeded,
3205
2716
  failed
3206
- }), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })) : dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" }), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
2717
+ }), 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();
3207
2718
  } catch (error) {
3208
- throw devLog.error(
2719
+ if (devLog.error(
3209
2720
  "translateDocumentSet failed with unexpected error",
3210
2721
  error instanceof Error ? error : new Error(String(error)),
3211
2722
  logContext
3212
- ), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
2723
+ ), releaseId && createdNewRelease)
2724
+ try {
2725
+ await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
2726
+ ...logContext,
2727
+ releaseId
2728
+ });
2729
+ } catch (cleanupError) {
2730
+ devLog.error(
2731
+ "Failed to cleanup release",
2732
+ cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
2733
+ logContext
2734
+ );
2735
+ }
2736
+ throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id), error;
3213
2737
  }
2738
+ }, ReleaseConflictWarning = ({ conflicts, onCancel, onReplace }) => {
2739
+ 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]);
2740
+ return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2741
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2742
+ /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
2743
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "This release already contains translations for this content and target language(s)." })
2744
+ ] }),
2745
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2746
+ "The selected release already has ",
2747
+ summaryText,
2748
+ " that will be overwritten if you continue:"
2749
+ ] }),
2750
+ /* @__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: [
2751
+ conflict.sourceDocumentTitle || conflict.sourceDocumentId,
2752
+ conflict.sourceDocumentType && ` (${conflict.sourceDocumentType})`,
2753
+ " \u2192 ",
2754
+ conflict.targetLanguage
2755
+ ] }, conflict.existingVersionId)) }) }) }),
2756
+ /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2757
+ /* @__PURE__ */ jsx(Heading, { size: 0, children: "Choose an action:" }),
2758
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2759
+ /* @__PURE__ */ jsx("strong", { children: "Cancel" }),
2760
+ " \u2014 Go back and select a different release, or create a new one."
2761
+ ] }),
2762
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2763
+ /* @__PURE__ */ jsx("strong", { children: "Replace" }),
2764
+ " \u2014 Delete the existing translations in this release and create new ones."
2765
+ ] })
2766
+ ] }),
2767
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
2768
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Cancel", onClick: onCancel }),
2769
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "caution", text: "Replace Existing", onClick: onReplace })
2770
+ ] })
2771
+ ] }) });
3214
2772
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
3215
2773
  const {
3216
2774
  document,
@@ -3218,40 +2776,83 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3218
2776
  overallStatus,
3219
2777
  dispatch,
3220
2778
  references,
2779
+ selectedReferenceIds,
3221
2780
  selectedTargetLanguages,
3222
- shouldOverwriteFreshTranslations,
3223
- translationMode
3224
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
3225
- translateDocumentSet({
3226
- sourceDocument: document,
3227
- targetLanguages: selectedTargetLanguages,
2781
+ translationMode,
2782
+ releaseConfig
2783
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1), filteredReferences = references.filter((ref) => selectedReferenceIds.has(ref._id));
2784
+ useEffect(() => {
2785
+ releaseConfig.mode === "new" && conflictContext && setConflictContext(null);
2786
+ }, [releaseConfig.mode, conflictContext]);
2787
+ const startTranslation = useCallback(
2788
+ (replaceExisting, overrideReleaseId) => {
2789
+ setConflictContext(null), translateDocumentSet({
2790
+ sourceDocument: document,
2791
+ targetLanguages: selectedTargetLanguages,
2792
+ client,
2793
+ dispatch,
2794
+ languageFieldName: config.languageFieldName,
2795
+ schemaId: config.schemaId,
2796
+ references: filteredReferences,
2797
+ maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
2798
+ maxReferenceDepth: config.maxReferenceDepth,
2799
+ translationMode,
2800
+ openaiConfig: config.openai,
2801
+ excludeFields: config.excludeFields,
2802
+ releaseConfig: {
2803
+ ...releaseConfig,
2804
+ replaceExisting,
2805
+ // Use the override release ID if provided (from conflict context),
2806
+ // otherwise use the current releaseConfig value
2807
+ existingReleaseId: overrideReleaseId ?? releaseConfig.existingReleaseId
2808
+ }
2809
+ });
2810
+ },
2811
+ [
2812
+ document,
2813
+ selectedTargetLanguages,
3228
2814
  client,
3229
2815
  dispatch,
3230
- languageFieldName: config.languageFieldName,
3231
- schemaId: config.schemaId,
3232
- references,
3233
- shouldOverwriteFreshTranslations,
3234
- maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
3235
- maxReferenceDepth: config.maxReferenceDepth,
2816
+ config.languageFieldName,
2817
+ config.schemaId,
2818
+ config.maxConcurrentTranslationJobs,
2819
+ config.maxReferenceDepth,
2820
+ config.openai,
2821
+ config.excludeFields,
2822
+ filteredReferences,
3236
2823
  translationMode,
3237
- openaiConfig: config.openai,
3238
- excludeFields: config.excludeFields
3239
- });
3240
- }, [
3241
- document,
3242
- selectedTargetLanguages,
3243
- client,
3244
- dispatch,
3245
- config.languageFieldName,
3246
- config.schemaId,
3247
- config.maxConcurrentTranslationJobs,
3248
- config.maxReferenceDepth,
3249
- config.openai,
3250
- config.excludeFields,
3251
- references,
3252
- shouldOverwriteFreshTranslations,
3253
- translationMode
3254
- ]), handleSelectAll = useCallback(() => {
2824
+ releaseConfig
2825
+ ]
2826
+ ), handleTranslate = useCallback(async () => {
2827
+ if (releaseConfig.mode === "new") {
2828
+ startTranslation(!1);
2829
+ return;
2830
+ }
2831
+ if (releaseConfig.mode === "existing" && releaseConfig.existingReleaseId) {
2832
+ const checkedReleaseId = releaseConfig.existingReleaseId;
2833
+ setIsCheckingConflicts(!0);
2834
+ const foundConflicts = await checkReleaseConflicts({
2835
+ client,
2836
+ releaseId: checkedReleaseId,
2837
+ sourceDocument: document,
2838
+ targetLanguages: selectedTargetLanguages,
2839
+ languageFieldName: config.languageFieldName,
2840
+ references: filteredReferences
2841
+ });
2842
+ if (setIsCheckingConflicts(!1), foundConflicts.length > 0) {
2843
+ setConflictContext({
2844
+ releaseId: checkedReleaseId,
2845
+ conflicts: foundConflicts
2846
+ });
2847
+ return;
2848
+ }
2849
+ }
2850
+ startTranslation(!1);
2851
+ }, [releaseConfig, client, document, selectedTargetLanguages, config.languageFieldName, startTranslation, filteredReferences]), handleCancelConflict = useCallback(() => {
2852
+ setConflictContext(null);
2853
+ }, []), handleReplaceConflict = useCallback(() => {
2854
+ conflictContext && startTranslation(!0, conflictContext.releaseId);
2855
+ }, [startTranslation, conflictContext]), handleSelectAll = useCallback(() => {
3255
2856
  dispatch({
3256
2857
  type: "SET_SELECTED_TARGET_LANGUAGES",
3257
2858
  payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
@@ -3262,7 +2863,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3262
2863
  payload: []
3263
2864
  });
3264
2865
  }, [dispatch]);
3265
- return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
2866
+ return conflictContext ? /* @__PURE__ */ jsx(Stack, { space: 4, children: /* @__PURE__ */ jsx(
2867
+ ReleaseConflictWarning,
2868
+ {
2869
+ conflicts: conflictContext.conflicts,
2870
+ onCancel: handleCancelConflict,
2871
+ onReplace: handleReplaceConflict
2872
+ }
2873
+ ) }) : /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
3266
2874
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
3267
2875
  /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
3268
2876
  /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
@@ -3276,16 +2884,41 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3276
2884
  tone: "positive",
3277
2885
  size: 2,
3278
2886
  onClick: handleTranslate,
3279
- disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
3280
- children: "Translate"
2887
+ disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus) || releaseConfig.mode === "existing" && !releaseConfig.existingReleaseId || isCheckingConflicts,
2888
+ children: isCheckingConflicts ? "Checking..." : "Translate"
3281
2889
  }
3282
2890
  )
3283
2891
  ] })
3284
2892
  ] });
2893
+ }, TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
2894
+ const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
2895
+ params: {
2896
+ documentId: undraftId(documentId),
2897
+ translationMetadataType: TRANSLATION_METADATA_TYPE
2898
+ },
2899
+ initialValue: null
2900
+ });
2901
+ return useEffect(() => {
2902
+ if (loading) {
2903
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "fetching_metadata" });
2904
+ return;
2905
+ }
2906
+ if (error) {
2907
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "error" });
2908
+ return;
2909
+ }
2910
+ if (data) {
2911
+ dispatch({ type: "SET_TRANSLATION_METADATA", payload: data }), dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
2912
+ return;
2913
+ }
2914
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
2915
+ }, [data, loading, error, dispatch]), {
2916
+ data,
2917
+ loading,
2918
+ error
2919
+ };
3285
2920
  }, LanguageCheckbox = ({ lang, isLoading }) => {
3286
- 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(
3287
- (history) => history.language === lang.id && history.sourceRev !== document._rev
3288
- ), handleChange = () => {
2921
+ const { document, dispatch, selectedTargetLanguages } = useTranslationContext(), isDisabled = lang.id === document.language, isChecked = selectedTargetLanguages.includes(lang.id), handleChange = () => {
3289
2922
  if (isDisabled) return;
3290
2923
  const updatedLanguages = isChecked ? selectedTargetLanguages.filter((langId) => langId !== lang.id) : [...selectedTargetLanguages, lang.id];
3291
2924
  dispatch({
@@ -3303,16 +2936,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3303
2936
  disabled: isDisabled,
3304
2937
  tone: isChecked ? "primary" : "default",
3305
2938
  muted: !0,
3306
- children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, style: { cursor: "pointer" }, children: [
3307
- /* @__PURE__ */ jsxs(Inline, { space: 3, children: [
3308
- /* @__PURE__ */ jsx(Checkbox, { checked: isChecked, onChange: handleChange, label: lang.title, disabled: isDisabled }),
3309
- /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
3310
- /* @__PURE__ */ jsx(Text, { children: lang.title }),
3311
- 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" })
3312
- ] })
3313
- ] }),
3314
- !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" }) })
3315
- ] })
2939
+ children: /* @__PURE__ */ jsx(Flex, { align: "center", gap: 2, style: { cursor: "pointer" }, children: /* @__PURE__ */ jsxs(Inline, { space: 3, children: [
2940
+ /* @__PURE__ */ jsx(Checkbox, { checked: isChecked, onChange: handleChange, label: lang.title, disabled: isDisabled }),
2941
+ /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
2942
+ /* @__PURE__ */ jsx(Text, { children: lang.title }),
2943
+ isDisabled && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "The current language" })
2944
+ ] })
2945
+ ] }) })
3316
2946
  }
3317
2947
  );
3318
2948
  }, LanguageSelectionPanel = () => {
@@ -3320,6 +2950,134 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3320
2950
  documentId: document._id
3321
2951
  });
3322
2952
  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)) }) });
2953
+ }, ReleaseSelectionPanel = () => {
2954
+ const { dispatch, releaseConfig } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [releases, setReleases] = useState([]), [isLoading, setIsLoading] = useState(!1), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState("");
2955
+ useEffect(() => {
2956
+ (async () => {
2957
+ setIsLoading(!0), setError(null);
2958
+ try {
2959
+ const result = await client.fetch(
2960
+ 'releases::all()[state == "active"] | order(_createdAt desc)'
2961
+ );
2962
+ setReleases(result), devLog.debug("Fetched active releases", {
2963
+ count: result.length,
2964
+ component: "ReleaseSelectionPanel"
2965
+ });
2966
+ } catch (err) {
2967
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch releases";
2968
+ setError(errorMessage), devLog.error("Failed to fetch releases", err instanceof Error ? err : new Error(errorMessage), {
2969
+ component: "ReleaseSelectionPanel"
2970
+ });
2971
+ } finally {
2972
+ setIsLoading(!1);
2973
+ }
2974
+ })();
2975
+ }, [client]);
2976
+ const handleModeChange = useCallback(
2977
+ (mode) => {
2978
+ dispatch({
2979
+ type: "SET_RELEASE_CONFIG",
2980
+ payload: { mode }
2981
+ });
2982
+ },
2983
+ [dispatch]
2984
+ ), handleTitleChange = useCallback(
2985
+ (event) => {
2986
+ dispatch({
2987
+ type: "SET_RELEASE_CONFIG",
2988
+ payload: { newReleaseTitle: event.target.value }
2989
+ });
2990
+ },
2991
+ [dispatch]
2992
+ ), handleSelectRelease = useCallback(
2993
+ (releaseId) => {
2994
+ dispatch({
2995
+ type: "SET_RELEASE_CONFIG",
2996
+ payload: { existingReleaseId: releaseId }
2997
+ });
2998
+ },
2999
+ [dispatch]
3000
+ ), handleSearchChange = useCallback((event) => {
3001
+ setSearchQuery(event.target.value);
3002
+ }, []), filteredReleases = useMemo(() => {
3003
+ if (!searchQuery.trim())
3004
+ return releases;
3005
+ const query = searchQuery.toLowerCase();
3006
+ return releases.filter((release) => release.metadata.title.toLowerCase().includes(query));
3007
+ }, [releases, searchQuery]), formatDate = (dateString) => new Date(dateString).toLocaleDateString(void 0, {
3008
+ month: "short",
3009
+ day: "numeric",
3010
+ year: "numeric"
3011
+ });
3012
+ return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3013
+ /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3014
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, as: "label", style: { cursor: "pointer" }, onClick: () => handleModeChange("new"), children: [
3015
+ /* @__PURE__ */ jsx(Radio, { checked: releaseConfig.mode === "new", readOnly: !0 }),
3016
+ /* @__PURE__ */ jsx(Text, { weight: "medium", children: "Create new release" })
3017
+ ] }),
3018
+ /* @__PURE__ */ jsxs(
3019
+ Flex,
3020
+ {
3021
+ align: "center",
3022
+ gap: 3,
3023
+ as: "label",
3024
+ style: { cursor: "pointer" },
3025
+ onClick: () => handleModeChange("existing"),
3026
+ children: [
3027
+ /* @__PURE__ */ jsx(Radio, { checked: releaseConfig.mode === "existing", readOnly: !0 }),
3028
+ /* @__PURE__ */ jsx(Text, { weight: "medium", children: "Add to existing release" })
3029
+ ]
3030
+ }
3031
+ )
3032
+ ] }),
3033
+ releaseConfig.mode === "new" && /* @__PURE__ */ jsx(
3034
+ TextInput,
3035
+ {
3036
+ value: releaseConfig.newReleaseTitle,
3037
+ onChange: handleTitleChange,
3038
+ placeholder: "Release title..."
3039
+ }
3040
+ ),
3041
+ releaseConfig.mode === "existing" && /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3042
+ isLoading && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
3043
+ /* @__PURE__ */ jsx(Spinner, { muted: !0 }),
3044
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Loading..." })
3045
+ ] }),
3046
+ error && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: error }),
3047
+ !isLoading && !error && releases.length === 0 && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "No active releases found." }),
3048
+ !isLoading && !error && releases.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
3049
+ /* @__PURE__ */ jsx(
3050
+ TextInput,
3051
+ {
3052
+ icon: SearchIcon,
3053
+ value: searchQuery,
3054
+ onChange: handleSearchChange,
3055
+ placeholder: "Search releases..."
3056
+ }
3057
+ ),
3058
+ /* @__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) => {
3059
+ const releaseName = release._id.replace("_.releases.", ""), isSelected = releaseConfig.existingReleaseId === releaseName;
3060
+ return /* @__PURE__ */ jsx(
3061
+ Card,
3062
+ {
3063
+ padding: 3,
3064
+ radius: 2,
3065
+ tone: isSelected ? "primary" : "default",
3066
+ style: { cursor: "pointer" },
3067
+ onClick: () => handleSelectRelease(releaseName),
3068
+ border: !0,
3069
+ muted: isSelected,
3070
+ children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
3071
+ /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
3072
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
3073
+ ] })
3074
+ },
3075
+ release._id
3076
+ );
3077
+ }) }) })
3078
+ ] })
3079
+ ] })
3080
+ ] }) });
3323
3081
  }, TranslationModePanel = () => {
3324
3082
  const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
3325
3083
  if (!config?.openai?.enabled || !config?.openai?.apiKey)
@@ -3338,7 +3096,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3338
3096
  return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3339
3097
  /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3340
3098
  /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
3341
- /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
3099
+ /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "OpenAI Available" })
3342
3100
  ] }),
3343
3101
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3344
3102
  /* @__PURE__ */ jsx(
@@ -3368,32 +3126,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3368
3126
  /* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
3369
3127
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3370
3128
  /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3371
- /* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
3129
+ /* @__PURE__ */ jsx(Text, { children: "OpenAI Only" }),
3372
3130
  /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
3373
3131
  ] }),
3374
3132
  /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
3375
3133
  ] })
3376
3134
  ] })
3377
3135
  }
3378
- ),
3379
- /* @__PURE__ */ jsx(
3380
- Button,
3381
- {
3382
- type: "button",
3383
- mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
3384
- tone: translationMode === "sanity-with-review" ? "primary" : "default",
3385
- onClick: () => handleModeChange("sanity-with-review"),
3386
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3387
- /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
3388
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3389
- /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3390
- /* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
3391
- /* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
3392
- ] }),
3393
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
3394
- ] })
3395
- ] })
3396
- }
3397
3136
  )
3398
3137
  ] }),
3399
3138
  translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
@@ -3483,8 +3222,104 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3483
3222
  });
3484
3223
  }
3485
3224
  return result;
3225
+ }, ReferenceSelectionPanel = () => {
3226
+ const { references, selectedReferenceIds, dispatch } = useTranslationContext(), [typeFilter, setTypeFilter] = useState("all"), documentTypes = useMemo(() => {
3227
+ const types = new Set(references.map((ref) => ref._type));
3228
+ return Array.from(types).sort();
3229
+ }, [references]), filteredReferences = useMemo(() => typeFilter === "all" ? references : references.filter((ref) => ref._type === typeFilter), [references, typeFilter]), selectedCount = useMemo(() => filteredReferences.filter((ref) => selectedReferenceIds.has(ref._id)).length, [filteredReferences, selectedReferenceIds]), handleToggleReference = (referenceId) => {
3230
+ const newSelection = new Set(selectedReferenceIds);
3231
+ newSelection.has(referenceId) ? newSelection.delete(referenceId) : newSelection.add(referenceId), dispatch({
3232
+ type: "SET_SELECTED_REFERENCE_IDS",
3233
+ payload: newSelection
3234
+ });
3235
+ }, handleSelectAll = () => {
3236
+ const newSelection = new Set(selectedReferenceIds);
3237
+ filteredReferences.forEach((ref) => {
3238
+ newSelection.add(ref._id);
3239
+ }), dispatch({
3240
+ type: "SET_SELECTED_REFERENCE_IDS",
3241
+ payload: newSelection
3242
+ });
3243
+ }, handleSelectNone = () => {
3244
+ const newSelection = new Set(selectedReferenceIds);
3245
+ filteredReferences.forEach((ref) => {
3246
+ newSelection.delete(ref._id);
3247
+ }), dispatch({
3248
+ type: "SET_SELECTED_REFERENCE_IDS",
3249
+ payload: newSelection
3250
+ });
3251
+ };
3252
+ return references.length === 0 ? null : /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3253
+ /* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", gap: 2, children: [
3254
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3255
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "Select References" }),
3256
+ /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3257
+ "(",
3258
+ selectedCount,
3259
+ " of ",
3260
+ filteredReferences.length,
3261
+ " selected)"
3262
+ ] })
3263
+ ] }),
3264
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
3265
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 1, onClick: handleSelectAll, text: "Select All" }),
3266
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 1, onClick: handleSelectNone, text: "Select None" })
3267
+ ] })
3268
+ ] }),
3269
+ documentTypes.length > 1 && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
3270
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Filter by type:" }),
3271
+ /* @__PURE__ */ jsxs(
3272
+ Select,
3273
+ {
3274
+ value: typeFilter,
3275
+ onChange: (event) => setTypeFilter(event.target.value),
3276
+ style: { minWidth: "150px" },
3277
+ children: [
3278
+ /* @__PURE__ */ jsxs("option", { value: "all", children: [
3279
+ "All types (",
3280
+ references.length,
3281
+ ")"
3282
+ ] }),
3283
+ documentTypes.map((type) => {
3284
+ const count = references.filter((ref) => ref._type === type).length;
3285
+ return /* @__PURE__ */ jsxs("option", { value: type, children: [
3286
+ type,
3287
+ " (",
3288
+ count,
3289
+ ")"
3290
+ ] }, type);
3291
+ })
3292
+ ]
3293
+ }
3294
+ )
3295
+ ] }),
3296
+ /* @__PURE__ */ jsx(Box, { style: { maxHeight: "400px", overflowY: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 2, children: filteredReferences.length === 0 ? /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "No references match the selected filter." }) : filteredReferences.map((reference) => {
3297
+ const isSelected = selectedReferenceIds.has(reference._id), displayTitle = reference.title || reference._id;
3298
+ return /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
3299
+ /* @__PURE__ */ jsx(
3300
+ Checkbox,
3301
+ {
3302
+ checked: isSelected,
3303
+ onChange: () => handleToggleReference(reference._id)
3304
+ }
3305
+ ),
3306
+ /* @__PURE__ */ jsxs(Flex, { flex: 1, align: "center", gap: 2, children: [
3307
+ /* @__PURE__ */ jsx(Text, { size: 1, children: displayTitle }),
3308
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: reference._type })
3309
+ ] })
3310
+ ] }) }, reference._id);
3311
+ }) }) })
3312
+ ] }) });
3486
3313
  }, ReferenceConfigPanel = () => {
3487
- const { dispatch, document, shouldTranslateReferences, maxReferenceDepth, config, references } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1), maxDepth = config.maxReferenceDepth || 3;
3314
+ const {
3315
+ dispatch,
3316
+ document,
3317
+ shouldTranslateReferences,
3318
+ maxReferenceDepth,
3319
+ config,
3320
+ references,
3321
+ selectedReferenceIds
3322
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1), [showSelectionPanel, setShowSelectionPanel] = useState(!1), maxDepth = config.maxReferenceDepth || 3;
3488
3323
  useEffect(() => {
3489
3324
  (async () => {
3490
3325
  if (setIsLoadingReferences(!0), !shouldTranslateReferences) {
@@ -3554,43 +3389,26 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3554
3389
  )
3555
3390
  ] }) : null
3556
3391
  ] }),
3557
- 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
3558
- ] });
3559
- }, WriteSettingsPanel = () => {
3560
- const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
3561
- dispatch({
3562
- type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
3563
- payload: !shouldOverwriteFreshTranslations
3564
- });
3565
- };
3566
- return /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3567
- /* @__PURE__ */ jsx(ReferenceConfigPanel, {}),
3568
- /* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
3569
- Button,
3570
- {
3571
- type: "button",
3572
- mode: shouldOverwriteFreshTranslations ? "ghost" : "bleed",
3573
- size: 2,
3574
- onClick: handleShouldOverwriteFreshTranslationsChange,
3575
- tone: shouldOverwriteFreshTranslations ? "primary" : "neutral",
3576
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3577
- /* @__PURE__ */ jsx(
3578
- Checkbox,
3579
- {
3580
- checked: shouldOverwriteFreshTranslations,
3581
- onChange: handleShouldOverwriteFreshTranslationsChange,
3582
- label: shouldOverwriteFreshTranslations ? "Disable" : "Enable"
3583
- }
3584
- ),
3585
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3586
- /* @__PURE__ */ jsx(Text, { children: "Overwrite Fresh Translations" }),
3587
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "All existing translations will be overwritten even if they are fresh." })
3588
- ] })
3589
- ] })
3590
- }
3591
- ) })
3392
+ shouldTranslateReferences ? /* @__PURE__ */ jsxs(Fragment, { children: [
3393
+ /* @__PURE__ */ jsxs(Flex, { padding: 2, justify: "space-between", align: "center", children: [
3394
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingReferences ? "Loading references..." : references.length === 0 ? "No translatable references found." : (() => {
3395
+ const selectedCount = references.filter((ref) => selectedReferenceIds.has(ref._id)).length, totalCount = references.length;
3396
+ return selectedCount === totalCount ? `${totalCount} reference${totalCount === 1 ? "" : "s"} will be translated along with this document.` : `${selectedCount} of ${totalCount} reference${totalCount === 1 ? "" : "s"} will be translated along with this document.`;
3397
+ })() }),
3398
+ !isLoadingReferences && references.length > 0 && /* @__PURE__ */ jsx(
3399
+ Button,
3400
+ {
3401
+ mode: "ghost",
3402
+ size: 1,
3403
+ text: showSelectionPanel ? "Hide selection" : "Select specific references...",
3404
+ onClick: () => setShowSelectionPanel(!showSelectionPanel)
3405
+ }
3406
+ )
3407
+ ] }),
3408
+ !isLoadingReferences && references.length > 0 && showSelectionPanel && /* @__PURE__ */ jsx(ReferenceSelectionPanel, {})
3409
+ ] }) : null
3592
3410
  ] });
3593
- }, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3411
+ }, WriteSettingsPanel = () => /* @__PURE__ */ jsx(ReferenceConfigPanel, {}), TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3594
3412
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3595
3413
  /* @__PURE__ */ jsx(Label, { children: "Languages" }),
3596
3414
  /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
@@ -3599,16 +3417,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3599
3417
  /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
3600
3418
  /* @__PURE__ */ jsx(TranslationModePanel, {})
3601
3419
  ] }),
3420
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3421
+ /* @__PURE__ */ jsx(Label, { children: "Content Release" }),
3422
+ /* @__PURE__ */ jsx(ReleaseSelectionPanel, {})
3423
+ ] }),
3602
3424
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3603
3425
  /* @__PURE__ */ jsx(Label, { children: "Settings" }),
3604
3426
  /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
3605
3427
  ] }),
3606
3428
  /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
3607
3429
  ] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
3608
- const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
3609
- () => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
3610
- [overallStatus]
3611
- ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
3430
+ 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(() => {
3431
+ forceCleanupActiveTranslation(document._id), dispatch({ type: "RESET_TRANSLATION_STATE" });
3432
+ }, [dispatch, document._id]), openaiStats = useMemo(() => {
3612
3433
  if (translationMode === "sanity-only") return null;
3613
3434
  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);
3614
3435
  return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
@@ -3642,17 +3463,12 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3642
3463
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3643
3464
  "Error: ",
3644
3465
  job.errorMessage
3645
- ] }) }) : null,
3646
- job.status === "retrying" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3647
- "Retrying after: ",
3648
- job.errorMessage
3649
3466
  ] }) }) : null
3650
3467
  ] })
3651
3468
  ] }),
3652
3469
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3653
3470
  job.status === "skipped" ? /* @__PURE__ */ jsx(SkippedBadge, {}) : null,
3654
- job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, { retryCount: job.retryCount }) : null,
3655
- job.status === "retrying" ? /* @__PURE__ */ jsx(RetryingBadge, { retryCount: job.retryCount }) : null,
3471
+ job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, {}) : null,
3656
3472
  job.targetId ? /* @__PURE__ */ jsx(
3657
3473
  TranslationOpenerButton,
3658
3474
  {
@@ -3669,18 +3485,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3669
3485
  /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
3670
3486
  /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
3671
3487
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3672
- "\u{1F4CA} Total Tokens: ",
3488
+ "Total Tokens: ",
3673
3489
  /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
3674
3490
  ] }),
3675
3491
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3676
- "\u{1F4B0} Total Cost: ",
3492
+ "Total Cost: ",
3677
3493
  /* @__PURE__ */ jsxs("strong", { children: [
3678
3494
  "$",
3679
3495
  openaiStats.totalCost.toFixed(4)
3680
3496
  ] })
3681
3497
  ] })
3682
3498
  ] })
3683
- ] }) })
3499
+ ] }) }),
3500
+ isTerminalState && /* @__PURE__ */ jsx(Flex, { justify: "flex-end", children: /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "primary", text: "Translate More", icon: ResetIcon, onClick: handleReset }) })
3684
3501
  ] });
3685
3502
  }, ProgressBar = ({ progress, max = 100 }) => {
3686
3503
  const percentage = Math.min(progress / max * 100, 100);
@@ -3732,35 +3549,20 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3732
3549
  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." }) }),
3733
3550
  children: /* @__PURE__ */ jsx(Badge, { tone: "neutral", children: "Skipped" })
3734
3551
  }
3735
- ), ErrorBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3736
- Tooltip,
3737
- {
3738
- portal: !0,
3739
- 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." }) }),
3740
- children: /* @__PURE__ */ jsxs(Badge, { tone: "critical", children: [
3741
- "Failed",
3742
- retryCount && retryCount > 0 ? ` (${retryCount} retries)` : ""
3743
- ] })
3744
- }
3745
- ), RetryingBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3552
+ ), ErrorBadge = () => /* @__PURE__ */ jsx(
3746
3553
  Tooltip,
3747
3554
  {
3748
3555
  portal: !0,
3749
- content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Retrying translation after a temporary failure." }) }),
3750
- children: /* @__PURE__ */ jsxs(Badge, { tone: "caution", children: [
3751
- "Retry ",
3752
- retryCount || 1
3753
- ] })
3556
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
3557
+ children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
3754
3558
  }
3755
3559
  ), getStatusLabel = (status) => ({
3756
3560
  idle: "Idle",
3757
3561
  queued: "Queued...",
3758
3562
  preparing_translation: "Preparing translation...",
3759
3563
  translating: "Translating with Sanity Agent...",
3760
- translating_with_openai: "Translating with GPT-5...",
3761
- reviewing_with_openai: "Reviewing translation with GPT-5...",
3564
+ translating_with_openai: "Translating with OpenAI...",
3762
3565
  updating_metadata: "Saving metadata...",
3763
- retrying: "Retrying...",
3764
3566
  error: "Error",
3765
3567
  skipped: "Skipped",
3766
3568
  completed: "Completed"
@@ -3768,14 +3570,30 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3768
3570
  "translating",
3769
3571
  "linking_references",
3770
3572
  "completed"
3771
- ], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen }) => {
3573
+ ], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen, onStatusChange }) => {
3772
3574
  const { overallStatus, document } = useTranslationContext();
3773
- 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", {
3575
+ return useEffect(() => {
3576
+ onStatusChange?.(overallStatus);
3577
+ }, [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", {
3774
3578
  documentId: document._id,
3775
3579
  overallStatus
3776
3580
  }), /* @__PURE__ */ jsx("div", { children: "Unhandled overall state" }));
3777
- }, getTranslateAction = (config) => ({ draft, published }) => {
3778
- const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1);
3581
+ }, IN_PROGRESS_STATUSES = ["translating", "linking_references"], getButtonLabel = (status) => status === "completed" ? "Translation Complete" : status && IN_PROGRESS_STATUSES.includes(status) ? "Translating..." : "Translate", getTranslateAction = (config) => ({ draft, published }) => {
3582
+ const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1), [currentStatus, setCurrentStatus] = useState(null), isTranslating = currentStatus !== null && IN_PROGRESS_STATUSES.includes(currentStatus), handleStatusChange = useCallback((status) => {
3583
+ setCurrentStatus(status);
3584
+ }, []), handleClose = useCallback(() => {
3585
+ if (isTranslating) {
3586
+ if (!window.confirm(
3587
+ "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."
3588
+ ))
3589
+ return;
3590
+ devLog.warn("User closed dialog during active translation", {
3591
+ documentId: document?._id,
3592
+ component: "TranslateAction"
3593
+ });
3594
+ }
3595
+ setIsDialogOpen(!1);
3596
+ }, [isTranslating, document?._id]);
3779
3597
  return devLog.debug("TranslateAction initialized", {
3780
3598
  documentId: document?._id,
3781
3599
  documentType: document?._type
@@ -3783,7 +3601,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3783
3601
  documentType: document?._type,
3784
3602
  translatableTypes: config.translatableSchemaTypes
3785
3603
  }), null) : config ? {
3786
- label: "Translate",
3604
+ label: getButtonLabel(currentStatus),
3787
3605
  icon: TranslateIcon,
3788
3606
  onHandle: () => {
3789
3607
  devLog.info("Translation dialog opened", {
@@ -3794,9 +3612,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3794
3612
  },
3795
3613
  dialog: isDialogOpen && {
3796
3614
  type: "dialog",
3797
- onClose: () => setIsDialogOpen(!1),
3615
+ onClose: handleClose,
3798
3616
  header: "Translate Document",
3799
- content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen }) })
3617
+ content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen, onStatusChange: handleStatusChange }) })
3800
3618
  }
3801
3619
  } : (devLog.error("Missing config in TranslateAction"), null);
3802
3620
  }, translationGlossaryTerm = defineType({
@@ -3912,7 +3730,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3912
3730
  defineField({
3913
3731
  name: "changeHistory",
3914
3732
  title: "Change History",
3915
- description: "History of changes made via user feedback.",
3733
+ description: "History of changes made to translation settings.",
3916
3734
  type: "array",
3917
3735
  readOnly: !0,
3918
3736
  group: "history",
@@ -4164,10 +3982,14 @@ function serializeVTTCaptionText(captions) {
4164
3982
  firstCaption: captions.length > 0 ? captions[0] : null
4165
3983
  }), !Array.isArray(captions) || captions.length === 0)
4166
3984
  return "";
4167
- const result = captions.map((caption, index) => (devLog.debug(`Processing caption ${index}:`, {
4168
- text: caption.text,
4169
- hasText: typeof caption.text == "string"
4170
- }), `[${index}] ${caption.text || ""}`)).join(`
3985
+ const result = captions.map((caption, index) => {
3986
+ devLog.debug(`Processing caption ${index}:`, {
3987
+ text: caption.text,
3988
+ hasText: typeof caption.text == "string"
3989
+ });
3990
+ const escapedText = (caption.text || "").replace(/\n/g, "\\n");
3991
+ return `[${index}] ${escapedText}`;
3992
+ }).join(`
4171
3993
  `);
4172
3994
  return devLog.debug("serializeVTTCaptionText - output:", {
4173
3995
  resultLength: result.length,
@@ -4184,29 +4006,46 @@ function deserializeVTTCaptionText(translationText, originalMetadata) {
4184
4006
  return [];
4185
4007
  const result = [], lines = translationText.split(`
4186
4008
  `), lineRegex = /^\[(\d+)\]\s*(.*)$/;
4009
+ let currentIndex = null, currentTextParts = [];
4187
4010
  for (const line of lines) {
4188
4011
  const match = line.match(lineRegex);
4189
- if (!match) {
4190
- devLog.warn("Failed to parse caption line", { line });
4191
- continue;
4192
- }
4193
- const index = Number.parseInt(match[1], 10), text = match[2];
4194
- devLog.debug("Found caption:", {
4195
- index,
4196
- text
4197
- });
4198
- const metadata = originalMetadata.find((m) => m._originalIndex === index);
4012
+ if (match) {
4013
+ if (currentIndex !== null) {
4014
+ const text = currentTextParts.join(`
4015
+ `).replace(/\\n/g, `
4016
+ `), metadata = originalMetadata.find((m) => m._originalIndex === currentIndex);
4017
+ if (metadata) {
4018
+ const { _originalIndex, ...metadataWithoutIndex } = metadata;
4019
+ result.push({
4020
+ ...metadataWithoutIndex,
4021
+ text
4022
+ });
4023
+ } else
4024
+ devLog.warn("Missing metadata for caption", { index: currentIndex }), result.push({
4025
+ _type: "vtt.cue",
4026
+ _key: `caption-${currentIndex}`,
4027
+ text,
4028
+ startTime: 0,
4029
+ endTime: 0
4030
+ });
4031
+ }
4032
+ currentIndex = Number.parseInt(match[1], 10), currentTextParts = [match[2]];
4033
+ } else currentIndex !== null ? currentTextParts.push(line) : devLog.warn("Failed to parse caption line", { line });
4034
+ }
4035
+ if (currentIndex !== null) {
4036
+ const text = currentTextParts.join(`
4037
+ `).replace(/\\n/g, `
4038
+ `), metadata = originalMetadata.find((m) => m._originalIndex === currentIndex);
4199
4039
  if (metadata) {
4200
- devLog.debug(`Found matching metadata for index ${index}`);
4201
4040
  const { _originalIndex, ...metadataWithoutIndex } = metadata;
4202
4041
  result.push({
4203
4042
  ...metadataWithoutIndex,
4204
4043
  text
4205
4044
  });
4206
4045
  } else
4207
- devLog.warn("Missing metadata for caption", { index }), result.push({
4046
+ devLog.warn("Missing metadata for caption", { index: currentIndex }), result.push({
4208
4047
  _type: "vtt.cue",
4209
- _key: `caption-${index}`,
4048
+ _key: `caption-${currentIndex}`,
4210
4049
  text,
4211
4050
  startTime: 0,
4212
4051
  endTime: 0
@@ -4217,12 +4056,299 @@ function deserializeVTTCaptionText(translationText, originalMetadata) {
4217
4056
  resultExample: result.length > 0 ? result[0] : null
4218
4057
  }), result;
4219
4058
  }
4059
+ const LOCALE_TO_ISO = {
4060
+ // English variants
4061
+ "en-us": "eng",
4062
+ "en-US": "eng",
4063
+ "en-gb": "eng",
4064
+ "en-GB": "eng",
4065
+ "en-ca": "eng",
4066
+ "en-CA": "eng",
4067
+ "en-au": "eng",
4068
+ "en-AU": "eng",
4069
+ // Spanish variants
4070
+ es: "spa",
4071
+ "es-es": "spa",
4072
+ "es-ES": "spa",
4073
+ "es-mx": "spa",
4074
+ "es-MX": "spa",
4075
+ // French variants
4076
+ fr: "fra",
4077
+ "fr-fr": "fra",
4078
+ "fr-FR": "fra",
4079
+ "fr-ca": "fra",
4080
+ "fr-CA": "fra",
4081
+ // German variants
4082
+ de: "ger",
4083
+ "de-de": "ger",
4084
+ "de-DE": "ger",
4085
+ "de-at": "ger",
4086
+ "de-AT": "ger",
4087
+ // Italian variants
4088
+ it: "ita",
4089
+ "it-it": "ita",
4090
+ "it-IT": "ita",
4091
+ // Portuguese variants
4092
+ pt: "por",
4093
+ "pt-br": "por",
4094
+ "pt-BR": "por",
4095
+ "pt-pt": "por",
4096
+ "pt-PT": "por",
4097
+ // Russian variants
4098
+ ru: "rus",
4099
+ "ru-ru": "rus",
4100
+ "ru-RU": "rus",
4101
+ // Japanese variants
4102
+ ja: "jpn",
4103
+ "ja-jp": "jpn",
4104
+ "ja-JP": "jpn",
4105
+ // Korean variants
4106
+ ko: "kor",
4107
+ "ko-kr": "kor",
4108
+ "ko-KR": "kor",
4109
+ // Chinese variants
4110
+ zh: "chi",
4111
+ "zh-cn": "chi",
4112
+ "zh-CN": "chi",
4113
+ "zh-tw": "chi",
4114
+ "zh-TW": "chi",
4115
+ "zh-hk": "chi",
4116
+ "zh-HK": "chi"
4117
+ }, normalizeToIsoLanguageCode = (code) => {
4118
+ const normalizedCode = code.toLowerCase();
4119
+ return LOCALE_TO_ISO[normalizedCode];
4120
+ }, wistiaCaptionTransform = {
4121
+ matcher: {
4122
+ fieldName: "wistiaCaptions"
4123
+ },
4124
+ toTranslatable: (wistiaCaptions, context) => {
4125
+ if (devLog.info("Wistia captions toTranslatable - input:", {
4126
+ fieldPath: context.fieldPath,
4127
+ hasCaptionContent: !!wistiaCaptions?.captionContent,
4128
+ captionContentLength: wistiaCaptions?.captionContent?.length ?? 0,
4129
+ isoLanguage: wistiaCaptions?.isoLanguage
4130
+ }), !wistiaCaptions || !wistiaCaptions.captionContent)
4131
+ return devLog.debug("Wistia caption field is missing or has no content, returning empty transform", {
4132
+ fieldPath: context.fieldPath,
4133
+ value: wistiaCaptions
4134
+ }), {
4135
+ _type: "translatable.wistia.captions",
4136
+ translationText: "",
4137
+ _originalMetadata: {
4138
+ captionFileName: wistiaCaptions?.captionFileName,
4139
+ isoLanguage: wistiaCaptions?.isoLanguage,
4140
+ captionUploadStatus: wistiaCaptions?.captionUploadStatus,
4141
+ captionUploadMessage: wistiaCaptions?.captionUploadMessage,
4142
+ _originalIndex: 0
4143
+ },
4144
+ _srtCues: []
4145
+ };
4146
+ devLog.info("Applying Wistia caption transform", {
4147
+ fieldPath: context.fieldPath,
4148
+ captionContentPreview: wistiaCaptions.captionContent.substring(0, 200)
4149
+ });
4150
+ const srtCues = parseSRTContent(wistiaCaptions.captionContent);
4151
+ devLog.debug("Parsed SRT cues:", {
4152
+ fieldPath: context.fieldPath,
4153
+ cueCount: srtCues.length,
4154
+ firstCue: srtCues.length > 0 ? srtCues[0] : null
4155
+ });
4156
+ const translationText = serializeWistiaCaptionText(srtCues), originalMetadata = {
4157
+ captionFileName: wistiaCaptions.captionFileName,
4158
+ isoLanguage: wistiaCaptions.isoLanguage,
4159
+ captionUploadStatus: wistiaCaptions.captionUploadStatus,
4160
+ captionUploadMessage: wistiaCaptions.captionUploadMessage,
4161
+ _originalIndex: 0
4162
+ }, srtCuesMetadata = srtCues.map((cue) => ({
4163
+ index: cue.index,
4164
+ startTime: cue.startTime,
4165
+ endTime: cue.endTime
4166
+ }));
4167
+ return devLog.info("Wistia captions serialized:", {
4168
+ fieldPath: context.fieldPath,
4169
+ translationTextLength: translationText.length,
4170
+ translationTextPreview: translationText.substring(0, 200) + (translationText.length > 200 ? "..." : ""),
4171
+ cueCount: srtCues.length
4172
+ }), {
4173
+ _type: "translatable.wistia.captions",
4174
+ translationText,
4175
+ _originalMetadata: originalMetadata,
4176
+ _srtCues: srtCuesMetadata
4177
+ };
4178
+ },
4179
+ fromTranslatable: (translatedValue, originalValue, context) => {
4180
+ if (devLog.info("Wistia captions fromTranslatable - input:", {
4181
+ fieldPath: context.fieldPath,
4182
+ valueType: typeof translatedValue,
4183
+ isCorrectType: translatedValue._type === "translatable.wistia.captions",
4184
+ hasText: !!translatedValue.translationText,
4185
+ hasMetadata: !!translatedValue._originalMetadata,
4186
+ hasCues: Array.isArray(translatedValue._srtCues)
4187
+ }), !translatedValue || translatedValue._type !== "translatable.wistia.captions" || !translatedValue._originalMetadata || !Array.isArray(translatedValue._srtCues))
4188
+ return devLog.debug("Value is not a translatable Wistia caption object, returning original", {
4189
+ fieldPath: context.fieldPath,
4190
+ valueType: typeof translatedValue,
4191
+ translatedValue
4192
+ }), originalValue || {};
4193
+ devLog.info("Processing translated Wistia captions", {
4194
+ fieldPath: context.fieldPath,
4195
+ cueCount: translatedValue._srtCues.length
4196
+ });
4197
+ const { translationText, _originalMetadata, _srtCues } = translatedValue;
4198
+ devLog.info("Wistia captions translationText:", {
4199
+ fieldPath: context.fieldPath,
4200
+ translationTextLength: translationText.length,
4201
+ translationTextPreview: translationText.substring(0, 200) + (translationText.length > 200 ? "..." : "")
4202
+ });
4203
+ const reconstructedCaptionContent = deserializeWistiaCaptionText(translationText, _srtCues), documentLanguage = getDocumentLanguage(context.document);
4204
+ let updatedIsoLanguage = _originalMetadata.isoLanguage;
4205
+ if (documentLanguage) {
4206
+ const isoCode = normalizeToIsoLanguageCode(documentLanguage);
4207
+ isoCode ? (updatedIsoLanguage = isoCode, devLog.debug("Updated isoLanguage based on document language", {
4208
+ fieldPath: context.fieldPath,
4209
+ documentLanguage,
4210
+ isoCode
4211
+ })) : devLog.debug("Could not convert document language to ISO code, preserving original", {
4212
+ fieldPath: context.fieldPath,
4213
+ documentLanguage,
4214
+ originalIsoLanguage: _originalMetadata.isoLanguage
4215
+ });
4216
+ } else
4217
+ devLog.debug("Document language field not found, preserving original isoLanguage", {
4218
+ fieldPath: context.fieldPath,
4219
+ originalIsoLanguage: _originalMetadata.isoLanguage
4220
+ });
4221
+ const result = {
4222
+ captionContent: reconstructedCaptionContent,
4223
+ captionFileName: _originalMetadata.captionFileName,
4224
+ isoLanguage: updatedIsoLanguage,
4225
+ captionUploadStatus: _originalMetadata.captionUploadStatus,
4226
+ captionUploadMessage: _originalMetadata.captionUploadMessage
4227
+ };
4228
+ return devLog.info("Wistia captions final result:", {
4229
+ fieldPath: context.fieldPath,
4230
+ hasCaptionContent: !!result.captionContent,
4231
+ captionContentLength: result.captionContent?.length ?? 0,
4232
+ isoLanguage: result.isoLanguage
4233
+ }), result;
4234
+ }
4235
+ };
4236
+ function parseSRTContent(content) {
4237
+ if (devLog.debug("parseSRTContent - input:", {
4238
+ contentLength: content.length,
4239
+ contentPreview: content.substring(0, 100) + (content.length > 100 ? "..." : "")
4240
+ }), !content || content.trim() === "")
4241
+ return [];
4242
+ let normalizedContent = content;
4243
+ content.includes("\\n") && !content.includes(`
4244
+ `) && (normalizedContent = content.replace(/\\n/g, `
4245
+ `), devLog.debug("Unescaped newlines in SRT content", {
4246
+ originalLength: content.length,
4247
+ normalizedLength: normalizedContent.length
4248
+ }));
4249
+ const cues = [], cueBlocks = normalizedContent.split(/\n\s*\n/);
4250
+ for (const block of cueBlocks) {
4251
+ const lines = block.trim().split(`
4252
+ `);
4253
+ if (lines.length < 3)
4254
+ continue;
4255
+ const cueNumber = Number.parseInt(lines[0].trim(), 10);
4256
+ if (Number.isNaN(cueNumber)) {
4257
+ devLog.warn("Invalid SRT cue number", { line: lines[0] });
4258
+ continue;
4259
+ }
4260
+ const timestampLine = lines[1].trim(), timestampMatch = timestampLine.match(/^(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})$/);
4261
+ if (!timestampMatch) {
4262
+ devLog.warn("Invalid SRT timestamp format", { line: timestampLine });
4263
+ continue;
4264
+ }
4265
+ const startTime = timestampMatch[1].replace(".", ","), endTime = timestampMatch[2].replace(".", ","), text = lines.slice(2).join(`
4266
+ `).trim();
4267
+ text && cues.push({
4268
+ index: cueNumber,
4269
+ startTime,
4270
+ endTime,
4271
+ text
4272
+ });
4273
+ }
4274
+ return devLog.debug("parseSRTContent - output:", {
4275
+ cueCount: cues.length,
4276
+ firstCue: cues.length > 0 ? cues[0] : null
4277
+ }), cues;
4278
+ }
4279
+ function serializeWistiaCaptionText(cues) {
4280
+ if (devLog.debug("serializeWistiaCaptionText - input:", {
4281
+ cuesCount: cues.length,
4282
+ firstCue: cues.length > 0 ? cues[0] : null
4283
+ }), !Array.isArray(cues) || cues.length === 0)
4284
+ return "";
4285
+ const result = cues.map((cue) => {
4286
+ devLog.debug(`Processing cue ${cue.index}:`, {
4287
+ text: cue.text,
4288
+ hasText: typeof cue.text == "string"
4289
+ });
4290
+ const escapedText = cue.text.replace(/\n/g, "\\n");
4291
+ return `[${cue.index}] ${escapedText}`;
4292
+ }).join(`
4293
+ `);
4294
+ return devLog.debug("serializeWistiaCaptionText - output:", {
4295
+ resultLength: result.length,
4296
+ resultPreview: result.substring(0, 100) + (result.length > 100 ? "..." : "")
4297
+ }), result;
4298
+ }
4299
+ function deserializeWistiaCaptionText(translationText, srtCuesMetadata) {
4300
+ if (devLog.debug("deserializeWistiaCaptionText - input:", {
4301
+ translationTextLength: translationText.length,
4302
+ translationTextPreview: translationText.substring(0, 100) + (translationText.length > 100 ? "..." : ""),
4303
+ metadataCount: srtCuesMetadata.length,
4304
+ metadataExample: srtCuesMetadata.length > 0 ? srtCuesMetadata[0] : null
4305
+ }), !translationText || translationText.trim() === "")
4306
+ return "";
4307
+ const result = [], lines = translationText.split(`
4308
+ `), lineRegex = /^\[(\d+)\]\s*(.*)$/, metadataByIndex = /* @__PURE__ */ new Map();
4309
+ for (const metadata of srtCuesMetadata)
4310
+ metadataByIndex.set(metadata.index, { startTime: metadata.startTime, endTime: metadata.endTime });
4311
+ let currentIndex = null, currentTextParts = [];
4312
+ for (const line of lines) {
4313
+ const match = line.match(lineRegex);
4314
+ if (match) {
4315
+ if (currentIndex !== null) {
4316
+ const text = currentTextParts.join(`
4317
+ `).replace(/\\n/g, `
4318
+ `), metadata = metadataByIndex.get(currentIndex);
4319
+ metadata ? (result.push(`${currentIndex}`), result.push(`${metadata.startTime} --> ${metadata.endTime}`), result.push(text), result.push("")) : devLog.warn("Missing metadata for caption", { index: currentIndex });
4320
+ }
4321
+ currentIndex = Number.parseInt(match[1], 10), currentTextParts = [match[2]];
4322
+ } else currentIndex !== null ? currentTextParts.push(line) : devLog.warn("Failed to parse caption line", { line });
4323
+ }
4324
+ if (currentIndex !== null) {
4325
+ const text = currentTextParts.join(`
4326
+ `).replace(/\\n/g, `
4327
+ `), metadata = metadataByIndex.get(currentIndex);
4328
+ metadata ? (result.push(`${currentIndex}`), result.push(`${metadata.startTime} --> ${metadata.endTime}`), result.push(text), result.push("")) : devLog.warn("Missing metadata for caption", { index: currentIndex });
4329
+ }
4330
+ result.length > 0 && result[result.length - 1] === "" && result.pop();
4331
+ const reconstructed = result.join(`
4332
+ `);
4333
+ return devLog.debug("deserializeWistiaCaptionText - output:", {
4334
+ resultLength: reconstructed.length,
4335
+ resultPreview: reconstructed.substring(0, 200) + (reconstructed.length > 200 ? "..." : "")
4336
+ }), reconstructed;
4337
+ }
4338
+ function getDocumentLanguage(document) {
4339
+ if (!document || typeof document != "object")
4340
+ return;
4341
+ const languageFieldNames = ["language", "lang", "locale"];
4342
+ for (const fieldName of languageFieldNames)
4343
+ if (fieldName in document && typeof document[fieldName] == "string")
4344
+ return document[fieldName];
4345
+ }
4220
4346
  const aiTranslationPlugin = definePlugin((config) => (config.fieldTransforms?.length && (devLog.info(`Registering ${config.fieldTransforms.length} custom field transforms`), config.fieldTransforms.forEach((transform) => {
4221
4347
  globalTransformRegistry.register(transform);
4222
4348
  })), {
4223
4349
  name: "sanity-plugin-ai-translation",
4224
4350
  document: {
4225
- actions: [getTranslateAction(config), getGiveTranslationFeedbackAction(config)]
4351
+ actions: [getTranslateAction(config)]
4226
4352
  },
4227
4353
  schema: {
4228
4354
  types: [translationSettings, translationGlossaryTerm]
@@ -4236,6 +4362,7 @@ export {
4236
4362
  restoreExcludedFields,
4237
4363
  richTextTransform,
4238
4364
  translationHistorySchema,
4239
- vttCaptionTransform
4365
+ vttCaptionTransform,
4366
+ wistiaCaptionTransform
4240
4367
  };
4241
4368
  //# sourceMappingURL=index.js.map