gt-sanity 2.0.21 → 2.1.1

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.
Files changed (44) hide show
  1. package/dist/index.cjs +1220 -763
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +113 -1
  4. package/dist/index.d.ts +113 -1
  5. package/dist/index.js +1211 -754
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/adapter/core.ts +27 -2
  9. package/src/adapter/types.ts +9 -0
  10. package/src/components/TranslationsProvider.tsx +92 -6
  11. package/src/components/page/TranslationsTable.tsx +5 -3
  12. package/src/components/shared/SingleDocumentView.tsx +5 -3
  13. package/src/components/tab/TranslationView.tsx +24 -14
  14. package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +1 -1
  15. package/src/configuration/baseFieldLevelConfig.ts +1 -1
  16. package/src/configuration/internationalizedArrayConfig/internationalizedArrayPatch.ts +59 -0
  17. package/src/index.ts +114 -58
  18. package/src/schema/InternationalizedArrayInput.tsx +278 -0
  19. package/src/schema/__tests__/createInternationalizedArrayTypes.test.ts +169 -0
  20. package/src/schema/createInternationalizedArrayTypes.ts +209 -0
  21. package/src/schema/types.ts +84 -0
  22. package/src/serialization/__tests__/BaseDocumentDeserializer/baseDeserialization.test.ts +3 -3
  23. package/src/serialization/__tests__/BaseDocumentMerger/baseMerge.test.ts +1 -1
  24. package/src/serialization/__tests__/BaseDocumentMerger/documentLevelMerge.test.ts +1 -1
  25. package/src/serialization/__tests__/BaseDocumentMerger/fieldLevelMerge.test.ts +1 -1
  26. package/src/serialization/__tests__/BaseDocumentSerializer/baseSerialization.test.ts +2 -2
  27. package/src/serialization/__tests__/BaseDocumentSerializer/documentInlineMarks.test.ts +4 -2
  28. package/src/serialization/__tests__/global.setup.ts +6 -0
  29. package/src/serialization/__tests__/helpers.ts +2 -1
  30. package/src/serialization/internationalizedArray/__tests__/internationalizedArray.test.ts +265 -0
  31. package/src/serialization/internationalizedArray/__tests__/serializeRoundTrip.test.ts +83 -0
  32. package/src/serialization/internationalizedArray/collapse.ts +52 -0
  33. package/src/serialization/internationalizedArray/detect.ts +81 -0
  34. package/src/serialization/internationalizedArray/merge.ts +149 -0
  35. package/src/serialization/types.ts +5 -1
  36. package/src/translation/__tests__/strategy.test.ts +47 -0
  37. package/src/translation/importDocument.ts +9 -5
  38. package/src/translation/strategy.ts +92 -0
  39. package/src/translation/uploadFiles.ts +1 -1
  40. package/src/types.ts +1 -1
  41. package/src/utils/__tests__/batchProcessor.test.ts +61 -2
  42. package/src/utils/batchProcessor.ts +11 -6
  43. package/src/utils/serialize.ts +24 -7
  44. package/src/serialization/index.ts +0 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gt-sanity",
3
- "version": "2.0.21",
3
+ "version": "2.1.1",
4
4
  "description": "General Translation integration with Sanity",
