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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
- import { useClient, getPublishedId, pathToString, defineType, defineField, definePlugin } from "sanity";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, WarningOutlineIcon, InfoOutlineIcon, ArrowLeftIcon, EditIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
4
- import { createContext, useReducer, useEffect, useContext, useState, useCallback, useMemo } from "react";
1
+ import { getPublishedId, pathToString, useClient, defineType, defineField, definePlugin } from "sanity";
2
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
+ import { WarningOutlineIcon, SearchIcon, CheckmarkIcon, ErrorOutlineIcon, CircleIcon, ArrowRightIcon, ResetIcon, PanelRightIcon, TranslateIcon } from "@sanity/icons";
4
+ import { createContext, useReducer, useEffect, useContext, useMemo, useState, useCallback } from "react";
5
5
  import defaults from "lodash.defaults";
6
- import { Card, Flex, Stack, Text, Spinner, Button, Box, Tooltip, Checkbox, Select, TextInput, TextArea, Heading, Inline, Badge, Grid, Radio, Label, Code } from "@sanity/ui";
7
- import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
6
+ import { Card, Stack, Flex, Text, Box, Heading, Button, Spinner, Inline, Checkbox, Grid, Radio, TextInput, Badge, Select, Label, Code, Tooltip } from "@sanity/ui";
8
7
  import { toPlainText } from "@portabletext/react";
9
8
  import { uuid } from "@sanity/uuid";
10
9
  import cloneDeep from "lodash.clonedeep";
11
10
  import get from "lodash.get";
12
11
  import set from "lodash.set";
12
+ import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
13
13
  const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true" || process.env.SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true", devLog = {
14
14
  info: (message, context) => {
15
15
  isLoggingEnabled() && console.info("[AI Translation]", message, context ? JSON.stringify(context, null, 2) : "");
@@ -62,19 +62,63 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
62
62
  error: error instanceof Error ? error.message : "Network error"
63
63
  };
64
64
  }
65
+ }, activeTranslations = /* @__PURE__ */ new Map(), registerActiveTranslation = (documentId, releaseId) => {
66
+ const entry = {
67
+ state: {
68
+ documentId,
69
+ releaseId,
70
+ overallStatus: "translating",
71
+ translationJobs: /* @__PURE__ */ new Map(),
72
+ startedAt: /* @__PURE__ */ new Date()
73
+ },
74
+ subscribers: /* @__PURE__ */ new Set()
75
+ };
76
+ activeTranslations.set(documentId, entry);
77
+ }, updateActiveTranslationStatus = (documentId, status) => {
78
+ const entry = activeTranslations.get(documentId);
79
+ entry && (entry.state.overallStatus = status, notifySubscribers(entry));
80
+ }, updateActiveTranslationJob = (documentId, job) => {
81
+ const entry = activeTranslations.get(documentId);
82
+ if (entry) {
83
+ const jobKey = `${job._id}_${job.language}`, existingJob = entry.state.translationJobs.get(jobKey), mergedJob = existingJob ? { ...existingJob, ...job } : job;
84
+ entry.state.translationJobs.set(jobKey, mergedJob), notifySubscribers(entry);
85
+ }
86
+ }, getActiveTranslation = (documentId) => activeTranslations.get(documentId)?.state ?? null, subscribeToActiveTranslation = (documentId, callback) => {
87
+ const entry = activeTranslations.get(documentId);
88
+ return entry ? (entry.subscribers.add(callback), callback(entry.state), () => {
89
+ entry.subscribers.delete(callback);
90
+ }) : () => {
91
+ };
92
+ }, cleanupActiveTranslation = (documentId, delayMs = 6e4) => {
93
+ setTimeout(() => {
94
+ const entry = activeTranslations.get(documentId);
95
+ entry && ["completed", "error"].includes(entry.state.overallStatus) && activeTranslations.delete(documentId);
96
+ }, delayMs);
97
+ }, notifySubscribers = (entry) => {
98
+ for (const subscriber of entry.subscribers)
99
+ try {
100
+ subscriber(entry.state);
101
+ } catch (error) {
102
+ console.error("Error in active translation subscriber:", error);
103
+ }
104
+ }, forceCleanupActiveTranslation = (documentId) => {
105
+ activeTranslations.delete(documentId);
65
106
  }, TranslationContext = createContext({
66
107
  document: null,
67
108
  config: null,
68
109
  overallStatus: "idle",
69
110
  selectedTargetLanguages: [],
70
111
  translationJobs: /* @__PURE__ */ new Map(),
71
- shouldOverwriteFreshTranslations: !1,
72
112
  shouldTranslateReferences: !1,
73
113
  maxReferenceDepth: 1,
74
114
  references: [],
75
115
  translationMetadata: null,
76
116
  translationMode: "sanity-only",
77
117
  openaiValidated: !1,
118
+ releaseConfig: {
119
+ mode: "new",
120
+ newReleaseTitle: ""
121
+ },
78
122
  dispatch: () => {
79
123
  }
80
124
  }), translationReducer = (context, action) => {
@@ -85,7 +129,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
85
129
  };
86
130
  switch (devLog.debug("Translation action dispatched", {
87
131
  ...logContext,
88
- payload: action.payload
132
+ payload: "payload" in action ? action.payload : void 0
89
133
  }), action.type) {
90
134
  case "SET_OVERALL_STATUS":
91
135
  return { ...context, overallStatus: action.payload };
@@ -116,11 +160,6 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
116
160
  ...context,
117
161
  maxReferenceDepth: action.payload
118
162
  };
119
- case "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS":
120
- return {
121
- ...context,
122
- shouldOverwriteFreshTranslations: action.payload
123
- };
124
163
  case "SET_REFERENCES":
125
164
  return {
126
165
  ...context,
@@ -133,31 +172,82 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
133
172
  translationJobs: newTranslationJobs
134
173
  };
135
174
  }
175
+ case "SET_RELEASE_CONFIG":
176
+ return {
177
+ ...context,
178
+ releaseConfig: {
179
+ ...context.releaseConfig,
180
+ ...action.payload
181
+ }
182
+ };
183
+ case "RESET_TRANSLATION_STATE":
184
+ return devLog.info("Resetting translation state for new translation", {
185
+ ...logContext,
186
+ previousStatus: context.overallStatus,
187
+ previousJobCount: context.translationJobs.size
188
+ }), {
189
+ ...context,
190
+ overallStatus: "idle",
191
+ translationJobs: /* @__PURE__ */ new Map(),
192
+ // Clear any release conflicts from previous run
193
+ releaseConfig: {
194
+ ...context.releaseConfig,
195
+ conflicts: void 0,
196
+ replaceExisting: void 0
197
+ }
198
+ };
136
199
  default:
137
200
  return context;
138
201
  }
