gt-sanity 0.0.6 → 1.0.0

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 (88) hide show
  1. package/LICENSE.md +1 -1
  2. package/dist/index.d.mts +95 -73
  3. package/dist/index.d.ts +95 -73
  4. package/dist/index.js +9066 -1207
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +9083 -1197
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +8 -3
  9. package/src/adapter/core.ts +41 -4
  10. package/src/adapter/getLocales.ts +2 -2
  11. package/src/components/TranslationsProvider.tsx +942 -0
  12. package/src/components/page/BatchProgress.tsx +27 -0
  13. package/src/components/page/ImportAllDialog.tsx +51 -0
  14. package/src/components/page/ImportMissingDialog.tsx +55 -0
  15. package/src/components/page/TranslateAllDialog.tsx +55 -0
  16. package/src/components/page/TranslationsTable.tsx +81 -0
  17. package/src/components/page/TranslationsTool.tsx +299 -837
  18. package/src/components/shared/BaseTranslationWrapper.tsx +82 -0
  19. package/src/components/shared/LocaleCheckbox.tsx +47 -0
  20. package/src/components/shared/SingleDocumentView.tsx +108 -0
  21. package/src/components/tab/TranslationView.tsx +379 -0
  22. package/src/components/tab/TranslationsTab.tsx +25 -0
  23. package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +6 -9
  24. package/src/configuration/baseDocumentLevelConfig/helpers/createI18nDocAndPatchMetadata.ts +5 -24
  25. package/src/configuration/baseDocumentLevelConfig/helpers/patchI18nDoc.ts +3 -23
  26. package/src/configuration/baseDocumentLevelConfig/index.ts +16 -68
  27. package/src/configuration/baseFieldLevelConfig.ts +15 -50
  28. package/src/index.ts +29 -43
  29. package/src/sanity-api/findDocuments.ts +44 -0
  30. package/src/sanity-api/publishDocuments.ts +49 -0
  31. package/src/sanity-api/resolveRefs.ts +146 -0
  32. package/src/serialization/BaseDocumentMerger.ts +138 -0
  33. package/src/serialization/BaseSerializationConfig.ts +220 -0
  34. package/src/serialization/__tests__/BaseDocumentDeserializer/__snapshots__/documentLevelDeserialization.test.ts.snap +189 -0
  35. package/src/serialization/__tests__/BaseDocumentDeserializer/__snapshots__/fieldLevelDeserialization.test.ts.snap +107 -0
  36. package/src/serialization/__tests__/BaseDocumentDeserializer/baseDeserialization.test.ts +397 -0
  37. package/src/serialization/__tests__/BaseDocumentDeserializer/documentLevelDeserialization.test.ts +107 -0
  38. package/src/serialization/__tests__/BaseDocumentDeserializer/fieldLevelDeserialization.test.ts +107 -0
  39. package/src/serialization/__tests__/BaseDocumentMerger/__snapshots__/documentLevelMerge.test.ts.snap +193 -0
  40. package/src/serialization/__tests__/BaseDocumentMerger/__snapshots__/fieldLevelMerge.test.ts.snap +97 -0
  41. package/src/serialization/__tests__/BaseDocumentMerger/baseMerge.test.ts +36 -0
  42. package/src/serialization/__tests__/BaseDocumentMerger/documentLevelMerge.test.ts +96 -0
  43. package/src/serialization/__tests__/BaseDocumentMerger/fieldLevelMerge.test.ts +142 -0
  44. package/src/serialization/__tests__/BaseDocumentMerger/utils.ts +52 -0
  45. package/src/serialization/__tests__/BaseDocumentSerializer/__snapshots__/documentInlineMarks.test.ts.snap +39 -0
  46. package/src/serialization/__tests__/BaseDocumentSerializer/__snapshots__/documentLevelSerialization.test.ts.snap +8 -0
  47. package/src/serialization/__tests__/BaseDocumentSerializer/__snapshots__/fieldLevelSerialization.test.ts.snap +8 -0
  48. package/src/serialization/__tests__/BaseDocumentSerializer/baseSerialization.test.ts +345 -0
  49. package/src/serialization/__tests__/BaseDocumentSerializer/documentInlineMarks.test.ts +53 -0
  50. package/src/serialization/__tests__/BaseDocumentSerializer/documentLevelSerialization.test.ts +120 -0
  51. package/src/serialization/__tests__/BaseDocumentSerializer/fieldLevelSerialization.test.ts +153 -0
  52. package/src/serialization/__tests__/BaseDocumentSerializer/utils.ts +27 -0
  53. package/src/serialization/__tests__/README +2 -0
  54. package/src/serialization/__tests__/__fixtures__/annotationAndInlineBlocks.json +140 -0
  55. package/src/serialization/__tests__/__fixtures__/customStyles.json +62 -0
  56. package/src/serialization/__tests__/__fixtures__/documentInlineMarks.json +70 -0
  57. package/src/serialization/__tests__/__fixtures__/documentLevelArticle.json +185 -0
  58. package/src/serialization/__tests__/__fixtures__/fieldLevelArticle.json +107 -0
  59. package/src/serialization/__tests__/__fixtures__/inlineDocumentLevelArticle.json +134 -0
  60. package/src/serialization/__tests__/__fixtures__/inlineSchema.ts +270 -0
  61. package/src/serialization/__tests__/__fixtures__/messy-html.html +26 -0
  62. package/src/serialization/__tests__/__fixtures__/nestedLanguageFields.json +54 -0
  63. package/src/serialization/__tests__/__fixtures__/schema.ts +310 -0
  64. package/src/serialization/__tests__/global.setup.ts +40 -0
  65. package/src/serialization/__tests__/helpers.ts +132 -0
  66. package/src/serialization/data.ts +82 -0
  67. package/src/serialization/deserialize/BaseDocumentDeserializer.ts +171 -0
  68. package/src/serialization/deserialize/helpers.ts +42 -0
  69. package/src/serialization/helpers.ts +18 -0
  70. package/src/serialization/index.ts +11 -0
  71. package/src/serialization/serialize/fieldFilters.ts +124 -0
  72. package/src/serialization/serialize/index.ts +284 -0
  73. package/src/serialization/types.ts +41 -0
  74. package/src/translation/importDocument.ts +4 -5
  75. package/src/translation/uploadFiles.ts +1 -1
  76. package/src/types.ts +3 -19
  77. package/src/utils/batchProcessor.ts +111 -0
  78. package/src/utils/importUtils.ts +95 -0
  79. package/src/utils/serialize.ts +25 -5
  80. package/src/utils/shared.ts +1 -1
  81. package/src/adapter/index.ts +0 -13
  82. package/src/components/NewTask.tsx +0 -251
  83. package/src/components/TaskView.tsx +0 -257
  84. package/src/components/TranslationContext.tsx +0 -24
  85. package/src/components/TranslationView.tsx +0 -114
  86. package/src/components/TranslationsTab.tsx +0 -181
  87. /package/src/components/{LanguageStatus.tsx → shared/LanguageStatus.tsx} +0 -0
  88. /package/src/components/{ProgressBar.tsx → shared/ProgressBar.tsx} +0 -0
