@braze/sanity-plugin-ai-translation 0.2.67 → 0.2.69

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) {
@@ -1659,13 +1759,6 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1659
1759
  } else
1660
1760
  root.push(node);
1661
1761
  remainder && (lines[i] = remainder, i--);
1662
- } else if (content.endsWith(closingTag)) {
1663
- const textContent = content.substring(0, content.lastIndexOf("<"));
1664
- if (node.text = unescapeXml(textContent), stack.length > 0) {
1665
- const parent = stack[stack.length - 1];
1666
- parent && parent.children.push(node);
1667
- } else
1668
- root.push(node);
1669
1762
  } else if (attributes.type && typeof attributes.type == "string" && attributes.type.startsWith("translatable.")) {
1670
1763
  const contentLines = [];
1671
1764
  for (i++; i < lines.length; ) {
@@ -1921,10 +2014,17 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1921
2014
  });
1922
2015
  return;
1923
2016
  }
1924
- 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", {
1925
- path: path.join("."),
1926
- attemptedValue: node.text
1927
- }));
2017
+ if (node.text !== void 0 && !node.attributes.readonly) {
2018
+ const existingValue = get(doc, path);
2019
+ 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)", {
2020
+ path: path.join("."),
2021
+ existingType: typeof existingValue,
2022
+ attemptedValue: node.text?.substring(0, 50)
2023
+ }) : devLog.debug("Skipping field not present in original document", {
2024
+ path: path.join("."),
2025
+ attemptedValue: node.text
2026
+ });
2027
+ }
1928
2028
  }, applyBlockChanges = (block, blockNode) => {
1929
2029
  if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
1930
2030
  devLog.warn("Block key mismatch, skipping", {
@@ -1955,7 +2055,11 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1955
2055
  });
1956
2056
  return;
1957
2057
  }
1958
- fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (fieldName in block ? block[fieldName] = fieldNode.text : devLog.debug("Skipping block field not present in original", {
2058
+ 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)", {
2059
+ fieldName,
2060
+ existingType: typeof block[fieldName],
2061
+ attemptedValue: fieldNode.text?.substring(0, 50)
2062
+ }) : devLog.debug("Skipping block field not present in original", {
1959
2063
  fieldName,
1960
2064
  attemptedValue: fieldNode.text
1961
2065
  }));
@@ -2793,8 +2897,28 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2793
2897
  sourceDocument
2794
2898
  });
2795
2899
  setupTranslationJobStatuses(dispatch, sourceDocuments, targetLanguages, sourceDocument._id);
2796
- const translationTasks = sourceDocumentIds.flatMap(
2900
+ const shouldSkipConflict = (documentId, targetLanguage) => !releaseConfig.skipConflicts || releaseConfig.skipConflicts.length === 0 ? !1 : releaseConfig.skipConflicts.some(
2901
+ (conflict) => conflict.sourceDocumentId === documentId && conflict.targetLanguage === targetLanguage
2902
+ ), skippedCount = sourceDocumentIds.reduce((count, documentId) => count + targetLanguages.filter((lang) => shouldSkipConflict(documentId, lang)).length, 0), translationTasks = sourceDocumentIds.flatMap(
2797
2903
  (documentId) => targetLanguages.map(async (targetLanguage) => {
2904
+ if (shouldSkipConflict(documentId, targetLanguage)) {
2905
+ const docInfo = sourceDocuments.find((d) => d._id === documentId);
2906
+ devLog.info("Skipping conflicting translation", {
2907
+ documentId,
2908
+ targetLanguage,
2909
+ reason: "User chose to skip conflicts"
2910
+ }), dispatch({
2911
+ type: "SET_TRANSLATION_JOB_STATUS",
2912
+ payload: {
2913
+ _id: documentId,
2914
+ _type: docInfo?._type,
2915
+ language: targetLanguage,
2916
+ status: "skipped",
2917
+ title: docInfo?.title
2918
+ }
2919
+ });
2920
+ return;
2921
+ }
2798
2922
  try {
2799
2923
  await translationSemaphore.withPermit(async () => {
2800
2924
  const document = await client.withConfig({
@@ -2833,12 +2957,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2833
2957
  );
2834
2958
  }
2835
2959
  })
2836
- ), results = await Promise.allSettled(translationTasks), succeeded = results.filter((r) => r.status === "fulfilled").length, failed = results.filter((r) => r.status === "rejected").length;
2960
+ ), results = await Promise.allSettled(translationTasks), succeeded = results.filter((r) => r.status === "fulfilled").length - skippedCount, failed = results.filter((r) => r.status === "rejected").length;
2837
2961
  devLog.info("Translation tasks completed", {
2838
2962
  ...logContext,
2839
2963
  total: results.length,
2840
2964
  succeeded,
2841
2965
  failed,
2966
+ skipped: skippedCount,
2842
2967
  targetDocuments: versionedTargetDocumentIds.length
2843
2968
  }), dispatch({
2844
2969
  type: "SET_OVERALL_STATUS",
@@ -2852,7 +2977,15 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2852
2977
  languageFieldName
2853
2978
  });
2854
2979
  })
2855
- );
2980
+ ), releaseConfig.mode === "existing" && releaseId && await updateBackwardReferences({
2981
+ client,
2982
+ releaseId,
2983
+ sourceDocumentIds,
2984
+ targetLanguages,
2985
+ sourceLanguage,
2986
+ languageFieldName,
2987
+ excludeDocumentIds: versionedTargetDocumentIds
2988
+ });
2856
2989
  const hasAnyFailures = failed > 0;