139
- }, TranslationContextProvider = ({ document, config, children }) => {
202
+ }, generateDefaultReleaseTitle = (document) => `[AI Translation] ${document.title || document.name || document._id.split("-").slice(0, 2).join("-")}`, TranslationContextProvider = ({ document, config, children }) => {
140
203
  devLog.debug("TranslationContextProvider initialized", {
141
204
  documentId: document._id,
142
205
  documentType: document._type,
143
206
  supportedLanguages: config.supportedLanguages?.map((l) => l.id),
144
207
  component: "TranslationContextProvider"
145
208
  });
146
- const initialContext = {
209
+ const defaultReleaseTitle = generateDefaultReleaseTitle(document), activeTranslation = getActiveTranslation(document._id), initialContext = {
147
210
  document,
148
211
  config,
149
- overallStatus: "idle",
212
+ // If there's an active translation, restore its state
213
+ overallStatus: activeTranslation?.overallStatus ?? "idle",
150
214
  translationMetadata: null,
151
215
  selectedTargetLanguages: [],
152
- shouldOverwriteFreshTranslations: !1,
153
216
  shouldTranslateReferences: !1,
154
217
  maxReferenceDepth: 1,
155
218
  references: [],
156
- translationJobs: /* @__PURE__ */ new Map(),
219
+ // Restore jobs from active translation if available
220
+ translationJobs: activeTranslation?.translationJobs ?? /* @__PURE__ */ new Map(),
157
221
  translationMode: config?.openai?.defaultMode || "sanity-only",
158
- openaiValidated: !1
159
- }, [context, dispatch] = useReducer(translationReducer, initialContext);
222
+ openaiValidated: !1,
223
+ releaseConfig: {
224
+ mode: "new",
225
+ newReleaseTitle: defaultReleaseTitle
226
+ }
227
+ };
228
+ activeTranslation && devLog.info("Reconnecting to active translation", {
229
+ documentId: document._id,
230
+ status: activeTranslation.overallStatus,
231
+ jobCount: activeTranslation.translationJobs.size,
232
+ component: "TranslationContextProvider"
233
+ });
234
+ const [context, dispatch] = useReducer(translationReducer, initialContext);
160
235
  return useEffect(() => {
236
+ const handleActiveTranslationUpdate = (state) => {
237
+ dispatch({
238
+ type: "SET_OVERALL_STATUS",
239
+ payload: state.overallStatus
240
+ });
241
+ for (const job of state.translationJobs.values())
242
+ dispatch({
243
+ type: "SET_TRANSLATION_JOB_STATUS",
244
+ payload: job
245
+ });
246
+ }, unsubscribe = subscribeToActiveTranslation(document._id, handleActiveTranslationUpdate);
247
+ return () => {
248
+ unsubscribe();
249
+ };
250
+ }, [document._id]), useEffect(() => {
161
251
  (async () => {
162
252
  if (config?.openai?.enabled && config?.openai?.apiKey)
163
253
  try {
@@ -196,1043 +286,128 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
196
286
  "useTranslationContext must be used within a TranslationContextProvider with document and config props"
197
287
  );
198
288
  return context;
199
- }, undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_TYPE = "translation.metadata", TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
200
- const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
201
- params: {
202
- documentId: undraftId(documentId),
203
- translationMetadataType: TRANSLATION_METADATA_TYPE
204
- },
205
- initialValue: null
206
- });
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;
219
- }
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
- }
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({
289
+ }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
290
+ (translation) => translation._key === targetLanguage
291
+ )?.value._ref || null, undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_TYPE = "translation.metadata", getTranslationMetadata = async ({
367
292
  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) {
293
+ sourceDocument,
294
+ sourceLanguage
295
+ }) => {
562
296
  try {
563
- const response = await client.agent.action.prompt({
564
- instruction: prompt,
565
- format: "json"
297
+ return await client.fetch(
298
+ "*[_type == $tmType && references($sourceDocumentId)][0]",
299
+ {
300
+ tmType: TRANSLATION_METADATA_TYPE,
301
+ sourceDocumentId: undraftId(sourceDocument._id)
302
+ }
303
+ ) || await client.create({
304
+ _type: TRANSLATION_METADATA_TYPE,
305
+ translations: [
306
+ {
307
+ _key: sourceLanguage,
308
+ _type: "internationalizedArrayReferenceValue",
309
+ value: {
310
+ _type: "reference",
311
+ _ref: undraftId(sourceDocument._id),
312
+ _weak: !0,
313
+ _strengthenOnPublish: {
314
+ _type: sourceDocument._type,
315
+ _weak: !1
316
+ }
317
+ }
318
+ }
319
+ ],
320
+ translationHistory: []
566
321
  });
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
- } catch (error) {
573
- throw devLog.error("Error during AI analysis", { error }), new Error(`Failed to analyze feedback: ${error instanceof Error ? error.message : "Unknown error"}`);
574
- }
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
322
  } catch (error) {
599
- throw devLog.error("Error parsing AI response", { error, responseText }), new Error("Failed to parse AI response as JSON");
600
- }
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));
618
- }
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));
323
+ throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
630
324
  }
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(
325
+ }, checkDocumentConflicts = async ({
326
+ client,
327
+ releaseId,
328
+ documentId,
329
+ documentTitle,
330
+ documentType,
331
+ targetLanguages,
332
+ languageFieldName
333
+ }) => {
334
+ const conflicts = [], document = await client.withConfig({ perspective: "drafts" }).getDocument(documentId);
335
+ if (!document)
336
+ return conflicts;
337
+ const sourceLanguage = document[languageFieldName], translationMetadata = await getTranslationMetadata({
632
338
  client,
633
- settingsId,
634
- targetLanguage,
635
- suggestions.filter((s) => s.approved)
636
- );
637
- }
638
- async function updatePromptsWithTemplate(client, _schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
639
- if (!targetLanguage)
640
- throw devLog.error("targetLanguage is undefined or empty!", {
641
- targetLanguage,
642
- globalPrompts: globalPrompts.length,
643
- localePrompts: localePrompts.length
644
- }), new Error("targetLanguage is required but was undefined");
645
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], currentSettings = await client.fetch("*[_id == $settingsId][0]{ globalPrompt, localePrompts }", { settingsId });
646
- if (globalPrompts.length > 0) {
647
- const currentGlobalPrompt = currentSettings?.globalPrompt || "", updates = globalPrompts.map((s) => s.description.trim()).filter(Boolean), newGlobalPrompt = [
648
- currentGlobalPrompt,
649
- currentGlobalPrompt ? `
650
- ` : "",
651
- `## User Feedback (${timestamp})`,
652
- ...updates.map((update) => `- ${update}`)
653
- ].filter(Boolean).join(`
654
- `);
655
- await client.patch(settingsId).set({ globalPrompt: newGlobalPrompt }).commit();
656
- }
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();
679
- }
680
- }
681
- devLog.info("Prompts updated successfully", {
682
- targetLanguage,
683
- globalUpdated: globalPrompts.length > 0,
684
- localeUpdated: localePrompts.length > 0
339
+ sourceDocument: document,
340
+ sourceLanguage
685
341
  });
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 }));
697
- }
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
- });
342
+ for (const targetLanguage of targetLanguages) {
343
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
344
+ if (existingTranslationId) {
345
+ const versionedId = `versions.${releaseId}.${existingTranslationId}`;
346
+ await client.getDocument(versionedId) && (conflicts.push({
347
+ sourceDocumentId: documentId,
348
+ sourceDocumentTitle: documentTitle || document.title || document.name,
349
+ sourceDocumentType: documentType || document._type,
350
+ targetLanguage,
351
+ existingVersionId: versionedId
352
+ }), devLog.debug("Found existing version in release", {
353
+ releaseId,
354
+ sourceDocumentId: documentId,
355
+ targetLanguage,
356
+ versionedId
357
+ }));
358
+ }
747
359
  }