5
5
  "keywords": [
6
6
  "sanity",
@@ -54,7 +54,7 @@
54
54
  "jsonpath-plus": "^10.3.0",
55
55
  "jsonpointer": "^5.0.1",
56
56
  "lodash.merge": "^4.6.2",
57
- "generaltranslation": "8.2.19"
57
+ "generaltranslation": "9.0.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@portabletext/types": "^2.0.15",
@@ -6,6 +6,7 @@ import type {
6
6
  DedupeFields,
7
7
  IgnoreFields,
8
8
  SkipFields,
9
+ FieldLevelTranslationMode,
9
10
  } from './types';
10
11
  import { SECRETS_NAMESPACE } from '../utils/shared';
11
12
  import type { PortableTextHtmlComponents } from '@portabletext/to-html';
@@ -34,6 +35,9 @@ export class GTConfig {
34
35
  additionalSerializers: Partial<PortableTextHtmlComponents>;
35
36
  additionalDeserializers: CustomDeserializers;
36
37
  additionalBlockDeserializers: unknown[];
38
+ translationLevel: FieldLevelTranslationMode;
39
+ fieldLevelDocuments: TranslateDocumentFilter[];
40
+ fieldLevelTypePrefix: string;
37
41
 
38
42
  private static instance: GTConfig;
39
43
  constructor(
@@ -50,7 +54,10 @@ export class GTConfig {
50
54
  additionalStopTypes: string[] = [],
51
55
  additionalSerializers: Partial<PortableTextHtmlComponents> = {},
52
56
  additionalDeserializers: CustomDeserializers = { types: {} },
53
- additionalBlockDeserializers: unknown[] = []
57
+ additionalBlockDeserializers: unknown[] = [],
58
+ translationLevel: FieldLevelTranslationMode = 'document',
59
+ fieldLevelDocuments: TranslateDocumentFilter[] = [],
60
+ fieldLevelTypePrefix: string = 'internationalizedArray'
54
61
  ) {
55
62
  this.secretsNamespace = secretsNamespace;
56
63
  this.languageField = languageField;
@@ -66,6 +73,9 @@ export class GTConfig {
66
73
  this.additionalSerializers = additionalSerializers;
67
74
  this.additionalDeserializers = additionalDeserializers;
68
75
  this.additionalBlockDeserializers = additionalBlockDeserializers;
76
+ this.translationLevel = translationLevel;
77
+ this.fieldLevelDocuments = fieldLevelDocuments;
78
+ this.fieldLevelTypePrefix = fieldLevelTypePrefix;
69
79
  }
70
80
 
71
81
  static getInstance() {
@@ -104,7 +114,10 @@ export class GTConfig {
104
114
  additionalStopTypes: string[] = [],
105
115
  additionalSerializers: Partial<PortableTextHtmlComponents> = {},
106
116
  additionalDeserializers: CustomDeserializers = { types: {} },
107
- additionalBlockDeserializers: unknown[] = []
117
+ additionalBlockDeserializers: unknown[] = [],
118
+ translationLevel: FieldLevelTranslationMode = 'document',
119
+ fieldLevelDocuments: TranslateDocumentFilter[] = [],
120
+ fieldLevelTypePrefix: string = 'internationalizedArray'
108
121
  ) {
109
122
  this.secretsNamespace = secretsNamespace;
110
123
  this.languageField = languageField;
@@ -120,6 +133,9 @@ export class GTConfig {
120
133
  this.additionalSerializers = additionalSerializers;
121
134
  this.additionalDeserializers = additionalDeserializers;
122
135
  this.additionalBlockDeserializers = additionalBlockDeserializers;
136
+ this.translationLevel = translationLevel;
137
+ this.fieldLevelDocuments = fieldLevelDocuments;
138
+ this.fieldLevelTypePrefix = fieldLevelTypePrefix;
123
139
  }
124
140
 
125
141
  getSecretsNamespace() {
@@ -166,5 +182,14 @@ export class GTConfig {
166
182
  getAdditionalBlockDeserializers() {
167
183
  return this.additionalBlockDeserializers;
168
184
  }
185
+ getTranslationLevel() {
186
+ return this.translationLevel;
187
+ }
188
+ getFieldLevelDocuments() {
189
+ return this.fieldLevelDocuments;
190
+ }
191
+ getFieldLevelTypePrefix() {
192
+ return this.fieldLevelTypePrefix;
193
+ }
169
194
  }
170
195
  export const pluginConfig = GTConfig.getInstance();
@@ -14,6 +14,15 @@ export type TranslateDocumentFilter = {
14
14
  type?: string;
15
15
  };
16
16
 
17
+ // How matched documents are translated:
18
+ // - 'document' translate whole documents (per-locale documents).
19
+ // - 'internationalizedArray' localize fields in place (array shape).
20
+ // - 'mixed' array strategy for `fieldLevelDocuments`, else doc.
21
+ export type FieldLevelTranslationMode =
22
+ | 'document'
23
+ | 'internationalizedArray'
24
+ | 'mixed';
25
+
17
26
  export type FileProperties = {
18
27
  versionId: string;
19
28
  fileId: string;
@@ -18,7 +18,7 @@ import {
18
18
  TranslationFunctionContext,
19
19
  } from '../types';
20
20
  import { gt, overrideConfig, pluginConfig } from '../adapter/core';
21
- import { serializeDocument } from '../utils/serialize';
21
+ import { getTranslationStrategy } from '../translation/strategy';
22
22
  import { uploadFiles } from '../translation/uploadFiles';
23
23
  import { initProject } from '../translation/initProject';
24
24
  import { createJobs } from '../translation/createJobs';
@@ -84,6 +84,11 @@ interface TranslationsContextType {
84
84
  loadingSecrets: boolean;
85
85
  secrets: Secrets | null;
86
86
  branchId: string | undefined;
87
+ // Version to use for translation status keys and GT file queries. Prefers
88
+ // the _rev pinned at upload time over the live _rev, so in-place imports
89
+ // (internationalized arrays) bumping the document _rev don't orphan the
90
+ // statuses of the version actually uploaded to GT.
91
+ getVersionId: (document: SanityDocument) => string;
87
92
 
88
93
  // Actions
89
94
  setLocales: (locales: TranslationLocale[]) => void;
@@ -106,6 +111,51 @@ interface TranslationsContextType {
106
111
 
107
112
  const TranslationsContext = createContext<TranslationsContextType | null>(null);
108
113
 
114
+ // Pinned upload versions are mirrored to sessionStorage so they survive a
115
+ // Studio page refresh: falling back to the live document._rev after a refresh
116
+ // would query GT with a version that was never uploaded (in-place imports bump
117
+ // the _rev), making completed translations show as "not started". Keyed by
118
+ // project + dataset so Studios sharing an origin don't collide.
119
+ const getUploadedVersionsStorageKey = (
120
+ projectId: string | undefined,
121
+ dataset: string | undefined
122
+ ) => `gt-sanity:uploadedVersions:${projectId ?? ''}:${dataset ?? ''}`;
123
+
124
+ function readUploadedVersions(storageKey: string): Map<string, string> {
125
+ try {
126
+ const raw = window.sessionStorage.getItem(storageKey);
127
+ if (!raw) return new Map();
128
+ const parsed: unknown = JSON.parse(raw);
129
+ if (!isPlainRecord(parsed)) return new Map();
130
+ return new Map(
131
+ Object.entries(parsed).filter(
132
+ (entry): entry is [string, string] => typeof entry[1] === 'string'
133
+ )
134
+ );
135
+ } catch {
136
+ return new Map();
137
+ }
138
+ }
139
+
140
+ function writeUploadedVersions(
141
+ storageKey: string,
142
+ versions: Map<string, string>
143
+ ): void {
144
+ try {
145
+ window.sessionStorage.setItem(
146
+ storageKey,
147
+ JSON.stringify(Object.fromEntries(versions))
148
+ );
149
+ } catch {
150
+ // sessionStorage unavailable (SSR, private mode, quota) — pinned versions
151
+ // fall back to in-memory only.
152
+ }
153
+ }
154
+
155
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
156
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
157
+ }
158
+
109
159
  export const useTranslations = () => {
110
160
  const context = useContext(TranslationsContext);
111
161
  if (!context) {
@@ -154,6 +204,14 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
154
204
  const [isRefreshing, setIsRefreshing] = useState(false);
155
205
 
156
206
  const client = useClient();
207
+ const { projectId, dataset } = client.config();
208
+ const uploadedVersionsStorageKey = getUploadedVersionsStorageKey(
209
+ projectId,
210
+ dataset
211
+ );
212
+ const [uploadedVersions, setUploadedVersions] = useState<Map<string, string>>(
213
+ () => readUploadedVersions(uploadedVersionsStorageKey)
214
+ );
157
215
  const schema = useSchema();
158
216
  const translationContext: TranslationFunctionContext = { client, schema };
159
217
  const toast = useToast();
@@ -166,6 +224,12 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
166
224
  downloadStatusRef.current = downloadStatus;
167
225
  }, [downloadStatus]);
168
226
 
227
+ const getVersionId = useCallback(
228
+ (document: SanityDocument) =>
229
+ uploadedVersions.get(getDocumentPublishedId(document)) ?? document._rev,
230
+ [uploadedVersions]
231
+ );
232
+
169
233
  const fetchDocuments = useCallback(async () => {
170
234
  setLoadingDocuments(true);
171
235
  try {
@@ -299,7 +363,8 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
299
363
  const { [pluginConfig.getLanguageField()]: _, ...cleanDoc } = doc;
300
364
  const baseLanguage = pluginConfig.getSourceLocale();
301
365
  try {
302
- const serialized = serializeDocument(
366
+ const strategy = getTranslationStrategy(doc);
367
+ const serialized = strategy.serialize(
303
368
  cleanDoc as typeof doc,
304
369
  schema,
305
370
  baseLanguage
@@ -322,6 +387,19 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
322
387
  await initProject(uploadResult, { timeout: 600 }, secrets);
323
388
  await createJobs(uploadResult, availableLocaleIds, secrets);
324
389
 
390
+ // Pin the _rev each file was uploaded under. GT status queries need the
391
+ // exact uploaded versionId, and the live _rev moves on (in-place array
392
+ // imports bump it), so status lookups go through getVersionId instead.
393
+ // Persisted to sessionStorage so the pins survive a page refresh.
394
+ const nextUploadedVersions = new Map(uploadedVersions);
395
+ for (const { info } of transformedDocuments) {
396
+ if (info.versionId) {
397
+ nextUploadedVersions.set(info.documentId, info.versionId);
398
+ }
399
+ }
400
+ writeUploadedVersions(uploadedVersionsStorageKey, nextUploadedVersions);
401
+ setUploadedVersions(nextUploadedVersions);
402
+
325
403
  toast.push({
326
404
  title: `Translation tasks created for ${documents.length} documents`,
327
405
  status: 'success',
@@ -337,7 +415,14 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
337
415
  } finally {
338
416
  setIsBusy(false);
339
417
  }
340
- }, [secrets, documents, locales, schema]);
418
+ }, [
419
+ secrets,
420
+ documents,
421
+ locales,
422
+ schema,
423
+ uploadedVersions,
424
+ uploadedVersionsStorageKey,
425
+ ]);
341
426
 
342
427
  const handleImportAll = useCallback(async () => {
343
428
  if (!secrets || documents.length === 0 || !branchId) return;
@@ -592,7 +677,7 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
592
677
  for (const localeId of availableLocaleIds) {
593
678
  const documentId = getDocumentPublishedId(doc);
594
679
  fileQueryData.push({
595
- versionId: doc._rev,
680
+ versionId: getVersionId(doc),
596
681
  fileId: documentId,
597
682
  branchId: branchId,
598
683
  locale: localeId,
@@ -612,7 +697,7 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
612
697
  for (const doc of documents) {
613
698
  for (const localeId of availableLocaleIds) {
614
699
  const documentId = getDocumentPublishedId(doc);
615
- const versionId = doc._rev;
700
+ const versionId = getVersionId(doc);
616
701
  const key = createTranslationStatusKey(
617
702
  branchId,
618
703
  documentId,
@@ -663,7 +748,7 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
663
748
  } finally {
664
749
  setIsRefreshing(false);
665
750
  }
666
- }, [secrets, documents, locales, branchId]);
751
+ }, [secrets, documents, locales, branchId, getVersionId]);
667
752
 
668
753
  const handleImportDocument = useCallback(
669
754
  async (documentId: string, versionId: string, localeId: string) => {
@@ -1038,6 +1123,7 @@ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
1038
1123
  loadingSecrets,
1039
1124
  secrets,
1040
1125
  branchId,
1126
+ getVersionId,
1041
1127
 
1042
1128
  // Actions
1043
1129
  setLocales,
@@ -17,6 +17,7 @@ export const TranslationsTable: React.FC = () => {
17
17
  importedTranslations,
18
18
  handleImportDocument,
19
19
  branchId,
20
+ getVersionId,
20
21
  } = useTranslations();
21
22
 
22
23
  if (loadingDocuments) {
@@ -50,10 +51,11 @@ export const TranslationsTable: React.FC = () => {
50
51
  .filter((locale) => locale.enabled !== false)
51
52
  .map((locale) => {
52
53
  const documentId = getDocumentPublishedId(document);
54
+ const versionId = getVersionId(document);
53
55
  const key = createTranslationStatusKey(
54
56
  branchId,
55
57
  documentId,
56
- document._rev,
58
+ versionId,
57
59
  locale.localeId
58
60
  );
59
61
  const status = translationStatuses.get(key);
@@ -62,14 +64,14 @@ export const TranslationsTable: React.FC = () => {
62
64
 
63
65
  return (
64
66
  <LanguageStatus
65
- key={`${document._id}-${document._rev}-${locale.localeId}`}
67
+ key={`${document._id}-${versionId}-${locale.localeId}`}
66
68
  title={locale.description || locale.localeId}
67
69
  progress={status?.progress || 0}
68
70
  isImported={isImported || isDownloaded}
69
71
  importFile={async () => {
70
72
  await handleImportDocument(
71
73
  documentId,
72
- document._rev,
74
+ versionId,
73
75
  locale.localeId
74
76
  );
75
77
  }}
@@ -18,6 +18,7 @@ export const SingleDocumentView: React.FC = () => {
18
18
  importedTranslations,
19
19
  handleImportDocument,
20
20
  branchId,
21
+ getVersionId,
21
22
  } = useTranslations();
22
23
 
23
24
  // Get the first (and only) document in single document mode
@@ -78,10 +79,11 @@ export const SingleDocumentView: React.FC = () => {
78
79
  .filter((locale) => locale.enabled !== false)
79
80
  .map((locale) => {
80
81
  const documentId = getDocumentPublishedId(document);
82
+ const versionId = getVersionId(document);
81
83
  const key = createTranslationStatusKey(
82
84
  branchId,
83
85
  documentId,
84
- document._rev,
86
+ versionId,
85
87
  locale.localeId
86
88
  );
87
89
  const status = translationStatuses.get(key);
@@ -90,14 +92,14 @@ export const SingleDocumentView: React.FC = () => {
90
92
 
91
93
  return (
92
94
  <LanguageStatus
93
- key={`${document._id}-${document._rev}-${locale.localeId}`}
95
+ key={`${document._id}-${versionId}-${locale.localeId}`}
94
96
  title={locale.description || locale.localeId}
95
97
  progress={status?.progress || 0}
96
98
  isImported={isImported || isDownloaded}
97
99
  importFile={async () => {
98
100
  await handleImportDocument(
99
101
  documentId,
100
- document._rev,
102
+ versionId,
101
103
  locale.localeId
102
104
  );
103
105
  }}
@@ -50,6 +50,7 @@ export const TranslationView = () => {
50
50
  setAutoPatchReferences,
51
51
  autoPublish,
52
52
  setAutoPublish,
53
+ getVersionId,
53
54
  } = useTranslations();
54
55
 
55
56
  const [isImporting, setIsImporting] = useState(false);
@@ -92,13 +93,21 @@ export const TranslationView = () => {
92
93
  return getDocumentPublishedId(document);
93
94
  }, [document]);
94
95
 
96
+ // Version the status entries are keyed under: the _rev pinned at upload
97
+ // time when available, so importing in place (which bumps the live _rev)
98
+ // doesn't detach the UI from the statuses of the uploaded version.
99
+ const versionId = useMemo(() => {
100
+ if (!document) return null;
101
+ return getVersionId(document);
102
+ }, [document, getVersionId]);
103
+
95
104
  // Unified import functionality
96
105
  const handleImportTranslations = useCallback(
97
106
  async (options: { autoOnly?: boolean } = {}) => {
98
107
  const { autoOnly = false } = options;
99
108
 
100
109
  // Check preconditions
101
- if (isImporting || !documentId) return;
110
+ if (isImporting || !documentId || !versionId) return;
102
111
  if (autoOnly && !autoImport) return;
103
112
 
104
113
  // Find translations ready to import
@@ -106,7 +115,7 @@ export const TranslationView = () => {
106
115
  const key = createTranslationStatusKey(
107
116
  branchId,
108
117
  documentId,
109
- document._rev,
118
+ versionId,
110
119
  locale.localeId
111
120
  );
112
121
  const status = translationStatuses.get(key);
@@ -117,12 +126,12 @@ export const TranslationView = () => {
117
126
 
118
127
  setIsImporting(true);
119
128
  try {
120
- // Import all ready translations
121
- await Promise.all(
122
- readyTranslations.map((locale) =>
123
- handleImportDocument(documentId, document._rev, locale.localeId)
124
- )
125
- );
129
+ // Import sequentially: internationalized-array imports patch this same
130
+ // document in place, so concurrent locale imports would overwrite each
131
+ // other and only the last locale would survive.
132
+ for (const locale of readyTranslations) {
133
+ await handleImportDocument(documentId, versionId, locale.localeId);
134
+ }
126
135
 
127
136
  // Auto patch document references if enabled
128
137
  if (autoPatchReferences) {
@@ -141,6 +150,7 @@ export const TranslationView = () => {
141
150
  autoImport,
142
151
  isImporting,
143
152
  documentId,
153
+ versionId,
144
154
  availableLocales,
145
155
  translationStatuses,
146
156
  importedTranslations,
@@ -261,7 +271,7 @@ export const TranslationView = () => {
261
271
  </Stack>
262
272
 
263
273
  {/* Translation Status Section */}
264
- {documentId && availableLocales.length > 0 && (
274
+ {documentId && versionId && availableLocales.length > 0 && (
265
275
  <Stack space={4}>
266
276
  <Flex align='center' justify='space-between'>
267
277
  <Text as='h2' weight='semibold' size={2}>
@@ -290,7 +300,7 @@ export const TranslationView = () => {
290
300
  const key = createTranslationStatusKey(
291
301
  branchId,
292
302
  documentId,
293
- document._rev,
303
+ versionId,
294
304
  locale.localeId
295
305
  );
296
306
  const status = translationStatuses.get(key);
@@ -307,7 +317,7 @@ export const TranslationView = () => {
307
317
  if (!isImported && status?.isReady) {
308
318
  await handleImportDocument(
309
319
  documentId,
310
- document._rev,
320
+ versionId,
311
321
  locale.localeId
312
322
  );
313
323
  }
@@ -333,7 +343,7 @@ export const TranslationView = () => {
333
343
  const key = createTranslationStatusKey(
334
344
  branchId,
335
345
  documentId,
336
- document._rev,
346
+ versionId,
337
347
  locale.localeId
338
348
  );
339
349
  const status = translationStatuses.get(key);
@@ -358,7 +368,7 @@ export const TranslationView = () => {
358
368
  const key = createTranslationStatusKey(
359
369
  branchId,
360
370
  documentId,
361
- document._rev,
371
+ versionId,
362
372
  locale.localeId
363
373
  );
364
374
  return importedTranslations.has(key);
@@ -370,7 +380,7 @@ export const TranslationView = () => {
370
380
  const key = createTranslationStatusKey(
371
381
  branchId,
372
382
  documentId,
373
- document._rev,
383
+ versionId,
374
384
  locale.localeId
375
385
  );
376
386
  const status = translationStatuses.get(key);
@@ -1,7 +1,7 @@
1
1
  // adapted from https://github.com/sanity-io/sanity-translations-tab. See LICENSE.md for more details.
2
2
 
3
3
  import { SanityClient, SanityDocument, SanityDocumentLike } from 'sanity';
4
- import { BaseDocumentMerger } from '../../serialization';
4
+ import { BaseDocumentMerger } from '../../serialization/BaseDocumentMerger';
5
5
 
6
6
  import { findLatestDraft } from '../utils/findLatestDraft';
7
7
  import { findDocumentAtRevision } from '../utils/findDocumentAtRevision';
@@ -1,7 +1,7 @@
1
1
  // adapted from https://github.com/sanity-io/sanity-translations-tab. See LICENSE.md for more details.
2
2
 
3
3
  import { SanityClient, SanityDocument } from 'sanity';
4
- import { BaseDocumentMerger } from '../serialization/';
4
+ import { BaseDocumentMerger } from '../serialization/BaseDocumentMerger';
5
5
 
6
6
  import type {
7
7
  ExportForTranslation,
@@ -0,0 +1,59 @@
1
+ import { SanityClient, SanityDocument } from 'sanity';
2
+ import { pluginConfig } from '../../adapter/core';
3
+ import { mergeInternationalizedArrays } from '../../serialization/internationalizedArray/merge';
4
+ import type { GTFile } from '../../types';
5
+ import { getPublishedId } from '../../utils/documentIds';
6
+ import { findLatestDraft } from '../utils/findLatestDraft';
7
+
8
+ /**
9
+ * Import a translated internationalized-array document in place.
10
+ *
11
+ * Unlike document-level import (which creates per-locale documents and tracks
12
+ * translation metadata), array localization upserts the target-locale `value`
13
+ * into the same source document. We read the latest draft so concurrent edits
14
+ * to the source are not clobbered, merge only the changed top-level fields, and
15
+ * commit a single patch.
16
+ */
17
+ export const internationalizedArrayPatch = async (
18
+ docInfo: GTFile,
19
+ translatedFields: SanityDocument,
20
+ localeId: string,
21
+ client: SanityClient
22
+ ): Promise<void> => {
23
+ const sourceLocale = pluginConfig.getSourceLocale();
24
+
25
+ const baseDoc = await findLatestDraft(docInfo.documentId, client);
26
+ if (!baseDoc) {
27
+ return;
28
+ }
29
+
30
+ const changes = mergeInternationalizedArrays(
31
+ baseDoc as Record<string, unknown>,
32
+ translatedFields as Record<string, unknown>,
33
+ localeId,
34
+ sourceLocale
35
+ );
36
+
37
+ if (Object.keys(changes).length === 0) {
38
+ return;
39
+ }
40
+
41
+ // Every key in `changes` already exists on the base document (merge only
42
+ // returns existing fields), so a full `set` of the merged field is safe and
43
+ // preserves all other locale items and their random `_key`s.
44
+ if (baseDoc._id.startsWith('drafts.')) {
45
+ await client.patch(baseDoc._id).set(changes).commit();
46
+ return;
47
+ }
48
+
49
+ // Only the published document exists. Mutations via the client write to the
50
+ // exact id given (drafts are a Studio convention, not created server-side),
51
+ // so patch a draft seeded from the published state instead of publishing
52
+ // translated content directly.
53
+ const draftId = `drafts.${getPublishedId(baseDoc._id)}`;
54
+ await client
55
+ .transaction()
56
+ .createIfNotExists({ ...baseDoc, _id: draftId })
57
+ .patch(draftId, (patch) => patch.set(changes))
58
+ .commit();
59
+ };