@braze/sanity-plugin-ai-translation 0.2.6 → 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,6 +1,6 @@
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";
3
+ import { WarningOutlineIcon, SyncIcon, SearchIcon, CheckmarkIcon, ErrorOutlineIcon, CircleIcon, ArrowRightIcon, ResetIcon, PanelRightIcon, TranslateIcon } from "@sanity/icons";
4
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";
@@ -122,6 +122,8 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
122
122
  mode: "new",
123
123
  newReleaseTitle: ""
124
124
  },
125
+ reconcilingStatus: "idle",
126
+ reconciliationProgress: void 0,
125
127
  dispatch: () => {
126
128
  }
127
129
  }), translationReducer = (context, action) => {
@@ -207,8 +209,24 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
207
209
  releaseConfig: {
208
210
  ...context.releaseConfig,
209
211
  conflicts: void 0,
210
- replaceExisting: void 0
211
- }
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
212
230
  };
213
231
  default:
214
232
  return context;
@@ -238,7 +256,10 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
238
256
  releaseConfig: {
239
257
  mode: "new",
240
258
  newReleaseTitle: defaultReleaseTitle
241
- }
259
+ },
260
+ // Reconciliation state for existing releases
261
+ reconcilingStatus: "idle",
262
+ reconciliationProgress: void 0
242
263
  };
243
264
  activeTranslation && devLog.info("Reconnecting to active translation", {
244
265
  documentId: document._id,
@@ -505,7 +526,7 @@ const DEFAULT_RETRY_OPTIONS = {
505
526
  logRetries: !0
506
527
  };
507
528
  function calculateDelay(attempt, options) {
508
- const delay = options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt);
529
+ const delay = options.initialDelayMs * options.backoffMultiplier ** attempt;
509
530
  return Math.min(delay, options.maxDelayMs);
510
531
  }
511
532
  function sleep(ms) {
@@ -924,6 +945,85 @@ const fetchTranslationPrompts = async ({
924
945
  translatedCount: translationMap.size,
925
946
  component: "updateReferences"
926
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
+ }
927
1027
  }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages, mainSourceDocumentId) => {
928
1028
  for (const sourceDocument of sourceDocuments)
929
1029
  for (const lang of targetLanguages) {
@@ -939,6 +1039,57 @@ const fetchTranslationPrompts = async ({
939
1039
  payload
940
1040
  }), mainSourceDocumentId && updateActiveTranslationJob(mainSourceDocumentId, payload);
941
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";
942
1093
  }, updateTranslationMetadata = async ({
943
1094
  client,
944
1095
  targetLanguage,
@@ -1649,25 +1800,27 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1649
1800
  attributes,
1650
1801
  children: []
1651
1802
  }, closingTag = `</${tagName}>`;
1652
- if (content.endsWith(closingTag.replace(/[<>]/g, ""))) {
1653
- const textContent = content.substring(0, content.lastIndexOf("<"));
1654
- if (node.text = unescapeXml(textContent), stack.length > 0) {
1655
- const parent = stack[stack.length - 1];
1656
- parent && parent.children.push(node);
1657
- } else
1658
- root.push(node);
1659
- } else if (content.includes(closingTag))
1660
- 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) {
1661
1808
  const parent = stack[stack.length - 1];
1662
1809
  parent && parent.children.push(node);
1663
1810
  } else
1664
1811
  root.push(node);
1665
- else if (attributes.type && typeof attributes.type == "string" && attributes.type.startsWith("translatable.")) {
1812
+ remainder && (lines[i] = remainder, i--);
1813
+ } else if (attributes.type && typeof attributes.type == "string" && attributes.type.startsWith("translatable.")) {
1666
1814
  const contentLines = [];
1667
1815
  for (i++; i < lines.length; ) {
1668
1816
  const currentLine = lines[i];
1669
- 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--);
1670
1822
  break;
1823
+ }
1671
1824
  contentLines.push(currentLine), i++;
1672
1825
  }
1673
1826
  if (node.text = contentLines.join(`
@@ -1676,18 +1829,36 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1676
1829
  parent && parent.children.push(node);
1677
1830
  } else
1678
1831
  root.push(node);
1679
- } else if (attributes.type === "string" && content && content.includes(closingTag))
1680
- 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) {
1681
1847
  const parent = stack[stack.length - 1];
1682
1848
  parent && parent.children.push(node);
1683
1849
  } else
1684
1850
  root.push(node);
1685
- else if (attributes.type === "string" && !content) {
1851
+ } else if (attributes.type === "string" && !content) {
1686
1852
  const contentLines = [];
1687
1853
  for (i++; i < lines.length; ) {
1688
1854
  const currentLine = lines[i];
1689
- 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--);
1690
1860
  break;
1861
+ }
1691
1862
  contentLines.push(currentLine), i++;
1692
1863
  }
1693
1864
  if (node.text = contentLines.join(`
@@ -1700,25 +1871,122 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1700
1871
  stack.push(node);
1701
1872
  continue;
1702
1873
  }
1703
- if (trimmed.match(/^<\/(field|block|span)>$/) && stack.length > 0) {
1704
- matchedPatternCount++;
1705
- const node = stack.pop();
1706
- if (node)
1707
- if (stack.length > 0) {
1708
- const parent = stack[stack.length - 1];
1709
- 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);
1710
1950
  } else
1711
- root.push(node);
1951
+ lines[i] = afterClosingTag, i--;
1952
+ }
1712
1953
  continue;
1713
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
+ });
1968
+ }
1714
1969
  processedLineCount <= 20 && devLog.debug("Line did not match any pattern", {
1715
1970
  line: i,
1716
1971
  trimmed,
1717
1972
  startsWithLessThan: trimmed.startsWith("<"),
1718
1973
  startsWithField: trimmed.startsWith("<field"),
1719
- startsWithBacktick: trimmed.startsWith("`")
1974
+ startsWithBacktick: trimmed.startsWith("`"),
1975
+ startsWithClosingTag: trimmed.startsWith("</")
1720
1976
  });