748
- devLog.info("Glossary terms processed", {
749
- processed: validTerms.length,
750
- skipped: terms.length - validTerms.length
751
- });
752
- }
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();
763
- }
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
360
+ return conflicts;
361
+ }, checkReleaseConflicts = async ({
362
+ client,
363
+ releaseId,
364
+ sourceDocument,
365
+ targetLanguages,
366
+ languageFieldName,
367
+ references = []
805
368
  }) => {
806
- const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
807
- const updated = suggestions.map((s) => s.id === id && s.validationStatus !== "invalid" ? { ...s, approved: !s.approved } : s);
808
- onSuggestionsChange(updated);
809
- }, handleTypeChange = (id, newType) => {
810
- const updated = suggestions.map((s) => {
811
- if (s.id !== id)
812
- return s;
813
- if (newType === "glossaryTerm" && s.type !== "glossaryTerm") {
814
- const match = s.description.match(/^(.+?)\s*→\s*(.+)$/);
815
- return {
816
- ...s,
817
- type: newType,
818
- sourceValue: match ? match[1].trim() : "",
819
- targetValue: match ? match[2].trim() : "",
820
- sourceLocale: sourceLanguage,
821
- targetLocale: targetLanguage
822
- };
823
- }
824
- return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
825
- ...s,
826
- type: newType,
827
- description: s.sourceValue && s.targetValue ? `${s.sourceValue} \u2192 ${s.targetValue}` : s.description,
828
- sourceValue: void 0,
829
- targetValue: void 0
830
- } : { ...s, type: newType };
831
- });
832
- onSuggestionsChange(updated);
833
- }, handleDescriptionChange = (id, newDescription) => {
834
- const updated = suggestions.map((s) => s.id === id ? { ...s, description: newDescription } : s);
835
- onSuggestionsChange(updated);
836
- }, handleGlossarySourceChange = (id, newSource) => {
837
- const updated = suggestions.map(
838
- (s) => s.id === id ? {
839
- ...s,
840
- sourceValue: newSource,
841
- description: `${newSource} \u2192 ${s.targetValue || ""}`
842
- // Preserve sourceLocale and targetLocale
843
- } : s
844
- );
845
- onSuggestionsChange(updated);
846
- }, handleGlossaryTargetChange = (id, newTarget) => {
847
- const updated = suggestions.map(
848
- (s) => s.id === id ? {
849
- ...s,
850
- targetValue: newTarget,
851
- description: `${s.sourceValue || ""} \u2192 ${newTarget}`
852
- // Preserve sourceLocale and targetLocale
853
- } : s
854
- );
855
- onSuggestionsChange(updated);
856
- }, handleRemove = (id) => {
857
- const updated = suggestions.filter((s) => s.id !== id);
858
- onSuggestionsChange(updated);
859
- }, handleAddSuggestion = (type) => {
860
- const newSuggestion = createNewSuggestion(type, sourceLanguage, targetLanguage);
861
- onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
862
- };
863
- return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
864
- /* @__PURE__ */ jsx(
865
- ProgressPanel,
866
- {
867
- status: "error",
868
- tone: "transparent",
869
- title: "No suggestions generated",
870
- description: 'Try providing more specific feedback or enabling "Include Edits".',
871
- icon: /* @__PURE__ */ jsx(EmptyIcon, { fontSize: 24 })
872
- }
873
- ),
874
- /* @__PURE__ */ jsx(Flex, { justify: "flex-start", children: /* @__PURE__ */ jsx(
875
- Button,
876
- {
877
- mode: "ghost",
878
- tone: "primary",
879
- icon: AddIcon,
880
- text: "Add Suggestion",
881
- fontSize: 1,
882
- onClick: () => handleAddSuggestion("protectedPhrase")
883
- }
884
- ) })
885
- ] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
886
- suggestions.map((suggestion) => {
887
- const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt", hasWarning = suggestion.warning && suggestion.validationStatus !== "valid", isInvalid = suggestion.validationStatus === "invalid";
888
- return /* @__PURE__ */ jsx(
889
- Card,
890
- {
891
- padding: 4,
892
- border: !0,
893
- tone: isInvalid ? "critical" : hasWarning ? "caution" : suggestion.approved ? "default" : "transparent",
894
- style: { opacity: suggestion.approved ? 1 : 0.5 },
895
- children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
896
- /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: isInvalid ? /* @__PURE__ */ jsx(
897
- Tooltip,
898
- {
899
- content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "This suggestion cannot be applied because it conflicts with existing settings. See the error message below for details." }) }),
900
- placement: "top",
901
- portal: !0,
902
- children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Checkbox, { checked: !1, disabled: !0, readOnly: !0 }) })
903
- }
904
- ) : /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
905
- /* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
906
- /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
907
- /* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
908
- Select,
909
- {
910
- fontSize: 1,
911
- value: suggestion.type,
912
- onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
913
- "aria-label": "Suggestion type",
914
- children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
915
- }
916
- ) }),
917
- /* @__PURE__ */ jsx(
918
- Button,
919
- {
920
- mode: "bleed",
921
- tone: "critical",
922
- icon: TrashIcon,
923
- fontSize: 1,
924
- padding: 2,
925
- onClick: () => handleRemove(suggestion.id),
926
- "aria-label": "Remove suggestion",
927
- title: "Remove suggestion"
928
- }
929
- )
930
- ] }),
931
- isGlossaryTerm ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
932
- (suggestion.sourceLocale || suggestion.targetLocale) && /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
933
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.sourceLocale ? `Source (${suggestion.sourceLocale})` : "Source" }) }),
934
- /* @__PURE__ */ jsx(Box, { style: { width: "20px" } }),
935
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.targetLocale ? `Target (${suggestion.targetLocale})` : "Target" }) })
936
- ] }),
937
- /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
938
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
939
- TextInput,
940
- {
941
- fontSize: 1,
942
- placeholder: "Source term",
943
- value: suggestion.sourceValue || "",
944
- onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value),
945
- "aria-label": `Source term in ${suggestion.sourceLocale || "source language"}`
946
- }
947
- ) }),
948
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, "aria-hidden": "true", children: "\u2192" }),
949
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
950
- TextInput,
951
- {
952
- fontSize: 1,
953
- placeholder: "Target term",
954
- value: suggestion.targetValue || "",
955
- onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value),
956
- "aria-label": `Target term in ${suggestion.targetLocale || "target language"}`
957
- }
958
- ) })
959
- ] })
960
- ] }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
961
- TextArea,
962
- {
963
- fontSize: 1,
964
- rows: isEditing ? 6 : 3,
965
- value: suggestion.description,
966
- onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
967
- onFocus: () => setEditingId(suggestion.id),
968
- onBlur: () => setEditingId(null),
969
- placeholder: "Enter prompt guidance...",
970
- "aria-label": `${IMPROVEMENT_TYPE_LABELS[suggestion.type]} content`
971
- }
972
- ) }) : /* @__PURE__ */ jsx(
973
- TextInput,
974
- {
975
- fontSize: 1,
976
- value: suggestion.description,
977
- onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
978
- placeholder: "Enter protected phrase...",
979
- "aria-label": "Protected phrase"
980
- }
981
- ),
982
- hasWarning && /* @__PURE__ */ jsx(Card, { padding: 3, tone: isInvalid ? "critical" : "caution", border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 2, children: [
983
- /* @__PURE__ */ jsx(Text, { size: 1, children: isInvalid ? /* @__PURE__ */ jsx(WarningOutlineIcon, {}) : /* @__PURE__ */ jsx(InfoOutlineIcon, {}) }),
984
- /* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
985
- /* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: isInvalid ? "Invalid Suggestion" : "Warning" }),
986
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: suggestion.warning })
987
- ] })
988
- ] }) })
989
- ] })
990
- ] }) })
991
- },
992
- suggestion.id
993
- );
994
- }),
995
- /* @__PURE__ */ jsx(Flex, { justify: "flex-start", children: /* @__PURE__ */ jsx(
996
- Button,
369
+ const allConflicts = [];
370
+ try {
371
+ const documentsToCheck = [
997
372
  {
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");
1065
- try {
1066
- const translationHistory2 = translationMetadata?.translationHistory?.find(
1067
- (history) => history.language === document[config.languageFieldName]
1068
- );
1069
- if (!translationHistory2)
1070
- throw new Error("Translation history not found");
1071
- await applySuggestions({
1072
- client,
1073
- schemaId: config.schemaId,
1074
- sourceLanguage: translationHistory2.sourceLanguage || "en-us",
1075
- targetLanguage: translationHistory2.language,
1076
- suggestions,
1077
- onProgress: ({ stage, message }) => {
1078
- setProcessingState(stage), setChangeSummary(message);
1079
- }
1080
- }), setProcessingState("completed");
1081
- } catch (err) {
1082
- console.error("Error applying suggestions:", err), setError(err instanceof Error ? err.message : "An unexpected error occurred"), setProcessingState("error");
1083
- }
1084
- };
1085
- if (loading)
1086
- return /* @__PURE__ */ jsx(ProgressPanel, { title: "Loading translation metadata" });
1087
- if (processingState === "analyzing")
1088
- return /* @__PURE__ */ jsx(ProgressPanel, { title: "Analyzing feedback with AI...", description: changeSummary });
1089
- if (processingState === "reviewing")
1090
- return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1091
- /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1092
- /* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
1093
- /* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
1094
- ] }),
1095
- /* @__PURE__ */ jsx(
1096
- SuggestionReviewList,
1097
- {
1098
- suggestions,
1099
- onSuggestionsChange: setSuggestions,
1100
- sourceLanguage,
1101
- targetLanguage
1102
- }
1103
- ),
1104
- error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
1105
- /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
1106
- /* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
1107
- Button,
1108
- {
1109
- type: "button",
1110
- mode: "bleed",
1111
- tone: "neutral",
1112
- onClick: () => setProcessingState("idle"),
1113
- icon: /* @__PURE__ */ jsx(ArrowLeftIcon, {}),
1114
- text: "Back"
1115
- }
1116
- ) }),
1117
- /* @__PURE__ */ jsxs(
1118
- Button,
1119
- {
1120
- type: "button",
1121
- mode: "ghost",
1122
- tone: "positive",
1123
- size: 1,
1124
- onClick: handleApplySuggestions,
1125
- disabled: suggestions.filter((s) => s.approved).length === 0,
1126
- children: [
1127
- "Apply ",
1128
- suggestions.filter((s) => s.approved).length,
1129
- " Suggestion",
1130
- suggestions.filter((s) => s.approved).length !== 1 ? "s" : ""
1131
- ]
1132
- }
1133
- )
1134
- ] })
1135
- ] });
1136
- if (processingState === "applying")
1137
- return /* @__PURE__ */ jsx(ProgressPanel, { title: "Updating translation settings...", description: changeSummary, icon: /* @__PURE__ */ jsx(EditIcon, {}) });
1138
- if (processingState === "completed") {
1139
- const appliedCount = suggestions.filter((s) => s.approved).length, description = `${appliedCount} suggestion${appliedCount !== 1 ? "s" : ""} applied. Translation prompts and glossary havebeen updated based on your feedback.`;
1140
- return /* @__PURE__ */ jsxs(Box, { padding: 4, children: [
1141
- /* @__PURE__ */ jsx(ProgressPanel, { title: "Translation settings updated", description, status: "completed" }),
1142
- /* @__PURE__ */ jsx(Flex, { justify: "flex-end", paddingTop: 3, children: /* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", tone: "primary", size: 1, onClick: handleCancel, children: "Close" }) })
1143
- ] });
1144
- }
1145
- return processingState === "error" ? /* @__PURE__ */ jsx(Box, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1146
- /* @__PURE__ */ jsx(ProgressPanel, { status: "error", title: "Error processing feedback", description: error, tone: "critical" }),
1147
- /* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
1148
- /* @__PURE__ */ jsx(Button, { type: "button", mode: "ghost", onClick: handleCancel, children: "Close" }),
1149
- /* @__PURE__ */ jsx(Button, { type: "reset", mode: "ghost", tone: "primary", onClick: () => setProcessingState("idle"), children: "Reset" })
1150
- ] })
1151
- ] }) }) : /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1152
- /* @__PURE__ */ jsx(Card, { tone: "transparent", padding: 4, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1153
- /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
1154
- /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
1155
- /* @__PURE__ */ jsx(Text, { size: 2, muted: !0, children: "Enter your feedback for the translation AI agent." }),
1156
- /* @__PURE__ */ jsx(
1157
- Tooltip,
1158
- {
1159
- portal: !0,
1160
- content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "The AI agent will use your feedback to improve translation prompts, update protected phrases, and modify the glossary. Be specific about what should be changed." }) }),
1161
- children: /* @__PURE__ */ jsx(Text, { size: 1, children: /* @__PURE__ */ jsx(InfoOutlineIcon, {}) })
1162
- }
1163
- )
1164
- ] }),
1165
- /* @__PURE__ */ jsx(
1166
- TextArea,
1167
- {
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
1174
- }
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
- }
373
+ _id: sourceDocument._id,
374
+ _type: sourceDocument._type,
375
+ title: sourceDocument.title || sourceDocument.name
376
+ },
377
+ ...references.map((ref) => ({
378
+ _id: ref._id,
379
+ _type: ref._type,
380
+ title: ref.title
381
+ }))
382
+ ], conflictChecks = await Promise.all(
383
+ documentsToCheck.map(
384
+ (doc) => checkDocumentConflicts({
385
+ client,
386
+ releaseId,
387
+ documentId: doc._id,
388
+ documentTitle: doc.title,
389
+ documentType: doc._type,
390
+ targetLanguages,
391
+ languageFieldName
392
+ })
1208
393
  )
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 }) })
1234
- }
1235
- } : (devLog.error("Missing config in TranslateAction"), null);
394
+ );
395
+ for (const conflicts of conflictChecks)
396
+ allConflicts.push(...conflicts);
397
+ devLog.debug("Release conflict check complete", {
398
+ releaseId,
399
+ documentsChecked: documentsToCheck.length,
400
+ conflictsFound: allConflicts.length
401
+ });
402
+ } catch (error) {
403
+ devLog.error("Error checking release conflicts", error instanceof Error ? error : new Error(String(error)), {
404
+ releaseId,
405
+ sourceDocumentId: sourceDocument._id,
406
+ targetLanguages,
407
+ referencesCount: references.length
408
+ });
409
+ }
410
+ return allConflicts;
1236
411
  };
