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

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,7 +1,7 @@
1
1
  import { getPublishedId, pathToString, useClient, defineType, defineField, definePlugin } from "sanity";
2
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";
3
+ import { WarningOutlineIcon, SyncIcon, SearchIcon, CheckmarkIcon, ErrorOutlineIcon, CircleIcon, ArrowRightIcon, ResetIcon, PanelRightIcon, TranslateIcon } from "@sanity/icons";
4
+ import { createContext, useReducer, useRef, useEffect, useContext, useMemo, useState, useCallback } from "react";
5
5
  import defaults from "lodash.defaults";
6
6
  import { Card, Stack, Flex, Text, Box, Heading, Button, Spinner, Inline, Checkbox, Grid, Radio, TextInput, Badge, Select, Label, Code, Tooltip } from "@sanity/ui";
7
7
  import { toPlainText } from "@portabletext/react";
@@ -62,7 +62,7 @@ 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) => {
65
+ }, undraftId = (id) => id.replace(/^drafts\./, ""), activeTranslations = /* @__PURE__ */ new Map(), registerActiveTranslation = (documentId, releaseId) => {
66
66
  const entry = {
67
67
  state: {
68
68
  documentId,
@@ -80,7 +80,9 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
80
80
  }, updateActiveTranslationJob = (documentId, job) => {
81
81
  const entry = activeTranslations.get(documentId);
82
82
  if (entry) {
83
- const jobKey = `${job._id}_${job.language}`, existingJob = entry.state.translationJobs.get(jobKey), mergedJob = existingJob ? { ...existingJob, ...job } : job;
83
+ const jobKey = `${undraftId(job._id)}_${job.language}`, existingJob = entry.state.translationJobs.get(jobKey), jobWithDefinedValues = Object.fromEntries(
84
+ Object.entries(job).filter(([_, value]) => value !== void 0)
85
+ ), mergedJob = existingJob ? { ...existingJob, ...jobWithDefinedValues } : jobWithDefinedValues;
84
86
  entry.state.translationJobs.set(jobKey, mergedJob), notifySubscribers(entry);
85
87
  }
86
88
  }, getActiveTranslation = (documentId) => activeTranslations.get(documentId)?.state ?? null, subscribeToActiveTranslation = (documentId, callback) => {
@@ -112,6 +114,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
112
114
  shouldTranslateReferences: !1,
113
115
  maxReferenceDepth: 1,
114
116
  references: [],
117
+ selectedReferenceIds: /* @__PURE__ */ new Set(),
115
118
  translationMetadata: null,
116
119
  translationMode: "sanity-only",
117
120
  openaiValidated: !1,
@@ -119,6 +122,8 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
119
122
  mode: "new",
120
123
  newReleaseTitle: ""
121
124
  },
125
+ reconcilingStatus: "idle",
126
+ reconciliationProgress: void 0,
122
127
  dispatch: () => {
123
128
  }
124
129
  }), translationReducer = (context, action) => {
@@ -165,8 +170,18 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
165
170
  ...context,
166
171
  references: action.payload
167
172
  };
173
+ case "SET_SELECTED_REFERENCE_IDS":
174
+ return {
175
+ ...context,
176
+ selectedReferenceIds: action.payload
177
+ };
168
178
  case "SET_TRANSLATION_JOB_STATUS": {
169
- const jobKey = `${action.payload._id}_${action.payload.language}`, newTranslationJobs = new Map(context.translationJobs), existingJob = newTranslationJobs.get(jobKey), mergedJob = defaults(action.payload, existingJob);
179
+ if (!action.payload._id)
180
+ return devLog.warn("SET_TRANSLATION_JOB_STATUS action missing _id", {
181
+ ...logContext,
182
+ payload: action.payload
183
+ }), context;
184
+ const jobKey = `${undraftId(action.payload._id)}_${action.payload.language}`, newTranslationJobs = new Map(context.translationJobs), existingJob = newTranslationJobs.get(jobKey), mergedJob = defaults(action.payload, existingJob);
170
185
  return newTranslationJobs.set(jobKey, mergedJob), {
171
186
  ...context,
172
187
  translationJobs: newTranslationJobs
@@ -189,12 +204,29 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
189
204
  ...context,
190
205
  overallStatus: "idle",
191
206
  translationJobs: /* @__PURE__ */ new Map(),
207
+ selectedReferenceIds: /* @__PURE__ */ new Set(),
192
208
  // Clear any release conflicts from previous run
193
209
  releaseConfig: {
194
210
  ...context.releaseConfig,
195
211
  conflicts: void 0,
196
- replaceExisting: void 0
197
- }
212
+ replaceExisting: void 0,
213
+ skipConflicts: void 0
214
+ },
215
+ // Reset reconciliation state
216
+ reconcilingStatus: "idle",
217
+ reconciliationProgress: void 0
218
+ };
219
+ case "SET_RECONCILING_STATUS":
220
+ return {
221
+ ...context,
222
+ reconcilingStatus: action.payload,
223
+ // Clear progress when returning to idle or on error
224
+ reconciliationProgress: action.payload === "idle" || action.payload === "error" ? void 0 : context.reconciliationProgress
225
+ };
226
+ case "SET_RECONCILIATION_PROGRESS":
227
+ return {
228
+ ...context,
229
+ reconciliationProgress: action.payload
198
230
  };
199
231
  default:
200
232
  return context;
@@ -216,6 +248,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
216
248
  shouldTranslateReferences: !1,
217
249
  maxReferenceDepth: 1,
218
250
  references: [],
251
+ selectedReferenceIds: /* @__PURE__ */ new Set(),
219
252
  // Restore jobs from active translation if available
220
253
  translationJobs: activeTranslation?.translationJobs ?? /* @__PURE__ */ new Map(),
221
254
  translationMode: config?.openai?.defaultMode || "sanity-only",
@@ -223,7 +256,10 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
223
256
  releaseConfig: {
224
257
  mode: "new",
225
258
  newReleaseTitle: defaultReleaseTitle
226
- }
259
+ },
260
+ // Reconciliation state for existing releases
261
+ reconcilingStatus: "idle",
262
+ reconciliationProgress: void 0
227
263
  };
228
264
  activeTranslation && devLog.info("Reconnecting to active translation", {
229
265
  documentId: document._id,
@@ -231,7 +267,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
231
267
  jobCount: activeTranslation.translationJobs.size,
232
268
  component: "TranslationContextProvider"
233
269
  });
234
- const [context, dispatch] = useReducer(translationReducer, initialContext);
270
+ const [context, dispatch] = useReducer(translationReducer, initialContext), lastProcessedRefsRef = useRef("");
235
271
  return useEffect(() => {
236
272
  const handleActiveTranslationUpdate = (state) => {
237
273
  dispatch({
@@ -271,7 +307,28 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
271
307
  });
272
308
  }
273
309
  })();
274
- }, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), devLog.debug("Translation context state initialized", {
310
+ }, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), useEffect(() => {
311
+ const currentRefsKey = context.references.map((ref) => ref._id).sort().join(",");
312
+ if (currentRefsKey !== lastProcessedRefsRef.current) {
313
+ if (context.references.length > 0) {
314
+ const currentReferenceIds = new Set(context.references.map((ref) => ref._id)), previousReferenceIds = new Set(
315
+ lastProcessedRefsRef.current ? lastProcessedRefsRef.current.split(",") : []
316
+ ), hasNewReferences = Array.from(currentReferenceIds).some((id) => !previousReferenceIds.has(id)), currentSelection = context.selectedReferenceIds;
317
+ if (currentSelection.size === 0 || hasNewReferences) {
318
+ const newSelection = new Set(currentSelection);
319
+ currentReferenceIds.forEach((id) => {
320
+ newSelection.add(id);
321
+ });
322
+ const currentIds = Array.from(currentSelection).sort(), newIds = Array.from(newSelection).sort();
323
+ (currentIds.length !== newIds.length || !currentIds.every((id, i) => id === newIds[i])) && dispatch({
324
+ type: "SET_SELECTED_REFERENCE_IDS",
325
+ payload: newSelection
326
+ });
327
+ }
328
+ }
329
+ lastProcessedRefsRef.current = currentRefsKey;
330
+ }
331
+ }, [context.references, context.selectedReferenceIds]), devLog.debug("Translation context state initialized", {
275
332
  documentId: document._id,
276
333
  overallStatus: context.overallStatus,
277
334
  selectedLanguages: context.selectedTargetLanguages,
@@ -288,7 +345,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
288
345
  return context;
289
346
  }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
290
347
  (translation) => translation._key === targetLanguage
291
- )?.value._ref || null, undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_TYPE = "translation.metadata", getTranslationMetadata = async ({
348
+ )?.value._ref || null, TRANSLATION_METADATA_TYPE = "translation.metadata", getTranslationMetadata = async ({
292
349
  client,
293
350
  sourceDocument,
294
351
  sourceLanguage
@@ -409,6 +466,115 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
409
466
  }
410
467
  return allConflicts;
411
468
  };
469
+ function isNetworkError(error) {
470
+ if (!error) return !1;
471
+ if (error instanceof TypeError) {
472
+ const message = error.message.toLowerCase();
473
+ return message.includes("fetch") || message.includes("network") || message.includes("connection") || message.includes("failed to fetch") || message.includes("networkerror");
474
+ }
475
+ if (error && typeof error == "object" && "code" in error) {
476
+ const code = error.code;
477
+ return ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "ECONNREFUSED", "EHOSTUNREACH"].includes(code);
478
+ }
479
+ if (error && typeof error == "object" && "response" in error) {
480
+ const response = error.response;
481
+ if (response?.status)
482
+ return response.status >= 500 || response.status === 429;
483
+ }
484
+ if (error instanceof Error) {
485
+ const message = error.message;
486
+ if (/\b(50[0-9]|429)\b/.test(message))
487
+ return !0;
488
+ }
489
+ return !1;
490
+ }
491
+ function isSanityMutationError(error) {
492
+ if (!error) return !1;
493
+ if (error instanceof Error) {
494
+ const message = error.message.toLowerCase();
495
+ return [
496
+ "validation",
497
+ "schema",
498
+ "invalid document",
499
+ "invalid field",
500
+ "type mismatch",
501
+ "reference",
502
+ "patch",
503
+ "mutation",
504
+ "document structure",
505
+ "required field",
506
+ "field type",
507
+ "sanity"
508
+ ].some((pattern) => message.includes(pattern));
509
+ }
510
+ if (error && typeof error == "object") {
511
+ const errorObj = error;
512
+ if (("statusCode" in errorObj || "response" in errorObj || "details" in errorObj) && "statusCode" in errorObj) {
513
+ const statusCode = errorObj.statusCode;
514
+ if (statusCode >= 400 && statusCode < 500 && statusCode !== 429)
515
+ return !0;
516
+ }
517
+ }
518
+ return !1;
519
+ }
520
+ const DEFAULT_RETRY_OPTIONS = {
521
+ maxAttempts: 2,
522
+ initialDelayMs: 0,
523
+ maxDelayMs: 5e3,
524
+ backoffMultiplier: 2,
525
+ retryableErrors: isNetworkError,
526
+ logRetries: !0
527
+ };
528
+ function calculateDelay(attempt, options) {
529
+ const delay = options.initialDelayMs * options.backoffMultiplier ** attempt;
530
+ return Math.min(delay, options.maxDelayMs);
531
+ }
532
+ function sleep(ms) {
533
+ return new Promise((resolve) => setTimeout(resolve, ms));
534
+ }
535
+ async function retryWithBackoff(operation, options = {}) {
536
+ const opts = {
537
+ ...DEFAULT_RETRY_OPTIONS,
538
+ ...options,
539
+ retryableErrors: options.retryableErrors || DEFAULT_RETRY_OPTIONS.retryableErrors
540
+ };
541
+ let lastError;
542
+ const maxAttempts = opts.maxAttempts + 1;
543
+ for (let attempt = 1; attempt <= maxAttempts; attempt++)
544
+ try {
545
+ const result = await operation();
546
+ return attempt > 1 && opts.logRetries && devLog.info(`Operation succeeded after ${attempt} attempts`, {
547
+ ...opts.context,
548
+ attempt,
549
+ totalAttempts: attempt
550
+ }), result;
551
+ } catch (error) {
552
+ if (lastError = error, !opts.retryableErrors(error))
553
+ throw opts.logRetries && devLog.warn("Error is not retryable, aborting retries", {
554
+ ...opts.context,
555
+ attempt,
556
+ error: error instanceof Error ? error.message : String(error)
557
+ }), error;
558
+ if (attempt >= maxAttempts)
559
+ throw opts.logRetries && devLog.error(
560
+ `Operation failed after ${maxAttempts} attempts`,
561
+ error instanceof Error ? error : new Error(String(error)),
562
+ {
563
+ ...opts.context,
564
+ totalAttempts: maxAttempts
565
+ }
566
+ ), error;
567
+ const delay = calculateDelay(attempt - 1, opts);
568
+ opts.logRetries && devLog.warn(`Operation failed, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`, {
569
+ ...opts.context,
570
+ attempt,
571
+ totalAttempts: maxAttempts,
572
+ delayMs: delay,
573
+ error: error instanceof Error ? error.message : String(error)
574
+ }), delay > 0 && await sleep(delay);
575
+ }
576
+ throw lastError;
577
+ }
412
578
  class Semaphore {
413
579
  permits;
414
580
  queue = [];
@@ -759,12 +925,105 @@ const fetchTranslationPrompts = async ({
759
925
  }
760
926
  }), hasChanges = !0);
761
927
  }
762
- hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
928
+ hasChanges && (await retryWithBackoff(
929
+ async () => {
930
+ await patch.commit();
931
+ },
932
+ {
933
+ maxAttempts: 2,
934
+ initialDelayMs: 0,
935
+ retryableErrors: isNetworkError,
936
+ context: {
937
+ operation: "localizeReferences",
938
+ documentId,
939
+ referenceCount: refs.length
940
+ }
941
+ }
942
+ ), devLog.info("Updated references in batch", {
763
943
  documentId,
764
944
  count: refs.length,
765
945
  translatedCount: translationMap.size,
766
946
  component: "updateReferences"
767
947
  }));