1721
1977
  }
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
+ }
1722
1990
  return devLog.debug("Parse complete", {
1723
1991
  totalLines: lines.length,
1724
1992
  processedLines: processedLineCount,
@@ -1766,8 +2034,28 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1766
2034
  return;
1767
2035
  }
1768
2036
  if (node.attributes.type === "object") {
1769
- for (const child of node.children)
1770
- 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
+ });
1771
2059
  return;
1772
2060
  }
1773
2061
  if (node.children.some((c) => c.type === "block")) {
@@ -1777,10 +2065,17 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1777
2065
  });
1778
2066
  return;
1779
2067
  }
1780
- 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", {
1781
- path: path.join("."),
1782
- attemptedValue: node.text
1783
- }));
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
+ }
1784
2079
  }, applyBlockChanges = (block, blockNode) => {
1785
2080
  if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
1786
2081
  devLog.warn("Block key mismatch, skipping", {
@@ -1811,7 +2106,11 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1811
2106
  });
1812
2107
  return;
1813
2108
  }
1814
- 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", {
1815
2114
  fieldName,
1816
2115
  attemptedValue: fieldNode.text
1817
2116
  }));
@@ -1983,16 +2282,18 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1983
2282
  translationPrompts,
1984
2283
  targetLanguage,
1985
2284
  openaiConfig,
1986
- previousError,
1987
- errorContext
2285
+ previousError: _previousError,
2286
+ errorContext,
2287
+ telemetryConfig
1988
2288
  }) => {
1989
- const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2289
+ const startTime = Date.now(), sourceLanguage = sourceDocument[languageFieldName], logContext = {
1990
2290
  sourceId: sourceDocument._id,
1991
2291
  sourceLanguage
1992
2292
  };
2293
+ let serializedDocument, openaiResponseContent;
1993
2294
  try {
1994
2295
  const sourceText = serializeDocument(sourceDocument);
1995
- devLog.info("Serialized document for OpenAI", {
2296
+ serializedDocument = sourceText, devLog.info("Serialized document for OpenAI", {
1996
2297
  ...logContext,
1997
2298
  characterCount: sourceText.length,
1998
2299
  estimatedTokens: Math.ceil(sourceText.length / 4),
@@ -2015,7 +2316,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2015
2316
  model: openaiConfig.model,
2016
2317
  reasoningEffort: openaiConfig.reasoningEffort
2017
2318
  });
2018
- devLog.info("OpenAI translation response received", {
2319
+ openaiResponseContent = response.content, devLog.info("OpenAI translation response received", {
2019
2320
  ...logContext,
2020
2321
  tokensUsed: response.tokensUsed,
2021
2322
  cost: response.cost,
@@ -2053,9 +2354,35 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2053
2354
  })), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
2054
2355
  tokensUsed: response.tokensUsed.total,
2055
2356
  estimatedCost: response.cost
2056
- }), result;
2357
+ }), {
2358
+ document: result,
2359
+ tokensUsed: response.tokensUsed.total,
2360
+ cost: response.cost
2361
+ };
2057
2362
  } catch (error) {
2058
- 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;
2059
2386
  }