1237
412
  class Semaphore {
1238
413
  permits;
@@ -1570,179 +745,41 @@ const fetchTranslationPrompts = async ({
1570
745
  refIds,
1571
746
  sourceLanguage,
1572
747
  targetLanguage
1573
- }
1574
- ), translationMap = /* @__PURE__ */ new Map();
1575
- for (const t of translations)
1576
- t.sourceId && t.targetId && translationMap.set(t.sourceId, t.targetId);
1577
- const patch = client.patch(documentId);
1578
- let hasChanges = !1;
1579
- for (const ref of refs) {
1580
- const sourceId = getPublishedId(ref.value), newId = translationMap.get(sourceId);
1581
- newId && (patch.set({ [pathToString(ref.path)]: getPublishedId(newId) }), patch.set({
1582
- [`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
1583
- type: translations.find((t) => t.targetId === getPublishedId(newId))?.targetType || void 0
1584
- }
1585
- }), hasChanges = !0);
1586
- }
1587
- hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
1588
- documentId,
1589
- count: refs.length,
1590
- translatedCount: translationMap.size,
1591
- component: "updateReferences"
1592
- }));
1593
- }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
1594
- for (const sourceDocument of sourceDocuments)
1595
- for (const lang of targetLanguages) {
1596
- const payload = {
1597
- _id: sourceDocument._id,
1598
- _type: sourceDocument._type,
1599
- language: lang,
1600
- status: "queued",
1601
- title: sourceDocument.title
1602
- };
1603
- dispatch({
1604
- type: "SET_TRANSLATION_JOB_STATUS",
1605
- 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);
1706
- }
1707
- }
1708
- return { success: !1, error: lastError, attempts };
1709
- }
1710
- const getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1711
- (translation) => translation._key === targetLanguage
1712
- )?.value._ref || null, getTranslationMetadata = async ({
1713
- client,
1714
- sourceDocument,
1715
- sourceLanguage
1716
- }) => {
1717
- try {
1718
- return await client.fetch(
1719
- "*[_type == $tmType && references($sourceDocumentId)][0]",
1720
- {
1721
- tmType: TRANSLATION_METADATA_TYPE,
1722
- sourceDocumentId: undraftId(sourceDocument._id)
1723
- }
1724
- ) || await client.create({
1725
- _type: TRANSLATION_METADATA_TYPE,
1726
- translations: [
1727
- {
1728
- _key: sourceLanguage,
1729
- _type: "internationalizedArrayReferenceValue",
1730
- value: {
1731
- _type: "reference",
1732
- _ref: undraftId(sourceDocument._id),
1733
- _weak: !0,
1734
- _strengthenOnPublish: {
1735
- _type: sourceDocument._type,
1736
- _weak: !1
1737
- }
1738
- }
1739
- }
1740
- ],
1741
- translationHistory: []
1742
- });
1743
- } catch (error) {
1744
- throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
748
+ }
749
+ ), translationMap = /* @__PURE__ */ new Map();
750
+ for (const t of translations)
751
+ t.sourceId && t.targetId && translationMap.set(t.sourceId, t.targetId);
752
+ const patch = client.patch(documentId);
753
+ let hasChanges = !1;
754
+ for (const ref of refs) {
755
+ const sourceId = getPublishedId(ref.value), newId = translationMap.get(sourceId);
756
+ newId && (patch.set({ [pathToString(ref.path)]: getPublishedId(newId) }), patch.set({
757
+ [`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
758
+ type: translations.find((t) => t.targetId === getPublishedId(newId))?.targetType || void 0
759
+ }
760
+ }), hasChanges = !0);
1745
761
  }
762
+ hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
763
+ documentId,
764
+ count: refs.length,
765
+ translatedCount: translationMap.size,
766
+ component: "updateReferences"
767
+ }));
768
+ }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages, mainSourceDocumentId) => {
769
+ for (const sourceDocument of sourceDocuments)
770
+ for (const lang of targetLanguages) {
771
+ const payload = {
772
+ _id: sourceDocument._id,
773
+ _type: sourceDocument._type,
774
+ language: lang,
775
+ status: "queued",
776
+ title: sourceDocument.title
777
+ };
778
+ dispatch({
779
+ type: "SET_TRANSLATION_JOB_STATUS",
780
+ payload
781
+ }), mainSourceDocumentId && updateActiveTranslationJob(mainSourceDocumentId, payload);
782
+ }
1746
783
  }, updateTranslationMetadata = async ({
1747
784
  client,
1748
785
  targetLanguage,
@@ -1789,15 +826,16 @@ const getExistingTranslationId = (translationMetadata, targetLanguage) => transl
1789
826
  component: "updateTranslationMetadata"
1790
827
  }), error;
1791
828
  }
1792
- }, setJobStatus = (dispatch, _id, language, status, payload) => {
829
+ }, setJobStatus = (dispatch, _id, language, status, payload, sourceDocumentId) => {
830
+ const job = defaults(payload, {
831
+ _id,
832
+ language,
833
+ status
834
+ });
1793
835
  dispatch({
1794
836
  type: "SET_TRANSLATION_JOB_STATUS",
1795
- payload: defaults(payload, {
1796
- _id,
1797
- language,
1798
- status
1799
- })
1800
- });
837
+ payload: job
838
+ }), sourceDocumentId && updateActiveTranslationJob(sourceDocumentId, job);
1801
839
  };
1802
840
  function shouldExcludeField(fieldPath, excludeFields) {
1803
841
  for (const pattern of excludeFields) {
@@ -2331,7 +1369,21 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2331
1369
  parent && parent.children.push(node);
2332
1370
  } else
2333
1371
  root.push(node);
2334
- else
1372
+ else if (attributes.type === "string" && !content) {
1373
+ const contentLines = [];
1374
+ for (i++; i < lines.length; ) {
1375
+ const currentLine = lines[i];
1376
+ if (currentLine.trim() === "</field>")
1377
+ break;
1378
+ contentLines.push(currentLine), i++;
1379
+ }
1380
+ if (node.text = contentLines.join(`
1381
+ `).trim(), stack.length > 0) {
1382
+ const parent = stack[stack.length - 1];
1383
+ parent && parent.children.push(node);
1384
+ } else
1385
+ root.push(node);
1386
+ } else
2335
1387
  stack.push(node);
2336
1388
  continue;
2337
1389
  }
@@ -2879,7 +1931,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2879
1931
  translationMode,
2880
1932
  openaiConfig,
2881
1933
  releaseId,