2857
2990
  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", {
2858
2991
  ...logContext,
@@ -2879,8 +3012,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2879
3012
  }
2880
3013
  throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), updateActiveTranslationStatus(sourceDocument._id, "error"), cleanupActiveTranslation(sourceDocument._id), error;
2881
3014
  }
2882
- }, ReleaseConflictWarning = ({ conflicts, onCancel, onReplace }) => {
2883
- 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]);
3015
+ }, ReleaseConflictWarning = ({
3016
+ conflicts,
3017
+ totalTranslations,
3018
+ onCancel,
3019
+ onReplace,
3020
+ onSkipConflicts
3021
+ }) => {
3022
+ 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]);
2884
3023
  return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2885
3024
  /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
2886
3025
  /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
@@ -2904,13 +3043,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2904
3043
  " \u2014 Go back and select a different release, or create a new one."
2905
3044
  ] }),
2906
3045
  /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2907
- /* @__PURE__ */ jsx("strong", { children: "Replace" }),
3046
+ /* @__PURE__ */ jsx("strong", { children: "Replace All" }),
2908
3047
  " \u2014 Delete the existing translations in this release and create new ones."
3048
+ ] }),
3049
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3050
+ /* @__PURE__ */ jsx("strong", { children: "Skip Conflicts" }),
3051
+ " \u2014 Translate only the ",
3052
+ nonConflictingCount,
3053
+ " non-conflicting translation",
3054
+ nonConflictingCount !== 1 ? "s" : "",
3055
+ ", leaving existing ones unchanged.",
3056
+ !hasNonConflicting && " (All translations have conflicts)"
2909
3057
  ] })
2910
3058
  ] }),
2911
- /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
3059
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "space-between", children: [
2912
3060
  /* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Cancel", onClick: onCancel }),
2913
- /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "caution", text: "Replace Existing", onClick: onReplace })
3061
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, justify: "flex-end", children: [
3062
+ /* @__PURE__ */ jsx(Button, { mode: "bleed", tone: "caution", text: "Replace All", onClick: onReplace }),
3063
+ /* @__PURE__ */ jsx(
3064
+ Button,
3065
+ {
3066
+ mode: "ghost",
3067
+ tone: "caution",
3068
+ text: "Skip Conflicts",
3069
+ onClick: onSkipConflicts,
3070
+ disabled: !hasNonConflicting
3071
+ }
3072
+ )
3073
+ ] })
2914
3074
  ] })
2915
3075
  ] }) });
2916
3076
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
@@ -2924,12 +3084,12 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2924
3084
  selectedTargetLanguages,
2925
3085
  translationMode,
2926
3086
  releaseConfig
2927
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [conflictContext, setConflictContext] = useState(null), [isCheckingConflicts, setIsCheckingConflicts] = useState(!1), filteredReferences = references.filter((ref) => selectedReferenceIds.has(ref._id));
3087
+ } = 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]);
2928
3088
  useEffect(() => {
2929
3089
  releaseConfig.mode === "new" && conflictContext && setConflictContext(null);
2930
3090
  }, [releaseConfig.mode, conflictContext]);