@@ -0,0 +1,942 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useEffect,
7
+ ReactNode,
8
+ } from 'react';
9
+ import { SanityDocument, useSchema } from 'sanity';
10
+ import { useToast } from '@sanity/ui';
11
+ import { useClient } from '../hooks/useClient';
12
+ import { useSecrets } from '../hooks/useSecrets';
13
+ import {
14
+ GTFile,
15
+ Secrets,
16
+ TranslationLocale,
17
+ TranslationFunctionContext,
18
+ } from '../types';
19
+ import { pluginConfig } from '../adapter/core';
20
+ import { serializeDocument } from '../utils/serialize';
21
+ import { uploadFiles } from '../translation/uploadFiles';
22
+ import { initProject } from '../translation/initProject';
23
+ import { createJobs } from '../translation/createJobs';
24
+ import { downloadTranslations } from '../translation/downloadTranslations';
25
+ import { checkTranslationStatus } from '../translation/checkTranslationStatus';
26
+ import { importDocument } from '../translation/importDocument';
27
+ import { resolveRefs } from '../sanity-api/resolveRefs';
28
+ import { findTranslatedDocumentForLocale } from '../sanity-api/findDocuments';
29
+ import {
30
+ getReadyFilesForImport,
31
+ importTranslations,
32
+ ImportOptions,
33
+ } from '../utils/importUtils';
34
+ import { processBatch } from '../utils/batchProcessor';
35
+ import { publishTranslations } from '../sanity-api/publishDocuments';
36
+ import { getLocales } from '../adapter/getLocales';
37
+
38
+ interface ImportProgress {
39
+ current: number;
40
+ total: number;
41
+ isImporting: boolean;
42
+ }
43
+
44
+ interface DownloadStatus {
45
+ downloaded: Set<string>;
46
+ failed: Set<string>;
47
+ skipped: Set<string>;
48
+ }
49
+
50
+ interface TranslationStatus {
51
+ progress: number;
52
+ isReady: boolean;
53
+ translationId?: string;
54
+ }
55
+
56
+ interface TranslationsContextType {
57
+ // State
58
+ isBusy: boolean;
59
+ documents: SanityDocument[];
60
+ locales: TranslationLocale[];
61
+ autoRefresh: boolean;
62
+ loadingDocuments: boolean;
63
+ importProgress: ImportProgress;
64
+ importedTranslations: Set<string>;
65
+ existingTranslations: Set<string>;
66
+ downloadStatus: DownloadStatus;
67
+ translationStatuses: Map<string, TranslationStatus>;
68
+ isRefreshing: boolean;
69
+ loadingSecrets: boolean;
70
+ secrets: Secrets | null;
71
+
72
+ // Actions
73
+ setLocales: (locales: TranslationLocale[]) => void;
74
+ setAutoRefresh: (value: boolean) => void;
75
+ handleTranslateAll: () => Promise<void>;
76
+ handleImportAll: () => Promise<void>;
77
+ handleImportMissing: () => Promise<void>;
78
+ handleRefreshAll: () => Promise<void>;
79
+ handleImportDocument: (documentId: string, localeId: string) => Promise<void>;
80
+ handlePatchDocumentReferences: () => Promise<void>;
81
+ handlePublishAllTranslations: () => Promise<void>;
82
+ }
83
+
84
+ const TranslationsContext = createContext<TranslationsContextType | null>(null);
85
+
86
+ export const useTranslations = () => {
87
+ const context = useContext(TranslationsContext);
88
+ if (!context) {
89
+ throw new Error('useTranslations must be used within TranslationsProvider');
90
+ }
91
+ return context;
92
+ };
93
+
94
+ interface TranslationsProviderProps {
95
+ children: ReactNode;
96
+ singleDocument?: SanityDocument | null;
97
+ }
98
+
99
+ export const TranslationsProvider: React.FC<TranslationsProviderProps> = ({
100
+ children,
101
+ singleDocument,
102
+ }) => {
103
+ const [isBusy, setIsBusy] = useState(false);
104
+ const [documents, setDocuments] = useState<SanityDocument[]>([]);
105
+ const [locales, setLocales] = useState<TranslationLocale[]>([]);
106
+ const [autoRefresh, setAutoRefresh] = useState(false);
107
+ const [loadingDocuments, setLoadingDocuments] = useState(false);
108
+ const [importProgress, setImportProgress] = useState<ImportProgress>({
109
+ current: 0,
110
+ total: 0,
111
+ isImporting: false,
112
+ });
113
+ const [importedTranslations, setImportedTranslations] = useState<Set<string>>(
114
+ new Set()
115
+ );
116
+ const [existingTranslations, setExistingTranslations] = useState<Set<string>>(
117
+ new Set()
118
+ );
119
+ const [downloadStatus, setDownloadStatus] = useState<DownloadStatus>({
120
+ downloaded: new Set<string>(),
121
+ failed: new Set<string>(),
122
+ skipped: new Set<string>(),
123
+ });
124
+ const [translationStatuses, setTranslationStatuses] = useState<
125
+ Map<string, TranslationStatus>
126
+ >(new Map());
127
+ const [isRefreshing, setIsRefreshing] = useState(false);
128
+
129
+ const client = useClient();
130
+ const schema = useSchema();
131
+ const translationContext: TranslationFunctionContext = { client, schema };
132
+ const toast = useToast();
133
+ const { loading: loadingSecrets, secrets } = useSecrets<Secrets>(
134
+ pluginConfig.getSecretsNamespace()
135
+ );
136
+
137
+ const fetchDocuments = useCallback(async () => {
138
+ setLoadingDocuments(true);
139
+ try {
140
+ if (singleDocument) {
141
+ setDocuments([singleDocument]);
142
+ return;
143
+ }
144
+
145
+ const translateDocuments = pluginConfig.getTranslateDocuments();
146
+
147
+ const filterConditions = translateDocuments
148
+ .map((filter) => {
149
+ if (filter.type && filter.documentId) {
150
+ return `(_type == "${filter.type}" && _id == "${filter.documentId}")`;
151
+ } else if (filter.type) {
152
+ return `_type == "${filter.type}"`;
153
+ } else if (filter.documentId) {
154
+ return `_id == "${filter.documentId}"`;
155
+ }
156
+ return null;
157
+ })
158
+ .filter(Boolean);
159
+
160
+ const languageField = pluginConfig.getLanguageField();
161
+ const sourceLocale = pluginConfig.getSourceLocale();
162
+ const languageFilter = `(!defined(${languageField}) || ${languageField} == "${sourceLocale}")`;
163
+
164
+ let query;
165
+ if (filterConditions.length === 0) {
166
+ query = `*[!(_type in ["system.group"]) && !(_id in path("_.**")) && ${languageFilter}]`;
167
+ } else {
168
+ const filterQuery = filterConditions.join(' || ');
169
+ query = `*[!(_type in ["system.group"]) && !(_id in path("_.**")) && (${filterQuery}) && ${languageFilter}]`;
170
+ }
171
+
172
+ const docs = await client.fetch(query);
173
+ setDocuments(docs);
174
+ } catch {
175
+ toast.push({
176
+ title: 'Error fetching documents',
177
+ status: 'error',
178
+ closable: true,
179
+ });
180
+ } finally {
181
+ setLoadingDocuments(false);
182
+ }
183
+ }, [client, singleDocument]);
184
+
185
+ const fetchLocales = useCallback(async () => {
186
+ if (!secrets) return;
187
+ try {
188
+ const availableLocales = await getLocales(secrets);
189
+ setLocales(availableLocales);
190
+ } catch {
191
+ toast.push({
192
+ title: 'Error fetching locales',
193
+ status: 'error',
194
+ closable: true,
195
+ });
196
+ }
197
+ }, [secrets]);
198
+
199
+ const fetchExistingTranslations = useCallback(async () => {
200
+ if (!documents.length || !locales.length) return;
201
+
202
+ try {
203
+ const sourceLocale = pluginConfig.getSourceLocale();
204
+ const availableLocaleIds = locales
205
+ .filter((locale) => locale.enabled !== false)
206
+ .map((locale) => locale.localeId);
207
+
208
+ const documentIds = documents.map(
209
+ (doc) => doc._id?.replace('drafts.', '') || doc._id
210
+ );
211
+
212
+ const query = `*[
213
+ _type == 'translation.metadata' &&
214
+ translations[_key == $sourceLocale][0].value._ref in $documentIds
215
+ ] {
216
+ 'sourceDocId': translations[_key == $sourceLocale][0].value._ref,
217
+ 'existingTranslations': translations[_key in $localeIds]._key
218
+ }`;
219
+
220
+ const existingMetadata = await client.fetch(query, {
221
+ sourceLocale,
222
+ documentIds,
223
+ localeIds: availableLocaleIds,
224
+ });
225
+
226
+ const existing = new Set<string>();
227
+ existingMetadata.forEach((metadata: any) => {
228
+ metadata.existingTranslations?.forEach((localeId: string) => {
229
+ if (localeId !== sourceLocale) {
230
+ existing.add(`${metadata.sourceDocId}:${localeId}`);
231
+ }
232
+ });
233
+ });
234
+
235
+ setExistingTranslations(existing);
236
+ } catch (error) {
237
+ console.error('Error fetching existing translations:', error);
238
+ toast.push({
239
+ title: 'Error fetching existing translations',
240
+ status: 'error',
241
+ closable: true,
242
+ });
243
+ }
244
+ }, [documents, locales, client]);
245
+
246
+ const handleTranslateAll = useCallback(async () => {
247
+ if (!secrets || documents.length === 0) return;
248
+
249
+ setIsBusy(true);
250
+
251
+ try {
252
+ const availableLocaleIds = locales
253
+ .filter((locale) => locale.enabled !== false)
254
+ .map((locale) => locale.localeId);
255
+
256
+ const transformedDocuments = documents
257
+ .map((doc) => {
258
+ delete doc[pluginConfig.getLanguageField()];
259
+ const baseLanguage = pluginConfig.getSourceLocale();
260
+ try {
261
+ const serialized = serializeDocument(doc, schema, baseLanguage);
262
+ return {
263
+ info: {
264
+ documentId: doc._id?.replace('drafts.', '') || doc._id,
265
+ versionId: doc._rev,
266
+ },
267
+ serializedDocument: serialized,
268
+ };
269
+ } catch (error) {
270
+ console.error('Error transforming document', doc._id, error);
271
+ }
272
+ return null;
273
+ })
274
+ .filter((doc) => doc !== null);
275
+
276
+ const uploadResult = await uploadFiles(transformedDocuments, secrets);
277
+ await initProject(uploadResult, { timeout: 600 }, secrets);
278
+ await createJobs(uploadResult, availableLocaleIds, secrets);
279
+
280
+ toast.push({
281
+ title: `Translation tasks created for ${documents.length} documents`,
282
+ status: 'success',
283
+ closable: true,
284
+ });
285
+ } catch {
286
+ toast.push({
287
+ title: 'Error creating translation tasks',
288
+ status: 'error',
289
+ closable: true,
290
+ });
291
+ } finally {
292
+ setIsBusy(false);
293
+ }
294
+ }, [secrets, documents, locales, schema]);
295
+
296
+ const handleImportAll = useCallback(async () => {
297
+ if (!secrets || documents.length === 0) return;
298
+
299
+ setIsBusy(true);
300
+
301
+ try {
302
+ const readyFiles = await getReadyFilesForImport(
303
+ documents,
304
+ translationStatuses
305
+ );
306
+
307
+ if (readyFiles.length === 0) {
308
+ toast.push({
309
+ title: 'No ready translations to import',
310
+ status: 'warning',
311
+ closable: true,
312
+ });
313
+ return;
314
+ }
315
+
316
+ setImportProgress({
317
+ current: 0,
318
+ total: readyFiles.length,
319
+ isImporting: true,
320
+ });
321
+
322
+ const importOptions: ImportOptions = {
323
+ onProgress: (current, total) => {
324
+ setImportProgress({
325
+ current,
326
+ total,
327
+ isImporting: true,
328
+ });
329
+ },
330
+ onImportSuccess: (key) => {
331
+ setImportedTranslations((prev) => new Set([...prev, key]));
332
+ },
333
+ };
334
+
335
+ const result = await importTranslations(
336
+ readyFiles,
337
+ secrets,
338
+ translationContext,
339
+ importOptions
340
+ );
341
+
342
+ if (result.successfulImports.length > 0) {
343
+ setDownloadStatus((prev) => ({
344
+ ...prev,
345
+ downloaded: new Set([
346
+ ...prev.downloaded,
347
+ ...result.successfulImports,
348
+ ]),
349
+ }));
350
+ }
351
+
352
+ toast.push({
353
+ title: `Imported ${result.successCount} translations${result.failureCount > 0 ? `, ${result.failureCount} failed` : ''}`,
354
+ status: result.successCount > 0 ? 'success' : 'error',
355
+ closable: true,
356
+ });
357
+ } catch (error) {
358
+ console.error('Error importing translations:', error);
359
+ toast.push({
360
+ title: 'Error importing translations',
361
+ status: 'error',
362
+ closable: true,
363
+ });
364
+ } finally {
365
+ setIsBusy(false);
366
+ setImportProgress({ current: 0, total: 0, isImporting: false });
367
+ }
368
+ }, [
369
+ secrets,
370
+ documents,
371
+ translationStatuses,
372
+ downloadStatus,
373
+ translationContext,
374
+ ]);
375
+
376
+ const getMissingTranslations = useCallback(
377
+ async (
378
+ documentIds: string[],
379
+ localeIds: string[]
380
+ ): Promise<Set<string>> => {
381
+ const sourceLocale = pluginConfig.getSourceLocale();
382
+
383
+ const query = `*[
384
+ _type == 'translation.metadata' &&
385
+ translations[_key == $sourceLocale][0].value._ref in $documentIds
386
+ ] {
387
+ 'sourceDocId': translations[_key == $sourceLocale][0].value._ref,
388
+ 'existingTranslations': translations[_key in $localeIds]._key
389
+ }`;
390
+
391
+ const existingMetadata = await client.fetch(query, {
392
+ sourceLocale,
393
+ documentIds,
394
+ localeIds,
395
+ });
396
+
397
+ const existing = new Set<string>();
398
+ existingMetadata.forEach((metadata: any) => {
399
+ metadata.existingTranslations?.forEach((localeId: string) => {
400
+ if (localeId !== sourceLocale) {
401
+ existing.add(`${metadata.sourceDocId}:${localeId}`);
402
+ }
403
+ });
404
+ });
405
+
406
+ const missing = new Set<string>();
407
+ documentIds.forEach((docId) => {
408
+ localeIds.forEach((localeId) => {
409
+ if (localeId !== sourceLocale) {
410
+ const key = `${docId}:${localeId}`;
411
+ if (!existing.has(key)) {
412
+ missing.add(key);
413
+ }
414
+ }
415
+ });
416
+ });
417
+
418
+ return missing;
419
+ },
420
+ [client]
421
+ );
422
+
423
+ const handleImportMissing = useCallback(async () => {
424
+ if (!secrets || documents.length === 0) return;
425
+
426
+ setIsBusy(true);
427
+
428
+ try {
429
+ const availableLocaleIds = locales
430
+ .filter((locale) => locale.enabled !== false)
431
+ .map((locale) => locale.localeId);
432
+
433
+ const documentIds = documents.map(
434
+ (doc) => doc._id?.replace('drafts.', '') || doc._id
435
+ );
436
+
437
+ const missingTranslations = await getMissingTranslations(
438
+ documentIds,
439
+ availableLocaleIds
440
+ );
441
+
442
+ console.log('missingTranslations', missingTranslations);
443
+ const readyFiles = await getReadyFilesForImport(
444
+ documents,
445
+ translationStatuses,
446
+ {
447
+ filterReadyFiles: (key) => missingTranslations.has(key),
448
+ }
449
+ );
450
+
451
+ if (readyFiles.length === 0) {
452
+ toast.push({
453
+ title: 'No missing translations to import',
454
+ status: 'warning',
455
+ closable: true,
456
+ });
457
+ return;
458
+ }
459
+
460
+ setImportProgress({
461
+ current: 0,
462
+ total: readyFiles.length,
463
+ isImporting: true,
464
+ });
465
+
466
+ const importOptions: ImportOptions = {
467
+ onProgress: (current, total) => {
468
+ setImportProgress({
469
+ current,
470
+ total,
471
+ isImporting: true,
472
+ });
473
+ },
474
+ onImportSuccess: (key) => {
475
+ setImportedTranslations((prev) => new Set([...prev, key]));
476
+ setExistingTranslations((prev) => new Set([...prev, key]));
477
+ },
478
+ };
479
+
480
+ const result = await importTranslations(
481
+ readyFiles,
482
+ secrets,
483
+ translationContext,
484
+ importOptions
485
+ );
486
+
487
+ if (result.successfulImports.length > 0) {
488
+ setDownloadStatus((prev) => ({
489
+ ...prev,
490
+ downloaded: new Set([
491
+ ...prev.downloaded,
492
+ ...result.successfulImports,
493
+ ]),
494
+ }));
495
+ }
496
+
497
+ toast.push({
498
+ title: `Imported ${result.successCount} missing translations${result.failureCount > 0 ? `, ${result.failureCount} failed` : ''}`,
499
+ status: result.successCount > 0 ? 'success' : 'error',
500
+ closable: true,
501
+ });
502
+ } catch (error) {
503
+ console.error('Error importing missing translations:', error);
504
+ toast.push({
505
+ title: 'Error importing missing translations',
506
+ status: 'error',
507
+ closable: true,
508
+ });
509
+ } finally {
510
+ setIsBusy(false);
511
+ setImportProgress({ current: 0, total: 0, isImporting: false });
512
+ }
513
+ }, [
514
+ secrets,
515
+ documents,
516
+ locales,
517
+ translationStatuses,
518
+ downloadStatus,
519
+ translationContext,
520
+ getMissingTranslations,
521
+ ]);
522
+
523
+ const handleRefreshAll = useCallback(async () => {
524
+ if (!secrets || documents.length === 0) return;
525
+
526
+ setIsRefreshing(true);
527
+
528
+ try {
529
+ const availableLocaleIds = locales
530
+ .filter((locale) => locale.enabled !== false)
531
+ .map((locale) => locale.localeId);
532
+
533
+ const fileQueryData = [];
534
+ for (const doc of documents) {
535
+ for (const localeId of availableLocaleIds) {
536
+ const documentId = doc._id?.replace('drafts.', '') || doc._id;
537
+ fileQueryData.push({
538
+ versionId: doc._rev,
539
+ fileId: documentId,
540
+ locale: localeId,
541
+ });
542
+ }
543
+ }
544
+
545
+ const readyTranslations = await checkTranslationStatus(
546
+ fileQueryData,
547
+ downloadStatus,
548
+ secrets
549
+ );
550
+
551
+ setTranslationStatuses((prevStatuses) => {
552
+ const newStatuses = new Map();
553
+
554
+ for (const doc of documents) {
555
+ for (const localeId of availableLocaleIds) {
556
+ const documentId = doc._id?.replace('drafts.', '') || doc._id;
557
+ const key = `${documentId}:${localeId}`;
558
+ newStatuses.set(key, { progress: 0, isReady: false });
559
+ }
560
+ }
561
+
562
+ if (Array.isArray(readyTranslations)) {
563
+ for (const translation of readyTranslations) {
564
+ const key = `${translation.fileId}:${translation.locale}`;
565
+ newStatuses.set(key, {
566
+ progress: 100,
567
+ isReady: true,
568
+ translationId: translation.id,
569
+ });
570
+ }
571
+ }
572
+
573
+ return newStatuses;
574
+ });
575
+
576
+ toast.push({
577
+ title: `Refreshed status for ${documents.length} documents`,
578
+ status: 'success',
579
+ closable: true,
580
+ });
581
+ } catch (error) {
582
+ console.error('Error refreshing translation status:', error);
583
+ toast.push({
584
+ title: 'Error refreshing translation status',
585
+ status: 'error',
586
+ closable: true,
587
+ });
588
+ } finally {
589
+ setIsRefreshing(false);
590
+ }
591
+ }, [secrets, documents, locales]);
592
+
593
+ const handleImportDocument = useCallback(
594
+ async (documentId: string, localeId: string) => {
595
+ if (!secrets) return;
596
+
597
+ try {
598
+ const key = `${documentId}:${localeId}`;
599
+ const status = translationStatuses.get(key);
600
+
601
+ if (!status?.isReady || !status.translationId) {
602
+ toast.push({
603
+ title: `Translation not ready for ${documentId} (${localeId})`,
604
+ status: 'warning',
605
+ closable: true,
606
+ });
607
+ return;
608
+ }
609
+
610
+ const document = documents.find(
611
+ (doc) => (doc._id?.replace('drafts.', '') || doc._id) === documentId
612
+ );
613
+
614
+ if (!document) {
615
+ toast.push({
616
+ title: `Document ${documentId} not found`,
617
+ status: 'error',
618
+ closable: true,
619
+ });
620
+ return;
621
+ }
622
+
623
+ const downloadedFiles = await downloadTranslations(
624
+ [
625
+ {
626
+ documentId,
627
+ versionId: document._rev,
628
+ translationId: status.translationId,
629
+ locale: localeId,
630
+ },
631
+ ],
632
+ secrets
633
+ );
634
+
635
+ if (downloadedFiles.length > 0) {
636
+ try {
637
+ const docInfo: GTFile = {
638
+ documentId,
639
+ versionId: document._rev,
640
+ };
641
+
642
+ await importDocument(
643
+ docInfo,
644
+ localeId,
645
+ downloadedFiles[0].data,
646
+ translationContext,
647
+ false
648
+ );
649
+
650
+ setDownloadStatus((prev) => ({
651
+ ...prev,
652
+ downloaded: new Set([...prev.downloaded, key]),
653
+ }));
654
+ setImportedTranslations((prev) => new Set([...prev, key]));
655
+
656
+ toast.push({
657
+ title: `Successfully imported translation for ${documentId} (${localeId})`,
658
+ status: 'success',
659
+ closable: true,
660
+ });
661
+ } catch (importError) {
662
+ console.error('Failed to import translation:', importError);
663
+ toast.push({
664
+ title: `Failed to import translation for ${documentId} (${localeId})`,
665
+ status: 'error',
666
+ closable: true,
667
+ });
668
+ }
669
+ } else {
670
+ toast.push({
671
+ title: `No translation content received for ${documentId}`,
672
+ status: 'warning',
673
+ closable: true,
674
+ });
675
+ }
676
+ } catch (error) {
677
+ console.error('Error importing translation:', error);
678
+ toast.push({
679
+ title: `Error importing translation for ${documentId}`,
680
+ status: 'error',
681
+ closable: true,
682
+ });
683
+ }
684
+ },
685
+ [secrets, documents, translationContext]
686
+ );
687
+
688
+ const handlePatchDocumentReferences = useCallback(async () => {
689
+ if (!secrets || documents.length === 0) return;
690
+
691
+ setIsBusy(true);
692
+
693
+ try {
694
+ const availableLocaleIds = locales
695
+ .filter((locale) => locale.enabled !== false)
696
+ .map((locale) => locale.localeId);
697
+
698
+ const patchTasks: Array<{ doc: SanityDocument; localeId: string }> = [];
699
+ for (const doc of documents) {
700
+ for (const localeId of availableLocaleIds) {
701
+ patchTasks.push({ doc, localeId });
702
+ }
703
+ }
704
+
705
+ setImportProgress({
706
+ current: 0,
707
+ total: patchTasks.length,
708
+ isImporting: true,
709
+ });
710
+
711
+ const result = await processBatch(
712
+ patchTasks,
713
+ async ({ doc, localeId }) => {
714
+ const sourceLocale = pluginConfig.getSourceLocale();
715
+
716
+ // Skip source locale - only process translated documents
717
+ if (localeId === sourceLocale) {
718
+ return { patched: false, doc, localeId, skipped: true };
719
+ }
720
+
721
+ // Find the translated document for this locale
722
+ const translatedDoc = await findTranslatedDocumentForLocale(
723
+ doc._id,
724
+ localeId,
725
+ client
726
+ );
727
+
728
+ if (!translatedDoc) {
729
+ return { patched: false, doc, localeId, noTranslation: true };
730
+ }
731
+
732
+ const resolvedDoc = await resolveRefs(
733
+ translatedDoc,
734
+ localeId,
735
+ client
736
+ );
737
+
738
+ if (resolvedDoc !== translatedDoc) {
739
+ const mutation = {
740
+ patch: {
741
+ id: translatedDoc._id,
742
+ set: resolvedDoc,
743
+ },
744
+ };
745
+
746
+ await client.mutate([mutation]);
747
+ return { patched: true, doc: translatedDoc, localeId };
748
+ }
749
+ return { patched: false, doc: translatedDoc, localeId };
750
+ },
751
+ {
752
+ onProgress: (current, total) => {
753
+ setImportProgress({
754
+ current,
755
+ total,
756
+ isImporting: true,
757
+ });
758
+ },
759
+ onItemFailure: ({ doc, localeId }, error) => {
760
+ console.error(
761
+ `Failed to patch references for ${doc._id} (${localeId}):`,
762
+ error
763
+ );
764
+ },
765
+ }
766
+ );
767
+
768
+ const patchedCount = result.successfulItems.filter(
769
+ (item) => item.patched
770
+ ).length;
771
+
772
+ toast.push({
773
+ title: `Patched references in ${patchedCount} documents${result.failureCount > 0 ? `, ${result.failureCount} failed` : ''}`,
774
+ status:
775
+ patchedCount > 0 || result.failureCount === 0 ? 'success' : 'error',
776
+ closable: true,
777
+ });
778
+ } catch (error) {
779
+ console.error('Error patching document references:', error);
780
+ toast.push({
781
+ title: 'Error patching document references',
782
+ status: 'error',
783
+ closable: true,
784
+ });
785
+ } finally {
786
+ setIsBusy(false);
787
+ setImportProgress({ current: 0, total: 0, isImporting: false });
788
+ }
789
+ }, [secrets, documents, locales, client]);
790
+
791
+ const handlePublishAllTranslations = useCallback(async () => {
792
+ if (!secrets || documents.length === 0) return;
793
+
794
+ setIsBusy(true);
795
+
796
+ try {
797
+ const sourceLocale = pluginConfig.getSourceLocale();
798
+ const publishedDocumentIds = documents
799
+ .filter((doc) => !doc._id.startsWith('drafts.'))
800
+ .map((doc) => doc._id);
801
+
802
+ if (publishedDocumentIds.length === 0) {
803
+ toast.push({
804
+ title:
805
+ 'No published source documents found to publish translations for',
806
+ status: 'warning',
807
+ closable: true,
808
+ });
809
+ return;
810
+ }
811
+
812
+ const query = `*[
813
+ _type == 'translation.metadata' &&
814
+ translations[_key == $sourceLocale][0].value._ref in $publishedDocumentIds
815
+ ] {
816
+ 'sourceDocId': translations[_key == $sourceLocale][0].value._ref,
817
+ 'translationDocs': translations[_key != $sourceLocale && defined(value._ref)]{
818
+ _key,
819
+ 'docId': value._ref
820
+ }
821
+ }`;
822
+
823
+ const translationMetadata = await client.fetch(query, {
824
+ sourceLocale,
825
+ publishedDocumentIds,
826
+ });
827
+
828
+ const translationDocIds: string[] = [];
829
+ translationMetadata.forEach((metadata: any) => {
830
+ metadata.translationDocs?.forEach((translation: any) => {
831
+ if (translation.docId) {
832
+ translationDocIds.push(translation.docId);
833
+ }
834
+ });
835
+ });
836
+
837
+ if (translationDocIds.length === 0) {
838
+ toast.push({
839
+ title: 'No translation documents found to publish',
840
+ status: 'warning',
841
+ closable: true,
842
+ });
843
+ return;
844
+ }
845
+
846
+ const translatedDocumentIds = await publishTranslations(
847
+ translationDocIds,
848
+ client
849
+ );
850
+
851
+ toast.push({
852
+ title: `Published ${translatedDocumentIds.length} translation documents`,
853
+ status: 'success',
854
+ closable: true,
855
+ });
856
+ } catch (error) {
857
+ console.error('Error publishing translations:', error);
858
+ toast.push({
859
+ title: 'Error publishing translations',
860
+ status: 'error',
861
+ closable: true,
862
+ });
863
+ } finally {
864
+ setIsBusy(false);
865
+ }
866
+ }, [secrets, documents, client]);
867
+
868
+ useEffect(() => {
869
+ fetchDocuments();
870
+ }, [fetchDocuments]);
871
+
872
+ useEffect(() => {
873
+ if (secrets) {
874
+ fetchLocales();
875
+ }
876
+ }, [fetchLocales, secrets]);
877
+
878
+ useEffect(() => {
879
+ if (documents.length > 0 && locales.length > 0) {
880
+ fetchExistingTranslations();
881
+ }
882
+ }, [fetchExistingTranslations]);
883
+
884
+ useEffect(() => {
885
+ if (
886
+ documents.length > 0 &&
887
+ locales.length > 0 &&
888
+ secrets &&
889
+ !loadingDocuments
890
+ ) {
891
+ handleRefreshAll();
892
+ }
893
+ }, [documents]);
894
+
895
+ useEffect(() => {
896
+ if (!autoRefresh || documents.length === 0 || !secrets) return;
897
+
898
+ const interval = setInterval(async () => {
899
+ await handleRefreshAll();
900
+ }, 10000);
901
+
902
+ return () => clearInterval(interval);
903
+ }, [autoRefresh, documents.length, secrets, handleRefreshAll]);
904
+
905
+ useEffect(() => {
906
+ setImportedTranslations(new Set(downloadStatus.downloaded));
907
+ }, [downloadStatus.downloaded]);
908
+
909
+ const contextValue: TranslationsContextType = {
910
+ // State
911
+ isBusy,
912
+ documents,
913
+ locales,
914
+ autoRefresh,
915
+ loadingDocuments,
916
+ importProgress,
917
+ importedTranslations,
918
+ existingTranslations,
919
+ downloadStatus,
920
+ translationStatuses,
921
+ isRefreshing,
922
+ loadingSecrets,
923
+ secrets,
924
+
925
+ // Actions
926
+ setLocales,
927
+ setAutoRefresh,
928
+ handleTranslateAll,
929
+ handleImportAll,
930
+ handleImportMissing,
931
+ handleRefreshAll,
932
+ handleImportDocument,
933
+ handlePatchDocumentReferences,
934
+ handlePublishAllTranslations,
935
+ };
936
+
937
+ return (
938
+ <TranslationsContext.Provider value={contextValue}>
939
+ {children}
940
+ </TranslationsContext.Provider>
941
+ );
942
+ };