2882
- excludeFields = []
1934
+ excludeFields = [],
1935
+ replaceExisting = !1,
1936
+ mainSourceDocumentId
2883
1937
  }) => {
2884
1938
  const safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2885
1939
  excludeFields.length !== safeExcludeFields.length && devLog.warn("Language field was in excludeFields - removing it to prevent translation errors", {
@@ -2902,20 +1956,29 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2902
1956
  filteredTitleValue: filteredDocument.title,
2903
1957
  excludedValuesKeys: Object.keys(excludedValues),
2904
1958
  excludedValues
2905
- }), devLog.debug("Applying field transforms before translation", {
2906
- documentId: sourceDocument._id,
2907
- documentType: sourceDocument._type
2908
- });
2909
- const { transformedDocument, originalStructure } = applyTransforms({
2910
- document: filteredDocument,
2911
- registry: globalTransformRegistry,
2912
- schemaType: sourceDocument._type
2913
1959
  });
1960
+ let documentToTranslate, originalStructure = {};
1961
+ if (translationMode === "openai-only") {
1962
+ devLog.debug("Applying field transforms before translation (OpenAI mode)", {
1963
+ documentId: sourceDocument._id,
1964
+ documentType: sourceDocument._type
1965
+ });
1966
+ const transformResult = applyTransforms({
1967
+ document: filteredDocument,
1968
+ registry: globalTransformRegistry,
1969
+ schemaType: sourceDocument._type
1970
+ });
1971
+ documentToTranslate = transformResult.transformedDocument, originalStructure = transformResult.originalStructure;
1972
+ } else
1973
+ devLog.debug("Skipping field transforms (Sanity Agent mode)", {
1974
+ documentId: sourceDocument._id,
1975
+ documentType: sourceDocument._type
1976
+ }), documentToTranslate = filteredDocument;
2914
1977
  let translatedDoc;
2915
1978
  switch (translationMode) {
2916
1979
  case "sanity-only": {
2917
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), translatedDoc = await getSanityAgentTranslation({
2918
- sourceDocument: transformedDocument,
1980
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating", void 0, mainSourceDocumentId), translatedDoc = await getSanityAgentTranslation({
1981
+ sourceDocument: documentToTranslate,
2919
1982
  targetLanguage,
2920
1983
  languageFieldName,
2921
1984
  schemaId,
@@ -2927,9 +1990,16 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2927
1990
  case "openai-only": {
2928
1991
  if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2929
1992
  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({
1993
+ setJobStatus(
1994
+ dispatch,
1995
+ sourceDocument._id,
1996
+ targetLanguage,
1997
+ "translating_with_openai",
1998
+ void 0,
1999
+ mainSourceDocumentId
2000
+ ), translatedDoc = await getOpenAITranslation({
2931
2001
  dispatch,
2932
- sourceDocument: transformedDocument,
2002
+ sourceDocument: documentToTranslate,
2933
2003
  targetLanguage,
2934
2004
  languageFieldName,
2935
2005
  translationPrompts,
@@ -2937,23 +2007,22 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2937
2007
  });
2938
2008
  break;
2939
2009
  }
2940
- case "sanity-with-review":
2941
- throw new Error("Sanity with review translation method is not implemented");
2942
2010
  default:
2943
2011
  throw new Error(`Invalid translation mode: ${translationMode}`);
2944
2012
  }
2945
2013
  if (!translatedDoc)
2946
2014
  return null;
2947
- devLog.debug("Restoring field transforms after translation", {
2015
+ let transformedResult;
2016
+ Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
2948
2017
  documentId: sourceDocument._id,
2949
2018
  documentType: sourceDocument._type
2950
- });
2951
- const transformedResult = restoreTransforms({
2019
+ }), transformedResult = restoreTransforms({
2952
2020
  translatedDocument: translatedDoc,
2953
2021
  originalStructure,
2954
2022
  registry: globalTransformRegistry,
2955
2023
  schemaType: sourceDocument._type
2956
- }), excludedValuesToRestore = { ...excludedValues };
2024
+ })) : transformedResult = translatedDoc;
2025
+ const excludedValuesToRestore = { ...excludedValues };
2957
2026
  delete excludedValuesToRestore[languageFieldName], devLog.info("Before restoring excluded fields", {
2958
2027
  documentId: sourceDocument._id,
2959
2028
  excludedFieldCount: Object.keys(excludedValues).length,
@@ -2972,7 +2041,11 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2972
2041
  ...finalDoc,
2973
2042
  _id: targetDocumentVersionId
2974
2043
  };
2975
- return await client.action({
2044
+ return replaceExisting && await client.getDocument(targetDocumentVersionId) && (devLog.info("Replacing existing version in release", {
2045
+ documentId: sourceDocument._id,
2046
+ targetDocumentVersionId,
2047
+ releaseId
2048
+ }), await client.delete(targetDocumentVersionId)), await client.action({
2976
2049
  actionType: "sanity.action.document.version.create",
2977
2050
  publishedId: targetDocumentPublishedId,
2978
2051
  document: newDoc
@@ -2984,84 +2057,66 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2984
2057
  sourceLanguage,
2985
2058
  targetLanguage,
2986
2059
  languageFieldName,
2987
- shouldOverwriteFreshTranslations,
2988
2060
  schemaId,
2989
2061
  translationPrompts,
2990
2062
  translationMode,
2991
2063
  openaiConfig,
2992
2064
  releaseId,
2993
- excludeFields
2065
+ excludeFields,
2066
+ replaceExisting,
2067
+ mainSourceDocumentId
2994
2068
  }) => {
2995
2069
  const logContext = {
2996
2070
  documentId: sourceDocument._id,
2997
2071
  targetLanguage,
2998
2072
  schemaId,
2999
- shouldOverwriteFreshTranslations,
3000
2073
  translationMode
3001
2074
  };
3002
- devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
2075
+ devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation", void 0, mainSourceDocumentId);
3003
2076
  try {
3004
2077
  const translationMetadata = await getTranslationMetadata({
3005
2078
  client,
3006
2079
  sourceDocument,
3007
2080
  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({
2081
+ }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2082
+ dispatch,
2083
+ client,
2084
+ schemaId,
2085
+ sourceDocument,
2086
+ targetLanguage,
2087
+ existingTranslationId,
2088
+ languageFieldName,
2089
+ translationPrompts,
2090
+ translationMode,
2091
+ openaiConfig,
2092
+ releaseId,
2093
+ excludeFields,
2094
+ replaceExisting,
2095
+ mainSourceDocumentId
2096
+ });
2097
+ if (!newDocument) {
2098
+ devLog.error("Translation failed: no document returned", new Error("Translation returned null"), logContext), setJobStatus(
3019
2099
  dispatch,
3020
- client,
3021
- schemaId,
3022
- sourceDocument,
2100
+ sourceDocument._id,
3023
2101
  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();
2102
+ "error",
2103
+ {
2104
+ errorMessage: "Translation failed"
2105
+ },
2106
+ mainSourceDocumentId
2107
+ ), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3059
2108
  return;
3060
2109
  }
3061
- const newDocument = translationResult.result;
3062
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "updating_metadata", {
3063
- targetId: newDocument._id
3064
- }), await updateTranslationMetadata({
2110
+ return setJobStatus(
2111
+ dispatch,
2112
+ sourceDocument._id,
2113
+ targetLanguage,
2114
+ "updating_metadata",
2115
+ {
2116
+ targetId: newDocument._id
2117
+ },
2118
+ mainSourceDocumentId
2119
+ ), await updateTranslationMetadata({
3065
2120
  client,
3066
2121
  sourceDocument,
3067
2122
  sourceLanguage,
@@ -3069,17 +2124,31 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3069
2124
  targetLanguage,
3070
2125
  translationMetadata,
3071
2126
  isNewTranslation: !existingTranslationId
3072
- }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "completed", {
3073
- targetId: newDocument._id
3074
- }), devLog.info("Translation completed successfully", {
2127
+ }), setJobStatus(
2128
+ dispatch,
2129
+ sourceDocument._id,
2130
+ targetLanguage,
2131
+ "completed",
2132
+ {
2133
+ targetId: newDocument._id
2134
+ },
2135
+ mainSourceDocumentId
2136
+ ), devLog.info("Translation completed successfully", {
3075
2137
  ...logContext,
3076
2138
  newDocumentId: newDocument._id
3077
2139
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
3078
2140
  } catch (error) {
3079
2141
  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();
2142
+ devLog.error("Translation failed", error instanceof Error ? error : new Error(String(error)), logContext), setJobStatus(
2143
+ dispatch,
2144
+ sourceDocument._id,
2145
+ targetLanguage,
2146
+ "error",
2147
+ {
2148
+ errorMessage
2149
+ },
2150
+ mainSourceDocumentId
2151
+ ), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3083
2152
  return;
3084
2153
  }
3085
2154
  }, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
@@ -3088,13 +2157,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3088
2157
  targetLanguages,
3089
2158
  dispatch,
3090
2159
  languageFieldName,
3091
- shouldOverwriteFreshTranslations,
3092
2160
  schemaId,
3093
2161
  references = [],
3094
2162
  maxConcurrentTranslationJobs,
3095
2163
  translationMode,
3096
2164
  openaiConfig,
3097
- excludeFields
2165
+ excludeFields,
2166
+ releaseConfig
3098
2167
  }) => {
3099
2168
  const logContext = {
3100
2169
  documentId: sourceDocument._id,
@@ -3103,28 +2172,36 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3103
2172
  referencesCount: references.length,
3104
2173
  maxConcurrentJobs: maxConcurrentTranslationJobs,
3105
2174
  translationMode,
3106
- shouldOverwriteFreshTranslations
2175
+ releaseMode: releaseConfig.mode
3107
2176
  };
3108
- let releaseId;
2177
+ let releaseId, createdNewRelease = !1;
3109
2178
  try {
3110
2179
  const sourceLanguage = sourceDocument[languageFieldName];
3111
2180
  if (!sourceLanguage)
3112
2181
  throw devLog.error("Source language not found"), new Error("Source language not found");
3113
- maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
2182
+ if (maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
3114
2183
  type: "SET_OVERALL_STATUS",
3115
2184
  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
- });
2185
+ }), releaseConfig.mode === "existing" && releaseConfig.existingReleaseId)
2186
+ releaseId = releaseConfig.existingReleaseId, devLog.info("Using existing Content Release", {
2187
+ ...logContext,
2188
+ releaseId
2189
+ });
2190
+ else {
2191
+ const releaseTitle = releaseConfig.newReleaseTitle, releaseDescription = `AI translation batch created on ${(/* @__PURE__ */ new Date()).toLocaleDateString()} at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
2192
+ releaseId = (await client.releases.create({
2193
+ metadata: {
2194
+ title: releaseTitle,
2195
+ description: releaseDescription,
2196
+ releaseType: "undecided"
2197
+ }
2198
+ })).releaseId, createdNewRelease = !0, devLog.info("Content Release created", {
2199
+ ...logContext,
2200
+ releaseId,
2201
+ releaseTitle
2202
+ });
2203
+ }
2204
+ registerActiveTranslation(sourceDocument._id, releaseId), updateActiveTranslationStatus(sourceDocument._id, "translating");
3128
2205
  const sourceDocuments = [
3129
2206
  {
3130
2207
  _id: sourceDocument._id,
@@ -3139,7 +2216,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3139
2216
  targetLanguages,
3140
2217
  sourceDocument
3141
2218
  });
3142
- setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages);
2219
+ setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages, sourceDocument._id);
3143
2220
  const translationTasks = sourceDocumentIds.flatMap(
3144
2221
  (documentId) => targetLanguages.map(async (targetLanguage) => {
3145
2222
  try {
@@ -3147,7 +2224,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3147
2224
  const document = await client.withConfig({
3148
2225
  perspective: "drafts"
3149
2226
  }).getDocument(documentId);
3150
- if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId2) {
2227
+ if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId) {
3151
2228
  const versionedTargetDocumentId = await translateDocument({
3152
2229
  dispatch,
3153
2230
  client,
@@ -3155,13 +2232,15 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3155
2232
  sourceLanguage,
3156
2233
  targetLanguage,
3157
2234
  languageFieldName,
3158
- shouldOverwriteFreshTranslations,
3159
2235
  schemaId,
3160
2236
  translationPrompts,
3161
2237
  translationMode,
3162
2238
  openaiConfig,
3163
- releaseId: releaseId2,
3164
- excludeFields
2239
+ releaseId,
2240
+ excludeFields,
2241
+ replaceExisting: releaseConfig.replaceExisting,
2242
+ // Pass main source document ID for active translations tracking
2243
+ mainSourceDocumentId: sourceDocument._id
3165
2244
  });
3166
2245
  versionedTargetDocumentId && versionedTargetDocumentIds.push(versionedTargetDocumentId);
3167
2246
  }
@@ -3188,7 +2267,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3188
2267
  }), dispatch({
3189
2268
  type: "SET_OVERALL_STATUS",
3190
2269
  payload: "linking_references"
3191
- }), await Promise.all(
2270
+ }), updateActiveTranslationStatus(sourceDocument._id, "linking_references"), await Promise.all(
3192
2271
  versionedTargetDocumentIds.map(async (documentId) => {
3193
2272
  await localizeReferences({
3194
2273
  client,
@@ -3199,18 +2278,65 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3199
2278
  })
3200
2279
  );
3201
2280
  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", {
2281
+ 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
2282
  ...logContext,
3204
2283
  succeeded,
3205
2284
  failed
3206
- }), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })) : dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" }), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
2285
+ }), 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
2286
  } catch (error) {
3208
- throw devLog.error(
2287
+ if (devLog.error(
3209
2288
  "translateDocumentSet failed with unexpected error",
3210
2289
  error instanceof Error ? error : new Error(String(error)),
3211
2290
  logContext
3212
- ), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
3213
- }
2291
+ ), releaseId && createdNewRelease)
2292
+ try {
2293
+ await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
2294
+ ...logContext,
2295
+ releaseId
2296
+ });
2297
+ } catch (cleanupError) {
2298
+ devLog.error(
2299
+ "Failed to cleanup release",
2300
+ cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
2301
+ logContext
2302
+ );
2303
+ }
2304
+ throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id), error;
2305
+ }
2306
+ }, ReleaseConflictWarning = ({ conflicts, onCancel, onReplace }) => {
2307
+ 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]);
2308
+ return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2309
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2310
+ /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
2311
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "This release already contains translations for this content and target language(s)." })
2312
+ ] }),
2313
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2314
+ "The selected release already has ",
2315
+ summaryText,
2316
+ " that will be overwritten if you continue:"
2317
+ ] }),
2318
+ /* @__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: [
2319
+ conflict.sourceDocumentTitle || conflict.sourceDocumentId,
2320
+ conflict.sourceDocumentType && ` (${conflict.sourceDocumentType})`,
2321
+ " \u2192 ",
2322
+ conflict.targetLanguage
2323
+ ] }, conflict.existingVersionId)) }) }) }),
2324
+ /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2325
+ /* @__PURE__ */ jsx(Heading, { size: 0, children: "Choose an action:" }),
2326
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2327
+ /* @__PURE__ */ jsx("strong", { children: "Cancel" }),
2328
+ " \u2014 Go back and select a different release, or create a new one."
2329
+ ] }),
2330
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2331
+ /* @__PURE__ */ jsx("strong", { children: "Replace" }),
2332
+ " \u2014 Delete the existing translations in this release and create new ones."
2333
+ ] })
2334
+ ] }),
2335
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
2336
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Cancel", onClick: onCancel }),
2337
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "caution", text: "Replace Existing", onClick: onReplace })
2338
+ ] })
2339
+ ] }) });
3214
2340
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
3215
2341
  const {
3216
2342
  document,
@@ -3219,39 +2345,81 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3219
2345
  dispatch,
3220
2346
  references,
3221
2347
  selectedTargetLanguages,
3222
- shouldOverwriteFreshTranslations,
3223
- translationMode
3224
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
3225
- translateDocumentSet({
3226
- sourceDocument: document,
3227
- targetLanguages: selectedTargetLanguages,
2348
+ translationMode,
2349
+ releaseConfig
2350
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1);
2351
+ useEffect(() => {
2352
+ releaseConfig.mode === "new" && conflictContext && setConflictContext(null);
2353
+ }, [releaseConfig.mode, conflictContext]);
2354
+ const startTranslation = useCallback(
2355
+ (replaceExisting, overrideReleaseId) => {
2356
+ setConflictContext(null), translateDocumentSet({
2357
+ sourceDocument: document,
2358
+ targetLanguages: selectedTargetLanguages,
2359
+ client,
2360
+ dispatch,
2361
+ languageFieldName: config.languageFieldName,
2362
+ schemaId: config.schemaId,
2363
+ references,
2364
+ maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
2365
+ maxReferenceDepth: config.maxReferenceDepth,
2366
+ translationMode,
2367
+ openaiConfig: config.openai,
2368
+ excludeFields: config.excludeFields,
2369
+ releaseConfig: {
2370
+ ...releaseConfig,
2371
+ replaceExisting,
2372
+ // Use the override release ID if provided (from conflict context),
2373
+ // otherwise use the current releaseConfig value
2374
+ existingReleaseId: overrideReleaseId ?? releaseConfig.existingReleaseId
2375
+ }
2376
+ });
2377
+ },
2378
+ [
2379
+ document,
2380
+ selectedTargetLanguages,
3228
2381
  client,
3229
2382
  dispatch,
3230
- languageFieldName: config.languageFieldName,
3231
- schemaId: config.schemaId,
2383
+ config.languageFieldName,
2384
+ config.schemaId,
2385
+ config.maxConcurrentTranslationJobs,
2386
+ config.maxReferenceDepth,
2387
+ config.openai,
2388
+ config.excludeFields,
3232
2389
  references,
3233
- shouldOverwriteFreshTranslations,
3234
- maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
3235
- maxReferenceDepth: config.maxReferenceDepth,
3236
2390
  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(() => {
2391
+ releaseConfig
2392
+ ]
2393
+ ), handleTranslate = useCallback(async () => {
2394
+ if (releaseConfig.mode === "new") {
2395
+ startTranslation(!1);
2396
+ return;
2397
+ }
2398
+ if (releaseConfig.mode === "existing" && releaseConfig.existingReleaseId) {
2399
+ const checkedReleaseId = releaseConfig.existingReleaseId;
2400
+ setIsCheckingConflicts(!0);
2401
+ const foundConflicts = await checkReleaseConflicts({
2402
+ client,
2403
+ releaseId: checkedReleaseId,
2404
+ sourceDocument: document,
2405
+ targetLanguages: selectedTargetLanguages,
2406
+ languageFieldName: config.languageFieldName,
2407
+ references
2408
+ });
2409
+ if (setIsCheckingConflicts(!1), foundConflicts.length > 0) {
2410
+ setConflictContext({
2411
+ releaseId: checkedReleaseId,
2412
+ conflicts: foundConflicts
2413
+ });
2414
+ return;
2415
+ }
2416
+ }
2417
+ startTranslation(!1);
2418
+ }, [releaseConfig, client, document, selectedTargetLanguages, config.languageFieldName, startTranslation, references]), handleCancelConflict = useCallback(() => {
2419
+ setConflictContext(null);
2420
+ }, []), handleReplaceConflict = useCallback(() => {
2421
+ conflictContext && startTranslation(!0, conflictContext.releaseId);
2422
+ }, [startTranslation, conflictContext]), handleSelectAll = useCallback(() => {
3255
2423
  dispatch({
3256
2424
  type: "SET_SELECTED_TARGET_LANGUAGES",
3257
2425
  payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
@@ -3262,7 +2430,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3262
2430
  payload: []
3263
2431
  });
3264
2432
  }, [dispatch]);
3265
- return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
2433
+ return conflictContext ? /* @__PURE__ */ jsx(Stack, { space: 4, children: /* @__PURE__ */ jsx(
2434
+ ReleaseConflictWarning,
2435
+ {
2436
+ conflicts: conflictContext.conflicts,
2437
+ onCancel: handleCancelConflict,
2438
+ onReplace: handleReplaceConflict
2439
+ }
2440
+ ) }) : /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
3266
2441
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
3267
2442
  /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
3268
2443
  /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
@@ -3276,16 +2451,41 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3276
2451
  tone: "positive",
3277
2452
  size: 2,
3278
2453
  onClick: handleTranslate,
3279
- disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
3280
- children: "Translate"
2454
+ disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus) || releaseConfig.mode === "existing" && !releaseConfig.existingReleaseId || isCheckingConflicts,
2455
+ children: isCheckingConflicts ? "Checking..." : "Translate"
3281
2456
  }
3282
2457
  )
3283
2458
  ] })
3284
2459
  ] });
2460
+ }, TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
2461
+ const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
2462
+ params: {
2463
+ documentId: undraftId(documentId),
2464
+ translationMetadataType: TRANSLATION_METADATA_TYPE
2465
+ },
2466
+ initialValue: null
2467
+ });
2468
+ return useEffect(() => {
2469
+ if (loading) {
2470
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "fetching_metadata" });
2471
+ return;
2472
+ }
2473
+ if (error) {
2474
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "error" });
2475
+ return;
2476
+ }
2477
+ if (data) {
2478
+ dispatch({ type: "SET_TRANSLATION_METADATA", payload: data }), dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
2479
+ return;
2480
+ }
2481
+ dispatch({ type: "SET_OVERALL_STATUS", payload: "idle" });
2482
+ }, [data, loading, error, dispatch]), {
2483
+ data,
2484
+ loading,
2485
+ error
2486
+ };
3285
2487
  }, 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 = () => {
2488
+ const { document, dispatch, selectedTargetLanguages } = useTranslationContext(), isDisabled = lang.id === document.language, isChecked = selectedTargetLanguages.includes(lang.id), handleChange = () => {
3289
2489
  if (isDisabled) return;
3290
2490
  const updatedLanguages = isChecked ? selectedTargetLanguages.filter((langId) => langId !== lang.id) : [...selectedTargetLanguages, lang.id];
3291
2491
  dispatch({
@@ -3303,16 +2503,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3303
2503
  disabled: isDisabled,
3304
2504
  tone: isChecked ? "primary" : "default",
3305
2505
  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
- ] })
2506
+ children: /* @__PURE__ */ jsx(Flex, { align: "center", gap: 2, style: { cursor: "pointer" }, children: /* @__PURE__ */ jsxs(Inline, { space: 3, children: [
2507
+ /* @__PURE__ */ jsx(Checkbox, { checked: isChecked, onChange: handleChange, label: lang.title, disabled: isDisabled }),
2508
+ /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
2509
+ /* @__PURE__ */ jsx(Text, { children: lang.title }),
2510
+ isDisabled && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "The current language" })
2511
+ ] })
2512
+ ] }) })
3316
2513
  }
3317
2514
  );
3318
2515
  }, LanguageSelectionPanel = () => {
@@ -3320,6 +2517,134 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3320
2517
  documentId: document._id
3321
2518
  });
3322
2519
  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)) }) });
2520
+ }, ReleaseSelectionPanel = () => {
2521
+ const { dispatch, releaseConfig } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [releases, setReleases] = useState([]), [isLoading, setIsLoading] = useState(!1), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState("");
2522
+ useEffect(() => {
2523
+ (async () => {
2524
+ setIsLoading(!0), setError(null);
2525
+ try {
2526
+ const result = await client.fetch(
2527
+ 'releases::all()[state == "active"] | order(_createdAt desc)'
2528
+ );
2529
+ setReleases(result), devLog.debug("Fetched active releases", {
2530
+ count: result.length,
2531
+ component: "ReleaseSelectionPanel"
2532
+ });
2533
+ } catch (err) {
2534
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch releases";
2535
+ setError(errorMessage), devLog.error("Failed to fetch releases", err instanceof Error ? err : new Error(errorMessage), {
2536
+ component: "ReleaseSelectionPanel"
2537
+ });
2538
+ } finally {
2539
+ setIsLoading(!1);
2540
+ }
2541
+ })();
2542
+ }, [client]);
2543
+ const handleModeChange = useCallback(
2544
+ (mode) => {
2545
+ dispatch({
2546
+ type: "SET_RELEASE_CONFIG",
2547
+ payload: { mode }
2548
+ });
2549
+ },
2550
+ [dispatch]
2551
+ ), handleTitleChange = useCallback(
2552
+ (event) => {
2553
+ dispatch({
2554
+ type: "SET_RELEASE_CONFIG",
2555
+ payload: { newReleaseTitle: event.target.value }
2556
+ });
2557
+ },
2558
+ [dispatch]
2559
+ ), handleSelectRelease = useCallback(
2560
+ (releaseId) => {
2561
+ dispatch({
2562
+ type: "SET_RELEASE_CONFIG",
2563
+ payload: { existingReleaseId: releaseId }
2564
+ });
2565
+ },
2566
+ [dispatch]
2567
+ ), handleSearchChange = useCallback((event) => {
2568
+ setSearchQuery(event.target.value);
2569
+ }, []), filteredReleases = useMemo(() => {
2570
+ if (!searchQuery.trim())
2571
+ return releases;
2572
+ const query = searchQuery.toLowerCase();
2573
+ return releases.filter((release) => release.metadata.title.toLowerCase().includes(query));
2574
+ }, [releases, searchQuery]), formatDate = (dateString) => new Date(dateString).toLocaleDateString(void 0, {
2575
+ month: "short",
2576
+ day: "numeric",
2577
+ year: "numeric"
2578
+ });
2579
+ return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2580
+ /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2581
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, as: "label", style: { cursor: "pointer" }, onClick: () => handleModeChange("new"), children: [
2582
+ /* @__PURE__ */ jsx(Radio, { checked: releaseConfig.mode === "new", readOnly: !0 }),
2583
+ /* @__PURE__ */ jsx(Text, { weight: "medium", children: "Create new release" })
2584
+ ] }),
2585
+ /* @__PURE__ */ jsxs(
2586
+ Flex,
2587
+ {
2588
+ align: "center",
2589
+ gap: 3,
2590
+ as: "label",
2591
+ style: { cursor: "pointer" },
2592
+ onClick: () => handleModeChange("existing"),
2593
+ children: [
2594
+ /* @__PURE__ */ jsx(Radio, { checked: releaseConfig.mode === "existing", readOnly: !0 }),
2595
+ /* @__PURE__ */ jsx(Text, { weight: "medium", children: "Add to existing release" })
2596
+ ]
2597
+ }
2598
+ )
2599
+ ] }),
2600
+ releaseConfig.mode === "new" && /* @__PURE__ */ jsx(
2601
+ TextInput,
2602
+ {
2603
+ value: releaseConfig.newReleaseTitle,
2604
+ onChange: handleTitleChange,
2605
+ placeholder: "Release title..."
2606
+ }
2607
+ ),
2608
+ releaseConfig.mode === "existing" && /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2609
+ isLoading && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2610
+ /* @__PURE__ */ jsx(Spinner, { muted: !0 }),
2611
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Loading..." })
2612
+ ] }),
2613
+ error && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: error }),
2614
+ !isLoading && !error && releases.length === 0 && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "No active releases found." }),
2615
+ !isLoading && !error && releases.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
2616
+ /* @__PURE__ */ jsx(
2617
+ TextInput,
2618
+ {
2619
+ icon: SearchIcon,
2620
+ value: searchQuery,
2621
+ onChange: handleSearchChange,
2622
+ placeholder: "Search releases..."
2623
+ }
2624
+ ),
2625
+ /* @__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) => {
2626
+ const releaseName = release._id.replace("_.releases.", ""), isSelected = releaseConfig.existingReleaseId === releaseName;
2627
+ return /* @__PURE__ */ jsx(
2628
+ Card,
2629
+ {
2630
+ padding: 3,
2631
+ radius: 2,
2632
+ tone: isSelected ? "primary" : "default",
2633
+ style: { cursor: "pointer" },
2634
+ onClick: () => handleSelectRelease(releaseName),
2635
+ border: !0,
2636
+ muted: isSelected,
2637
+ children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
2638
+ /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
2639
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
2640
+ ] })
2641
+ },
2642
+ release._id
2643
+ );
2644
+ }) }) })
2645
+ ] })
2646
+ ] })
2647
+ ] }) });
3323
2648
  }, TranslationModePanel = () => {
3324
2649
  const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
3325
2650
  if (!config?.openai?.enabled || !config?.openai?.apiKey)
@@ -3338,7 +2663,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3338
2663
  return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3339
2664
  /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3340
2665
  /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
3341
- /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
2666
+ /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "OpenAI Available" })
3342
2667
  ] }),
3343
2668
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3344
2669
  /* @__PURE__ */ jsx(
@@ -3368,32 +2693,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3368
2693
  /* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
3369
2694
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3370
2695
  /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3371
- /* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
2696
+ /* @__PURE__ */ jsx(Text, { children: "OpenAI Only" }),
3372
2697
  /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
3373
2698
  ] }),
3374
2699
  /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
3375
2700
  ] })
3376
2701
  ] })
3377
2702
  }
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
2703
  )
3398
2704
  ] }),
3399
2705
  translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
@@ -3556,41 +2862,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3556
2862
  ] }),
3557
2863
  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
2864
  ] });
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
- ) })
3592
- ] });
3593
- }, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2865
+ }, WriteSettingsPanel = () => /* @__PURE__ */ jsx(ReferenceConfigPanel, {}), TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3594
2866
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3595
2867
  /* @__PURE__ */ jsx(Label, { children: "Languages" }),
3596
2868
  /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
@@ -3599,16 +2871,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3599
2871
  /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
3600
2872
  /* @__PURE__ */ jsx(TranslationModePanel, {})
3601
2873
  ] }),
2874
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
2875
+ /* @__PURE__ */ jsx(Label, { children: "Content Release" }),
2876
+ /* @__PURE__ */ jsx(ReleaseSelectionPanel, {})
2877
+ ] }),
3602
2878
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3603
2879
  /* @__PURE__ */ jsx(Label, { children: "Settings" }),
3604
2880
  /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
3605
2881
  ] }),
3606
2882
  /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
3607
2883
  ] }), 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(() => {
2884
+ 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(() => {
2885
+ forceCleanupActiveTranslation(document._id), dispatch({ type: "RESET_TRANSLATION_STATE" });
2886
+ }, [dispatch, document._id]), openaiStats = useMemo(() => {
3612
2887
  if (translationMode === "sanity-only") return null;
3613
2888
  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
2889
  return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
@@ -3642,17 +2917,12 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3642
2917
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3643
2918
  "Error: ",
3644
2919
  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
2920
  ] }) }) : null
3650
2921
  ] })
3651
2922
  ] }),
3652
2923
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3653
2924
  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,
2925
+ job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, {}) : null,
3656
2926
  job.targetId ? /* @__PURE__ */ jsx(
3657
2927
  TranslationOpenerButton,
3658
2928
  {
@@ -3669,18 +2939,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3669
2939
  /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
3670
2940
  /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
3671
2941
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3672
- "\u{1F4CA} Total Tokens: ",
2942
+ "Total Tokens: ",
3673
2943
  /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
3674
2944
  ] }),
3675
2945
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3676
- "\u{1F4B0} Total Cost: ",
2946
+ "Total Cost: ",
3677
2947
  /* @__PURE__ */ jsxs("strong", { children: [
3678
2948
  "$",
3679
2949
  openaiStats.totalCost.toFixed(4)
3680
2950
  ] })
3681
2951
  ] })
3682
2952
  ] })
3683
- ] }) })
2953
+ ] }) }),
2954
+ isTerminalState && /* @__PURE__ */ jsx(Flex, { justify: "flex-end", children: /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "primary", text: "Translate More", icon: ResetIcon, onClick: handleReset }) })
3684
2955
  ] });
3685
2956
  }, ProgressBar = ({ progress, max = 100 }) => {
3686
2957
  const percentage = Math.min(progress / max * 100, 100);
@@ -3732,35 +3003,20 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3732
3003
  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
3004
  children: /* @__PURE__ */ jsx(Badge, { tone: "neutral", children: "Skipped" })
3734
3005
  }
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(
3006
+ ), ErrorBadge = () => /* @__PURE__ */ jsx(
3746
3007
  Tooltip,
3747
3008
  {
3748
3009
  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
- ] })
3010
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
3011
+ children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
3754
3012
  }
3755
3013
  ), getStatusLabel = (status) => ({
3756
3014
  idle: "Idle",
3757
3015
  queued: "Queued...",
3758
3016
  preparing_translation: "Preparing translation...",
3759
3017
  translating: "Translating with Sanity Agent...",
3760
- translating_with_openai: "Translating with GPT-5...",
3761
- reviewing_with_openai: "Reviewing translation with GPT-5...",
3018
+ translating_with_openai: "Translating with OpenAI...",
3762
3019
  updating_metadata: "Saving metadata...",
3763
- retrying: "Retrying...",
3764
3020
  error: "Error",
3765
3021
  skipped: "Skipped",
3766
3022
  completed: "Completed"
@@ -3768,14 +3024,30 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3768
3024
  "translating",
3769
3025
  "linking_references",
3770
3026
  "completed"
3771
- ], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen }) => {
3027
+ ], STATES_IN_WHICH_TO_SHOW_ERROR = ["error"], TranslationDialog = ({ setIsDialogOpen, onStatusChange }) => {
3772
3028
  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", {
3029
+ return useEffect(() => {
3030
+ onStatusChange?.(overallStatus);
3031
+ }, [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
3032
  documentId: document._id,
3775
3033
  overallStatus
3776
3034
  }), /* @__PURE__ */ jsx("div", { children: "Unhandled overall state" }));
3777
- }, getTranslateAction = (config) => ({ draft, published }) => {
3778
- const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1);
3035
+ }, IN_PROGRESS_STATUSES = ["translating", "linking_references"], getButtonLabel = (status) => status === "completed" ? "Translation Complete" : status && IN_PROGRESS_STATUSES.includes(status) ? "Translating..." : "Translate", getTranslateAction = (config) => ({ draft, published }) => {
3036
+ const document = draft ?? published, [isDialogOpen, setIsDialogOpen] = useState(!1), [currentStatus, setCurrentStatus] = useState(null), isTranslating = currentStatus !== null && IN_PROGRESS_STATUSES.includes(currentStatus), handleStatusChange = useCallback((status) => {
3037
+ setCurrentStatus(status);
3038
+ }, []), handleClose = useCallback(() => {
3039
+ if (isTranslating) {
3040
+ if (!window.confirm(
3041
+ "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."
3042
+ ))
3043
+ return;
3044
+ devLog.warn("User closed dialog during active translation", {
3045
+ documentId: document?._id,
3046
+ component: "TranslateAction"
3047
+ });
3048
+ }
3049
+ setIsDialogOpen(!1);
3050
+ }, [isTranslating, document?._id]);
3779
3051
  return devLog.debug("TranslateAction initialized", {
3780
3052
  documentId: document?._id,
3781
3053
  documentType: document?._type
@@ -3783,7 +3055,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3783
3055
  documentType: document?._type,
3784
3056
  translatableTypes: config.translatableSchemaTypes
3785
3057
  }), null) : config ? {
3786
- label: "Translate",
3058
+ label: getButtonLabel(currentStatus),
3787
3059
  icon: TranslateIcon,
3788
3060
  onHandle: () => {
3789
3061
  devLog.info("Translation dialog opened", {
@@ -3794,9 +3066,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3794
3066
  },
3795
3067
  dialog: isDialogOpen && {
3796
3068
  type: "dialog",
3797
- onClose: () => setIsDialogOpen(!1),
3069
+ onClose: handleClose,
3798
3070
  header: "Translate Document",
3799
- content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen }) })
3071
+ content: /* @__PURE__ */ jsx(TranslationContextProvider, { document, config, children: /* @__PURE__ */ jsx(TranslationDialog, { setIsDialogOpen, onStatusChange: handleStatusChange }) })
3800
3072
  }
3801
3073
  } : (devLog.error("Missing config in TranslateAction"), null);
3802
3074
  }, translationGlossaryTerm = defineType({
@@ -3912,7 +3184,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3912
3184
  defineField({
3913
3185
  name: "changeHistory",
3914
3186
  title: "Change History",
3915
- description: "History of changes made via user feedback.",
3187
+ description: "History of changes made to translation settings.",
3916
3188
  type: "array",
3917
3189
  readOnly: !0,
3918
3190
  group: "history",
@@ -4222,7 +3494,7 @@ const aiTranslationPlugin = definePlugin((config) => (config.fieldTransforms?.le
4222
3494
  })), {
4223
3495
  name: "sanity-plugin-ai-translation",
4224
3496
  document: {
4225
- actions: [getTranslateAction(config), getGiveTranslationFeedbackAction(config)]
3497
+ actions: [getTranslateAction(config)]
4226
3498
  },
4227
3499
  schema: {
4228
3500
  types: [translationSettings, translationGlossaryTerm]