2931
3091
  const startTranslation = useCallback(
2932
- (replaceExisting, overrideReleaseId) => {
3092
+ (replaceExisting, overrideReleaseId, skipConflicts) => {
2933
3093
  setConflictContext(null), translateDocumentSet({
2934
3094
  sourceDocument: document,
2935
3095
  targetLanguages: selectedTargetLanguages,
@@ -2946,6 +3106,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2946
3106
  releaseConfig: {
2947
3107
  ...releaseConfig,
2948
3108
  replaceExisting,
3109
+ skipConflicts,
2949
3110
  // Use the override release ID if provided (from conflict context),
2950
3111
  // otherwise use the current releaseConfig value
2951
3112
  existingReleaseId: overrideReleaseId ?? releaseConfig.existingReleaseId
@@ -2992,10 +3153,20 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2992
3153
  }
2993
3154
  }
2994
3155
  startTranslation(!1);
2995
- }, [releaseConfig, client, document, selectedTargetLanguages, config.languageFieldName, startTranslation, filteredReferences]), handleCancelConflict = useCallback(() => {
3156
+ }, [
3157
+ releaseConfig,
3158
+ client,
3159
+ document,
3160
+ selectedTargetLanguages,
3161
+ config.languageFieldName,
3162
+ startTranslation,
3163
+ filteredReferences
3164
+ ]), handleCancelConflict = useCallback(() => {
2996
3165
  setConflictContext(null);
2997
3166
  }, []), handleReplaceConflict = useCallback(() => {
2998
3167
  conflictContext && startTranslation(!0, conflictContext.releaseId);
3168
+ }, [startTranslation, conflictContext]), handleSkipConflicts = useCallback(() => {
3169
+ conflictContext && startTranslation(!1, conflictContext.releaseId, conflictContext.conflicts);
2999
3170
  }, [startTranslation, conflictContext]), handleSelectAll = useCallback(() => {
3000
3171
  dispatch({
3001
3172
  type: "SET_SELECTED_TARGET_LANGUAGES",
@@ -3011,8 +3182,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3011
3182
  ReleaseConflictWarning,
3012
3183
  {
3013
3184
  conflicts: conflictContext.conflicts,
3185
+ totalTranslations,
3014
3186
  onCancel: handleCancelConflict,
3015
- onReplace: handleReplaceConflict
3187
+ onReplace: handleReplaceConflict,
3188
+ onSkipConflicts: handleSkipConflicts
3016
3189
  }
3017
3190
  ) }) : /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
3018
3191
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
@@ -3094,8 +3267,184 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3094
3267
  documentId: document._id
3095
3268
  });
3096
3269
  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)) }) });