2060
2387
  }, buildTranslationPrompt$1 = ({
2061
2388
  sourceText,
@@ -2291,9 +2618,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2291
2618
  releaseId,
2292
2619
  excludeFields = [],
2293
2620
  replaceExisting = !1,
2294
- mainSourceDocumentId
2621
+ mainSourceDocumentId,
2622
+ telemetryConfig
2295
2623
  }) => {
2296
- const safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2624
+ const startTime = Date.now(), sourceLanguage = sourceDocument[languageFieldName], safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2297
2625
  excludeFields.length !== safeExcludeFields.length && devLog.warn("Language field was in excludeFields - removing it to prevent translation errors", {
2298
2626
  documentId: sourceDocument._id,
2299
2627
  languageFieldName,
@@ -2332,29 +2660,55 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2332
2660
  documentId: sourceDocument._id,
2333
2661
  documentType: sourceDocument._type
2334
2662
  }), documentToTranslate = filteredDocument;
2335
- let translatedDoc, previousError = null;
2663
+ let translatedDoc, translationMetrics = {}, previousError = null;
2336
2664
  const performTranslation = async (errorContext) => {
2337
2665
  switch (translationMode) {
2338
- case "sanity-only":
2339
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating", void 0, mainSourceDocumentId), await getSanityAgentTranslation({
2340
- sourceDocument: documentToTranslate,
2341
- targetLanguage,
2342
- languageFieldName,
2343
- schemaId,
2344
- client,
2345
- translationPrompts
2346
- });
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
+ }
2347
2700
  case "openai-only": {
2348
2701
  if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2349
2702
  throw new Error("OpenAI is not configured but openai-only mode was selected");
2350
- return setJobStatus(
2703
+ setJobStatus(
2351
2704
  dispatch,
2352
2705
  sourceDocument._id,
2353
2706
  targetLanguage,
2354
2707
  "translating_with_openai",
2355
2708
  void 0,
2356
2709
  mainSourceDocumentId
2357
- ), await getOpenAITranslation({
2710
+ );
2711
+ const result = await getOpenAITranslation({
2358
2712
  dispatch,
2359
2713
  sourceDocument: documentToTranslate,
2360
2714
  targetLanguage,
@@ -2362,14 +2716,23 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2362
2716
  translationPrompts,
2363
2717
  openaiConfig,
2364
2718
  previousError: errorContext && previousError ? previousError : void 0,
2365
- errorContext
2719
+ errorContext,
2720
+ telemetryConfig
2366
2721
  });
2722
+ return result ? {
2723
+ document: result.document,
2724
+ tokensUsed: result.tokensUsed,
2725
+ cost: result.cost
2726
+ } : { document: null };
2367
2727
  }
2368
2728
  default:
2369
2729
  throw new Error(`Invalid translation mode: ${translationMode}`);
2370
2730
  }
2371
- };
2372
- if (translatedDoc = await performTranslation(), !translatedDoc)
2731
+ }, translationResult = await performTranslation();
2732
+ if (translatedDoc = translationResult.document, translationMetrics = {
2733
+ tokensUsed: translationResult.tokensUsed,
2734
+ cost: translationResult.cost
2735
+ }, !translatedDoc)
2373
2736
  return null;
2374
2737
  let transformedResult;
2375
2738
  Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
@@ -2432,9 +2795,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2432
2795
  targetLanguage,
2433
2796
  error: error instanceof Error ? error.message : String(error)
2434
2797
  }), previousError = error instanceof Error ? error : new Error(String(error));