948
+ }, updateBackwardReferences = async ({
949
+ client,
950
+ releaseId,
951
+ sourceDocumentIds,
952
+ targetLanguages,
953
+ sourceLanguage,
954
+ languageFieldName,
955
+ excludeDocumentIds
956
+ }) => {
957
+ if (!sourceDocumentIds.length || !targetLanguages.length)
958
+ return;
959
+ const normalizedSourceIds = sourceDocumentIds.map((id) => getPublishedId(id));
960
+ devLog.debug("Starting backward reference update", {
961
+ releaseId,
962
+ sourceDocumentCount: normalizedSourceIds.length,
963
+ targetLanguages,
964
+ excludeCount: excludeDocumentIds.length,
965
+ component: "updateBackwardReferences"
966
+ });
967
+ try {
968
+ for (const targetLanguage of targetLanguages) {
969
+ const existingDocsWithStaleRefs = await client.fetch(
970
+ `*[
971
+ _id match "versions." + $releaseId + ".*" &&
972
+ @[$languageFieldName] == $targetLanguage &&
973
+ !(_id in $excludeIds)
974
+ ] {
975
+ _id,
976
+ "hasStaleRefs": count(*[
977
+ _id in $sourceDocumentIds &&
978
+ ^references(@._id)
979
+ ]) > 0
980
+ }[hasStaleRefs == true]`,
981
+ {
982
+ releaseId,
983
+ targetLanguage,
984
+ languageFieldName,
985
+ excludeIds: excludeDocumentIds,
986
+ sourceDocumentIds: normalizedSourceIds
987
+ }
988
+ );
989
+ if (existingDocsWithStaleRefs.length === 0) {
990
+ devLog.debug("No existing documents with stale references found", {
991
+ releaseId,
992
+ targetLanguage,
993
+ component: "updateBackwardReferences"
994
+ });
995
+ continue;
996
+ }
997
+ devLog.info("Found existing documents with stale references", {
998
+ releaseId,
999
+ targetLanguage,
1000
+ count: existingDocsWithStaleRefs.length,
1001
+ documentIds: existingDocsWithStaleRefs.map((d) => d._id),
1002
+ component: "updateBackwardReferences"
1003
+ }), await Promise.all(
1004
+ existingDocsWithStaleRefs.map(async ({ _id }) => {
1005
+ await localizeReferences({
1006
+ client,
1007
+ documentId: _id,
1008
+ sourceLanguage,
1009
+ languageFieldName
1010
+ });
1011
+ })
1012
+ ), devLog.info("Updated backward references", {
1013
+ releaseId,
1014
+ targetLanguage,
1015
+ updatedCount: existingDocsWithStaleRefs.length,
1016
+ component: "updateBackwardReferences"
1017
+ });
1018
+ }
1019
+ } catch (error) {
1020
+ devLog.error("Error updating backward references", error instanceof Error ? error : new Error(String(error)), {
1021
+ releaseId,
1022
+ sourceDocumentIds: normalizedSourceIds,
1023
+ targetLanguages,
1024
+ component: "updateBackwardReferences"
1025
+ });
1026
+ }
768
1027
  }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages, mainSourceDocumentId) => {
769
1028
  for (const sourceDocument of sourceDocuments)
770
1029
  for (const lang of targetLanguages) {
@@ -780,6 +1039,57 @@ const fetchTranslationPrompts = async ({
780
1039
  payload
781
1040
  }), mainSourceDocumentId && updateActiveTranslationJob(mainSourceDocumentId, payload);
782
1041
  }
1042
+ }, emitTelemetryEvent = (config, event) => {
1043
+ if (config?.onEvent)
1044
+ try {
1045
+ config.onEvent(event), devLog.debug("Telemetry event emitted", { eventName: event.name });
1046
+ } catch (error) {
1047
+ devLog.warn("Failed to emit telemetry event", {
1048
+ eventName: event.name,
1049
+ error: error instanceof Error ? error.message : String(error)
1050
+ });
1051
+ }
1052
+ }, emitTelemetryError = (config, bundle) => {
1053
+ if (config?.onError)
1054
+ try {
1055
+ config.onError(bundle), devLog.debug("Telemetry error emitted", {
1056
+ documentId: bundle.documentId,
1057
+ errorType: bundle.error.type
1058
+ });
1059
+ } catch (error) {
1060
+ devLog.warn("Failed to emit telemetry error", {
1061
+ documentId: bundle.documentId,
1062
+ error: error instanceof Error ? error.message : String(error)
1063
+ });
1064
+ }
1065
+ }, buildErrorBundle = (params) => {
1066
+ const durationMs = Date.now() - params.startTime;
1067
+ return {
1068
+ documentId: params.documentId,
1069
+ documentType: params.documentType,
1070
+ targetLanguage: params.targetLanguage,
1071
+ sourceLanguage: params.sourceLanguage,
1072
+ translationMode: params.translationMode,
1073
+ error: {
1074
+ message: params.error.message,
1075
+ stack: params.error.stack,
1076
+ type: params.errorType
1077
+ },
1078
+ debug: {
1079
+ originalDocument: params.originalDocument,
1080
+ serializedDocument: params.serializedDocument,
1081
+ openaiResponse: params.openaiResponse,
1082
+ translationPrompts: params.translationPrompts,
1083
+ validationErrors: params.validationErrors
1084
+ },
1085
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1086
+ durationMs
1087
+ };
1088
+ }, createTimestamp = () => (/* @__PURE__ */ new Date()).toISOString(), classifyError = (error, context) => {
1089
+ if (context)
1090
+ return context;
1091
+ const message = error.message.toLowerCase();
1092
+ return message.includes("network") || message.includes("fetch") || message.includes("timeout") || message.includes("econnrefused") || message.includes("enotfound") ? "network" : message.includes("openai") || message.includes("gpt") || message.includes("api error") ? "openai_api" : message.includes("validation") || message.includes("structure") || message.includes("invalid") ? "validation" : message.includes("deserialize") || message.includes("parse") ? "deserialization" : message.includes("serialize") || message.includes("xml") ? "serialization" : message.includes("agent") || message.includes("sanity") ? "sanity_agent" : "unknown";
783
1093
  }, updateTranslationMetadata = async ({
784
1094
  client,
785
1095
  targetLanguage,
@@ -813,11 +1123,71 @@ const fetchTranslationPrompts = async ({
813
1123
  targetRev: newDocument._rev,
814
1124
  targetSnapshot: JSON.stringify(newDocument)
815
1125
  }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
816
- isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1126
+ isNewTranslation ? await retryWithBackoff(
1127
+ async () => {
1128
+ await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit();
1129
+ },
1130
+ {
1131
+ maxAttempts: 2,
1132
+ initialDelayMs: 0,
1133
+ retryableErrors: isNetworkError,
1134
+ context: {
1135
+ operation: "updateTranslationMetadata.translations",
1136
+ sourceDocumentId: sourceDocument._id,
1137
+ targetLanguage,
1138
+ isNewTranslation: !0
1139
+ }
1140
+ }
1141
+ ) : await retryWithBackoff(
1142
+ async () => {
1143
+ await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1144
+ },
1145
+ {
1146
+ maxAttempts: 2,
1147
+ initialDelayMs: 0,
1148
+ retryableErrors: isNetworkError,
1149
+ context: {
1150
+ operation: "updateTranslationMetadata.translations",
1151
+ sourceDocumentId: sourceDocument._id,
1152
+ targetLanguage,
1153
+ isNewTranslation: !1
1154
+ }
1155
+ }
1156
+ );
817
1157
  const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
818
1158
  translationMetadata.translationHistory?.some(
819
1159
  (history) => history.language === targetLanguage
820
- ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
1160
+ ) ? await retryWithBackoff(
1161
+ async () => {
1162
+ await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit();
1163
+ },
1164
+ {
1165
+ maxAttempts: 2,
1166
+ initialDelayMs: 0,
1167
+ retryableErrors: isNetworkError,
1168
+ context: {
1169
+ operation: "updateTranslationMetadata.history",
1170
+ sourceDocumentId: sourceDocument._id,
1171
+ targetLanguage,
1172
+ isNewHistory: !1
1173
+ }
1174
+ }
1175
+ ) : await retryWithBackoff(
1176
+ async () => {
1177
+ await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
1178
+ },
1179
+ {
1180
+ maxAttempts: 2,
1181
+ initialDelayMs: 0,
1182
+ retryableErrors: isNetworkError,
1183
+ context: {
1184
+ operation: "updateTranslationMetadata.history",
1185
+ sourceDocumentId: sourceDocument._id,
1186
+ targetLanguage,
1187
+ isNewHistory: !0
1188
+ }
1189
+ }
1190
+ );
821
1191
  } catch (error) {
822
1192
  throw devLog.error("Error updating translation metadata", error instanceof Error ? error : new Error(String(error)), {
823
1193
  sourceDocumentId: sourceDocument._id,
@@ -1068,7 +1438,9 @@ function restoreTransforms({
1068
1438
  schemaType,
1069
1439
  document: translatedDocument
1070
1440
  };
1071
- devLog.debug("Restoring transform for field", { fieldPath, schemaType }), restoredObj[key] = transform.fromTranslatable(value, originalValue, context);
1441
+ devLog.debug("Restoring transform for field", { fieldPath, schemaType });
1442
+ const restoredValue = transform.fromTranslatable(value, originalValue, context);
1443
+ restoredObj[key] = restoredValue;
1072
1444
  } else
1073
1445
  restoredObj[key] = restoreTransforms({
1074
1446
  translatedDocument: value,
@@ -1161,14 +1533,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1161
1533
  }), devLog.debug("OpenAI GPT-5 Full Prompt", {
1162
1534
  messages: request.messages
1163
1535
  });
1164
- const response = await fetch("https://api.openai.com/v1/responses", {
1165
- method: "POST",
1166
- headers: {
1167
- "Content-Type": "application/json",
1168
- Authorization: `Bearer ${apiKey}`
1536
+ const response = await retryWithBackoff(
1537
+ async () => {
1538
+ const fetchResponse = await fetch("https://api.openai.com/v1/responses", {
1539
+ method: "POST",
1540
+ headers: {
1541
+ "Content-Type": "application/json",
1542
+ Authorization: `Bearer ${apiKey}`
1543
+ },
1544
+ body: JSON.stringify(requestBody)
1545
+ });
1546
+ if (!fetchResponse.ok) {
1547
+ const errorMessage = `GPT-5 API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
1548
+ throw errorObj.response = { status: fetchResponse.status }, errorObj;
1549
+ }
1550
+ return fetchResponse;
1169
1551
  },
1170
- body: JSON.stringify(requestBody)
1171
- });
1552
+ {
1553
+ maxAttempts: 2,
1554
+ initialDelayMs: 0,
1555
+ retryableErrors: isNetworkError,
1556
+ context: { model: requestBody.model, endpoint: "responses" }
1557
+ }
1558
+ );
1172
1559
  if (!response.ok) {
1173
1560
  const error = await response.json();
1174
1561
  throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
@@ -1207,14 +1594,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1207
1594
  }), devLog.debug("OpenAI Chat Completions Full Prompt", {
1208
1595
  messages: request.messages
1209
1596
  });
1210
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
1211
- method: "POST",
1212
- headers: {
1213
- "Content-Type": "application/json",
1214
- Authorization: `Bearer ${apiKey}`
1597
+ const response = await retryWithBackoff(
1598
+ async () => {
1599
+ const fetchResponse = await fetch("https://api.openai.com/v1/chat/completions", {
1600
+ method: "POST",
1601
+ headers: {
1602
+ "Content-Type": "application/json",
1603
+ Authorization: `Bearer ${apiKey}`
1604
+ },
1605
+ body: JSON.stringify(requestBody)
1606
+ });
1607
+ if (!fetchResponse.ok) {
1608
+ const errorMessage = `OpenAI API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
1609
+ throw errorObj.response = { status: fetchResponse.status }, errorObj;
1610
+ }
1611
+ return fetchResponse;
1215
1612
  },
1216
- body: JSON.stringify(requestBody)
1217
- });
1613
+ {
1614
+ maxAttempts: 2,
1615
+ initialDelayMs: 0,
1616
+ retryableErrors: isNetworkError,
1617
+ context: { model, endpoint: "chat/completions" }
1618
+ }
1619
+ );
1218
1620
  if (!response.ok) {
1219
1621
  const error = await response.json();
1220
1622
  throw new Error(`OpenAI API error: ${error.error?.message || "Unknown error"}`);
@@ -1276,16 +1678,63 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1276
1678
  }, modelPricing = pricing[model] || pricing["gpt-5"], cachedInputRate = model === "gpt-5.1" ? 0.125 / 1e6 : modelPricing.input;
1277
1679
  return Math.max(0, tokens.prompt - tokens.cached) * modelPricing.input + tokens.cached * cachedInputRate + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
1278
1680
  }, deserializeDocument = (originalDoc, reviewedText) => {
1279
- const parsed = parseReviewFormat(reviewedText), result = cloneDeep(originalDoc);
1681
+ devLog.debug("Raw GPT-5 response received", {
1682
+ length: reviewedText.length,
1683
+ preview: reviewedText.substring(0, 1e3),
1684
+ hasReasoning: reviewedText.includes("<reasoning>"),
1685
+ hasCodeBlocks: reviewedText.includes("```")
1686
+ });
1687
+ const cleanedText = preprocessResponse(reviewedText);
1688
+ cleanedText.length !== reviewedText.length && devLog.debug("Response after preprocessing", {
1689
+ originalLength: reviewedText.length,
1690
+ cleanedLength: cleanedText.length,
1691
+ preview: cleanedText.substring(0, 500)
1692
+ });
1693
+ const parsed = parseReviewFormat(cleanedText, originalDoc);
1694
+ devLog.debug("Parsed response structure", {
1695
+ rootNodeCount: parsed.length,
1696
+ rootNodeNames: parsed.map((n) => n.type === "field" && n.name ? n.name : "unknown")
1697
+ });
1698
+ const result = cloneDeep(originalDoc);
1280
1699
  return applyTextChanges(result, parsed), result;
1281
- }, parseReviewFormat = (text) => {
1700
+ }, preprocessResponse = (text) => {
1701
+ const originalLength = text.length;
1702
+ let cleaned = text;
1703
+ const beforeReasoning = cleaned.length;
1704
+ cleaned = cleaned.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, "");
1705
+ const reasoningRemoved = beforeReasoning !== cleaned.length, beforeCodeBlocks = cleaned.length;
1706
+ cleaned = cleaned.replace(/^```[a-z]*\s*$/gim, ""), cleaned = cleaned.replace(/^```\s*$/gim, "");
1707
+ const codeBlocksRemoved = beforeCodeBlocks !== cleaned.length, finalLength = cleaned.trim().length;
1708
+ return (reasoningRemoved || codeBlocksRemoved) && devLog.debug("Preprocessed GPT-5 response", {
1709
+ originalLength,
1710
+ finalLength,
1711
+ reasoningRemoved,
1712
+ codeBlocksRemoved,
1713
+ preview: cleaned.trim().substring(0, 200)
1714
+ }), cleaned.trim();
1715
+ }, parseReviewFormat = (text, originalDoc) => {
1282
1716
  const root = [], stack = [], lines = text.split(`
1283
1717
  `);
1718
+ devLog.debug("Parsing response text", {
1719
+ lineCount: lines.length,
1720
+ firstLines: lines.slice(0, 10).map((l, idx) => ({ line: idx, content: l.substring(0, 100) })),
1721
+ hasFieldTags: text.includes("<field"),
1722
+ hasBlockTags: text.includes("<block"),
1723
+ hasSpanTags: text.includes("<span"),
1724
+ textLength: text.length,
1725
+ textPreview: text.substring(0, 500)
1726
+ });
1727
+ let processedLineCount = 0, skippedEmptyLineCount = 0, matchedPatternCount = 0;
1284
1728
  for (let i = 0; i < lines.length; i++) {
1285
1729
  const trimmed = lines[i].trim();
1286
- if (!trimmed) continue;
1730
+ if (!trimmed) {
1731
+ skippedEmptyLineCount++;
1732
+ continue;
1733
+ }
1734
+ processedLineCount++;
1287
1735
  const compactStringMatch = trimmed.match(/^(\w+):\s*(.*)$/);
1288
1736
  if (compactStringMatch && !trimmed.includes("<")) {
1737
+ matchedPatternCount++;
1289
1738
  const [, fieldName, text2] = compactStringMatch, node = {
1290
1739
  type: "field",
1291
1740
  name: fieldName,
@@ -1302,6 +1751,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1302
1751
  }
1303
1752
  const compactStartMatch = trimmed.match(/^---\s+(\w+)\s+---$/);
1304
1753
  if (compactStartMatch) {
1754
+ matchedPatternCount++;
1305
1755
  const fieldName = compactStartMatch[1], contentLines = [];
1306
1756
  for (i++; i < lines.length; ) {
1307
1757
  const currentLine = lines[i];
@@ -1311,8 +1761,16 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1311
1761
  }
1312
1762
  const content = contentLines.join(`
1313
1763
  `);
1314
- let contentType = "translatable.vtt.captions";
1315
- (content.includes("<block") || content.includes("<span")) && (contentType = "translatable.richtext");
1764
+ let contentType;
1765
+ try {
1766
+ const fieldValue = get(originalDoc, [fieldName]);
1767
+ if (fieldValue && typeof fieldValue == "object" && fieldValue !== null) {
1768
+ const fieldType = fieldValue._type;
1769
+ typeof fieldType == "string" && fieldType.startsWith("translatable.") && (contentType = fieldType);
1770
+ }
1771
+ } catch {
1772
+ }
1773
+ contentType || (content.includes("<block") || content.includes("<span") ? contentType = "translatable.richtext" : contentType = "translatable.vtt.captions");
1316
1774
  const node = {
1317
1775
  type: "field",
1318
1776
  name: fieldName,
@@ -1329,6 +1787,12 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1329
1787
  }
1330
1788
  const openMatch = trimmed.match(/^<(field|block|span)([^>]*)>(.*)$/);
1331
1789
  if (openMatch) {
1790
+ matchedPatternCount++, devLog.debug("Matched opening tag", {
1791
+ line: i,
1792
+ tagName: openMatch[1],
1793
+ attributes: openMatch[2],
1794
+ contentPreview: openMatch[3]?.substring(0, 50)
1795
+ });
1332
1796
  const [, tagName, attrsStr, content] = openMatch, attributes = parseAttributes(attrsStr), node = {
1333
1797
  type: tagName,
1334
1798
  name: attributes.name,
@@ -1336,25 +1800,27 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1336
1800
  attributes,
1337
1801
  children: []
1338
1802
  }, closingTag = `</${tagName}>`;
1339
- if (content.endsWith(closingTag.replace(/[<>]/g, ""))) {
1340
- const textContent = content.substring(0, content.lastIndexOf("<"));
1341
- if (node.text = unescapeXml(textContent), stack.length > 0) {
1342
- const parent = stack[stack.length - 1];
1343
- parent && parent.children.push(node);
1344
- } else
1345
- root.push(node);
1346
- } else if (content.includes(closingTag))
1347
- if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
1803
+ if (content.includes(closingTag)) {
1804
+ const closeIdx = content.indexOf(closingTag);
1805
+ node.text = unescapeXml(content.substring(0, closeIdx));
1806
+ const remainder = content.substring(closeIdx + closingTag.length).trim();
1807
+ if (stack.length > 0) {
1348
1808
  const parent = stack[stack.length - 1];
1349
1809
  parent && parent.children.push(node);
1350
1810
  } else
1351
1811
  root.push(node);
1352
- else if (attributes.type === "translatable.vtt.captions" || attributes.type === "translatable.richtext") {
1812
+ remainder && (lines[i] = remainder, i--);
1813
+ } else if (attributes.type && typeof attributes.type == "string" && attributes.type.startsWith("translatable.")) {
1353
1814
  const contentLines = [];
1354
1815
  for (i++; i < lines.length; ) {
1355
1816
  const currentLine = lines[i];
1356
- if (currentLine.trim().match(/^<\/field>$/))
1817
+ if (currentLine.includes("</field>")) {
1818
+ const idx = currentLine.indexOf("</field>"), before = currentLine.substring(0, idx).trimEnd();
1819
+ before.trim() && contentLines.push(before);
1820
+ const after = currentLine.substring(idx + 8).trim();
1821
+ after && (lines[i] = after, i--);
1357
1822
  break;
1823
+ }
1358
1824
  contentLines.push(currentLine), i++;
1359
1825
  }
1360
1826
  if (node.text = contentLines.join(`
@@ -1363,18 +1829,36 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1363
1829
  parent && parent.children.push(node);
1364
1830
  } else
1365
1831
  root.push(node);
1366
- } else if (attributes.type === "string" && content && content.includes(closingTag))
1367
- if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
1832
+ } else if (attributes.type === "string" && content && !content.includes(closingTag)) {
1833
+ const contentLines = [content];
1834
+ for (i++; i < lines.length; ) {
1835
+ const currentLine = lines[i];
1836
+ if (currentLine.includes(closingTag)) {
1837
+ const closingIndex = currentLine.indexOf(closingTag), remainingContent = currentLine.substring(0, closingIndex).trimEnd();
1838
+ remainingContent && contentLines.push(remainingContent);
1839
+ const after = currentLine.substring(closingIndex + closingTag.length).trim();
1840
+ after && (lines[i] = after, i--);
1841
+ break;
1842
+ }
1843
+ contentLines.push(currentLine), i++;
1844
+ }
1845
+ if (node.text = unescapeXml(contentLines.join(`
1846
+ `)), stack.length > 0) {
1368
1847
  const parent = stack[stack.length - 1];
1369
1848
  parent && parent.children.push(node);
1370
1849
  } else
1371
1850
  root.push(node);
1372
- else if (attributes.type === "string" && !content) {
1851
+ } else if (attributes.type === "string" && !content) {
1373
1852
  const contentLines = [];
1374
1853
  for (i++; i < lines.length; ) {
1375
1854
  const currentLine = lines[i];
1376
- if (currentLine.trim() === "</field>")
1855
+ if (currentLine.includes("</field>")) {
1856
+ const idx = currentLine.indexOf("</field>"), before = currentLine.substring(0, idx).trimEnd();
1857
+ before.trim() && contentLines.push(before);
1858
+ const after = currentLine.substring(idx + 8).trim();
1859
+ after && (lines[i] = after, i--);
1377
1860
  break;
1861
+ }
1378
1862
  contentLines.push(currentLine), i++;
1379
1863
  }
1380
1864
  if (node.text = contentLines.join(`
@@ -1387,17 +1871,130 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1387
1871
  stack.push(node);
1388
1872
  continue;
1389
1873
  }
1390
- if (trimmed.match(/^<\/(field|block|span)>$/) && stack.length > 0) {
1391
- const node = stack.pop();
1392
- if (node)
1393
- if (stack.length > 0) {
1394
- const parent = stack[stack.length - 1];
1395
- parent && parent.children.push(node);
1874
+ if (trimmed.match(/^<\/(field|block|span)>/)) {
1875
+ let afterClosingTag = trimmed;
1876
+ for (; ; ) {
1877
+ const match = afterClosingTag.match(/^<\/(field|block|span)>/);
1878
+ if (!match) break;
1879
+ if (stack.length === 0) {
1880
+ devLog.warn("Closing tag found but stack is empty", {
1881
+ line: i,
1882
+ tagName: match[1],
1883
+ trimmed: afterClosingTag
1884
+ }), afterClosingTag = afterClosingTag.substring(match[0].length).trim(), afterClosingTag = afterClosingTag.replace(/<!--[\s\S]*?-->/g, "").trim();
1885
+ continue;
1886
+ }
1887
+ matchedPatternCount++, devLog.debug("Matched closing tag", {
1888
+ line: i,
1889
+ tagName: match[1],
1890
+ trimmed: afterClosingTag,
1891
+ stackLength: stack.length,
1892
+ stackTopName: stack[stack.length - 1]?.name
1893
+ });
1894
+ const node = stack.pop();
1895
+ if (node)
1896
+ if (stack.length > 0) {
1897
+ const parent = stack[stack.length - 1];
1898
+ parent && parent.children.push(node);
1899
+ } else
1900
+ root.push(node);
1901
+ afterClosingTag = afterClosingTag.substring(match[0].length).trim(), afterClosingTag = afterClosingTag.replace(/<!--[\s\S]*?-->/g, "").trim();
1902
+ }
1903
+ if (afterClosingTag) {
1904
+ const remainingOpenMatch = afterClosingTag.match(/^<(field|block|span)([^>]*)>(.*)$/);
1905
+ if (remainingOpenMatch) {
1906
+ const [, tagName, attrsStr, content] = remainingOpenMatch, attributes = parseAttributes(attrsStr), remainingNode = {
1907
+ type: tagName,
1908
+ name: attributes.name,
1909
+ text: content ? unescapeXml(content) : void 0,
1910
+ attributes,
1911
+ children: []
1912
+ }, remainingClosingTag = `</${tagName}>`;
1913
+ if (content.includes(remainingClosingTag)) {
1914
+ const closeIdx = content.indexOf(remainingClosingTag);
1915
+ remainingNode.text = unescapeXml(content.substring(0, closeIdx));
1916
+ const remainder = content.substring(closeIdx + remainingClosingTag.length).trim();
1917
+ if (stack.length > 0) {
1918
+ const parent = stack[stack.length - 1];
1919
+ parent && parent.children.push(remainingNode);
1920
+ } else
1921
+ root.push(remainingNode);
1922
+ remainder && (lines[i] = remainder, i--);
1923
+ } else if (attributes.type === "string" && content)
1924
+ if (remainingNode.text = unescapeXml(content), stack.length > 0) {
1925
+ const parent = stack[stack.length - 1];
1926
+ parent && parent.children.push(remainingNode);
1927
+ } else
1928
+ root.push(remainingNode);
1929
+ else if (attributes.type === "string" && !content) {
1930
+ const contentLines = [];
1931
+ for (i++; i < lines.length; ) {
1932
+ const currentLine = lines[i];
1933
+ if (currentLine.includes(`</${tagName}>`)) {
1934
+ const idx = currentLine.indexOf(`</${tagName}>`), before = currentLine.substring(0, idx).trimEnd();
1935
+ before.trim() && contentLines.push(before);
1936
+ const after = currentLine.substring(idx + `</${tagName}>`.length).trim();
1937
+ after && (lines[i] = after, i--);
1938
+ break;
1939
+ }
1940
+ contentLines.push(currentLine), i++;
1941
+ }
1942
+ if (remainingNode.text = contentLines.join(`
1943
+ `).trim(), stack.length > 0) {
1944
+ const parent = stack[stack.length - 1];
1945
+ parent && parent.children.push(remainingNode);
1946
+ } else
1947
+ root.push(remainingNode);
1948
+ } else
1949
+ stack.push(remainingNode);
1396
1950
  } else
1397
- root.push(node);
1951
+ lines[i] = afterClosingTag, i--;
1952
+ }
1953
+ continue;
1954
+ }
1955
+ if (trimmed.startsWith("</")) {
1956
+ const attemptedCloseMatch = trimmed.match(/^<\/(field|block|span)>/);
1957
+ attemptedCloseMatch ? stack.length === 0 && devLog.warn("Closing tag matched but stack is empty (unmatched closing tag)", {
1958
+ line: i,
1959
+ tagName: attemptedCloseMatch[1],
1960
+ trimmed
1961
+ }) : devLog.warn("Line starts with </ but did not match closing tag pattern", {
1962
+ line: i,
1963
+ trimmed,
1964
+ stackLength: stack.length,
1965
+ stackTopName: stack[stack.length - 1]?.name,
1966
+ stackTopType: stack[stack.length - 1]?.type
1967
+ });
1398
1968
  }
1969
+ processedLineCount <= 20 && devLog.debug("Line did not match any pattern", {
1970
+ line: i,
1971
+ trimmed,
1972
+ startsWithLessThan: trimmed.startsWith("<"),
1973
+ startsWithField: trimmed.startsWith("<field"),
1974
+ startsWithBacktick: trimmed.startsWith("`"),
1975
+ startsWithClosingTag: trimmed.startsWith("</")
1976
+ });
1399
1977
  }
1400
- return root;
1978
+ if (stack.length > 0)
1979
+ for (devLog.warn("Nodes remaining in stack after parsing complete", {
1980
+ stackLength: stack.length,
1981
+ remainingNodes: stack.map((n) => ({
1982
+ type: n.type,
1983
+ name: n.name,
1984
+ attributes: n.attributes
1985
+ }))
1986
+ }); stack.length > 0; ) {
1987
+ const node = stack.pop();
1988
+ node && root.push(node);
1989
+ }
1990
+ return devLog.debug("Parse complete", {
1991
+ totalLines: lines.length,
1992
+ processedLines: processedLineCount,
1993
+ skippedEmptyLines: skippedEmptyLineCount,
1994
+ rootNodeCount: root.length,
1995
+ stackLength: stack.length,
1996
+ matchedPatterns: matchedPatternCount
1997
+ }), root;
1401
1998
  }, parseAttributes = (attrStr) => {
1402
1999
  const attrs = {}, attrRegex = /(\w+)="([^"]*)"/g;
1403
2000
  let match = attrRegex.exec(attrStr);
@@ -1405,20 +2002,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1405
2002
  attrs[match[1]] = unescapeXml(match[2]), match = attrRegex.exec(attrStr);
1406
2003
  return attrs;
1407
2004
  }, unescapeXml = (str) => str.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&"), applyTextChanges = (doc, parsedNodes) => {
2005
+ devLog.debug("Applying text changes from parsed nodes", {
2006
+ nodeCount: parsedNodes.length,
2007
+ nodeNames: parsedNodes.map((n) => n.type === "field" && n.name ? n.name : "unknown")
2008
+ });
1408
2009
  for (const node of parsedNodes)
1409
2010
  if (node.type === "field" && node.name) {
1410
- const path = [node.name];
2011
+ const path = [node.name], beforeValue = get(doc, path);
1411
2012
  applyFieldChanges(doc, path, node);
2013
+ const afterValue = get(doc, path);
2014
+ beforeValue !== afterValue && devLog.debug("Field updated during deserialization", {
2015
+ path: path.join("."),
2016
+ before: typeof beforeValue == "string" ? beforeValue.substring(0, 50) : beforeValue,
2017
+ after: typeof afterValue == "string" ? afterValue.substring(0, 50) : afterValue
2018
+ });
1412
2019
  }
1413
2020
  }, applyFieldChanges = (doc, path, node) => {
1414
- if (node.attributes.type === "translatable.vtt.captions") {
1415
- const fieldValue = get(doc, path);
1416
- fieldValue && typeof fieldValue == "object" && fieldValue._type === "translatable.vtt.captions" && node.text && set(doc, [...path, "translationText"], node.text);
1417
- return;
1418
- }
1419
- if (node.attributes.type === "translatable.richtext") {
2021
+ const nodeType = node.attributes.type;
2022
+ if (typeof nodeType == "string" && nodeType.startsWith("translatable.")) {
1420
2023
  const fieldValue = get(doc, path);
1421
- fieldValue && typeof fieldValue == "object" && fieldValue._type === "translatable.richtext" && node.text && set(doc, [...path, "translationText"], node.text);
2024
+ if (fieldValue && typeof fieldValue == "object" && fieldValue !== null) {
2025
+ const fieldType = fieldValue._type;
2026
+ typeof fieldType == "string" && fieldType === nodeType && node.text && set(doc, [...path, "translationText"], node.text);
2027
+ }
1422
2028
  return;
1423
2029
  }
1424
2030
  if (node.attributes.type === "array") {
@@ -1428,8 +2034,28 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1428
2034
  return;
1429
2035
  }
1430
2036
  if (node.attributes.type === "object") {
1431
- for (const child of node.children)
1432
- child.name && applyFieldChanges(doc, [...path, child.name], child);
2037
+ const currentValue = get(doc, path);
2038
+ if (currentValue && typeof currentValue == "object" && !Array.isArray(currentValue)) {
2039
+ const currentValueObj = currentValue;
2040
+ for (const child of node.children)
2041
+ if (child.name) {
2042
+ const childValue = currentValueObj[child.name];
2043
+ if (/^\d+$/.test(child.name) && childValue === void 0) {
2044
+ devLog.warn("Skipping numeric child name in object field - field does not exist (likely parsing error)", {
2045
+ path: path.join("."),
2046
+ childName: child.name,
2047
+ availableKeys: Object.keys(currentValueObj),
2048
+ childType: child.attributes.type
2049
+ });
2050
+ continue;
2051
+ }
2052
+ applyFieldChanges(doc, [...path, child.name], child);
2053
+ }
2054
+ } else
2055
+ devLog.warn("Object field type but value is not an object", {
2056
+ path: path.join("."),
2057
+ actualType: Array.isArray(currentValue) ? "array" : typeof currentValue
2058
+ });
1433
2059
  return;
1434
2060
  }
1435
2061
  if (node.children.some((c) => c.type === "block")) {
@@ -1439,10 +2065,17 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1439
2065
  });
1440
2066
  return;
1441
2067
  }
1442
- node.text !== void 0 && !node.attributes.readonly && (get(doc, path) !== void 0 ? set(doc, path, node.text) : devLog.debug("Skipping field not present in original document", {
1443
- path: path.join("."),
1444
- attemptedValue: node.text
1445
- }));
2068
+ if (node.text !== void 0 && !node.attributes.readonly) {
2069
+ const existingValue = get(doc, path);
2070
+ existingValue !== void 0 && typeof existingValue == "string" ? set(doc, path, node.text) : existingValue !== void 0 ? devLog.debug("Skipping field - existing value is not a string (would corrupt structure)", {
2071
+ path: path.join("."),
2072
+ existingType: typeof existingValue,
2073
+ attemptedValue: node.text?.substring(0, 50)
2074
+ }) : devLog.debug("Skipping field not present in original document", {
2075
+ path: path.join("."),
2076
+ attemptedValue: node.text
2077
+ });
2078
+ }
1446
2079
  }, applyBlockChanges = (block, blockNode) => {
1447
2080
  if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
1448
2081
  devLog.warn("Block key mismatch, skipping", {
@@ -1473,7 +2106,11 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1473
2106
  });
1474
2107
  return;
1475
2108
  }
1476
- fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (fieldName in block ? block[fieldName] = fieldNode.text : devLog.debug("Skipping block field not present in original", {
2109
+ fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (fieldName in block && typeof block[fieldName] == "string" ? block[fieldName] = fieldNode.text : fieldName in block ? devLog.debug("Skipping block field - existing value is not a string (would corrupt structure)", {
2110
+ fieldName,
2111
+ existingType: typeof block[fieldName],
2112
+ attemptedValue: fieldNode.text?.substring(0, 50)
2113
+ }) : devLog.debug("Skipping block field not present in original", {
1477
2114
  fieldName,
1478
2115
  attemptedValue: fieldNode.text
1479
2116
  }));
@@ -1506,12 +2143,9 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1506
2143
  return;
1507
2144
  }
1508
2145
  if (isPlainObject(value)) {
1509
- if (value._type === "translatable.vtt.captions" && typeof value.translationText == "string") {
1510
- lines.push(`${indentStr}--- ${fieldName} ---`), lines.push(value.translationText), lines.push(`${indentStr}--- /${fieldName} ---`);
1511
- return;
1512
- }
1513
- if (value._type === "translatable.richtext" && typeof value.translationText == "string") {
1514
- lines.push(`${indentStr}--- ${fieldName} ---`), lines.push(value.translationText), lines.push(`${indentStr}--- /${fieldName} ---`);
2146
+ const valueObj = value, valueType = valueObj._type, translationText = valueObj.translationText;
2147
+ if (typeof valueType == "string" && valueType.startsWith("translatable.") && typeof translationText == "string") {
2148
+ lines.push(`${indentStr}--- ${fieldName} ---`), lines.push(translationText), lines.push(`${indentStr}--- /${fieldName} ---`);
1515
2149
  return;
1516
2150
  }
1517
2151
  lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
@@ -1647,15 +2281,19 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1647
2281
  languageFieldName,
1648
2282
  translationPrompts,
1649
2283
  targetLanguage,
1650
- openaiConfig
2284
+ openaiConfig,
2285
+ previousError: _previousError,
2286
+ errorContext,
2287
+ telemetryConfig
1651
2288
  }) => {
1652
- const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2289
+ const startTime = Date.now(), sourceLanguage = sourceDocument[languageFieldName], logContext = {
1653
2290
  sourceId: sourceDocument._id,
1654
2291
  sourceLanguage
1655
2292
  };
2293
+ let serializedDocument, openaiResponseContent;
1656
2294
  try {
1657
2295
  const sourceText = serializeDocument(sourceDocument);
1658
- devLog.info("Serialized document for OpenAI", {
2296
+ serializedDocument = sourceText, devLog.info("Serialized document for OpenAI", {
1659
2297
  ...logContext,
1660
2298
  characterCount: sourceText.length,
1661
2299
  estimatedTokens: Math.ceil(sourceText.length / 4),
@@ -1665,7 +2303,8 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1665
2303
  sourceText,
1666
2304
  sourceLanguage,
1667
2305
  targetLanguage,
1668
- translationPrompts
2306
+ translationPrompts,
2307
+ errorContext
1669
2308
  }), response = await callGPT5(openaiConfig.apiKey, {
1670
2309
  messages: [
1671
2310
  {
@@ -1677,7 +2316,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1677
2316
  model: openaiConfig.model,
1678
2317
  reasoningEffort: openaiConfig.reasoningEffort
1679
2318
  });
1680
- devLog.info("OpenAI translation response received", {
2319
+ openaiResponseContent = response.content, devLog.info("OpenAI translation response received", {
1681
2320
  ...logContext,
1682
2321
  tokensUsed: response.tokensUsed,
1683
2322
  cost: response.cost,
@@ -1715,22 +2354,55 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1715
2354
  })), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
1716
2355
  tokensUsed: response.tokensUsed.total,
1717
2356
  estimatedCost: response.cost
1718
- }), result;
2357
+ }), {
2358
+ document: result,
2359
+ tokensUsed: response.tokensUsed.total,
2360
+ cost: response.cost
2361
+ };
1719
2362
  } catch (error) {
1720
- throw devLog.error("OpenAI translation failed", error instanceof Error ? error : new Error(String(error)), logContext), error;
2363
+ const err = error instanceof Error ? error : new Error(String(error));
2364
+ throw devLog.error("OpenAI translation failed", err, logContext), emitTelemetryError(
2365
+ telemetryConfig,
2366
+ buildErrorBundle({
2367
+ documentId: sourceDocument._id,
2368
+ documentType: sourceDocument._type,
2369
+ targetLanguage,
2370
+ sourceLanguage,
2371
+ translationMode: "openai-only",
2372
+ error: err,
2373
+ errorType: classifyError(err, "openai_api"),
2374
+ startTime,
2375
+ originalDocument: sourceDocument,
2376
+ serializedDocument,
2377
+ openaiResponse: openaiResponseContent,
2378
+ translationPrompts: {
2379
+ globalPrompt: translationPrompts.globalPrompt,
2380
+ localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
2381
+ protectedPhrases: translationPrompts.protectedPhrases,
2382
+ glossaryTerms: translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms ? [translationPrompts.localeInstructions[targetLanguage].glossaryTerms] : void 0
2383
+ }
2384
+ })
2385
+ ), error;
1721
2386
  }
1722
2387
  }, buildTranslationPrompt$1 = ({
1723
2388
  sourceText,
1724
2389
  sourceLanguage,
1725
2390
  targetLanguage,
1726
- translationPrompts
2391
+ translationPrompts,
2392
+ errorContext
1727
2393
  }) => {
1728
2394
  const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
1729
2395
  return `# TRANSLATION TASK
1730
2396
 
1731
2397
  Translate this ${sourceLanguage} document into ${targetLanguage}.
1732
2398
 
1733
- ## Guidelines
2399
+ ${errorContext ? `## \u26A0\uFE0F IMPORTANT: Previous Attempt Error
2400
+
2401
+ ${errorContext}
2402
+
2403
+ Please carefully review the error above and ensure your translation fixes the issue.
2404
+
2405
+ ` : ""}## Guidelines
1734
2406
 
1735
2407
  ${globalPrompt ? `### General Guidelines
1736
2408
  ${globalPrompt}
@@ -1904,21 +2576,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
1904
2576
  languageFieldName,
1905
2577
  targetLanguage
1906
2578
  });
1907
- return await client.agent.action.translate({
1908
- schemaId,
1909
- toLanguage,
1910
- fromLanguage,
1911
- documentId: sourceDocument._id,
1912
- noWrite: !0,
1913
- // Do not write to the document, just return the translated content
1914
- languageFieldPath: [languageFieldName],
1915
- styleGuide,
1916
- protectedPhrases: translationPrompts.protectedPhrases,
1917
- conditionalPaths: {
1918
- defaultHidden: !1,
1919
- defaultReadOnly: !1
2579
+ return await retryWithBackoff(
2580
+ async () => await client.agent.action.translate({
2581
+ schemaId,
2582
+ toLanguage,
2583
+ fromLanguage,
2584
+ documentId: sourceDocument._id,
2585
+ noWrite: !0,
2586
+ // Do not write to the document, just return the translated content
2587
+ languageFieldPath: [languageFieldName],
2588
+ styleGuide,
2589
+ protectedPhrases: translationPrompts.protectedPhrases,
2590
+ conditionalPaths: {
2591
+ defaultHidden: !1,
2592
+ defaultReadOnly: !1
2593
+ }
2594
+ }),
2595
+ {
2596
+ maxAttempts: 2,
2597
+ initialDelayMs: 0,
2598
+ retryableErrors: isNetworkError,
2599
+ context: {
2600
+ operation: "agent.action.translate",
2601
+ documentId: sourceDocument._id,
2602
+ targetLanguage,
2603
+ schemaId
2604
+ }
1920
2605
  }
1921
- });
2606
+ );
1922
2607
  }, handleTranslation = async ({
1923
2608
  dispatch,
1924
2609
  client,
@@ -1933,9 +2618,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
1933
2618
  releaseId,
1934
2619
  excludeFields = [],
1935
2620
  replaceExisting = !1,
1936
- mainSourceDocumentId
2621
+ mainSourceDocumentId,
2622
+ telemetryConfig
1937
2623
  }) => {
1938
- const safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2624
+ const startTime = Date.now(), sourceLanguage = sourceDocument[languageFieldName], safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
1939
2625
  excludeFields.length !== safeExcludeFields.length && devLog.warn("Language field was in excludeFields - removing it to prevent translation errors", {
1940
2626
  documentId: sourceDocument._id,
1941
2627
  languageFieldName,
@@ -1974,43 +2660,79 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
1974
2660
  documentId: sourceDocument._id,
1975
2661
  documentType: sourceDocument._type
1976
2662
  }), documentToTranslate = filteredDocument;
1977
- let translatedDoc;
1978
- switch (translationMode) {
1979
- case "sanity-only": {
1980
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating", void 0, mainSourceDocumentId), translatedDoc = await getSanityAgentTranslation({
1981
- sourceDocument: documentToTranslate,
1982
- targetLanguage,
1983
- languageFieldName,
1984
- schemaId,
1985
- client,
1986
- translationPrompts
1987
- });
1988
- break;
1989
- }
1990
- case "openai-only": {
1991
- if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
1992
- throw new Error("OpenAI is not configured but openai-only mode was selected");
1993
- setJobStatus(
1994
- dispatch,
1995
- sourceDocument._id,
1996
- targetLanguage,
1997
- "translating_with_openai",
1998
- void 0,
1999
- mainSourceDocumentId
2000
- ), translatedDoc = await getOpenAITranslation({
2001
- dispatch,
2002
- sourceDocument: documentToTranslate,
2003
- targetLanguage,
2004
- languageFieldName,
2005
- translationPrompts,
2006
- openaiConfig
2007
- });
2008
- break;
2663
+ let translatedDoc, translationMetrics = {}, previousError = null;
2664
+ const performTranslation = async (errorContext) => {
2665
+ switch (translationMode) {
2666
+ case "sanity-only": {
2667
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating", void 0, mainSourceDocumentId);
2668
+ try {
2669
+ return { document: await getSanityAgentTranslation({
2670
+ sourceDocument: documentToTranslate,
2671
+ targetLanguage,
2672
+ languageFieldName,
2673
+ schemaId,
2674
+ client,
2675
+ translationPrompts
2676
+ }) };
2677
+ } catch (error) {
2678
+ const err = error instanceof Error ? error : new Error(String(error));
2679
+ throw emitTelemetryError(
2680
+ telemetryConfig,
2681
+ buildErrorBundle({
2682
+ documentId: sourceDocument._id,
2683
+ documentType: sourceDocument._type,
2684
+ targetLanguage,
2685
+ sourceLanguage,
2686
+ translationMode: "sanity-only",
2687
+ error: err,
2688
+ errorType: classifyError(err, "sanity_agent"),
2689
+ startTime,
2690
+ originalDocument: sourceDocument,
2691
+ translationPrompts: {
2692
+ globalPrompt: translationPrompts.globalPrompt,
2693
+ localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
2694
+ protectedPhrases: translationPrompts.protectedPhrases
2695
+ }
2696
+ })
2697
+ ), error;
2698
+ }
2699
+ }
2700
+ case "openai-only": {
2701
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2702
+ throw new Error("OpenAI is not configured but openai-only mode was selected");
2703
+ setJobStatus(
2704
+ dispatch,
2705
+ sourceDocument._id,
2706
+ targetLanguage,
2707
+ "translating_with_openai",
2708
+ void 0,
2709
+ mainSourceDocumentId
2710
+ );
2711
+ const result = await getOpenAITranslation({
2712
+ dispatch,
2713
+ sourceDocument: documentToTranslate,
2714
+ targetLanguage,
2715
+ languageFieldName,
2716
+ translationPrompts,
2717
+ openaiConfig,
2718
+ previousError: errorContext && previousError ? previousError : void 0,
2719
+ errorContext,
2720
+ telemetryConfig
2721
+ });
2722
+ return result ? {
2723
+ document: result.document,
2724
+ tokensUsed: result.tokensUsed,
2725
+ cost: result.cost
2726
+ } : { document: null };
2727
+ }
2728
+ default:
2729
+ throw new Error(`Invalid translation mode: ${translationMode}`);
2009
2730
  }
2010
- default:
2011
- throw new Error(`Invalid translation mode: ${translationMode}`);
2012
- }
2013
- if (!translatedDoc)
2731
+ }, translationResult = await performTranslation();
2732
+ if (translatedDoc = translationResult.document, translationMetrics = {
2733
+ tokensUsed: translationResult.tokensUsed,
2734
+ cost: translationResult.cost
2735
+ }, !translatedDoc)
2014
2736
  return null;
2015
2737
  let transformedResult;
2016
2738
  Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
@@ -2041,15 +2763,88 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2041
2763
  ...finalDoc,
2042
2764
  _id: targetDocumentVersionId
2043
2765
  };
2044
- return replaceExisting && await client.getDocument(targetDocumentVersionId) && (devLog.info("Replacing existing version in release", {
2766
+ replaceExisting && await client.getDocument(targetDocumentVersionId) && (devLog.info("Replacing existing version in release", {
2045
2767
  documentId: sourceDocument._id,
2046
2768
  targetDocumentVersionId,
2047
2769
  releaseId
2048
- }), await client.delete(targetDocumentVersionId)), await client.action({
2049
- actionType: "sanity.action.document.version.create",
2050
- publishedId: targetDocumentPublishedId,
2051
- document: newDoc
2052
- }), newDoc;
2770
+ }), await client.delete(targetDocumentVersionId));
2771
+ try {
2772
+ await retryWithBackoff(
2773
+ async () => {
2774
+ await client.action({
2775
+ actionType: "sanity.action.document.version.create",
2776
+ publishedId: targetDocumentPublishedId,
2777
+ document: newDoc
2778
+ });
2779
+ },
2780
+ {
2781
+ maxAttempts: 2,
2782
+ initialDelayMs: 0,
2783
+ retryableErrors: isNetworkError,
2784
+ context: {
2785
+ documentId: sourceDocument._id,
2786
+ targetLanguage,
2787
+ operation: "version.create"
2788
+ }
2789
+ }
2790
+ );
2791
+ } catch (error) {
2792
+ if (translationMode === "openai-only" && isSanityMutationError(error)) {
2793
+ devLog.warn("Mutation error after OpenAI translation, retrying full translation", {
2794
+ documentId: sourceDocument._id,
2795
+ targetLanguage,
2796
+ error: error instanceof Error ? error.message : String(error)
2797
+ }), previousError = error instanceof Error ? error : new Error(String(error));
2798
+ const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`, retryResult = await performTranslation(errorContext);
2799
+ if (translatedDoc = retryResult.document, !translatedDoc)
2800
+ throw new Error("Translation retry failed: no document returned");
2801
+ translationMetrics = {
2802
+ tokensUsed: retryResult.tokensUsed,
2803
+ cost: retryResult.cost
2804
+ };
2805
+ let retryTransformedResult;
2806
+ Object.keys(originalStructure).length > 0 ? retryTransformedResult = restoreTransforms({
2807
+ translatedDocument: translatedDoc,
2808
+ originalStructure,
2809
+ registry: globalTransformRegistry,
2810
+ schemaType: sourceDocument._type
2811
+ }) : retryTransformedResult = translatedDoc;
2812
+ const retryNewDoc = {
2813
+ ...restoreExcludedFields(retryTransformedResult, excludedValuesToRestore),
2814
+ _id: targetDocumentVersionId
2815
+ };
2816
+ return await retryWithBackoff(
2817
+ async () => {
2818
+ await client.action({
2819
+ actionType: "sanity.action.document.version.create",
2820
+ publishedId: targetDocumentPublishedId,
2821
+ document: retryNewDoc
2822
+ });
2823
+ },
2824
+ {
2825
+ maxAttempts: 2,
2826
+ initialDelayMs: 0,
2827
+ retryableErrors: isNetworkError,
2828
+ context: {
2829
+ documentId: sourceDocument._id,
2830
+ targetLanguage,
2831
+ operation: "version.create",
2832
+ isRetry: !0
2833
+ }
2834
+ }
2835
+ ), {
2836
+ document: retryNewDoc,
2837
+ tokensUsed: translationMetrics.tokensUsed,
2838
+ cost: translationMetrics.cost
2839
+ };
2840
+ }
2841
+ throw error;
2842
+ }
2843
+ return {
2844
+ document: newDoc,
2845
+ tokensUsed: translationMetrics.tokensUsed,
2846
+ cost: translationMetrics.cost
2847
+ };
2053
2848
  }, translateDocument = async ({
2054
2849
  dispatch,
2055
2850
  client,
@@ -2064,9 +2859,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2064
2859
  releaseId,
2065
2860
  excludeFields,
2066
2861
  replaceExisting,
2067
- mainSourceDocumentId
2862
+ mainSourceDocumentId,
2863
+ telemetryConfig
2068
2864
  }) => {
2069
- const logContext = {
2865
+ const jobStartTime = Date.now(), logContext = {
2070
2866
  documentId: sourceDocument._id,
2071
2867
  targetLanguage,
2072
2868
  schemaId,
@@ -2078,7 +2874,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2078
2874
  client,
2079
2875
  sourceDocument,
2080
2876
  sourceLanguage
2081
- }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2877
+ }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await handleTranslation({
2082
2878
  dispatch,
2083
2879
  client,
2084
2880
  schemaId,
@@ -2092,8 +2888,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2092
2888
  releaseId,
2093
2889
  excludeFields,
2094
2890
  replaceExisting,
2095
- mainSourceDocumentId
2096
- });
2891
+ mainSourceDocumentId,
2892
+ telemetryConfig
2893
+ }), newDocument = translationResult?.document;
2097
2894
  if (!newDocument) {
2098
2895
  devLog.error("Translation failed: no document returned", new Error("Translation returned null"), logContext), setJobStatus(
2099
2896
  dispatch,
@@ -2130,12 +2927,27 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2130
2927
  targetLanguage,
2131
2928
  "completed",
2132
2929
  {
2133
- targetId: newDocument._id
2930
+ targetId: newDocument._id,
2931
+ tokensUsed: translationResult?.tokensUsed,
2932
+ estimatedCost: translationResult?.cost
2134
2933
  },
2135
2934
  mainSourceDocumentId
2136
2935
  ), devLog.info("Translation completed successfully", {
2137
2936
  ...logContext,
2138
2937
  newDocumentId: newDocument._id
2938
+ }), emitTelemetryEvent(telemetryConfig, {
2939
+ name: "translation.job_completed",
2940
+ timestamp: createTimestamp(),
2941
+ data: {
2942
+ documentId: sourceDocument._id,
2943
+ documentType: sourceDocument._type,
2944
+ sourceLanguage,
2945
+ targetLanguage,
2946
+ translationMode,
2947
+ durationMs: Date.now() - jobStartTime,
2948
+ tokensUsed: translationResult?.tokensUsed,
2949
+ estimatedCost: translationResult?.cost
2950
+ }
2139
2951
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
2140
2952
  } catch (error) {
2141
2953
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -2163,9 +2975,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2163
2975
  translationMode,
2164
2976
  openaiConfig,
2165
2977
  excludeFields,
2166
- releaseConfig
2978
+ releaseConfig,
2979
+ telemetryConfig
2167
2980
  }) => {
2168
- const logContext = {
2981
+ const batchStartTime = Date.now(), logContext = {
2169
2982
  documentId: sourceDocument._id,
2170
2983
  documentRev: sourceDocument._rev,
2171
2984
  targetLanguages,
@@ -2179,7 +2992,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2179
2992
  const sourceLanguage = sourceDocument[languageFieldName];
2180
2993
  if (!sourceLanguage)
2181
2994
  throw devLog.error("Source language not found"), new Error("Source language not found");
2182
- if (maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
2995
+ if (emitTelemetryEvent(telemetryConfig, {
2996
+ name: "translation.batch_started",
2997
+ timestamp: createTimestamp(),
2998
+ data: {
2999
+ documentId: sourceDocument._id,
3000
+ documentType: sourceDocument._type,
3001
+ sourceLanguage,
3002
+ targetLanguages,
3003
+ translationMode,
3004
+ referenceCount: references.length,
3005
+ releaseMode: releaseConfig.mode
3006
+ }
3007
+ }), maxConcurrentTranslationJobs && translationSemaphore.updatePermits(maxConcurrentTranslationJobs), dispatch({
2183
3008
  type: "SET_OVERALL_STATUS",
2184
3009
  payload: "translating"
2185
3010
  }), releaseConfig.mode === "existing" && releaseConfig.existingReleaseId)
@@ -2189,13 +3014,25 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2189
3014
  });
2190
3015
  else {
2191
3016
  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"
3017
+ releaseId = (await retryWithBackoff(
3018
+ async () => await client.releases.create({
3019
+ metadata: {
3020
+ title: releaseTitle,
3021
+ description: releaseDescription,
3022
+ releaseType: "undecided"
3023
+ }
3024
+ }),
3025
+ {
3026
+ maxAttempts: 2,
3027
+ initialDelayMs: 0,
3028
+ retryableErrors: isNetworkError,
3029
+ context: {
3030
+ operation: "releases.create",
3031
+ releaseTitle,
3032
+ ...logContext
3033
+ }
2197
3034
  }
2198
- })).releaseId, createdNewRelease = !0, devLog.info("Content Release created", {
3035
+ )).releaseId, createdNewRelease = !0, devLog.info("Content Release created", {
2199
3036
  ...logContext,
2200
3037
  releaseId,
2201
3038
  releaseTitle
@@ -2217,8 +3054,28 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2217
3054
  sourceDocument
2218
3055
  });
2219
3056
  setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages, sourceDocument._id);
2220
- const translationTasks = sourceDocumentIds.flatMap(
3057
+ const shouldSkipConflict = (documentId, targetLanguage) => !releaseConfig.skipConflicts || releaseConfig.skipConflicts.length === 0 ? !1 : releaseConfig.skipConflicts.some(
3058
+ (conflict) => conflict.sourceDocumentId === documentId && conflict.targetLanguage === targetLanguage
3059
+ ), skippedCount = sourceDocumentIds.reduce((count, documentId) => count + targetLanguages.filter((lang) => shouldSkipConflict(documentId, lang)).length, 0), translationTasks = sourceDocumentIds.flatMap(
2221
3060
  (documentId) => targetLanguages.map(async (targetLanguage) => {
3061
+ if (shouldSkipConflict(documentId, targetLanguage)) {
3062
+ const docInfo = sourceDocuments.find((d) => d._id === documentId);
3063
+ devLog.info("Skipping conflicting translation", {
3064
+ documentId,
3065
+ targetLanguage,
3066
+ reason: "User chose to skip conflicts"
3067
+ }), dispatch({
3068
+ type: "SET_TRANSLATION_JOB_STATUS",
3069
+ payload: {
3070
+ _id: documentId,
3071
+ _type: docInfo?._type,
3072
+ language: targetLanguage,
3073
+ status: "skipped",
3074
+ title: docInfo?.title
3075
+ }
3076
+ });
3077
+ return;
3078
+ }
2222
3079
  try {
2223
3080
  await translationSemaphore.withPermit(async () => {
2224
3081
  const document = await client.withConfig({
@@ -2240,7 +3097,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2240
3097
  excludeFields,
2241
3098
  replaceExisting: releaseConfig.replaceExisting,
2242
3099
  // Pass main source document ID for active translations tracking
2243
- mainSourceDocumentId: sourceDocument._id
3100
+ mainSourceDocumentId: sourceDocument._id,
3101
+ telemetryConfig
2244
3102
  });
2245
3103
  versionedTargetDocumentId && versionedTargetDocumentIds.push(versionedTargetDocumentId);
2246
3104
  }
@@ -2257,13 +3115,28 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2257
3115
  );
2258
3116
  }
2259
3117
  })
2260
- ), results = await Promise.allSettled(translationTasks), succeeded = results.filter((r) => r.status === "fulfilled").length, failed = results.filter((r) => r.status === "rejected").length;
3118
+ ), results = await Promise.allSettled(translationTasks), succeeded = results.filter((r) => r.status === "fulfilled").length - skippedCount, failed = results.filter((r) => r.status === "rejected").length;
2261
3119
  devLog.info("Translation tasks completed", {
2262
3120
  ...logContext,
2263
3121
  total: results.length,
2264
3122
  succeeded,
2265
3123
  failed,
3124
+ skipped: skippedCount,
2266
3125
  targetDocuments: versionedTargetDocumentIds.length
3126
+ }), emitTelemetryEvent(telemetryConfig, {
3127
+ name: "translation.batch_completed",
3128
+ timestamp: createTimestamp(),
3129
+ data: {
3130
+ documentId: sourceDocument._id,
3131
+ documentType: sourceDocument._type,
3132
+ sourceLanguage,
3133
+ targetLanguages,
3134
+ translationMode,
3135
+ succeeded,
3136
+ failed,
3137
+ skipped: skippedCount,
3138
+ durationMs: Date.now() - batchStartTime
3139
+ }
2267
3140
  }), dispatch({
2268
3141
  type: "SET_OVERALL_STATUS",
2269
3142
  payload: "linking_references"
@@ -2276,7 +3149,15 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2276
3149
  languageFieldName
2277
3150
  });
2278
3151
  })
2279
- );
3152
+ ), releaseConfig.mode === "existing" && releaseId && await updateBackwardReferences({
3153
+ client,
3154
+ releaseId,
3155
+ sourceDocumentIds,
3156
+ targetLanguages,
3157
+ sourceLanguage,
3158
+ languageFieldName,
3159
+ excludeDocumentIds: versionedTargetDocumentIds
3160
+ });
2280
3161
  const hasAnyFailures = failed > 0;
2281
3162
  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", {
2282
3163
  ...logContext,
@@ -2303,8 +3184,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2303
3184
  }
2304
3185
  throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id), error;
2305
3186
  }
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]);
3187
+ }, ReleaseConflictWarning = ({
3188
+ conflicts,
3189
+ totalTranslations,
3190
+ onCancel,
3191
+ onReplace,
3192
+ onSkipConflicts
3193
+ }) => {
3194
+ const uniqueDocuments = useMemo(() => new Set(conflicts.map((c) => c.sourceDocumentId)).size, [conflicts]), nonConflictingCount = totalTranslations - conflicts.length, hasNonConflicting = nonConflictingCount > 0, summaryText = useMemo(() => conflicts.length === 1 ? "1 translation" : uniqueDocuments === 1 ? `${conflicts.length} translations` : `${conflicts.length} translations across ${uniqueDocuments} documents`, [conflicts.length, uniqueDocuments]);
2308
3195
  return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2309
3196
  /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2310
3197
  /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
@@ -2328,13 +3215,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2328
3215
  " \u2014 Go back and select a different release, or create a new one."
2329
3216
  ] }),
2330
3217
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2331
- /* @__PURE__ */ jsx("strong", { children: "Replace" }),
3218
+ /* @__PURE__ */ jsx("strong", { children: "Replace All" }),
2332
3219
  " \u2014 Delete the existing translations in this release and create new ones."
3220
+ ] }),
3221
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3222
+ /* @__PURE__ */ jsx("strong", { children: "Skip Conflicts" }),
3223
+ " \u2014 Translate only the ",
3224
+ nonConflictingCount,
3225
+ " non-conflicting translation",
3226
+ nonConflictingCount !== 1 ? "s" : "",
3227
+ ", leaving existing ones unchanged.",
3228
+ !hasNonConflicting && " (All translations have conflicts)"
2333
3229
  ] })
2334
3230
  ] }),
2335
- /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
3231
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "space-between", children: [
2336
3232
  /* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Cancel", onClick: onCancel }),
2337
- /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "caution", text: "Replace Existing", onClick: onReplace })
3233
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
3234
+ /* @__PURE__ */ jsx(Button, { mode: "bleed", tone: "caution", text: "Replace All", onClick: onReplace }),
3235
+ /* @__PURE__ */ jsx(
3236
+ Button,
3237
+ {
3238
+ mode: "ghost",
3239
+ tone: "caution",
3240
+ text: "Skip Conflicts",
3241
+ onClick: onSkipConflicts,
3242
+ disabled: !hasNonConflicting
3243
+ }
3244
+ )
3245
+ ] })
2338
3246
  ] })
2339
3247
  ] }) });
2340
3248
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
@@ -2344,15 +3252,16 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2344
3252
  overallStatus,
2345
3253
  dispatch,
2346
3254
  references,
3255
+ selectedReferenceIds,
2347
3256
  selectedTargetLanguages,
2348
3257
  translationMode,
2349
3258
  releaseConfig
2350
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1);
3259
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1), filteredReferences = references.filter((ref) => selectedReferenceIds.has(ref._id)), totalTranslations = useMemo(() => (1 + filteredReferences.length) * selectedTargetLanguages.length, [filteredReferences.length, selectedTargetLanguages.length]);
2351
3260
  useEffect(() => {
2352
3261
  releaseConfig.mode === "new" && conflictContext && setConflictContext(null);
2353
3262
  }, [releaseConfig.mode, conflictContext]);
2354
3263
  const startTranslation = useCallback(
2355
- (replaceExisting, overrideReleaseId) => {
3264
+ (replaceExisting, overrideReleaseId, skipConflicts) => {
2356
3265
  setConflictContext(null), translateDocumentSet({
2357
3266
  sourceDocument: document,
2358
3267
  targetLanguages: selectedTargetLanguages,
@@ -2360,7 +3269,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2360
3269
  dispatch,
2361
3270
  languageFieldName: config.languageFieldName,
2362
3271
  schemaId: config.schemaId,
2363
- references,
3272
+ references: filteredReferences,
2364
3273
  maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
2365
3274
  maxReferenceDepth: config.maxReferenceDepth,
2366
3275
  translationMode,
@@ -2369,6 +3278,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2369
3278
  releaseConfig: {
2370
3279
  ...releaseConfig,
2371
3280
  replaceExisting,
3281
+ skipConflicts,
2372
3282
  // Use the override release ID if provided (from conflict context),
2373
3283
  // otherwise use the current releaseConfig value
2374
3284
  existingReleaseId: overrideReleaseId ?? releaseConfig.existingReleaseId
@@ -2386,7 +3296,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2386
3296
  config.maxReferenceDepth,
2387
3297
  config.openai,
2388
3298
  config.excludeFields,
2389
- references,
3299
+ filteredReferences,
2390
3300
  translationMode,
2391
3301
  releaseConfig
2392
3302
  ]
@@ -2404,7 +3314,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2404
3314
  sourceDocument: document,
2405
3315
  targetLanguages: selectedTargetLanguages,
2406
3316
  languageFieldName: config.languageFieldName,
2407
- references
3317
+ references: filteredReferences
2408
3318
  });
2409
3319
  if (setIsCheckingConflicts(!1), foundConflicts.length > 0) {
2410
3320
  setConflictContext({
@@ -2415,10 +3325,20 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2415
3325
  }
2416
3326
  }
2417
3327
  startTranslation(!1);
2418
- }, [releaseConfig, client, document, selectedTargetLanguages, config.languageFieldName, startTranslation, references]), handleCancelConflict = useCallback(() => {
3328
+ }, [
3329
+ releaseConfig,
3330
+ client,
3331
+ document,
3332
+ selectedTargetLanguages,
3333
+ config.languageFieldName,
3334
+ startTranslation,
3335
+ filteredReferences
3336
+ ]), handleCancelConflict = useCallback(() => {
2419
3337
  setConflictContext(null);
2420
3338
  }, []), handleReplaceConflict = useCallback(() => {
2421
3339
  conflictContext && startTranslation(!0, conflictContext.releaseId);
3340
+ }, [startTranslation, conflictContext]), handleSkipConflicts = useCallback(() => {
3341
+ conflictContext && startTranslation(!1, conflictContext.releaseId, conflictContext.conflicts);
2422
3342
  }, [startTranslation, conflictContext]), handleSelectAll = useCallback(() => {
2423
3343
  dispatch({
2424
3344
  type: "SET_SELECTED_TARGET_LANGUAGES",
@@ -2434,8 +3354,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2434
3354
  ReleaseConflictWarning,
2435
3355
  {
2436
3356
  conflicts: conflictContext.conflicts,
3357
+ totalTranslations,
2437
3358
  onCancel: handleCancelConflict,
2438
- onReplace: handleReplaceConflict
3359
+ onReplace: handleReplaceConflict,
3360
+ onSkipConflicts: handleSkipConflicts
2439
3361
  }
2440
3362
  ) }) : /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
2441
3363
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
@@ -2517,8 +3439,183 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2517
3439
  documentId: document._id
2518
3440
  });
2519
3441
  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)) }) });
3442
+ }, reconcileReleaseReferences = async ({
3443
+ client,
3444
+ releaseId,
3445
+ sourceLanguage,
3446
+ languageFieldName,
3447
+ onProgress
3448
+ }) => {
3449
+ devLog.info("Starting release reference reconciliation", {
3450
+ releaseId,
3451
+ sourceLanguage,
3452
+ component: "reconcileReleaseReferences"
3453
+ });
3454
+ try {
3455
+ const docsInRelease = await client.fetch(
3456
+ `*[_id match "versions." + $releaseId + ".*"] {
3457
+ _id,
3458
+ "language": @[$languageFieldName]
3459
+ }`,
3460
+ { releaseId, languageFieldName }
3461
+ );
3462
+ if (docsInRelease.length === 0)
3463
+ return devLog.info("No documents found in release", {
3464
+ releaseId,
3465
+ component: "reconcileReleaseReferences"
3466
+ }), { total: 0, updated: 0, errors: 0 };
3467
+ devLog.info("Found documents in release", {
3468
+ releaseId,
3469
+ count: docsInRelease.length,
3470
+ component: "reconcileReleaseReferences"
3471
+ });
3472
+ let updated = 0, errors = 0;
3473
+ for (let i = 0; i < docsInRelease.length; i++) {
3474
+ const doc = docsInRelease[i];
3475
+ onProgress && onProgress(i + 1, docsInRelease.length);
3476
+ try {
3477
+ if (!doc.language) {
3478
+ devLog.warn("Document missing language field, skipping", {
3479
+ documentId: doc._id,
3480
+ component: "reconcileReleaseReferences"
3481
+ });
3482
+ continue;
3483
+ }
3484
+ await localizeReferences({
3485
+ client,
3486
+ documentId: doc._id,
3487
+ sourceLanguage,
3488
+ languageFieldName
3489
+ }), updated++;
3490
+ } catch (error) {
3491
+ errors++, devLog.error(
3492
+ "Failed to reconcile references for document",
3493
+ error instanceof Error ? error : new Error(String(error)),
3494
+ {
3495
+ documentId: doc._id,
3496
+ component: "reconcileReleaseReferences"
3497
+ }
3498
+ );
3499
+ }
3500
+ }
3501
+ const result = {
3502
+ total: docsInRelease.length,
3503
+ updated,
3504
+ errors
3505
+ };
3506
+ return devLog.info("Release reference reconciliation complete", {
3507
+ releaseId,
3508
+ ...result,
3509
+ component: "reconcileReleaseReferences"
3510
+ }), result;
3511
+ } catch (error) {
3512
+ throw devLog.error("Failed to reconcile release references", error instanceof Error ? error : new Error(String(error)), {
3513
+ releaseId,
3514
+ component: "reconcileReleaseReferences"
3515
+ }), error;
3516
+ }
3517
+ }, ReconcileReferencesButton = ({ sourceLanguage }) => {
3518
+ const { releaseConfig, overallStatus, config } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [documentCount, setDocumentCount] = useState(null), [isLoadingCount, setIsLoadingCount] = useState(!1), [reconcilingStatus, setReconcilingStatus] = useState("idle"), [reconciliationProgress, setReconciliationProgress] = useState(
3519
+ void 0
3520
+ ), releaseId = releaseConfig.existingReleaseId, languageFieldName = config?.languageFieldName ?? "language";
3521
+ useEffect(() => {
3522
+ if (!releaseId || releaseConfig.mode !== "existing") {
3523
+ setDocumentCount(null);
3524
+ return;
3525
+ }
3526
+ (async () => {
3527
+ setIsLoadingCount(!0);
3528
+ try {
3529
+ const count = await client.fetch('count(*[_id match "versions." + $releaseId + ".*"])', { releaseId });
3530
+ setDocumentCount(count), devLog.debug("Fetched release document count", {
3531
+ releaseId,
3532
+ count,
3533
+ component: "ReconcileReferencesButton"
3534
+ });
3535
+ } catch (error) {
3536
+ devLog.error(
3537
+ "Failed to fetch release document count",
3538
+ error instanceof Error ? error : new Error(String(error)),
3539
+ {
3540
+ releaseId,
3541
+ component: "ReconcileReferencesButton"
3542
+ }
3543
+ ), setDocumentCount(null);
3544
+ } finally {
3545
+ setIsLoadingCount(!1);
3546
+ }
3547
+ })();
3548
+ }, [releaseId, releaseConfig.mode, client]);
3549
+ const handleReconcile = useCallback(async () => {
3550
+ if (releaseId) {
3551
+ devLog.info("Starting reference reconciliation", {
3552
+ releaseId,
3553
+ sourceLanguage,
3554
+ component: "ReconcileReferencesButton"
3555
+ }), setReconcilingStatus("reconciling");
3556
+ try {
3557
+ const result = await reconcileReleaseReferences({
3558
+ client,
3559
+ releaseId,
3560
+ sourceLanguage,
3561
+ languageFieldName,
3562
+ onProgress: (current, total) => {
3563
+ setReconciliationProgress({ current, total });
3564
+ }
3565
+ });
3566
+ devLog.info("Reference reconciliation completed", {
3567
+ releaseId,
3568
+ ...result,
3569
+ component: "ReconcileReferencesButton"
3570
+ }), setReconcilingStatus("completed"), setTimeout(() => {
3571
+ setReconcilingStatus("idle"), setReconciliationProgress(void 0);
3572
+ }, 2e3);
3573
+ } catch (error) {
3574
+ devLog.error("Reference reconciliation failed", error instanceof Error ? error : new Error(String(error)), {
3575
+ releaseId,
3576
+ component: "ReconcileReferencesButton"
3577
+ }), setReconcilingStatus("error"), setReconciliationProgress(void 0);
3578
+ }
3579
+ }
3580
+ }, [releaseId, sourceLanguage, languageFieldName, client]);
3581
+ if (releaseConfig.mode !== "existing" || !releaseId)
3582
+ return null;
3583
+ const isTranslating = overallStatus === "translating" || overallStatus === "linking_references", isReconciling = reconcilingStatus === "reconciling";
3584
+ return /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, tone: reconcilingStatus === "error" ? "critical" : "default", children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3585
+ /* @__PURE__ */ jsxs(Flex, { align: "center", justify: "space-between", gap: 3, children: [
3586
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3587
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: "Reconcile References" }),
3588
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingCount ? "Loading..." : documentCount === null ? "Unable to load document count" : documentCount === 0 ? "No documents in release" : /* @__PURE__ */ jsxs(Fragment, { children: [
3589
+ documentCount,
3590
+ " document",
3591
+ documentCount !== 1 ? "s" : "",
3592
+ " in release"
3593
+ ] }) })
3594
+ ] }),
3595
+ /* @__PURE__ */ jsx(
3596
+ Button,
3597
+ {
3598
+ icon: isReconciling ? Spinner : SyncIcon,
3599
+ text: reconcilingStatus === "completed" ? "Done!" : reconcilingStatus === "error" ? "Retry" : isReconciling ? "Reconciling..." : "Reconcile",
3600
+ tone: reconcilingStatus === "completed" ? "positive" : reconcilingStatus === "error" ? "critical" : "default",
3601
+ mode: "ghost",
3602
+ onClick: handleReconcile,
3603
+ disabled: (isTranslating || isReconciling || isLoadingCount || documentCount === 0) && reconcilingStatus !== "error"
3604
+ }
3605
+ )
3606
+ ] }),
3607
+ isReconciling && reconciliationProgress && /* @__PURE__ */ jsx(Flex, { align: "center", gap: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3608
+ "Processing ",
3609
+ reconciliationProgress.current,
3610
+ " of ",
3611
+ reconciliationProgress.total,
3612
+ "..."
3613
+ ] }) }),
3614
+ reconcilingStatus === "error" && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Reconciliation failed. Click Retry to try again." }),
3615
+ reconcilingStatus === "idle" && documentCount !== null && documentCount > 0 && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Updates all document references to point to their translated versions in this release." })
3616
+ ] }) });
2520
3617
  }, ReleaseSelectionPanel = () => {
2521
- const { dispatch, releaseConfig } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [releases, setReleases] = useState([]), [isLoading, setIsLoading] = useState(!1), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState("");
3618
+ const { dispatch, releaseConfig, document, config } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), languageFieldName = config?.languageFieldName ?? "language", sourceLanguage = document?.[languageFieldName], [releases, setReleases] = useState([]), [isLoading, setIsLoading] = useState(!1), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState(""), [showReconcileUI, setShowReconcileUI] = useState(!1);
2522
3619
  useEffect(() => {
2523
3620
  (async () => {
2524
3621
  setIsLoading(!0), setError(null);
@@ -2631,12 +3728,33 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2631
3728
  radius: 2,
2632
3729
  tone: isSelected ? "primary" : "default",
2633
3730
  style: { cursor: "pointer" },
2634
- onClick: () => handleSelectRelease(releaseName),
3731
+ onClick: () => {
3732
+ handleSelectRelease(releaseName), isSelected || setShowReconcileUI(!1);
3733
+ },
2635
3734
  border: !0,
2636
3735
  muted: isSelected,
2637
3736
  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) })
3737
+ /* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", children: [
3738
+ /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 2, children: [
3739
+ /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
3740
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
3741
+ ] }),
3742
+ isSelected && sourceLanguage && /* @__PURE__ */ jsx(
3743
+ Button,
3744
+ {
3745
+ icon: SyncIcon,
3746
+ text: showReconcileUI ? "Hide" : "Reconcile References",
3747
+ mode: "bleed",
3748
+ tone: "primary",
3749
+ fontSize: 0,
3750
+ padding: 2,
3751
+ onClick: (e) => {
3752
+ e.stopPropagation(), setShowReconcileUI(!showReconcileUI);
3753
+ }
3754
+ }
3755
+ )
3756
+ ] }),
3757
+ isSelected && sourceLanguage && showReconcileUI && /* @__PURE__ */ jsx(ReconcileReferencesButton, { sourceLanguage })
2640
3758
  ] })
2641
3759
  },
2642
3760
  release._id
@@ -2789,8 +3907,90 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2789
3907
  });
2790
3908
  }
2791
3909
  return result;
3910
+ }, ReferenceSelectionPanel = () => {
3911
+ const { references, selectedReferenceIds, dispatch } = useTranslationContext(), [typeFilter, setTypeFilter] = useState("all"), documentTypes = useMemo(() => {
3912
+ const types = new Set(references.map((ref) => ref._type));
3913
+ return Array.from(types).sort();
3914
+ }, [references]), filteredReferences = useMemo(() => typeFilter === "all" ? references : references.filter((ref) => ref._type === typeFilter), [references, typeFilter]), selectedCount = useMemo(() => filteredReferences.filter((ref) => selectedReferenceIds.has(ref._id)).length, [filteredReferences, selectedReferenceIds]), handleToggleReference = (referenceId) => {
3915
+ const newSelection = new Set(selectedReferenceIds);
3916
+ newSelection.has(referenceId) ? newSelection.delete(referenceId) : newSelection.add(referenceId), dispatch({
3917
+ type: "SET_SELECTED_REFERENCE_IDS",
3918
+ payload: newSelection
3919
+ });
3920
+ }, handleSelectAll = () => {
3921
+ const newSelection = new Set(selectedReferenceIds);
3922
+ filteredReferences.forEach((ref) => {
3923
+ newSelection.add(ref._id);
3924
+ }), dispatch({
3925
+ type: "SET_SELECTED_REFERENCE_IDS",
3926
+ payload: newSelection
3927
+ });
3928
+ }, handleSelectNone = () => {
3929
+ const newSelection = new Set(selectedReferenceIds);
3930
+ filteredReferences.forEach((ref) => {
3931
+ newSelection.delete(ref._id);
3932
+ }), dispatch({
3933
+ type: "SET_SELECTED_REFERENCE_IDS",
3934
+ payload: newSelection
3935
+ });
3936
+ };
3937
+ return references.length === 0 ? null : /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3938
+ /* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", gap: 2, children: [
3939
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3940
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "Select References" }),
3941
+ /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3942
+ "(",
3943
+ selectedCount,
3944
+ " of ",
3945
+ filteredReferences.length,
3946
+ " selected)"
3947
+ ] })
3948
+ ] }),
3949
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
3950
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 1, onClick: handleSelectAll, text: "Select All" }),
3951
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 1, onClick: handleSelectNone, text: "Select None" })
3952
+ ] })
3953
+ ] }),
3954
+ documentTypes.length > 1 && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
3955
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Filter by type:" }),
3956
+ /* @__PURE__ */ jsxs(
3957
+ Select,
3958
+ {
3959
+ value: typeFilter,
3960
+ onChange: (event) => setTypeFilter(event.target.value),
3961
+ style: { minWidth: "150px" },
3962
+ children: [
3963
+ /* @__PURE__ */ jsxs("option", { value: "all", children: [
3964
+ "All types (",
3965
+ references.length,
3966
+ ")"
3967
+ ] }),
3968
+ documentTypes.map((type) => {
3969
+ const count = references.filter((ref) => ref._type === type).length;
3970
+ return /* @__PURE__ */ jsxs("option", { value: type, children: [
3971
+ type,
3972
+ " (",
3973
+ count,
3974
+ ")"
3975
+ ] }, type);
3976
+ })
3977
+ ]
3978
+ }
3979
+ )
3980
+ ] }),
3981
+ /* @__PURE__ */ jsx(Box, { style: { maxHeight: "400px", overflowY: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 2, children: filteredReferences.length === 0 ? /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "No references match the selected filter." }) : filteredReferences.map((reference) => {
3982
+ const isSelected = selectedReferenceIds.has(reference._id), displayTitle = reference.title || reference._id;
3983
+ return /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
3984
+ /* @__PURE__ */ jsx(Checkbox, { checked: isSelected, onChange: () => handleToggleReference(reference._id) }),
3985
+ /* @__PURE__ */ jsxs(Flex, { flex: 1, align: "center", gap: 2, children: [
3986
+ /* @__PURE__ */ jsx(Text, { size: 1, children: displayTitle }),
3987
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: reference._type })
3988
+ ] })
3989
+ ] }) }, reference._id);
3990
+ }) }) })
3991
+ ] }) });
2792
3992
  }, ReferenceConfigPanel = () => {
2793
- const { dispatch, document, shouldTranslateReferences, maxReferenceDepth, config, references } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1), maxDepth = config.maxReferenceDepth || 3;
3993
+ const { dispatch, document, shouldTranslateReferences, maxReferenceDepth, config, references, selectedReferenceIds } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1), [showSelectionPanel, setShowSelectionPanel] = useState(!1), maxDepth = config.maxReferenceDepth || 3;
2794
3994
  useEffect(() => {
2795
3995
  (async () => {
2796
3996
  if (setIsLoadingReferences(!0), !shouldTranslateReferences) {
@@ -2860,7 +4060,24 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2860
4060
  )
2861
4061
  ] }) : null
2862
4062
  ] }),
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
4063
+ shouldTranslateReferences ? /* @__PURE__ */ jsxs(Fragment, { children: [
4064
+ /* @__PURE__ */ jsxs(Flex, { padding: 2, justify: "space-between", align: "center", children: [
4065
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingReferences ? "Loading references..." : references.length === 0 ? "No translatable references found." : (() => {
4066
+ const selectedCount = references.filter((ref) => selectedReferenceIds.has(ref._id)).length, totalCount = references.length;
4067
+ return selectedCount === totalCount ? `${totalCount} reference${totalCount === 1 ? "" : "s"} will be translated along with this document.` : `${selectedCount} of ${totalCount} reference${totalCount === 1 ? "" : "s"} will be translated along with this document.`;
4068
+ })() }),
4069
+ !isLoadingReferences && references.length > 0 && /* @__PURE__ */ jsx(
4070
+ Button,
4071
+ {
4072
+ mode: "ghost",
4073
+ size: 1,
4074
+ text: showSelectionPanel ? "Hide selection" : "Select specific references...",
4075
+ onClick: () => setShowSelectionPanel(!showSelectionPanel)
4076
+ }
4077
+ )
4078
+ ] }),
4079
+ !isLoadingReferences && references.length > 0 && showSelectionPanel && /* @__PURE__ */ jsx(ReferenceSelectionPanel, {})
4080
+ ] }) : null
2864
4081
  ] });
2865
4082
  }, WriteSettingsPanel = () => /* @__PURE__ */ jsx(ReferenceConfigPanel, {}), TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2866
4083
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
@@ -3436,10 +4653,14 @@ function serializeVTTCaptionText(captions) {
3436
4653
  firstCaption: captions.length > 0 ? captions[0] : null
3437
4654
  }), !Array.isArray(captions) || captions.length === 0)
3438
4655
  return "";
3439
- const result = captions.map((caption, index) => (devLog.debug(`Processing caption ${index}:`, {
3440
- text: caption.text,
3441
- hasText: typeof caption.text == "string"
3442
- }), `[${index}] ${caption.text || ""}`)).join(`
4656
+ const result = captions.map((caption, index) => {
4657
+ devLog.debug(`Processing caption ${index}:`, {
4658
+ text: caption.text,
4659
+ hasText: typeof caption.text == "string"
4660
+ });
4661
+ const escapedText = (caption.text || "").replace(/\n/g, "\\n");
4662
+ return `[${index}] ${escapedText}`;
4663
+ }).join(`
3443
4664
  `);
3444
4665
  return devLog.debug("serializeVTTCaptionText - output:", {
3445
4666
  resultLength: result.length,
@@ -3456,29 +4677,46 @@ function deserializeVTTCaptionText(translationText, originalMetadata) {
3456
4677
  return [];
3457
4678
  const result = [], lines = translationText.split(`
3458
4679
  `), lineRegex = /^\[(\d+)\]\s*(.*)$/;
4680
+ let currentIndex = null, currentTextParts = [];
3459
4681
  for (const line of lines) {
3460
4682
  const match = line.match(lineRegex);
3461
- if (!match) {
3462
- devLog.warn("Failed to parse caption line", { line });
3463
- continue;
3464
- }
3465
- const index = Number.parseInt(match[1], 10), text = match[2];
3466
- devLog.debug("Found caption:", {
3467
- index,
3468
- text
3469
- });
3470
- const metadata = originalMetadata.find((m) => m._originalIndex === index);
4683
+ if (match) {
4684
+ if (currentIndex !== null) {
4685
+ const text = currentTextParts.join(`
4686
+ `).replace(/\\n/g, `
4687
+ `), metadata = originalMetadata.find((m) => m._originalIndex === currentIndex);
4688
+ if (metadata) {
4689
+ const { _originalIndex, ...metadataWithoutIndex } = metadata;
4690
+ result.push({
4691
+ ...metadataWithoutIndex,
4692
+ text
4693
+ });
4694
+ } else
4695
+ devLog.warn("Missing metadata for caption", { index: currentIndex }), result.push({
4696
+ _type: "vtt.cue",
4697
+ _key: `caption-${currentIndex}`,
4698
+ text,
4699
+ startTime: 0,
4700
+ endTime: 0
4701
+ });
4702
+ }
4703
+ currentIndex = Number.parseInt(match[1], 10), currentTextParts = [match[2]];
4704
+ } else currentIndex !== null ? currentTextParts.push(line) : devLog.warn("Failed to parse caption line", { line });
4705
+ }
4706
+ if (currentIndex !== null) {
4707
+ const text = currentTextParts.join(`
4708
+ `).replace(/\\n/g, `
4709
+ `), metadata = originalMetadata.find((m) => m._originalIndex === currentIndex);
3471
4710
  if (metadata) {
3472
- devLog.debug(`Found matching metadata for index ${index}`);
3473
4711
  const { _originalIndex, ...metadataWithoutIndex } = metadata;
3474
4712
  result.push({
3475
4713
  ...metadataWithoutIndex,
3476
4714
  text
3477
4715
  });
3478
4716
  } else
3479
- devLog.warn("Missing metadata for caption", { index }), result.push({
4717
+ devLog.warn("Missing metadata for caption", { index: currentIndex }), result.push({
3480
4718
  _type: "vtt.cue",
3481
- _key: `caption-${index}`,
4719
+ _key: `caption-${currentIndex}`,
3482
4720
  text,
3483
4721
  startTime: 0,
3484
4722
  endTime: 0
@@ -3489,6 +4727,293 @@ function deserializeVTTCaptionText(translationText, originalMetadata) {
3489
4727
  resultExample: result.length > 0 ? result[0] : null
3490
4728
  }), result;
3491
4729
  }
4730
+ const LOCALE_TO_ISO = {
4731
+ // English variants
4732
+ "en-us": "eng",
4733
+ "en-US": "eng",
4734
+ "en-gb": "eng",
4735
+ "en-GB": "eng",
4736
+ "en-ca": "eng",
4737
+ "en-CA": "eng",
4738
+ "en-au": "eng",
4739
+ "en-AU": "eng",
4740
+ // Spanish variants
4741
+ es: "spa",
4742
+ "es-es": "spa",
4743
+ "es-ES": "spa",
4744
+ "es-mx": "spa",
4745
+ "es-MX": "spa",
4746
+ // French variants
4747
+ fr: "fra",
4748
+ "fr-fr": "fra",
4749
+ "fr-FR": "fra",
4750
+ "fr-ca": "fra",
4751
+ "fr-CA": "fra",
4752
+ // German variants
4753
+ de: "ger",
4754
+ "de-de": "ger",
4755
+ "de-DE": "ger",
4756
+ "de-at": "ger",
4757
+ "de-AT": "ger",
4758
+ // Italian variants
4759
+ it: "ita",
4760
+ "it-it": "ita",
4761
+ "it-IT": "ita",
4762
+ // Portuguese variants
4763
+ pt: "por",
4764
+ "pt-br": "por",
4765
+ "pt-BR": "por",
4766
+ "pt-pt": "por",
4767
+ "pt-PT": "por",
4768
+ // Russian variants
4769
+ ru: "rus",
4770
+ "ru-ru": "rus",
4771
+ "ru-RU": "rus",
4772
+ // Japanese variants
4773
+ ja: "jpn",
4774
+ "ja-jp": "jpn",
4775
+ "ja-JP": "jpn",
4776
+ // Korean variants
4777
+ ko: "kor",
4778
+ "ko-kr": "kor",
4779
+ "ko-KR": "kor",
4780
+ // Chinese variants
4781
+ zh: "chi",
4782
+ "zh-cn": "chi",
4783
+ "zh-CN": "chi",
4784
+ "zh-tw": "chi",
4785
+ "zh-TW": "chi",
4786
+ "zh-hk": "chi",
4787
+ "zh-HK": "chi"
4788
+ }, normalizeToIsoLanguageCode = (code) => {
4789
+ const normalizedCode = code.toLowerCase();
4790
+ return LOCALE_TO_ISO[normalizedCode];
4791
+ }, wistiaCaptionTransform = {
4792
+ matcher: {
4793
+ fieldName: "wistiaCaptions"
4794
+ },
4795
+ toTranslatable: (wistiaCaptions, context) => {
4796
+ if (devLog.info("Wistia captions toTranslatable - input:", {
4797
+ fieldPath: context.fieldPath,
4798
+ hasCaptionContent: !!wistiaCaptions?.captionContent,
4799
+ captionContentLength: wistiaCaptions?.captionContent?.length ?? 0,
4800
+ isoLanguage: wistiaCaptions?.isoLanguage
4801
+ }), !wistiaCaptions || !wistiaCaptions.captionContent)
4802
+ return devLog.debug("Wistia caption field is missing or has no content, returning empty transform", {
4803
+ fieldPath: context.fieldPath,
4804
+ value: wistiaCaptions
4805
+ }), {
4806
+ _type: "translatable.wistia.captions",
4807
+ translationText: "",
4808
+ _originalMetadata: {
4809
+ captionFileName: wistiaCaptions?.captionFileName,
4810
+ isoLanguage: wistiaCaptions?.isoLanguage,
4811
+ captionUploadStatus: wistiaCaptions?.captionUploadStatus,
4812
+ captionUploadMessage: wistiaCaptions?.captionUploadMessage,
4813
+ _originalIndex: 0
4814
+ },
4815
+ _srtCues: []
4816
+ };
4817
+ devLog.info("Applying Wistia caption transform", {
4818
+ fieldPath: context.fieldPath,
4819
+ captionContentPreview: wistiaCaptions.captionContent.substring(0, 200)
4820
+ });
4821
+ const srtCues = parseSRTContent(wistiaCaptions.captionContent);
4822
+ devLog.debug("Parsed SRT cues:", {
4823
+ fieldPath: context.fieldPath,
4824
+ cueCount: srtCues.length,
4825
+ firstCue: srtCues.length > 0 ? srtCues[0] : null
4826
+ });
4827
+ const translationText = serializeWistiaCaptionText(srtCues), originalMetadata = {
4828
+ captionFileName: wistiaCaptions.captionFileName,
4829
+ isoLanguage: wistiaCaptions.isoLanguage,
4830
+ captionUploadStatus: wistiaCaptions.captionUploadStatus,
4831
+ captionUploadMessage: wistiaCaptions.captionUploadMessage,
4832
+ _originalIndex: 0
4833
+ }, srtCuesMetadata = srtCues.map((cue) => ({
4834
+ index: cue.index,
4835
+ startTime: cue.startTime,
4836
+ endTime: cue.endTime
4837
+ }));
4838
+ return devLog.info("Wistia captions serialized:", {
4839
+ fieldPath: context.fieldPath,
4840
+ translationTextLength: translationText.length,
4841
+ translationTextPreview: translationText.substring(0, 200) + (translationText.length > 200 ? "..." : ""),
4842
+ cueCount: srtCues.length
4843
+ }), {
4844
+ _type: "translatable.wistia.captions",
4845
+ translationText,
4846
+ _originalMetadata: originalMetadata,
4847
+ _srtCues: srtCuesMetadata
4848
+ };
4849
+ },
4850
+ fromTranslatable: (translatedValue, originalValue, context) => {
4851
+ if (devLog.info("Wistia captions fromTranslatable - input:", {
4852
+ fieldPath: context.fieldPath,
4853
+ valueType: typeof translatedValue,
4854
+ isCorrectType: translatedValue._type === "translatable.wistia.captions",
4855
+ hasText: !!translatedValue.translationText,
4856
+ hasMetadata: !!translatedValue._originalMetadata,
4857
+ hasCues: Array.isArray(translatedValue._srtCues)
4858
+ }), !translatedValue || translatedValue._type !== "translatable.wistia.captions" || !translatedValue._originalMetadata || !Array.isArray(translatedValue._srtCues))
4859
+ return devLog.debug("Value is not a translatable Wistia caption object, returning original", {
4860
+ fieldPath: context.fieldPath,
4861
+ valueType: typeof translatedValue,
4862
+ translatedValue
4863
+ }), originalValue || {};
4864
+ devLog.info("Processing translated Wistia captions", {
4865
+ fieldPath: context.fieldPath,
4866
+ cueCount: translatedValue._srtCues.length
4867
+ });
4868
+ const { translationText, _originalMetadata, _srtCues } = translatedValue;
4869
+ devLog.info("Wistia captions translationText:", {
4870
+ fieldPath: context.fieldPath,
4871
+ translationTextLength: translationText.length,
4872
+ translationTextPreview: translationText.substring(0, 200) + (translationText.length > 200 ? "..." : "")
4873
+ });
4874
+ const reconstructedCaptionContent = deserializeWistiaCaptionText(translationText, _srtCues), documentLanguage = getDocumentLanguage(context.document);
4875
+ let updatedIsoLanguage = _originalMetadata.isoLanguage;
4876
+ if (documentLanguage) {
4877
+ const isoCode = normalizeToIsoLanguageCode(documentLanguage);
4878
+ isoCode ? (updatedIsoLanguage = isoCode, devLog.debug("Updated isoLanguage based on document language", {
4879
+ fieldPath: context.fieldPath,
4880
+ documentLanguage,
4881
+ isoCode
4882
+ })) : devLog.debug("Could not convert document language to ISO code, preserving original", {
4883
+ fieldPath: context.fieldPath,
4884
+ documentLanguage,
4885
+ originalIsoLanguage: _originalMetadata.isoLanguage
4886
+ });
4887
+ } else
4888
+ devLog.debug("Document language field not found, preserving original isoLanguage", {
4889
+ fieldPath: context.fieldPath,
4890
+ originalIsoLanguage: _originalMetadata.isoLanguage
4891
+ });
4892
+ const result = {
4893
+ captionContent: reconstructedCaptionContent,
4894
+ captionFileName: _originalMetadata.captionFileName,
4895
+ isoLanguage: updatedIsoLanguage,
4896
+ captionUploadStatus: _originalMetadata.captionUploadStatus,
4897
+ captionUploadMessage: _originalMetadata.captionUploadMessage
4898
+ };
4899
+ return devLog.info("Wistia captions final result:", {
4900
+ fieldPath: context.fieldPath,
4901
+ hasCaptionContent: !!result.captionContent,
4902
+ captionContentLength: result.captionContent?.length ?? 0,
4903
+ isoLanguage: result.isoLanguage
4904
+ }), result;
4905
+ }
4906
+ };
4907
+ function parseSRTContent(content) {
4908
+ if (devLog.debug("parseSRTContent - input:", {
4909
+ contentLength: content.length,
4910
+ contentPreview: content.substring(0, 100) + (content.length > 100 ? "..." : "")
4911
+ }), !content || content.trim() === "")
4912
+ return [];
4913
+ let normalizedContent = content;
4914
+ content.includes("\\n") && !content.includes(`
4915
+ `) && (normalizedContent = content.replace(/\\n/g, `
4916
+ `), devLog.debug("Unescaped newlines in SRT content", {
4917
+ originalLength: content.length,
4918
+ normalizedLength: normalizedContent.length
4919
+ }));
4920
+ const cues = [], cueBlocks = normalizedContent.split(/\n\s*\n/);
4921
+ for (const block of cueBlocks) {
4922
+ const lines = block.trim().split(`
4923
+ `);
4924
+ if (lines.length < 3)
4925
+ continue;
4926
+ const cueNumber = Number.parseInt(lines[0].trim(), 10);
4927
+ if (Number.isNaN(cueNumber)) {
4928
+ devLog.warn("Invalid SRT cue number", { line: lines[0] });
4929
+ continue;
4930
+ }
4931
+ const timestampLine = lines[1].trim(), timestampMatch = timestampLine.match(/^(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})$/);
4932
+ if (!timestampMatch) {
4933
+ devLog.warn("Invalid SRT timestamp format", { line: timestampLine });
4934
+ continue;
4935
+ }
4936
+ const startTime = timestampMatch[1].replace(".", ","), endTime = timestampMatch[2].replace(".", ","), text = lines.slice(2).join(`
4937
+ `).trim();
4938
+ text && cues.push({
4939
+ index: cueNumber,
4940
+ startTime,
4941
+ endTime,
4942
+ text
4943
+ });
4944
+ }
4945
+ return devLog.debug("parseSRTContent - output:", {
4946
+ cueCount: cues.length,
4947
+ firstCue: cues.length > 0 ? cues[0] : null
4948
+ }), cues;
4949
+ }
4950
+ function serializeWistiaCaptionText(cues) {
4951
+ if (devLog.debug("serializeWistiaCaptionText - input:", {
4952
+ cuesCount: cues.length,
4953
+ firstCue: cues.length > 0 ? cues[0] : null
4954
+ }), !Array.isArray(cues) || cues.length === 0)
4955
+ return "";
4956
+ const result = cues.map((cue) => {
4957
+ devLog.debug(`Processing cue ${cue.index}:`, {
4958
+ text: cue.text,
4959
+ hasText: typeof cue.text == "string"
4960
+ });
4961
+ const escapedText = cue.text.replace(/\n/g, "\\n");
4962
+ return `[${cue.index}] ${escapedText}`;
4963
+ }).join(`
4964
+ `);
4965
+ return devLog.debug("serializeWistiaCaptionText - output:", {
4966
+ resultLength: result.length,
4967
+ resultPreview: result.substring(0, 100) + (result.length > 100 ? "..." : "")
4968
+ }), result;
4969
+ }
4970
+ function deserializeWistiaCaptionText(translationText, srtCuesMetadata) {
4971
+ if (devLog.debug("deserializeWistiaCaptionText - input:", {
4972
+ translationTextLength: translationText.length,
4973
+ translationTextPreview: translationText.substring(0, 100) + (translationText.length > 100 ? "..." : ""),
4974
+ metadataCount: srtCuesMetadata.length,
4975
+ metadataExample: srtCuesMetadata.length > 0 ? srtCuesMetadata[0] : null
4976
+ }), !translationText || translationText.trim() === "")
4977
+ return "";
4978
+ const result = [], lines = translationText.split(`
4979
+ `), lineRegex = /^\[(\d+)\]\s*(.*)$/, metadataByIndex = /* @__PURE__ */ new Map();
4980
+ for (const metadata of srtCuesMetadata)
4981
+ metadataByIndex.set(metadata.index, { startTime: metadata.startTime, endTime: metadata.endTime });
4982
+ let currentIndex = null, currentTextParts = [];
4983
+ for (const line of lines) {
4984
+ const match = line.match(lineRegex);
4985
+ if (match) {
4986
+ if (currentIndex !== null) {
4987
+ const text = currentTextParts.join(`
4988
+ `).replace(/\\n/g, `
4989
+ `), metadata = metadataByIndex.get(currentIndex);
4990
+ metadata ? (result.push(`${currentIndex}`), result.push(`${metadata.startTime} --> ${metadata.endTime}`), result.push(text), result.push("")) : devLog.warn("Missing metadata for caption", { index: currentIndex });
4991
+ }
4992
+ currentIndex = Number.parseInt(match[1], 10), currentTextParts = [match[2]];
4993
+ } else currentIndex !== null ? currentTextParts.push(line) : devLog.warn("Failed to parse caption line", { line });
4994
+ }
4995
+ if (currentIndex !== null) {
4996
+ const text = currentTextParts.join(`
4997
+ `).replace(/\\n/g, `
4998
+ `), metadata = metadataByIndex.get(currentIndex);
4999
+ metadata ? (result.push(`${currentIndex}`), result.push(`${metadata.startTime} --> ${metadata.endTime}`), result.push(text), result.push("")) : devLog.warn("Missing metadata for caption", { index: currentIndex });
5000
+ }
5001
+ result.length > 0 && result[result.length - 1] === "" && result.pop();
5002
+ const reconstructed = result.join(`
5003
+ `);
5004
+ return devLog.debug("deserializeWistiaCaptionText - output:", {
5005
+ resultLength: reconstructed.length,
5006
+ resultPreview: reconstructed.substring(0, 200) + (reconstructed.length > 200 ? "..." : "")
5007
+ }), reconstructed;
5008
+ }
5009
+ function getDocumentLanguage(document) {
5010
+ if (!document || typeof document != "object")
5011
+ return;
5012
+ const languageFieldNames = ["language", "lang", "locale"];
5013
+ for (const fieldName of languageFieldNames)
5014
+ if (fieldName in document && typeof document[fieldName] == "string")
5015
+ return document[fieldName];
5016
+ }
3492
5017
  const aiTranslationPlugin = definePlugin((config) => (config.fieldTransforms?.length && (devLog.info(`Registering ${config.fieldTransforms.length} custom field transforms`), config.fieldTransforms.forEach((transform) => {
3493
5018
  globalTransformRegistry.register(transform);
3494
5019
  })), {
@@ -3508,6 +5033,7 @@ export {
3508
5033
  restoreExcludedFields,
3509
5034
  richTextTransform,
3510
5035
  translationHistorySchema,
3511
- vttCaptionTransform
5036
+ vttCaptionTransform,
5037
+ wistiaCaptionTransform
3512
5038
  };
3513
5039
  //# sourceMappingURL=index.js.map