3270
+ }, reconcileReleaseReferences = async ({
3271
+ client,
3272
+ releaseId,
3273
+ sourceLanguage,
3274
+ languageFieldName,
3275
+ onProgress
3276
+ }) => {
3277
+ devLog.info("Starting release reference reconciliation", {
3278
+ releaseId,
3279
+ sourceLanguage,
3280
+ component: "reconcileReleaseReferences"
3281
+ });
3282
+ try {
3283
+ const docsInRelease = await client.fetch(
3284
+ `*[_id match "versions." + $releaseId + ".*"] {
3285
+ _id,
3286
+ "language": @[$languageFieldName]
3287
+ }`,
3288
+ { releaseId, languageFieldName }
3289
+ );
3290
+ if (docsInRelease.length === 0)
3291
+ return devLog.info("No documents found in release", {
3292
+ releaseId,
3293
+ component: "reconcileReleaseReferences"
3294
+ }), { total: 0, updated: 0, errors: 0 };
3295
+ devLog.info("Found documents in release", {
3296
+ releaseId,
3297
+ count: docsInRelease.length,
3298
+ component: "reconcileReleaseReferences"
3299
+ });
3300
+ let updated = 0, errors = 0;
3301
+ for (let i = 0; i < docsInRelease.length; i++) {
3302
+ const doc = docsInRelease[i];
3303
+ onProgress && onProgress(i + 1, docsInRelease.length);
3304
+ try {
3305
+ if (!doc.language) {
3306
+ devLog.warn("Document missing language field, skipping", {
3307
+ documentId: doc._id,
3308
+ component: "reconcileReleaseReferences"
3309
+ });
3310
+ continue;
3311
+ }
3312
+ await localizeReferences({
3313
+ client,
3314
+ documentId: doc._id,
3315
+ sourceLanguage,
3316
+ languageFieldName
3317
+ }), updated++;
3318
+ } catch (error) {
3319
+ errors++, devLog.error(
3320
+ "Failed to reconcile references for document",
3321
+ error instanceof Error ? error : new Error(String(error)),
3322
+ {
3323
+ documentId: doc._id,
3324
+ component: "reconcileReleaseReferences"
3325
+ }
3326
+ );
3327
+ }
3328
+ }
3329
+ const result = {
3330
+ total: docsInRelease.length,
3331
+ updated,
3332
+ errors
3333
+ };
3334
+ return devLog.info("Release reference reconciliation complete", {
3335
+ releaseId,
3336
+ ...result,
3337
+ component: "reconcileReleaseReferences"
3338
+ }), result;
3339
+ } catch (error) {
3340
+ throw devLog.error("Failed to reconcile release references", error instanceof Error ? error : new Error(String(error)), {
3341
+ releaseId,
3342
+ component: "reconcileReleaseReferences"
3343
+ }), error;
3344
+ }
3345
+ }, ReconcileReferencesButton = ({ sourceLanguage }) => {
3346
+ const { dispatch, releaseConfig, overallStatus, reconcilingStatus, reconciliationProgress, config } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [documentCount, setDocumentCount] = useState(null), [isLoadingCount, setIsLoadingCount] = useState(!1), releaseId = releaseConfig.existingReleaseId, languageFieldName = config?.languageFieldName ?? "language";
3347
+ useEffect(() => {
3348
+ if (!releaseId || releaseConfig.mode !== "existing") {
3349
+ setDocumentCount(null);
3350
+ return;
3351
+ }
3352
+ (async () => {
3353
+ setIsLoadingCount(!0);
3354
+ try {
3355
+ const count = await client.fetch('count(*[_id match "versions." + $releaseId + ".*"])', { releaseId });
3356
+ setDocumentCount(count), devLog.debug("Fetched release document count", {
3357
+ releaseId,
3358
+ count,
3359
+ component: "ReconcileReferencesButton"
3360
+ });
3361
+ } catch (error) {
3362
+ devLog.error(
3363
+ "Failed to fetch release document count",
3364
+ error instanceof Error ? error : new Error(String(error)),
3365
+ {
3366
+ releaseId,
3367
+ component: "ReconcileReferencesButton"
3368
+ }
3369
+ ), setDocumentCount(null);
3370
+ } finally {
3371
+ setIsLoadingCount(!1);
3372
+ }
3373
+ })();
3374
+ }, [releaseId, releaseConfig.mode, client]);
3375
+ const handleReconcile = useCallback(async () => {
3376
+ if (releaseId) {
3377
+ devLog.info("Starting reference reconciliation", {
3378
+ releaseId,
3379
+ sourceLanguage,
3380
+ component: "ReconcileReferencesButton"
3381
+ }), dispatch({ type: "SET_RECONCILING_STATUS", payload: "reconciling" });
3382
+ try {
3383
+ const result = await reconcileReleaseReferences({
3384
+ client,
3385
+ releaseId,
3386
+ sourceLanguage,
3387
+ languageFieldName,
3388
+ onProgress: (current, total) => {
3389
+ dispatch({
3390
+ type: "SET_RECONCILIATION_PROGRESS",
3391
+ payload: { current, total }
3392
+ });
3393
+ }
3394
+ });
3395
+ devLog.info("Reference reconciliation completed", {
3396
+ releaseId,
3397
+ ...result,
3398
+ component: "ReconcileReferencesButton"
3399
+ }), dispatch({ type: "SET_RECONCILING_STATUS", payload: "completed" }), setTimeout(() => {
3400
+ dispatch({ type: "SET_RECONCILING_STATUS", payload: "idle" });
3401
+ }, 2e3);
3402
+ } catch (error) {
3403
+ devLog.error("Reference reconciliation failed", error instanceof Error ? error : new Error(String(error)), {
3404
+ releaseId,
3405
+ component: "ReconcileReferencesButton"
3406
+ }), dispatch({ type: "SET_RECONCILING_STATUS", payload: "error" });
3407
+ }
3408
+ }
3409
+ }, [releaseId, sourceLanguage, languageFieldName, client, dispatch]);
3410
+ if (releaseConfig.mode !== "existing" || !releaseId)
3411
+ return null;
3412
+ const isTranslating = overallStatus === "translating" || overallStatus === "linking_references", isReconciling = reconcilingStatus === "reconciling";
3413
+ return /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, tone: reconcilingStatus === "error" ? "critical" : "default", children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3414
+ /* @__PURE__ */ jsxs(Flex, { align: "center", justify: "space-between", gap: 3, children: [
3415
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3416
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: "Reconcile References" }),
3417
+ /* @__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: [
3418
+ documentCount,
3419
+ " document",
3420
+ documentCount !== 1 ? "s" : "",
3421
+ " in release"
3422
+ ] }) })
3423
+ ] }),
3424
+ /* @__PURE__ */ jsx(
3425
+ Button,
3426
+ {
3427
+ icon: isReconciling ? Spinner : SyncIcon,
3428
+ text: reconcilingStatus === "completed" ? "Done!" : reconcilingStatus === "error" ? "Retry" : isReconciling ? "Reconciling..." : "Reconcile",
3429
+ tone: reconcilingStatus === "completed" ? "positive" : reconcilingStatus === "error" ? "critical" : "default",
3430
+ mode: "ghost",
3431
+ onClick: handleReconcile,
3432
+ disabled: (isTranslating || isReconciling || isLoadingCount || documentCount === 0) && reconcilingStatus !== "error"
3433
+ }
3434
+ )
3435
+ ] }),
3436
+ isReconciling && reconciliationProgress && /* @__PURE__ */ jsx(Flex, { align: "center", gap: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3437
+ "Processing ",
3438
+ reconciliationProgress.current,
3439
+ " of ",
3440
+ reconciliationProgress.total,
3441
+ "..."
3442
+ ] }) }),
3443
+ reconcilingStatus === "error" && /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Reconciliation failed. Click Retry to try again." }),
3444
+ 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." })
3445
+ ] }) });
3097
3446
  }, ReleaseSelectionPanel = () => {
3098
- const { dispatch, releaseConfig } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [releases, setReleases] = useState([]), [isLoading, setIsLoading] = useState(!1), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState("");
3447
+ 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);
3099
3448
  useEffect(() => {
3100
3449
  (async () => {
3101
3450
  setIsLoading(!0), setError(null);
@@ -3208,12 +3557,33 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3208
3557
  radius: 2,
3209
3558
  tone: isSelected ? "primary" : "default",
3210
3559
  style: { cursor: "pointer" },
3211
- onClick: () => handleSelectRelease(releaseName),
3560
+ onClick: () => {
3561
+ handleSelectRelease(releaseName), isSelected || setShowReconcileUI(!1);
3562
+ },
3212
3563
  border: !0,
3213
3564
  muted: isSelected,
3214
3565
  children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 3, children: [
3215
- /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
3216
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
3566
+ /* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", children: [
3567
+ /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 2, children: [
3568
+ /* @__PURE__ */ jsx(Text, { size: 1, children: release.metadata.title }),
3569
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: formatDate(release._createdAt) })
3570
+ ] }),
3571
+ isSelected && sourceLanguage && /* @__PURE__ */ jsx(
3572
+ Button,
3573
+ {
3574
+ icon: SyncIcon,
3575
+ text: showReconcileUI ? "Hide" : "Reconcile References",
3576
+ mode: "bleed",
3577
+ tone: "primary",
3578
+ fontSize: 0,
3579
+ padding: 2,
3580
+ onClick: (e) => {
3581
+ e.stopPropagation(), setShowReconcileUI(!showReconcileUI);
3582
+ }
3583
+ }
3584
+ )
3585
+ ] }),
3586
+ releaseConfig.existingReleaseId && sourceLanguage && showReconcileUI && /* @__PURE__ */ jsx(ReconcileReferencesButton, { sourceLanguage })
3217
3587
  ] })