2435
- const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`;
2436
- if (translatedDoc = await performTranslation(errorContext), !translatedDoc)
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)
2437
2800
  throw new Error("Translation retry failed: no document returned");
2801
+ translationMetrics = {
2802
+ tokensUsed: retryResult.tokensUsed,
2803
+ cost: retryResult.cost
2804
+ };
2438
2805
  let retryTransformedResult;
2439
2806
  Object.keys(originalStructure).length > 0 ? retryTransformedResult = restoreTransforms({
2440
2807
  translatedDocument: translatedDoc,
@@ -2465,11 +2832,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2465
2832
  isRetry: !0
2466
2833
  }
2467
2834
  }
2468
- ), retryNewDoc;
2835
+ ), {
2836
+ document: retryNewDoc,
2837
+ tokensUsed: translationMetrics.tokensUsed,
2838
+ cost: translationMetrics.cost
2839
+ };
2469
2840
  }
2470
2841
  throw error;
2471
2842
  }
2472
- return newDoc;
2843
+ return {
2844
+ document: newDoc,
2845
+ tokensUsed: translationMetrics.tokensUsed,
2846
+ cost: translationMetrics.cost
2847
+ };
2473
2848
  }, translateDocument = async ({
2474
2849
  dispatch,
2475
2850
  client,
@@ -2484,9 +2859,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2484
2859
  releaseId,
2485
2860
  excludeFields,
2486
2861
  replaceExisting,
2487
- mainSourceDocumentId
2862
+ mainSourceDocumentId,
2863
+ telemetryConfig
2488
2864
  }) => {
2489
- const logContext = {
2865
+ const jobStartTime = Date.now(), logContext = {
2490
2866
  documentId: sourceDocument._id,
2491
2867
  targetLanguage,
2492
2868
  schemaId,
@@ -2498,7 +2874,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2498
2874
  client,
2499
2875
  sourceDocument,
2500
2876
  sourceLanguage
2501
- }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2877
+ }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await handleTranslation({
2502
2878
  dispatch,
2503
2879
  client,
2504
2880
  schemaId,
@@ -2512,8 +2888,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2512
2888
  releaseId,
2513
2889
  excludeFields,
2514
2890
  replaceExisting,
2515
- mainSourceDocumentId
2516
- });
2891
+ mainSourceDocumentId,
2892
+ telemetryConfig
2893
+ }), newDocument = translationResult?.document;
2517
2894
  if (!newDocument) {
2518
2895
  devLog.error("Translation failed: no document returned", new Error("Translation returned null"), logContext), setJobStatus(
2519
2896
  dispatch,
@@ -2550,12 +2927,27 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2550
2927
  targetLanguage,
2551
2928
  "completed",
2552
2929
  {
2553
- targetId: newDocument._id
2930
+ targetId: newDocument._id,
2931
+ tokensUsed: translationResult?.tokensUsed,
2932
+ estimatedCost: translationResult?.cost
2554
2933
  },
2555
2934
  mainSourceDocumentId
2556
2935
  ), devLog.info("Translation completed successfully", {
2557
2936
  ...logContext,
2558
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
+ }
2559
2951
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
2560
2952
  } catch (error) {
2561
2953
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -2583,9 +2975,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2583
2975
  translationMode,
2584
2976
  openaiConfig,
2585
2977
  excludeFields,
2586
- releaseConfig
2978
+ releaseConfig,
2979
+ telemetryConfig
2587
2980
  }) => {
2588
- const logContext = {
2981
+ const batchStartTime = Date.now(), logContext = {
2589
2982
  documentId: sourceDocument._id,
2590
2983
  documentRev: sourceDocument._rev,
2591
2984
  targetLanguages,
@@ -2599,7 +2992,19 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2599
2992
  const sourceLanguage = sourceDocument[languageFieldName];
2600
2993
  if (!sourceLanguage)
2601
2994
  throw devLog.error("Source language not found"), new Error("Source language not found");
2602
- 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({
2603
3008
  type: "SET_OVERALL_STATUS",
2604
3009
  payload: "translating"
2605
3010
  }), releaseConfig.mode === "existing" && releaseConfig.existingReleaseId)
@@ -2649,8 +3054,28 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2649
3054
  sourceDocument
2650
3055
  });
2651
3056
  setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages, sourceDocument._id);
2652
- 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(
2653
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
+ }
2654
3079
  try {
2655
3080
  await translationSemaphore.withPermit(async () => {
2656
3081
  const document = await client.withConfig({
@@ -2672,7 +3097,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2672
3097
  excludeFields,
2673
3098
  replaceExisting: releaseConfig.replaceExisting,
2674
3099
  // Pass main source document ID for active translations tracking
2675
- mainSourceDocumentId: sourceDocument._id
3100
+ mainSourceDocumentId: sourceDocument._id,
3101
+ telemetryConfig
2676
3102
  });
2677
3103
  versionedTargetDocumentId && versionedTargetDocumentIds.push(versionedTargetDocumentId);
2678
3104
  }
@@ -2689,13 +3115,28 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2689
3115
  );
2690
3116
  }
2691
3117
  })
2692
- ), 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;
2693
3119
  devLog.info("Translation tasks completed", {
2694
3120
  ...logContext,
2695
3121
  total: results.length,
2696
3122
  succeeded,
2697
3123
  failed,
3124
+ skipped: skippedCount,
2698
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
+ }
2699
3140
  }), dispatch({
2700
3141
  type: "SET_OVERALL_STATUS",
2701
3142
  payload: "linking_references"
@@ -2708,7 +3149,15 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2708
3149
  languageFieldName
2709
3150
  });
2710
3151
  })
2711
- );
3152
+ ), releaseConfig.mode === "existing" && releaseId && await updateBackwardReferences({
3153
+ client,
3154
+ releaseId,
3155
+ sourceDocumentIds,
3156
+ targetLanguages,
3157
+ sourceLanguage,
3158
+ languageFieldName,
3159
+ excludeDocumentIds: versionedTargetDocumentIds
3160
+ });
2712
3161
  const hasAnyFailures = failed > 0;
2713
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", {
2714
3163
  ...logContext,
@@ -2735,8 +3184,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2735
3184
  }
2736
3185
  throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id), error;
2737
3186
  }
2738
- }, ReleaseConflictWarning = ({ conflicts, onCancel, onReplace }) => {
2739
- const uniqueDocuments = useMemo(() => new Set(conflicts.map((c) => c.sourceDocumentId)).size, [conflicts]), summaryText = useMemo(() => conflicts.length === 1 ? "1 translation" : uniqueDocuments === 1 ? `${conflicts.length} translations` : `${conflicts.length} translations across ${uniqueDocuments} documents`, [conflicts.length, uniqueDocuments]);
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]);
2740
3195
  return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2741
3196
  /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2742
3197
  /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
@@ -2760,13 +3215,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2760
3215
  " \u2014 Go back and select a different release, or create a new one."
2761
3216
  ] }),
2762
3217
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2763
- /* @__PURE__ */ jsx("strong", { children: "Replace" }),
3218
+ /* @__PURE__ */ jsx("strong", { children: "Replace All" }),
2764
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)"
2765
3229
  ] })
2766
3230
  ] }),
2767
- /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
3231
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "space-between", children: [
2768
3232
  /* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Cancel", onClick: onCancel }),
2769
- /* @__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
+ ] })
2770
3246
  ] })
2771
3247
  ] }) });
2772
3248
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
@@ -2780,12 +3256,12 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2780
3256
  selectedTargetLanguages,
2781
3257
  translationMode,
2782
3258
  releaseConfig
2783
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1), filteredReferences = references.filter((ref) => selectedReferenceIds.has(ref._id));
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]);
2784
3260
  useEffect(() => {
2785
3261
  releaseConfig.mode === "new" && conflictContext && setConflictContext(null);
2786
3262
  }, [releaseConfig.mode, conflictContext]);
2787
3263
  const startTranslation = useCallback(
2788
- (replaceExisting, overrideReleaseId) => {
3264
+ (replaceExisting, overrideReleaseId, skipConflicts) => {
2789
3265
  setConflictContext(null), translateDocumentSet({
2790
3266
  sourceDocument: document,
2791
3267
  targetLanguages: selectedTargetLanguages,
@@ -2802,6 +3278,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2802
3278
  releaseConfig: {
2803
3279
  ...releaseConfig,
2804
3280
  replaceExisting,
3281
+ skipConflicts,
2805
3282
  // Use the override release ID if provided (from conflict context),
2806
3283
  // otherwise use the current releaseConfig value
2807
3284
  existingReleaseId: overrideReleaseId ?? releaseConfig.existingReleaseId
@@ -2848,10 +3325,20 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2848
3325
  }
2849
3326
  }
2850
3327
  startTranslation(!1);
2851
- }, [releaseConfig, client, document, selectedTargetLanguages, config.languageFieldName, startTranslation, filteredReferences]), handleCancelConflict = useCallback(() => {
3328
+ }, [
3329
+ releaseConfig,
3330
+ client,
3331
+ document,
3332
+ selectedTargetLanguages,
3333
+ config.languageFieldName,
3334
+ startTranslation,
3335
+ filteredReferences
3336
+ ]), handleCancelConflict = useCallback(() => {
2852
3337
  setConflictContext(null);
2853
3338
  }, []), handleReplaceConflict = useCallback(() => {
2854
3339
  conflictContext && startTranslation(!0, conflictContext.releaseId);
3340
+ }, [startTranslation, conflictContext]), handleSkipConflicts = useCallback(() => {
3341
+ conflictContext && startTranslation(!1, conflictContext.releaseId, conflictContext.conflicts);
2855
3342
  }, [startTranslation, conflictContext]), handleSelectAll = useCallback(() => {
2856
3343
  dispatch({
2857
3344
  type: "SET_SELECTED_TARGET_LANGUAGES",
@@ -2867,8 +3354,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2867
3354
  ReleaseConflictWarning,
2868
3355
  {
2869
3356
  conflicts: conflictContext.conflicts,
3357
+ totalTranslations,
2870
3358
  onCancel: handleCancelConflict,
2871
- onReplace: handleReplaceConflict
3359
+ onReplace: handleReplaceConflict,
3360
+ onSkipConflicts: handleSkipConflicts
2872
3361
  }
2873
3362
  ) }) : /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
2874
3363
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
@@ -2950,8 +3439,183 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2950
3439
  documentId: document._id
2951
3440
  });
2952
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
+ ] }) });
2953
3617
  }, ReleaseSelectionPanel = () => {
2954
- 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);
2955
3619
  useEffect(() => {
2956
3620
  (async () => {
2957
3621
  setIsLoading(!0), setError(null);
@@ -3064,12 +3728,33 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3064
3728
  radius: 2,
3065
3729
  tone: isSelected ? "primary" : "default",
3066
3730
  style: { cursor: "pointer" },
3067
- onClick: () => handleSelectRelease(releaseName),
3731
+ onClick: () => {
3732
+ handleSelectRelease(releaseName), isSelected || setShowReconcileUI(!1);
3733
+ },
3068
3734
  border: !0,
3069
3735
  muted: isSelected,
3070
3736
  children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
3071
- /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
3072
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
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 })
3073
3758
  ] })
3074
3759
  },
3075
3760
  release._id
@@ -3296,13 +3981,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3296
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) => {
3297
3982
  const isSelected = selectedReferenceIds.has(reference._id), displayTitle = reference.title || reference._id;
3298
3983
  return /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
3299
- /* @__PURE__ */ jsx(
3300
- Checkbox,
3301
- {
3302
- checked: isSelected,
3303
- onChange: () => handleToggleReference(reference._id)
3304
- }
3305
- ),
3984
+ /* @__PURE__ */ jsx(Checkbox, { checked: isSelected, onChange: () => handleToggleReference(reference._id) }),
3306
3985
  /* @__PURE__ */ jsxs(Flex, { flex: 1, align: "center", gap: 2, children: [
3307
3986
  /* @__PURE__ */ jsx(Text, { size: 1, children: displayTitle }),
3308
3987
  /* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: reference._type })
@@ -3311,15 +3990,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3311
3990
  }) }) })
3312
3991
  ] }) });
3313
3992
  }, ReferenceConfigPanel = () => {
3314
- const {
3315
- dispatch,
3316
- document,
3317
- shouldTranslateReferences,
3318
- maxReferenceDepth,
3319
- config,
3320
- references,
3321
- selectedReferenceIds
3322
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1), [showSelectionPanel, setShowSelectionPanel] = useState(!1), maxDepth = config.maxReferenceDepth || 3;
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;
3323
3994
  useEffect(() => {
3324
3995
  (async () => {
3325
3996
  if (setIsLoadingReferences(!0), !shouldTranslateReferences) {