3218
3588
  },
3219
3589
  release._id
@@ -3440,13 +3810,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3440
3810
  /* @__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) => {
3441
3811
  const isSelected = selectedReferenceIds.has(reference._id), displayTitle = reference.title || reference._id;
3442
3812
  return /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
3443
- /* @__PURE__ */ jsx(
3444
- Checkbox,
3445
- {
3446
- checked: isSelected,
3447
- onChange: () => handleToggleReference(reference._id)
3448
- }
3449
- ),
3813
+ /* @__PURE__ */ jsx(Checkbox, { checked: isSelected, onChange: () => handleToggleReference(reference._id) }),
3450
3814
  /* @__PURE__ */ jsxs(Flex, { flex: 1, align: "center", gap: 2, children: [
3451
3815
  /* @__PURE__ */ jsx(Text, { size: 1, children: displayTitle }),
3452
3816
  /* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: reference._type })
@@ -3455,15 +3819,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3455
3819
  }) }) })
3456
3820
  ] }) });
3457
3821
  }, ReferenceConfigPanel = () => {
3458
- const {
3459
- dispatch,
3460
- document,
3461
- shouldTranslateReferences,
3462
- maxReferenceDepth,
3463
- config,
3464
- references,
3465
- selectedReferenceIds
3466
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), [isLoadingReferences, setIsLoadingReferences] = useState(!1), [showSelectionPanel, setShowSelectionPanel] = useState(!1), maxDepth = config.maxReferenceDepth || 3;
3822
+ 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;
3467
3823
  useEffect(() => {
3468
3824
  (async () => {
3469
3825
  if (setIsLoadingReferences(!0), !shouldTranslateReferences) {